File
Blob: src/client/stores/bootstrap.ts
| 1 | import { liveQuery, type Subscription } from "dexie"; |
| 2 | import { docCache } from "@/client/lib/doc-cache-registry"; |
| 3 | import { queryClient } from "@/client/lib/query-client"; |
| 4 | import { clearPwaRuntimeCaches } from "@/client/lib/pwa"; |
| 5 | import { SESSION_MODES, type SessionMode } from "@/client/lib/constants"; |
| 6 | import { useAuthStore } from "./auth-store"; |
| 7 | import { db, type MemberWorkspaceRow, type WorkspacePageRow, type WorkspaceReplicaRow } from "./db/bland-db"; |
| 8 | import { applyWorkspaceDirectoryProjection, resetWorkspaceDirectoryProjection } from "./workspace-directory"; |
| 9 | import { |
| 10 | applyWorkspaceReplicaProjection, |
| 11 | resetWorkspaceReplicaProjection, |
| 12 | type WorkspaceReplicaProjection, |
| 13 | } from "./workspace-replica"; |
| 14 | import { |
| 15 | applyWorkspaceNavigationProjection, |
| 16 | resetWorkspaceNavigationProjection, |
| 17 | type WorkspaceNavigationProjection, |
| 18 | } from "./workspace-navigation"; |
| 19 | import { applySharedInboxProjection, resetSharedInboxProjection, type SharedInboxState } from "./shared-inbox"; |
| 20 | import { workspaceLifecycleCommands } from "./db/workspace-lifecycle"; |
| 21 | |
| 22 | const subscriptions: Subscription[] = []; |
| 23 | |
| 24 | function stopSubscriptions(): void { |
| 25 | while (subscriptions.length > 0) { |
| 26 | const sub = subscriptions.pop(); |
| 27 | sub?.unsubscribe(); |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | async function readDirectoryProjection(): Promise<MemberWorkspaceRow[]> { |
| 32 | return db.memberWorkspaces.orderBy("rank").toArray(); |
| 33 | } |
| 34 | |
| 35 | async 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 | |
| 45 | async 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 | |
| 56 | async 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 | |
| 67 | function 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 | |
| 90 | function 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 | */ |
| 102 | export 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 | */ |
| 120 | export 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 | */ |
| 161 | export 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 | */ |
| 181 | export 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). |
| 187 | let 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. |
| 192 | let desiredState: { userId: string | null; needsLocal: boolean } | null = null; |
| 193 | |
| 194 | function 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 | */ |
| 217 | export 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 | */ |
| 228 | export 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 | */ |
| 238 | export 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 | } |