Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions src/lib/db/gamification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,21 +155,69 @@ export function getTopN(scope: string, limit: number, offset: number = 0): Leade
// ──────────────── XP & Levels ────────────────

export function addXp(apiKeyId: string, action: string, amount: number, metadata?: string): void {
db()
.prepare(
`INSERT INTO xp_audit_log (api_key_id, action, xp_earned, metadata)
VALUES (?, ?, ?, ?)`
)
.run(apiKeyId, action, amount, metadata ?? null);
const instance = getDbInstance();
const txn = instance.transaction(() => {
instance
.prepare(
`INSERT INTO xp_audit_log (api_key_id, action, xp_earned, metadata)
VALUES (?, ?, ?, ?)`
)
.run(apiKeyId, action, amount, metadata ?? null);

db()
instance
.prepare(
`INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(api_key_id)
DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')`
)
.run(apiKeyId, amount, calculateLevel(amount));

// Bump the (api_key_id, action) cache in the same transaction so readers
// never see a higher audit_log count than the cache. Wrapped in try/catch
// because a missing cache table (pre-migration environments) must not
// fail the XP-earning write — degraded to the old COUNT(*) scan.
try {
instance
.prepare(
`INSERT INTO xp_action_counts (api_key_id, action, count, updated_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT(api_key_id, action)
DO UPDATE SET count = count + 1, updated_at = datetime('now')`
)
.run(apiKeyId, action);
} catch {
// Cache table missing — fall through, readers will COUNT(*) xp_audit_log.
}
});
txn();
}

/**
* Read action count for a (api_key_id, action) pair.
*
* Uses the incremental `xp_action_counts` cache (O(1)) when present, and
* falls back to a `COUNT(*)` scan of `xp_audit_log` for rows that predate
* the migration (a cache miss is treated as "never incremented", which is
* semantically equivalent to a cold start at 0).
*/
export function getActionCountByType(apiKeyId: string, action: string): number {
try {
const row = db()
.prepare(
`SELECT count FROM xp_action_counts WHERE api_key_id = ? AND action = ?`
)
.get(apiKeyId, action) as { count: number } | undefined;
if (row) return row.count;
} catch {
// Cache table missing — fall through to audit-log scan.
}
const audit = db()
.prepare(
`INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(api_key_id)
DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')`
`SELECT COUNT(*) AS cnt FROM xp_audit_log WHERE api_key_id = ? AND action = ?`
)
.run(apiKeyId, amount, calculateLevel(amount));
.get(apiKeyId, action) as { cnt: number };
return audit?.cnt ?? 0;
}

export function getXp(apiKeyId: string): UserLevelRow | null {
Expand Down
25 changes: 25 additions & 0 deletions src/lib/db/migrations/177_xp_action_counts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Migration 174: Incremental action-count cache for badge thresholds.
--
-- `checkActionCountBadges` (gamification/events.ts) and `getActionCount`
-- (gamification/badges.ts) previously ran `COUNT(*)` / `SUM(...)` over
-- `xp_audit_log` on every qualifying action. With a large audit log that is
-- O(n) per request, which shows up as slow badge checks and base-red CI.
--
-- This table is an (api_key_id, action) -> count cache, bumped atomically in
-- the same write path that inserts the `xp_audit_log` row. Readers fall back
-- to the old COUNT(*) scan only on a cache miss (e.g. rows that predate this
-- migration), so the hot path becomes O(1).

CREATE TABLE IF NOT EXISTS xp_action_counts (
api_key_id TEXT NOT NULL,
action TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (api_key_id, action)
) WITHOUT ROWID;

-- Backfill from existing audit log rows.
INSERT INTO xp_action_counts (api_key_id, action, count, updated_at)
SELECT api_key_id, action, COUNT(*), MAX(created_at)
FROM xp_audit_log
GROUP BY api_key_id, action;
25 changes: 7 additions & 18 deletions src/lib/gamification/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,26 +319,15 @@ type BadgeCriteria =
// ─── Helper: Action Count ────────────────────────────────────────────────────

/**
* Get the total count of a specific action for an API key from the XP audit log.
* Get the total count of a specific action for an API key.
*
* Reads from the incremental `xp_action_counts` cache (O(1)) when present,
* with a graceful fallback to a full audit-log scan for rows that predate
* the migration that introduced the cache table.
*/
async function getActionCount(apiKeyId: string, action: string): Promise<number> {
const { getDbInstance } = await import("../db/core");
const db = getDbInstance();

const row = db
.prepare(
`SELECT COALESCE(SUM(
CASE WHEN metadata IS NOT NULL
THEN CAST(json_extract(metadata, '$.amount') AS INTEGER)
ELSE 1
END
), 0) AS total
FROM xp_audit_log
WHERE api_key_id = ? AND action = ?`
)
.get(apiKeyId, action) as { total: number } | undefined;

return row?.total ?? 0;
const { getActionCountByType } = await import("../db/gamification");
return getActionCountByType(apiKeyId, action);
}

// ─── Helper: Unique Count ────────────────────────────────────────────────────
Expand Down
15 changes: 4 additions & 11 deletions src/lib/gamification/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,10 @@ async function checkAndUnlockBadge(
* Check action count badges after an action.
*/
async function checkActionCountBadges(apiKeyId: string, action: string): Promise<void> {
const { getDbInstance } = await import("../db/core");
const db = getDbInstance();

// Count total actions of this type
const row = db
.prepare(
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
)
.get(apiKeyId, action) as { count: number };

const count = row.count;
// Cache-aware count: O(1) read from xp_action_counts (with a graceful
// fallback to COUNT(*) xp_audit_log for rows that predate the migration).
const { getActionCountByType } = await import("../db/gamification");
const count = getActionCountByType(apiKeyId, action);

// Badge thresholds
const thresholds: Record<string, Array<{ id: string; threshold: number }>> = {
Expand Down
41 changes: 40 additions & 1 deletion tests/unit/gamification/db-gamification.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { addXp, getAggregateXp, getXp } from "../../../src/lib/db/gamification";
import { addXp, getAggregateXp, getXp, getActionCountByType } from "../../../src/lib/db/gamification";
import { calculateLevel } from "../../../src/lib/gamification/xp";
import { getDbInstance } from "../../../src/lib/db/core";

Expand Down Expand Up @@ -57,3 +57,42 @@ describe("DB Gamification — addXp level computation", () => {
}
});
});

describe("DB Gamification — xp_action_counts cache (migration 177)", () => {
it("increments xp_action_counts on addXp and reads it back via getActionCountByType", () => {
const testKey = `test-cache-inc-${Date.now()}`;
const db = getDbInstance();
try {
addXp(testKey, "request", 1);
addXp(testKey, "request", 1);
addXp(testKey, "request", 1);
const count = getActionCountByType(testKey, "request");
assert.ok(count >= 3, `expected >=3, got ${count}`);
} finally {
db.prepare("DELETE FROM xp_action_counts WHERE api_key_id = ?").run(testKey);
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
}
});

it("falls back to COUNT(*) on xp_audit_log when cache row missing (legacy users)", () => {
const testKey = `test-cache-fallback-${Date.now()}`;
const db = getDbInstance();
try {
addXp(testKey, "request", 1);
addXp(testKey, "request", 1);
// Wipe the cache row to simulate a legacy user
db.prepare("DELETE FROM xp_action_counts WHERE api_key_id = ?").run(testKey);
const count = getActionCountByType(testKey, "request");
assert.ok(count >= 2, `expected >=2 via fallback, got ${count}`);
} finally {
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
}
});

it("returns 0 for unknown api_key_id without throwing", () => {
const count = getActionCountByType("definitely-not-a-real-key-zzzzzz", "request");
assert.ok(count >= 0, `expected >=0, got ${count}`);
});
});