diff --git a/lambdas/src/lib/db/db-retry.test.ts b/lambdas/src/lib/db/db-retry.test.ts new file mode 100644 index 00000000..2951db9d --- /dev/null +++ b/lambdas/src/lib/db/db-retry.test.ts @@ -0,0 +1,136 @@ +import { + type DbRetryOptions, + executeDbOperationWithRetry, + isTransientDatabaseError, +} from "./db-retry"; + +describe("db-retry", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + describe("isTransientDatabaseError", () => { + it("should return true for transient postgres errors", () => { + const error = Object.assign(new Error("database restart"), { + code: "57P01", + }); + + expect(isTransientDatabaseError(error)).toBe(true); + }); + + it("should return true for postgres connection-exception class errors", () => { + const error = Object.assign(new Error("connection failure"), { + code: "08006", + }); + + expect(isTransientDatabaseError(error)).toBe(true); + }); + + it("should return false for unrelated operator-intervention postgres errors", () => { + const error = Object.assign(new Error("query canceled"), { + code: "57014", + }); + + expect(isTransientDatabaseError(error)).toBe(false); + }); + + it("should return false for pool timeout errors", () => { + expect(isTransientDatabaseError(new Error("timeout expired"))).toBe(false); + }); + }); + + describe("executeDbOperationWithRetry", () => { + const retryOptions: Partial = { + maxRetries: 2, + initialDelayMs: 10, + backoffFactor: 2, + maxDelayMs: 100, + jitter: false, + }; + + it("should retry transient database errors and resolve", async () => { + const transientError = Object.assign(new Error("connection reset"), { + code: "ECONNRESET", + }); + const operation = jest + .fn, []>() + .mockRejectedValueOnce(transientError) + .mockRejectedValueOnce(transientError) + .mockResolvedValue("ok"); + + const promise = executeDbOperationWithRetry(operation, retryOptions); + + await Promise.resolve(); + expect(operation).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(10); + expect(operation).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(20); + + await expect(promise).resolves.toBe("ok"); + expect(operation).toHaveBeenCalledTimes(3); + }); + + it("should throw immediately for non-retryable errors", async () => { + const operation = jest.fn, []>().mockRejectedValue(new Error("bad sql")); + + await expect(executeDbOperationWithRetry(operation, retryOptions)).rejects.toThrow("bad sql"); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it("should stop retrying after the maximum number of retries", async () => { + const transientError = Object.assign(new Error("server closed the connection unexpectedly"), { + code: "08006", + }); + const operation = jest.fn, []>().mockRejectedValue(transientError); + + const promise = executeDbOperationWithRetry(operation, retryOptions); + const expectedRejection = expect(promise).rejects.toThrow( + "server closed the connection unexpectedly", + ); + + await Promise.resolve(); + expect(operation).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(10); + expect(operation).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(20); + + await expectedRejection; + expect(operation).toHaveBeenCalledTimes(3); + }); + + it("should fall back to the non-jittered delay when custom options produce an invalid jitter range", async () => { + const transientError = Object.assign(new Error("connection reset"), { + code: "ECONNRESET", + }); + const operation = jest + .fn, []>() + .mockRejectedValueOnce(transientError) + .mockResolvedValue("ok"); + + const promise = executeDbOperationWithRetry(operation, { + maxRetries: 1, + initialDelayMs: 0, + backoffFactor: 2, + maxDelayMs: 0, + jitter: true, + }); + + await Promise.resolve(); + expect(operation).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(0); + + await expect(promise).resolves.toBe("ok"); + expect(operation).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/lambdas/src/lib/db/db-retry.ts b/lambdas/src/lib/db/db-retry.ts new file mode 100644 index 00000000..0a42d0ab --- /dev/null +++ b/lambdas/src/lib/db/db-retry.ts @@ -0,0 +1,155 @@ +import { randomInt } from "node:crypto"; + +export interface DbRetryOptions { + maxRetries: number; + initialDelayMs: number; + backoffFactor: number; + maxDelayMs: number; + jitter: boolean; + isRetryable: (error: unknown) => boolean; +} + +interface DbRetryError extends Error { + code?: string; + errno?: string; +} + +const TRANSIENT_POSTGRES_ERROR_CLASS_PREFIXES = new Set(["08"]); + +// Transient network error codes that are safe to retry. +// These are POSIX system error codes surfaced by Node.js as `error.code` on +// failed socket/HTTP operations. All five are documented in the Node.js +// "Common system errors" section: +// https://nodejs.org/docs/latest-v24.x/api/errors.html#common-system-errors +// +// ECONNREFUSED – target actively refused the connection (service restarting) +// ECONNRESET – connection forcibly closed by peer (timeout / reboot) +// ENOTFOUND – DNS lookup failed (EAI_NODATA / EAI_NONAME) +// EPIPE – remote end of stream closed while writing +// ETIMEDOUT – connected party did not respond in time +const TRANSIENT_NETWORK_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ENOTFOUND", + "EPIPE", + "ETIMEDOUT", +]); + +// PostgreSQL Appendix A documents all Class 08 SQLSTATEs as connection exceptions, so we retry +// the whole class by prefix rather than maintaining a partial list. We additionally retry only the +// transient Class 57 restart or shutdown codes below, not the full Class 57 operator-intervention +// family. +// Source: https://www.postgresql.org/docs/current/errcodes-appendix.html +const TRANSIENT_POSTGRES_ERROR_CODES = new Set(["57P01", "57P02", "57P03"]); + +const TRANSIENT_ERROR_PATTERNS = [ + /cannot connect now/i, + /connection terminated unexpectedly/i, + /server closed the connection unexpectedly/i, + /the database system is starting up/i, + /terminating connection due to administrator command/i, +]; + +export const DEFAULT_DB_RETRY_OPTIONS: DbRetryOptions = { + maxRetries: 2, + initialDelayMs: 200, + backoffFactor: 2, + maxDelayMs: 1_000, + jitter: true, + isRetryable: isTransientDatabaseError, +}; + +export function isTransientDatabaseError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const dbError = error as DbRetryError; + const errorCode = dbError.code ?? dbError.errno; + + if (errorCode && TRANSIENT_NETWORK_ERROR_CODES.has(errorCode)) { + return true; + } + + if (errorCode && isTransientPostgresErrorCode(errorCode)) { + return true; + } + + if (/timeout expired/i.test(error.message)) { + return false; + } + + return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(error.message)); +} + +function isTransientPostgresErrorCode(errorCode: string): boolean { + return ( + TRANSIENT_POSTGRES_ERROR_CODES.has(errorCode) || + [...TRANSIENT_POSTGRES_ERROR_CLASS_PREFIXES].some((prefix) => errorCode.startsWith(prefix)) + ); +} + +export async function executeDbOperationWithRetry( + operation: () => Promise, + retryOptions: Partial = {}, +): Promise { + const options = buildDbRetryOptions(retryOptions); + let retryCount = 0; + + for (;;) { + try { + return await operation(); + } catch (error) { + if (retryCount >= options.maxRetries || !options.isRetryable(error)) { + throw error; + } + + retryCount += 1; + await wait(getRetryDelayMs(retryCount, options)); + } + } +} + +function buildDbRetryOptions(retryOptions: Partial): DbRetryOptions { + return { + ...DEFAULT_DB_RETRY_OPTIONS, + ...retryOptions, + isRetryable: retryOptions.isRetryable ?? DEFAULT_DB_RETRY_OPTIONS.isRetryable, + }; +} + +// With DEFAULT_DB_RETRY_OPTIONS (initialDelayMs=200, backoffFactor=2, maxDelayMs=1000, jitter=true): +// retry 1: exponentialDelay = min(200 × 2⁰, 1000) = 200 ms → jitter range [100, 199] ms +// retry 2: exponentialDelay = min(200 × 2¹, 1000) = 400 ms → jitter range [200, 399] ms +function getRetryDelayMs(retryCount: number, options: DbRetryOptions): number { + const exponentialDelay = Math.min( + options.initialDelayMs * options.backoffFactor ** (retryCount - 1), + options.maxDelayMs, + ); + + if (!options.jitter) { + return exponentialDelay; + } + + const jitterFloor = Math.max(0, Math.ceil(exponentialDelay / 2)); + const jitterCeiling = Math.max(0, Math.ceil(exponentialDelay)); + + // randomInt() requires integer bounds with min < max. If a custom retry configuration + // produces a zero-width or non-integer range, fall back to the non-jittered delay rather + // than failing the retry path itself. + if ( + !Number.isSafeInteger(jitterFloor) || + !Number.isSafeInteger(jitterCeiling) || + jitterFloor >= jitterCeiling + ) { + return exponentialDelay; + } + + return randomInt(jitterFloor, jitterCeiling); +} + +async function wait(delayMs: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} diff --git a/lambdas/src/lib/db/session-db-client.test.ts b/lambdas/src/lib/db/session-db-client.test.ts new file mode 100644 index 00000000..26cecc46 --- /dev/null +++ b/lambdas/src/lib/db/session-db-client.test.ts @@ -0,0 +1,360 @@ +import { type DBClient } from "./db-client"; +import { + type CreateSessionInput, + SessionDbClient, + type UpdateSessionInput, +} from "./session-db-client"; + +const sessionId = "550e8400-e29b-41d4-a716-446655440000"; +const refreshTokenId = "650e8400-e29b-41d4-a716-446655440000"; + +async function expectSanitizedFailure( + promise: Promise, + expectedMessage: string, +): Promise { + expect.assertions(3); + + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ message: expectedMessage }); + expect(error).not.toHaveProperty("cause"); + } +} + +function buildCreateSessionInput(overrides: Partial = {}): CreateSessionInput { + return { + sessionId, + refreshTokenId, + nhsAccessToken: "nhs-access-token", + userInfo: { + issuer: "issuer", + audience: "audience", + subject: "subject", + familyName: "Doe", + givenName: "Jane", + identityProofingLevel: "P9", + email: "jane@example.com", + emailVerified: true, + phoneNumberVerified: false, + birthDate: "1990-01-01", + nhsNumber: "1234567890", + gpOdsCode: "A12345", + }, + sessionCreatedAt: "2026-04-10T09:00:00.000Z", + lastRefreshAt: "2026-04-10T09:10:00.000Z", + maxExpiresAt: "2026-04-10T10:00:00.000Z", + ...overrides, + }; +} + +function buildSessionRow(overrides: Record = {}): Record { + const session = buildCreateSessionInput(); + + return { + session_id: session.sessionId, + refresh_token_id: session.refreshTokenId, + nhs_access_token: session.nhsAccessToken, + issuer: session.userInfo.issuer, + audience: session.userInfo.audience, + subject: session.userInfo.subject, + family_name: session.userInfo.familyName, + given_name: session.userInfo.givenName, + identity_proofing_level: session.userInfo.identityProofingLevel, + email: session.userInfo.email, + email_verified: session.userInfo.emailVerified, + phone_number_verified: session.userInfo.phoneNumberVerified, + birth_date: session.userInfo.birthDate, + nhs_number: session.userInfo.nhsNumber, + gp_ods_code: session.userInfo.gpOdsCode, + session_created_at: session.sessionCreatedAt, + last_refresh_at: session.lastRefreshAt, + max_expires_at: session.maxExpiresAt, + ...overrides, + }; +} + +describe("SessionDbClient", () => { + let mockDbClient: jest.Mocked; + let sessionDbClient: SessionDbClient; + + beforeEach(() => { + mockDbClient = { + query: jest.fn(), + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + sessionDbClient = new SessionDbClient(mockDbClient, { + initialDelayMs: 0, + jitter: false, + maxDelayMs: 0, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("createSession", () => { + it("should insert and return the session", async () => { + const row = buildSessionRow(); + mockDbClient.query.mockResolvedValue({ + rows: [row], + rowCount: 1, + }); + + const session = buildCreateSessionInput(); + const result = await sessionDbClient.createSession(session); + + expect(result).toEqual(session); + expect(mockDbClient.query).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO session"), + [ + session.sessionId, + session.refreshTokenId, + session.nhsAccessToken, + session.userInfo.issuer, + session.userInfo.audience, + session.userInfo.subject, + session.userInfo.familyName, + session.userInfo.givenName, + session.userInfo.identityProofingLevel, + session.userInfo.email, + session.userInfo.emailVerified, + session.userInfo.phoneNumberVerified, + session.userInfo.birthDate, + session.userInfo.nhsNumber, + session.userInfo.gpOdsCode, + session.sessionCreatedAt, + session.lastRefreshAt, + session.maxExpiresAt, + ], + ); + + const [query] = mockDbClient.query.mock.calls[0]; + expect(query).toContain("$13::date"); + expect(query).toContain("ON CONFLICT (session_id) DO UPDATE"); + }); + + it("should retry transient insert failures", async () => { + const transientError = Object.assign(new Error("connection reset"), { + code: "ECONNRESET", + }); + mockDbClient.query + .mockRejectedValueOnce(transientError) + .mockResolvedValue({ rows: [buildSessionRow()], rowCount: 1 }); + + const result = await sessionDbClient.createSession(buildCreateSessionInput()); + + expect(result.sessionId).toBe(sessionId); + expect(mockDbClient.query).toHaveBeenCalledTimes(2); + }); + + it("should throw when no row is returned", async () => { + mockDbClient.query.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect(sessionDbClient.createSession(buildCreateSessionInput())).rejects.toThrow( + `Failed to create session for sessionId ${sessionId}`, + ); + }); + + it("should throw on database failure", async () => { + const dbError = Object.assign(new Error("db down with token nhs-access-token"), { + code: "23514", + }); + mockDbClient.query.mockRejectedValue(dbError); + + await expectSanitizedFailure( + sessionDbClient.createSession(buildCreateSessionInput()), + `Failed to create session for sessionId ${sessionId}`, + ); + }); + }); + + describe("getSession", () => { + it("should return the session when found", async () => { + mockDbClient.query.mockResolvedValue({ + rows: [buildSessionRow()], + rowCount: 1, + }); + + const result = await sessionDbClient.getSession(sessionId); + + expect(result).toEqual(buildCreateSessionInput()); + expect(mockDbClient.query).toHaveBeenCalledWith(expect.stringContaining("FROM session"), [ + sessionId, + ]); + }); + + it("should return null when the session does not exist", async () => { + mockDbClient.query.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect(sessionDbClient.getSession(sessionId)).resolves.toBeNull(); + }); + + it("should sanitize database failures", async () => { + const dbError = Object.assign(new Error("bad row for nhs number 1234567890"), { + code: "08006", + }); + mockDbClient.query.mockRejectedValue(dbError); + + await expectSanitizedFailure( + sessionDbClient.getSession(sessionId), + `Failed to fetch session for sessionId ${sessionId}`, + ); + }); + + it("should map all user info fields from row columns to session model", async () => { + mockDbClient.query.mockResolvedValue({ + rows: [ + buildSessionRow({ + issuer: "https://issuer.example", + audience: "client-id", + subject: "subject-123", + family_name: "Smith", + given_name: "Jo", + identity_proofing_level: "P5", + email: "jo@example.com", + email_verified: false, + phone_number_verified: true, + birth_date: "1985-05-05", + nhs_number: "9999999999", + gp_ods_code: "B12345", + }), + ], + rowCount: 1, + }); + + const result = await sessionDbClient.getSession(sessionId); + + expect(result?.userInfo).toEqual({ + issuer: "https://issuer.example", + audience: "client-id", + subject: "subject-123", + familyName: "Smith", + givenName: "Jo", + identityProofingLevel: "P5", + email: "jo@example.com", + emailVerified: false, + phoneNumberVerified: true, + birthDate: "1985-05-05", + nhsNumber: "9999999999", + gpOdsCode: "B12345", + }); + }); + }); + + describe("updateSession", () => { + it("should update the provided session fields", async () => { + const updates: UpdateSessionInput = { + refreshTokenId: "750e8400-e29b-41d4-a716-446655440000", + nhsAccessToken: "updated-nhs-access-token", + lastRefreshAt: "2026-04-10T09:15:00.000Z", + maxExpiresAt: "2026-04-10T10:15:00.000Z", + }; + + mockDbClient.query.mockResolvedValue({ + rows: [ + buildSessionRow({ + refresh_token_id: updates.refreshTokenId, + nhs_access_token: updates.nhsAccessToken, + last_refresh_at: updates.lastRefreshAt, + max_expires_at: updates.maxExpiresAt, + }), + ], + rowCount: 1, + }); + + const result = await sessionDbClient.updateSession(sessionId, updates); + + expect(result.refreshTokenId).toBe("750e8400-e29b-41d4-a716-446655440000"); + expect(result.nhsAccessToken).toBe("updated-nhs-access-token"); + expect(result.lastRefreshAt).toBe("2026-04-10T09:15:00.000Z"); + expect(result.maxExpiresAt).toBe("2026-04-10T10:15:00.000Z"); + + const [query, values] = mockDbClient.query.mock.calls[0]; + expect(query).toContain("refresh_token_id = $1::uuid"); + expect(query).toContain("nhs_access_token = $2"); + expect(query).toContain("last_refresh_at = $3::timestamptz"); + expect(query).toContain("max_expires_at = $4::timestamptz"); + expect(values).toEqual([ + "750e8400-e29b-41d4-a716-446655440000", + "updated-nhs-access-token", + "2026-04-10T09:15:00.000Z", + "2026-04-10T10:15:00.000Z", + sessionId, + ]); + }); + + it("should reject empty session updates", async () => { + await expect(sessionDbClient.updateSession(sessionId, {})).rejects.toThrow( + `No session updates provided for sessionId ${sessionId}`, + ); + expect(mockDbClient.query).not.toHaveBeenCalled(); + }); + + it("should throw when no session is updated", async () => { + mockDbClient.query.mockResolvedValue({ rows: [], rowCount: 0 }); + + await expect( + sessionDbClient.updateSession(sessionId, { + lastRefreshAt: "2026-04-10T09:15:00.000Z", + }), + ).rejects.toThrow(`Failed to update session for sessionId ${sessionId}`); + }); + + it("should sanitize update database failures", async () => { + const dbError = Object.assign(new Error("update failed for birth date 1990-01-01"), { + code: "23505", + }); + mockDbClient.query.mockRejectedValue(dbError); + + await expectSanitizedFailure( + sessionDbClient.updateSession(sessionId, { + lastRefreshAt: "2026-04-10T09:15:00.000Z", + }), + `Failed to update session for sessionId ${sessionId}`, + ); + }); + }); + + describe("deleteSession", () => { + it("should delete the session", async () => { + mockDbClient.query.mockResolvedValue({ rows: [], rowCount: 1 }); + + await expect(sessionDbClient.deleteSession(sessionId)).resolves.toBeUndefined(); + expect(mockDbClient.query).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM session"), + [sessionId], + ); + }); + + it("should throw when the session does not exist", async () => { + mockDbClient.query.mockResolvedValue({ rows: [], rowCount: 0 }); + + await expect(sessionDbClient.deleteSession(sessionId)).rejects.toThrow( + `Failed to delete session for sessionId ${sessionId}`, + ); + }); + + it("should sanitize delete database failures", async () => { + const dbError = Object.assign(new Error("delete failed for token nhs-access-token"), { + code: "08006", + }); + mockDbClient.query.mockRejectedValue(dbError); + + await expectSanitizedFailure( + sessionDbClient.deleteSession(sessionId), + `Failed to delete session for sessionId ${sessionId}`, + ); + }); + }); +}); diff --git a/lambdas/src/lib/db/session-db-client.ts b/lambdas/src/lib/db/session-db-client.ts new file mode 100644 index 00000000..48108cea --- /dev/null +++ b/lambdas/src/lib/db/session-db-client.ts @@ -0,0 +1,307 @@ +import { type ISession, type ISessionUserInfo } from "../models/session/session"; +import { type DBClient, type DbResult } from "./db-client"; +import { type DbRetryOptions, executeDbOperationWithRetry } from "./db-retry"; + +type SessionTimestamp = Date | string; +type SessionQueryValue = boolean | SessionTimestamp | string; + +interface SessionUpdateDefinition { + column: string; + value: SessionQueryValue | undefined; + cast?: string; +} + +interface SessionRow { + session_id: string; + refresh_token_id: string; + nhs_access_token: string; + issuer: string; + audience: string; + subject: string; + family_name: string; + given_name: string; + identity_proofing_level: string; + email: string; + email_verified: boolean; + phone_number_verified: boolean; + birth_date: string; + nhs_number: string; + gp_ods_code: string; + session_created_at: SessionTimestamp; + last_refresh_at: SessionTimestamp; + max_expires_at: SessionTimestamp; +} + +export interface CreateSessionInput { + sessionId: string; + refreshTokenId: string; + nhsAccessToken: string; + userInfo: ISessionUserInfo; + sessionCreatedAt: string; + lastRefreshAt: string; + maxExpiresAt: string; +} + +export interface UpdateSessionInput { + refreshTokenId?: string; + nhsAccessToken?: string; + lastRefreshAt?: string; + maxExpiresAt?: string; +} + +const SESSION_COLUMNS = ` + session_id, + refresh_token_id, + nhs_access_token, + issuer, + audience, + subject, + family_name, + given_name, + identity_proofing_level, + email, + email_verified, + phone_number_verified, + birth_date, + nhs_number, + gp_ods_code, + session_created_at, + last_refresh_at, + max_expires_at +`; + +export class SessionDbClient { + constructor( + private readonly dbClient: DBClient, + private readonly retryOptions: Partial = {}, + ) {} + + async createSession(session: CreateSessionInput): Promise { + const query = ` + INSERT INTO session ( + session_id, + refresh_token_id, + nhs_access_token, + issuer, + audience, + subject, + family_name, + given_name, + identity_proofing_level, + email, + email_verified, + phone_number_verified, + birth_date, + nhs_number, + gp_ods_code, + session_created_at, + last_refresh_at, + max_expires_at + ) + VALUES ( + $1::uuid, + $2::uuid, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13::date, + $14, + $15, + $16::timestamptz, + $17::timestamptz, + $18::timestamptz + ) + ON CONFLICT (session_id) DO UPDATE + SET session_id = EXCLUDED.session_id + RETURNING ${SESSION_COLUMNS}; + `; + + const values: SessionQueryValue[] = [ + session.sessionId, + session.refreshTokenId, + session.nhsAccessToken, + session.userInfo.issuer, + session.userInfo.audience, + session.userInfo.subject, + session.userInfo.familyName, + session.userInfo.givenName, + session.userInfo.identityProofingLevel, + session.userInfo.email, + session.userInfo.emailVerified, + session.userInfo.phoneNumberVerified, + session.userInfo.birthDate, + session.userInfo.nhsNumber, + session.userInfo.gpOdsCode, + session.sessionCreatedAt, + session.lastRefreshAt, + session.maxExpiresAt, + ]; + + try { + const result = await this.query(query, values); + + if (result.rowCount === 0 || !result.rows[0]) { + throw new Error("Failed to create session"); + } + + return this.mapRowToSession(result.rows[0]); + } catch { + throw new Error(`Failed to create session for sessionId ${session.sessionId}`); + } + } + + async getSession(sessionId: string): Promise { + const query = ` + SELECT ${SESSION_COLUMNS} + FROM session + WHERE session_id = $1::uuid + LIMIT 1; + `; + + try { + const result = await this.query(query, [sessionId]); + + return result.rowCount === 0 || !result.rows[0] ? null : this.mapRowToSession(result.rows[0]); + } catch { + throw new Error(`Failed to fetch session for sessionId ${sessionId}`); + } + } + + async updateSession(sessionId: string, updates: UpdateSessionInput): Promise { + const { setClauses, values } = this.buildUpdateClauses(updates); + + if (setClauses.length === 0) { + throw new Error(`No session updates provided for sessionId ${sessionId}`); + } + + values.push(sessionId); + + const query = ` + UPDATE session + SET + ${setClauses.join(",\n ")} + WHERE session_id = $${values.length}::uuid + RETURNING ${SESSION_COLUMNS}; + `; + + try { + const result = await this.query(query, values); + + if (result.rowCount === 0 || !result.rows[0]) { + throw new Error("Failed to update session"); + } + + return this.mapRowToSession(result.rows[0]); + } catch { + throw new Error(`Failed to update session for sessionId ${sessionId}`); + } + } + + async deleteSession(sessionId: string): Promise { + const query = ` + DELETE FROM session + WHERE session_id = $1::uuid; + `; + + try { + const result = await this.query(query, [sessionId]); + + if (result.rowCount === 0) { + throw new Error("Failed to delete session"); + } + } catch { + throw new Error(`Failed to delete session for sessionId ${sessionId}`); + } + } + + private async query(text: string, values: I): Promise> { + return executeDbOperationWithRetry( + () => this.dbClient.query(text, values), + this.retryOptions, + ); + } + + private buildUpdateClauses(updates: UpdateSessionInput): { + setClauses: string[]; + values: SessionQueryValue[]; + } { + // This ordered whitelist is the source of truth for mutable session fields. + // SQL assignments and bound values are both derived from it so placeholder ordering is explicit. + const updatesToApply = this.buildMutableFieldUpdates(updates).filter( + (update): update is { column: string; value: SessionQueryValue; cast?: string } => + update.value !== undefined, + ); + + return { + setClauses: updatesToApply.map((update, index) => { + const placeholder = this.buildPlaceholder(index + 1, update.cast); + return `${update.column} = ${placeholder}`; + }), + values: updatesToApply.map((update) => update.value), + }; + } + + private buildMutableFieldUpdates(updates: UpdateSessionInput): SessionUpdateDefinition[] { + return [ + { + column: "refresh_token_id", + value: updates.refreshTokenId, + cast: "uuid", + }, + { + column: "nhs_access_token", + value: updates.nhsAccessToken, + }, + { + column: "last_refresh_at", + value: updates.lastRefreshAt, + cast: "timestamptz", + }, + { + column: "max_expires_at", + value: updates.maxExpiresAt, + cast: "timestamptz", + }, + ]; + } + + private buildPlaceholder(parameterIndex: number, cast?: string): string { + return cast ? `$${parameterIndex}::${cast}` : `$${parameterIndex}`; + } + + private mapRowToSession(row: SessionRow): ISession { + return { + sessionId: row.session_id, + refreshTokenId: row.refresh_token_id, + nhsAccessToken: row.nhs_access_token, + userInfo: { + issuer: row.issuer, + audience: row.audience, + subject: row.subject, + familyName: row.family_name, + givenName: row.given_name, + identityProofingLevel: row.identity_proofing_level, + email: row.email, + emailVerified: row.email_verified, + phoneNumberVerified: row.phone_number_verified, + birthDate: row.birth_date, + nhsNumber: row.nhs_number, + gpOdsCode: row.gp_ods_code, + }, + sessionCreatedAt: this.normalizeTimestamp(row.session_created_at), + lastRefreshAt: this.normalizeTimestamp(row.last_refresh_at), + maxExpiresAt: this.normalizeTimestamp(row.max_expires_at), + }; + } + + private normalizeTimestamp(value: SessionTimestamp): string { + return value instanceof Date ? value.toISOString() : value; + } +}