diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 567cba6ab..f8e2d4845 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -278,6 +278,10 @@ export function createCurrentSessionRepository(): SessionRepository { return new SessionRepository( createCurrentRepositoryContext(), createCurrentRepositoryWriteHook("session_repository_write"), + // Activity timestamps are informational; the periodic flush persists them. + needsExplicitPersist(resolveDatabaseDialect()) + ? () => DatabaseSaveTrigger.markDirty() + : undefined, ); } diff --git a/src/backend/database/repositories/session-repository.ts b/src/backend/database/repositories/session-repository.ts index 2d1785e3a..7799e956c 100644 --- a/src/backend/database/repositories/session-repository.ts +++ b/src/backend/database/repositories/session-repository.ts @@ -13,6 +13,8 @@ export class SessionRepository { constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, + private readonly onActivity: + (() => void | Promise) | undefined = onWrite, ) {} async create(session: NewSessionRecord): Promise { @@ -63,7 +65,7 @@ export class SessionRepository { .where(and(eq(sessions.id, id), lte(sessions.lastActiveAt, cutoff))); if (rowsAffected(result) === 0) return false; - await this.afterWrite(); + await this.onActivity?.(); return true; } diff --git a/src/backend/tests/database/repositories/factory-context.test.ts b/src/backend/tests/database/repositories/factory-context.test.ts index f2486cd9b..fbd899b6c 100644 --- a/src/backend/tests/database/repositories/factory-context.test.ts +++ b/src/backend/tests/database/repositories/factory-context.test.ts @@ -4,12 +4,13 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js"; // getDb() throws unless a database was initialized; the context's handle is // not what this file is about. vi.mock("../../../database/db/index.js", () => ({ - getDb: () => ({}), + getDb: vi.fn(() => ({})), getSqlite: () => ({}), DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() }, })); const { + createCurrentSessionRepository, createCurrentRepositoryContext, createCurrentRepositoryWriteHook, createCurrentRepositoryLazyWriteHook, @@ -86,3 +87,35 @@ describe("createCurrentRepositoryLazyWriteHook", () => { } }); }); + +it("wires SQLite activity to the periodic flush and token changes to immediate persistence", async () => { + const saved = process.env[DATABASE_DIALECT_ENV]; + process.env[DATABASE_DIALECT_ENV] = "sqlite"; + const { getDb } = await import("../../../database/db/index.js"); + const { DatabaseSaveTrigger } = + await import("../../../utils/database-save-trigger.js"); + const dirty = vi + .spyOn(DatabaseSaveTrigger, "markDirty") + .mockImplementation(() => {}); + const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue(); + const query = { + update: () => query, + set: () => query, + where: async () => ({ changes: 1 }), + }; + vi.mocked(getDb).mockReturnValue( + query as unknown as ReturnType, + ); + try { + const repo = createCurrentSessionRepository(); + await repo.touch("session-1"); + expect(dirty).toHaveBeenCalledOnce(); + expect(force).not.toHaveBeenCalled(); + await repo.updateToken("session-1", "new-token"); + expect(force).toHaveBeenCalledOnce(); + } finally { + vi.restoreAllMocks(); + if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV]; + else process.env[DATABASE_DIALECT_ENV] = saved; + } +}); diff --git a/src/backend/tests/database/repositories/user-session-repositories.test.ts b/src/backend/tests/database/repositories/user-session-repositories.test.ts index c8c2d11f1..729169b51 100644 --- a/src/backend/tests/database/repositories/user-session-repositories.test.ts +++ b/src/backend/tests/database/repositories/user-session-repositories.test.ts @@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => { async function createRepositories(options: { onUserWrite?: () => void | Promise; onSessionWrite?: () => void | Promise; + onSessionActivity?: () => void | Promise; }): Promise<{ users: UserRepository; sessions: SessionRepository; @@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => { options: { onUserWrite?: () => void | Promise; onSessionWrite?: () => void | Promise; + onSessionActivity?: () => void | Promise; } = {}, ): Promise<{ users: UserRepository; @@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => { return { users: new UserRepository(context, options.onUserWrite), - sessions: new SessionRepository(context, options.onSessionWrite), + sessions: new SessionRepository( + context, + options.onSessionWrite, + options.onSessionActivity, + ), }; } @@ -228,6 +234,46 @@ describe("UserRepository and SessionRepository", () => { ); }); + it("separates activity persistence from security-critical session writes", async () => { + let writes = 0, + activity = 0; + const repo = await createRepositories({ + onSessionWrite: () => { + writes++; + }, + onSessionActivity: () => { + activity++; + }, + }); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + await repo.sessions.create({ + id: "session-1", + userId: "user-1", + jwtToken: "token", + deviceType: "desktop", + deviceInfo: "Firefox", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: "2026-06-27T00:00:00.000Z", + lastActiveAt: "2026-06-26T00:00:00.000Z", + }); + expect(writes).toBe(1); + await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z"); + expect(activity).toBe(1); + expect(writes).toBe(1); + await repo.sessions.touch("session-1", "2026-06-26T00:01:10.000Z"); + expect(activity).toBe(1); + await repo.sessions.updateToken("session-1", "renewed"); + expect(writes).toBe(2); + await repo.sessions.revoke("session-1"); + expect(writes).toBe(3); + }); + it("revokes all user sessions except an optional current session", async () => { const repo = await createRepositories(); await repo.users.create({ diff --git a/src/backend/tests/utils/database-save-trigger.test.ts b/src/backend/tests/utils/database-save-trigger.test.ts index c33d33a9e..edf0c5d17 100644 --- a/src/backend/tests/utils/database-save-trigger.test.ts +++ b/src/backend/tests/utils/database-save-trigger.test.ts @@ -7,6 +7,22 @@ describe("DatabaseSaveTrigger", () => { DatabaseSaveTrigger.cleanup(); }); + it("batches informational touches without scheduling per-session saves", async () => { + vi.useFakeTimers(); + const save = vi.fn().mockResolvedValue(undefined); + DatabaseSaveTrigger.initialize(save); + for (let minute = 0; minute < 4; minute++) { + DatabaseSaveTrigger.markDirty(); + await vi.advanceTimersByTimeAsync(60_000); + } + expect(save).not.toHaveBeenCalled(); + expect(DatabaseSaveTrigger.isDirty).toBe(true); + expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(false); + await DatabaseSaveTrigger.forceSave("periodic_flush"); + expect(save).toHaveBeenCalledOnce(); + expect(DatabaseSaveTrigger.isDirty).toBe(false); + }); + it("force saves through the initialized save function", async () => { const save = vi.fn().mockResolvedValue(undefined); DatabaseSaveTrigger.initialize(save); diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts index 583286de1..4630b5c1c 100644 --- a/src/backend/utils/database-save-trigger.ts +++ b/src/backend/utils/database-save-trigger.ts @@ -18,6 +18,11 @@ export class DatabaseSaveTrigger { return this._dirty; } + /** Informational changes join the next periodic or critical save. */ + static markDirty(): void { + if (this.isInitialized) this._dirty = true; + } + static markClean(): void { this._dirty = false; } @@ -129,6 +134,7 @@ export class DatabaseSaveTrigger { this.pendingSave = false; this.activeSave = null; this.isInitialized = false; + this._dirty = false; this.saveFunction = null; } }