fix(database): batch session activity persistence (#1539)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-03 21:01:30 +08:00
1 parent dc5d57c012
commit c78bc37eb2
6 files changed
+110 -3

No files matched your search

@@ -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,
);
}
@@ -13,6 +13,8 @@ export class SessionRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
private readonly onActivity:
(() => void | Promise<void>) | undefined = onWrite,
) {}
async create(session: NewSessionRecord): Promise<SessionRecord> {
@@ -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;
}
@@ -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<typeof getDb>,
);
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;
}
});
@@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => {
async function createRepositories(options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
}): Promise<{
users: UserRepository;
sessions: SessionRepository;
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
} = {},
): 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({
@@ -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);
@@ -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;
}
}