Skip to content

Commit 5d19df0

Browse files
committed
Cache events count
1 parent fa5646c commit 5d19df0

9 files changed

Lines changed: 722 additions & 17 deletions

File tree

apps/server/routes/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ export const authRoutes = new Hono()
298298
const session = await getSessionFromRequest(c.req.raw);
299299
if (!session) return c.json({ error: "Unauthorized" }, 401);
300300
const limits = await import("../limits").then(m => m.getUserLimits(session.user.id));
301-
const used = getEventCount(session.user.id);
301+
const used = await getEventCount(session.user.id);
302302
return c.json({
303303
eventsUsed: used,
304304
eventsLimit: limits.eventsPerMonth,

apps/server/routes/events.ts

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,96 @@
11
import { Hono } from "hono";
22
import { cors } from "hono/cors";
3+
import { and, eq } from "drizzle-orm";
4+
import { db } from "@livedot/db";
5+
import { websiteUsage } from "@livedot/db/schema";
6+
import { createLogger } from "@livedot/logger";
37
import { websiteCache } from "../website-cache";
48
import { getServer } from "../index";
59
import { resolveGeo } from "../geo";
6-
import { upsertSession, recordCustomEvent } from "../sessions";
10+
import { upsertSession, recordCustomEvent, store } from "../sessions";
711
import { env } from "../env";
812

9-
// --- Monthly event counter: userId:YYYY-MM → count ---
10-
const monthlyEventCount = new Map<string, number>();
13+
const log = createLogger("events");
1114

1215
function currentMonth() {
1316
const now = new Date();
1417
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
1518
}
1619

17-
function userMonthKey(userId: string) {
18-
return `${userId}:${currentMonth()}`;
20+
function userMonthKey(userId: string) { return `user:${userId}:${currentMonth()}`; }
21+
function websiteMonthKey(websiteId: string) { return `website:${websiteId}:${currentMonth()}`; }
22+
23+
async function incrementEventCount(userId: string, websiteId: string): Promise<number> {
24+
const [uCount] = await Promise.all([
25+
store.incrementCounter(userMonthKey(userId)),
26+
store.incrementCounter(websiteMonthKey(websiteId)),
27+
]);
28+
return uCount;
1929
}
2030

21-
function incrementEventCount(userId: string): number {
22-
const key = userMonthKey(userId);
23-
const count = (monthlyEventCount.get(key) ?? 0) + 1;
24-
monthlyEventCount.set(key, count);
25-
return count;
31+
export async function getEventCount(userId: string): Promise<number> {
32+
return store.getCounter(userMonthKey(userId));
2633
}
2734

28-
export function getEventCount(userId: string): number {
29-
return monthlyEventCount.get(userMonthKey(userId)) ?? 0;
35+
// On startup: seed store from DB for current month (only needed for MemoryStore)
36+
async function seedFromDB() {
37+
try {
38+
const now = new Date();
39+
const rows = await db
40+
.select()
41+
.from(websiteUsage)
42+
.where(and(eq(websiteUsage.year, now.getFullYear()), eq(websiteUsage.month, now.getMonth() + 1)));
43+
44+
// Only seed if store has no data yet (avoids double-counting on Redis)
45+
for (const row of rows) {
46+
const wKey = websiteMonthKey(row.websiteId);
47+
const existing = await store.getCounter(wKey);
48+
if (existing === 0) {
49+
// MemoryStore: set directly; RedisStore: SET NX
50+
if ("setCounter" in store) {
51+
(store as any).setCounter(wKey, row.eventCount);
52+
(store as any).setCounter(userMonthKey(row.userId),
53+
(await store.getCounter(userMonthKey(row.userId))) + row.eventCount);
54+
}
55+
}
56+
}
57+
log.info({ rows: rows.length }, "Seeded event counts from DB");
58+
} catch (err) {
59+
log.error(err, "Failed to seed event counts from DB");
60+
}
3061
}
3162

63+
// Flush store counts to DB every 60s for analytics
64+
async function flushCountsToDB() {
65+
const now = new Date();
66+
const year = now.getFullYear();
67+
const month = now.getMonth() + 1;
68+
const counts = await store.getCountersByPattern(`website:`)
69+
.then(m => new Map([...m].filter(([k]) => k.includes(`:${currentMonth()}`))));
70+
71+
for (const [key, count] of counts) {
72+
// key: website:<websiteId>:<YYYY-MM>
73+
const parts = key.split(":");
74+
const websiteId = parts[1];
75+
if (!websiteId) continue;
76+
const cached = websiteCache.get(websiteId);
77+
if (!cached) continue;
78+
try {
79+
await db.insert(websiteUsage)
80+
.values({ websiteId, userId: cached.userId, year, month, eventCount: count, updatedAt: new Date() })
81+
.onConflictDoUpdate({
82+
target: [websiteUsage.websiteId, websiteUsage.year, websiteUsage.month],
83+
set: { eventCount: count, updatedAt: new Date() },
84+
});
85+
} catch (err) {
86+
log.error(err, "Failed to flush event count");
87+
}
88+
}
89+
}
90+
91+
seedFromDB();
92+
setInterval(() => { flushCountsToDB().catch(() => {}); }, 60_000);
93+
3294
// --- Rate limiting: max 1 request per 3s per IP+websiteId ---
3395
const rateMap = new Map<string, number>();
3496

@@ -103,10 +165,10 @@ export const eventRoutes = new Hono()
103165

104166
// Named events (data-livedot-event clicks): store + publish, no rate limit, no geo
105167
if (typeof eventName === "string" && eventName) {
106-
if (cached.eventsPerMonth > 0 && getEventCount(cached.userId) >= cached.eventsPerMonth) {
168+
if (cached.eventsPerMonth > 0 && await getEventCount(cached.userId) >= cached.eventsPerMonth) {
107169
return c.body(null, 204);
108170
}
109-
incrementEventCount(cached.userId);
171+
await incrementEventCount(cached.userId, websiteId);
110172
const timestamp = Date.now();
111173
recordCustomEvent(websiteId, { type: "event", sessionId, eventName, pageUrl: url || "", timestamp });
112174
const msg: import("@livedot/shared").WSMessage = {
@@ -141,10 +203,10 @@ export const eventRoutes = new Hono()
141203

142204
if (geo) {
143205
// Enforce monthly event limit
144-
if (cached.eventsPerMonth > 0 && getEventCount(cached.userId) >= cached.eventsPerMonth) {
206+
if (cached.eventsPerMonth > 0 && await getEventCount(cached.userId) >= cached.eventsPerMonth) {
145207
return c.body(null, 204); // silently drop
146208
}
147-
incrementEventCount(cached.userId);
209+
await incrementEventCount(cached.userId, websiteId);
148210
upsertSession(
149211
{
150212
sessionId,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
CREATE TABLE `website_usage` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`website_id` text NOT NULL,
4+
`user_id` text NOT NULL,
5+
`year` integer NOT NULL,
6+
`month` integer NOT NULL,
7+
`event_count` integer DEFAULT 0 NOT NULL,
8+
`updated_at` integer NOT NULL,
9+
FOREIGN KEY (`website_id`) REFERENCES `websites`(`id`) ON UPDATE no action ON DELETE cascade,
10+
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
11+
);
12+
--> statement-breakpoint
13+
CREATE UNIQUE INDEX `website_usage_period_uniq` ON `website_usage` (`website_id`,`year`,`month`);

0 commit comments

Comments
 (0)