({
projectId,
deviceIds,
eventTimeMs,
}: {
projectId: string;
/** Candidate device ids in priority order (e.g. [current, previous] salt
* windows, or just [override]). Deduped; the first is canonical. */
deviceIds: string[];
eventTimeMs: number;
})
| 82 | } |
| 83 | |
| 84 | async function getInfoFromSession({ |
| 85 | projectId, |
| 86 | deviceIds, |
| 87 | eventTimeMs, |
| 88 | }: { |
| 89 | projectId: string; |
| 90 | /** Candidate device ids in priority order (e.g. [current, previous] salt |
| 91 | * windows, or just [override]). Deduped; the first is canonical. */ |
| 92 | deviceIds: string[]; |
| 93 | eventTimeMs: number; |
| 94 | }): Promise<DeviceIdResult> { |
| 95 | const candidates = [...new Set(deviceIds.filter(Boolean))]; |
| 96 | const primary = candidates[0] ?? ''; |
| 97 | |
| 98 | try { |
| 99 | // Reading the live blob is the source of truth for an active session — it's |
| 100 | // what keeps a visit on one id across page reloads and salt rotation. Don't |
| 101 | // drop this read to "save" a lookup or sessions split at the bucket boundary. |
| 102 | const sessions = await Promise.all( |
| 103 | candidates.map((deviceId) => |
| 104 | sessionBuffer.getExistingSession({ projectId, deviceId }) |
| 105 | ) |
| 106 | ); |
| 107 | |
| 108 | for (const [i, session] of sessions.entries()) { |
| 109 | if (session && withinIdleWindow(session, eventTimeMs)) { |
| 110 | return { deviceId: candidates[i]!, sessionId: session.id }; |
| 111 | } |
| 112 | } |
| 113 | } catch (error) { |
| 114 | console.error('Error resolving session for device id', error); |
| 115 | } |
| 116 | |
| 117 | return { |
| 118 | deviceId: primary, |
| 119 | // Deterministic id for the first event of a session and to bridge the window |
| 120 | // before the worker persists the blob (API resolves synchronously, worker |
| 121 | // writes async — same bucket → same id, so they agree). |
| 122 | // |
| 123 | // The bucket window MUST track the idle timeout: a gap > the window has to |
| 124 | // land in a new bucket so a boundary mints a *fresh* id. If it didn't (e.g. |
| 125 | // a hardcoded 30min while SESSION_TIMEOUT_MS is shorter), the worker would |
| 126 | // reopen the just-closed id and its session_end would be skipped. Grace must |
| 127 | // stay < window or getSessionId throws. |
| 128 | sessionId: getSessionId({ |
| 129 | projectId, |
| 130 | deviceId: primary, |
| 131 | eventMs: eventTimeMs, |
| 132 | graceMs: Math.min(5_000, Math.floor(SESSION_TIMEOUT_MS / 6)), |
| 133 | windowMs: SESSION_TIMEOUT_MS, |
| 134 | }), |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Deterministic session id for (projectId, deviceId) within a time window, |
no test coverage detected