mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25: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(
|
return new SessionRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
createCurrentRepositoryWriteHook("session_repository_write"),
|
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(
|
constructor(
|
||||||
private readonly context: DatabaseContext,
|
private readonly context: DatabaseContext,
|
||||||
private readonly onWrite?: () => void | Promise<void>,
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
private readonly onActivity:
|
||||||
|
(() => void | Promise<void>) | undefined = onWrite,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(session: NewSessionRecord): Promise<SessionRecord> {
|
async create(session: NewSessionRecord): Promise<SessionRecord> {
|
||||||
@@ -63,7 +65,7 @@ export class SessionRepository {
|
|||||||
.where(and(eq(sessions.id, id), lte(sessions.lastActiveAt, cutoff)));
|
.where(and(eq(sessions.id, id), lte(sessions.lastActiveAt, cutoff)));
|
||||||
|
|
||||||
if (rowsAffected(result) === 0) return false;
|
if (rowsAffected(result) === 0) return false;
|
||||||
await this.afterWrite();
|
await this.onActivity?.();
|
||||||
return true;
|
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
|
// getDb() throws unless a database was initialized; the context's handle is
|
||||||
// not what this file is about.
|
// not what this file is about.
|
||||||
vi.mock("../../../database/db/index.js", () => ({
|
vi.mock("../../../database/db/index.js", () => ({
|
||||||
getDb: () => ({}),
|
getDb: vi.fn(() => ({})),
|
||||||
getSqlite: () => ({}),
|
getSqlite: () => ({}),
|
||||||
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
|
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
createCurrentSessionRepository,
|
||||||
createCurrentRepositoryContext,
|
createCurrentRepositoryContext,
|
||||||
createCurrentRepositoryWriteHook,
|
createCurrentRepositoryWriteHook,
|
||||||
createCurrentRepositoryLazyWriteHook,
|
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: {
|
async function createRepositories(options: {
|
||||||
onUserWrite?: () => void | Promise<void>;
|
onUserWrite?: () => void | Promise<void>;
|
||||||
onSessionWrite?: () => void | Promise<void>;
|
onSessionWrite?: () => void | Promise<void>;
|
||||||
|
onSessionActivity?: () => void | Promise<void>;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
users: UserRepository;
|
users: UserRepository;
|
||||||
sessions: SessionRepository;
|
sessions: SessionRepository;
|
||||||
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
|
|||||||
options: {
|
options: {
|
||||||
onUserWrite?: () => void | Promise<void>;
|
onUserWrite?: () => void | Promise<void>;
|
||||||
onSessionWrite?: () => void | Promise<void>;
|
onSessionWrite?: () => void | Promise<void>;
|
||||||
|
onSessionActivity?: () => void | Promise<void>;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
users: UserRepository;
|
users: UserRepository;
|
||||||
@@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
users: new UserRepository(context, options.onUserWrite),
|
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 () => {
|
it("revokes all user sessions except an optional current session", async () => {
|
||||||
const repo = await createRepositories();
|
const repo = await createRepositories();
|
||||||
await repo.users.create({
|
await repo.users.create({
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ describe("DatabaseSaveTrigger", () => {
|
|||||||
DatabaseSaveTrigger.cleanup();
|
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 () => {
|
it("force saves through the initialized save function", async () => {
|
||||||
const save = vi.fn().mockResolvedValue(undefined);
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
DatabaseSaveTrigger.initialize(save);
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export class DatabaseSaveTrigger {
|
|||||||
return this._dirty;
|
return this._dirty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Informational changes join the next periodic or critical save. */
|
||||||
|
static markDirty(): void {
|
||||||
|
if (this.isInitialized) this._dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
static markClean(): void {
|
static markClean(): void {
|
||||||
this._dirty = false;
|
this._dirty = false;
|
||||||
}
|
}
|
||||||
@@ -129,6 +134,7 @@ export class DatabaseSaveTrigger {
|
|||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
this.activeSave = null;
|
this.activeSave = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
|
this._dirty = false;
|
||||||
this.saveFunction = null;
|
this.saveFunction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user