mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
fix(database): batch session activity persistence (#1539)
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user