| 1 | import type { SessionRef } from "./sessionRef"; |
| 2 | |
| 3 | export type SessionIdentityRef = Readonly<SessionRef>; |
| 4 | |
| 5 | export type SessionIdentity = Readonly<{ |
| 6 | session?: SessionIdentityRef | null; |
| 7 | sessionPath?: string; |
| 8 | sessionGeneration?: number; |
| 9 | }>; |
| 10 | |
| 11 | /** Zero is the initial host binding; only absent/invalid generations are unknown. */ |
| 12 | export function hasSessionGeneration(value: unknown): value is number { |
| 13 | return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; |
| 14 | } |
| 15 | |
| 16 | export type SessionHydrationOptions<Item, SurfacePolicy extends string> = SessionIdentity & Readonly<{ |
| 17 | skipHistory?: boolean; |
| 18 | placeholderItems?: Item[]; |
| 19 | preserveCachedHistory?: boolean; |
| 20 | freshSnapshot?: boolean; |
| 21 | sessionRevision?: number; |
| 22 | sessionDigest?: string; |
| 23 | cancelHydrateGeneration?: number; |
| 24 | deferResetUntilHistory?: boolean; |
| 25 | surfacePolicy?: SurfacePolicy; |
| 26 | recoveryCurrent?: () => boolean; |
| 27 | }>; |
| 28 | |
| 29 | export function sessionIdentityFields(identity: SessionIdentity | undefined): SessionIdentity { |
| 30 | return { |
| 31 | session: identity?.session, |
| 32 | sessionPath: identity?.sessionPath, |
| 33 | sessionGeneration: identity?.sessionGeneration, |
| 34 | }; |
| 35 | } |
| 36 | |
| 37 | export function sessionIdentityBaseKey(identity: SessionIdentity | undefined): string { |
| 38 | const ref = identity?.session; |
| 39 | if (ref?.sessionId) return `ref\0${ref.hostId || "local"}\0${ref.sessionId}`; |
| 40 | const path = identity?.sessionPath?.trim() || ""; |
| 41 | return path ? `path\0${path}` : ""; |
| 42 | } |
| 43 | |
| 44 | /** Navigation adapters expose the canonical route even when tabs omit paths. */ |
| 45 | export function sessionIdentityRoute(identity: SessionIdentity | undefined): string | undefined { |
| 46 | return identity?.session?.sessionId ? `session-id:${identity.session.sessionId}` : identity?.sessionPath; |
| 47 | } |
| 48 | |
| 49 | function sameGeneration(target: SessionIdentity, current: SessionIdentity | undefined): boolean { |
| 50 | const targetGeneration = target.sessionGeneration; |
| 51 | const currentGeneration = current?.sessionGeneration; |
| 52 | return targetGeneration == null || currentGeneration == null || targetGeneration === currentGeneration; |
| 53 | } |
| 54 | |
| 55 | /** Canonical SessionRef first; legacy path is only the compatibility identity. */ |
| 56 | export function sameSessionIdentity( |
| 57 | target: SessionIdentity | undefined, |
| 58 | current: SessionIdentity | undefined, |
| 59 | ): boolean { |
| 60 | if (!target || !current) return false; |
| 61 | const key = sessionIdentityBaseKey(target); |
| 62 | return !!(key && key === sessionIdentityBaseKey(current) && sameGeneration(target, current)); |
| 63 | } |
| 64 | |
| 65 | /** Stable cache/fence key. An empty string means the identity is not yet proven. */ |
| 66 | export function sessionIdentityStableKey(identity: SessionIdentity | undefined): string { |
| 67 | const key = sessionIdentityBaseKey(identity); |
| 68 | return key ? `${key}\0${identity?.sessionGeneration ?? 0}` : ""; |
| 69 | } |
| 70 | |
| 71 | /** True when an in-flight hydrate still addresses the live Session identity. */ |
| 72 | export function hydrateIdentityCurrent( |
| 73 | load: SessionIdentity, |
| 74 | current: SessionIdentity | undefined, |
| 75 | ): boolean { |
| 76 | if (!sessionIdentityBaseKey(load)) { |
| 77 | return sameGeneration(load, current); |
| 78 | } |
| 79 | return sameSessionIdentity(load, current); |
| 80 | } |
| 81 |