Skip to content

Commit 9506f78

Browse files
committed
Add durable object analytics result cache
1 parent 2e9c68c commit 9506f78

6 files changed

Lines changed: 486 additions & 5 deletions

File tree

.beads/issues.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{"id":"kit-3d5","title":"Add persistent backend for Durable Object analytics cache","description":"Follow up on the new in-memory historical analytics cache in SiteDurableObject by wiring the persistence hook to a durable backend such as Workers KV or another shared store. Scope should include key versioning, invalidation strategy for historical backfills, and metrics for cache hit rate and payload size.","status":"open","priority":2,"issue_type":"task","owner":"j.riche64@gmail.com","created_at":"2026-03-06T23:21:42.463121464-05:00","created_by":"JonathanRiche","updated_at":"2026-03-06T23:21:42.463121464-05:00"}
12
{"id":"kit-69r","title":"Enhance Tag SDK - Capture","description":"Enhance the naming of tag capture of custom data captrue right now we use emit lytx.capture would be better where the shape would be sometihg along the lines of lytx.capture(\n 'event_name',\n { property1: 'value', property2: 'another value' }\n);","status":"closed","priority":2,"issue_type":"task","owner":"j.riche64@gmail.com","created_at":"2026-02-18T22:44:46.951997277-05:00","created_by":"JonathanRiche","updated_at":"2026-02-18T22:44:58.220367312-05:00","closed_at":"2026-02-18T22:44:58.220367312-05:00","close_reason":"Closed"}
23
{"id":"kit-a4u","title":"OSS readiness for @lytx/core Cloudflare self-hosting","description":"Track all work required to make @lytx/core a stable open-source library for user-managed Cloudflare deployments via Alchemy.","acceptance_criteria":"Public API surface and deployment workflow are documented, tested, and versioned for external users.","status":"closed","priority":0,"issue_type":"epic","owner":"j.riche64@gmail.com","created_at":"2026-02-17T23:57:57.186321933-05:00","created_by":"JonathanRiche","updated_at":"2026-02-18T00:50:04.90278984-05:00","closed_at":"2026-02-18T00:50:04.90278984-05:00","close_reason":"OSS readiness deliverables completed for @lytx/core","labels":["alchemy","core","oss","self-host"],"comments":[{"id":13,"issue_id":"kit-a4u","author":"JonathanRiche","text":"Epic completion summary: documented OSS contract boundaries; added canonical createLytxApp factory and typed startup validation; implemented modular feature toggles; added deterministic resource naming strategy and domain/route-prefix configuration; shipped copy-ready consumer starter template in demo; published self-host quickstart, release policy/compatibility matrix, and migration guide; added CI OSS smoke workflow for baseline/customized profiles; and added OSS governance docs/templates (CONTRIBUTING, SECURITY, issue/PR templates).","created_at":"2026-02-18T05:50:04Z"}]}
34
{"id":"kit-a4u.1","title":"Define OSS contract for @lytx/core","description":"Define what is public, what is internal, and which customization points are officially supported for consumers.","acceptance_criteria":"A published contract lists supported exports, extension points, and deprecation policy.","status":"closed","priority":0,"issue_type":"task","owner":"j.riche64@gmail.com","created_at":"2026-02-17T23:57:57.213612672-05:00","created_by":"JonathanRiche","updated_at":"2026-02-18T00:08:39.230379359-05:00","closed_at":"2026-02-18T00:08:39.230379359-05:00","close_reason":"OSS contract documented and linked","labels":["api","core","oss"],"dependencies":[{"issue_id":"kit-a4u.1","depends_on_id":"kit-a4u","type":"parent-child","created_at":"2026-02-17T23:57:57.214860566-05:00","created_by":"JonathanRiche"}],"comments":[{"id":1,"issue_id":"kit-a4u.1","author":"JonathanRiche","text":"Implemented OSS contract docs for @lytx/core in core/docs/oss-contract.md and linked it from core/README.md. Contract now explicitly classifies stable root exports, experimental public subpaths (@lytx/core/worker and @lytx/core/db/durable/siteDurableObject), and unsupported internal/deep imports. Added supported extension points (feature flags, binding/resource naming, domain/env expectations) plus semver/deprecation policy with migration guidance. Updated demo/alchemy.run.ts to use root public entrypoint for SiteDurableObject type import. Follow-up gaps intentionally documented: add metadata deprecation notice/removal target for legacy subpath export, add CI guardrails for deep imports in docs/examples, and decide long-term status of @lytx/core/worker.","created_at":"2026-02-18T05:08:39Z"}]}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//! Helpers for caching historical analytics results inside a site Durable Object.
2+
3+
const ONE_MINUTE_MS = 60 * 1000;
4+
const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
5+
const DEFAULT_MAX_ENTRIES = 128;
6+
const ANALYTICS_RESULT_CACHE_VERSION = 1;
7+
8+
export type AnalyticsResultCacheKind = "dashboard-aggregates" | "event-summary";
9+
10+
export type AnalyticsResultCacheEntry<T> = {
11+
expiresAt: number;
12+
value: T;
13+
};
14+
15+
export interface AnalyticsResultCachePersistence {
16+
get<T>(
17+
kind: AnalyticsResultCacheKind,
18+
key: string,
19+
): Promise<AnalyticsResultCacheEntry<T> | null>;
20+
set<T>(
21+
kind: AnalyticsResultCacheKind,
22+
key: string,
23+
entry: AnalyticsResultCacheEntry<T>,
24+
): Promise<void>;
25+
}
26+
27+
type HistoricalCacheWindow = {
28+
endDate?: Date;
29+
timezone?: string | null;
30+
now?: Date;
31+
};
32+
33+
type AnalyticsCacheKeyPartValue =
34+
| string
35+
| number
36+
| boolean
37+
| null
38+
| undefined
39+
| Record<string, unknown>;
40+
41+
const noopAnalyticsResultCachePersistence: AnalyticsResultCachePersistence = {
42+
async get() {
43+
return null;
44+
},
45+
async set() {},
46+
};
47+
48+
function normalizeTimeZone(timezone?: string | null): string {
49+
if (typeof timezone !== "string") return "UTC";
50+
const trimmed = timezone.trim();
51+
if (!trimmed) return "UTC";
52+
53+
try {
54+
Intl.DateTimeFormat(undefined, { timeZone: trimmed });
55+
return trimmed;
56+
} catch {
57+
return "UTC";
58+
}
59+
}
60+
61+
function getDateBucketInTimeZone(date: Date, timezone?: string | null): string {
62+
const formatter = new Intl.DateTimeFormat("en-CA", {
63+
timeZone: normalizeTimeZone(timezone),
64+
year: "numeric",
65+
month: "2-digit",
66+
day: "2-digit",
67+
});
68+
69+
const parts = formatter.formatToParts(date);
70+
const year = parts.find((part) => part.type === "year")?.value;
71+
const month = parts.find((part) => part.type === "month")?.value;
72+
const day = parts.find((part) => part.type === "day")?.value;
73+
74+
if (!year || !month || !day) {
75+
return date.toISOString().slice(0, 10);
76+
}
77+
78+
return `${year}-${month}-${day}`;
79+
}
80+
81+
function parseDateBucket(bucket: string): number {
82+
const [year, month, day] = bucket.split("-").map((value) => Number(value));
83+
return Date.UTC(year, month - 1, day);
84+
}
85+
86+
function normalizeKeyPart(value: AnalyticsCacheKeyPartValue): unknown {
87+
if (value instanceof Date) {
88+
return value.toISOString();
89+
}
90+
if (Array.isArray(value)) {
91+
return value.map((item) => normalizeKeyPart(item as AnalyticsCacheKeyPartValue));
92+
}
93+
if (value && typeof value === "object") {
94+
const entries = Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right));
95+
return Object.fromEntries(
96+
entries.map(([key, innerValue]) => [key, normalizeKeyPart(innerValue as AnalyticsCacheKeyPartValue)]),
97+
);
98+
}
99+
return value ?? null;
100+
}
101+
102+
export function isHistoricalAnalyticsRange({
103+
endDate,
104+
timezone,
105+
now = new Date(),
106+
}: HistoricalCacheWindow): boolean {
107+
if (!endDate) return false;
108+
const todayBucket = getDateBucketInTimeZone(now, timezone);
109+
const endBucket = getDateBucketInTimeZone(endDate, timezone);
110+
return endBucket < todayBucket;
111+
}
112+
113+
export function getHistoricalAnalyticsCacheTtlMs(input: HistoricalCacheWindow): number {
114+
if (!isHistoricalAnalyticsRange(input)) return 0;
115+
116+
const todayBucket = getDateBucketInTimeZone(input.now ?? new Date(), input.timezone);
117+
const endBucket = getDateBucketInTimeZone(input.endDate!, input.timezone);
118+
const diffDays = Math.max(
119+
0,
120+
Math.round((parseDateBucket(todayBucket) - parseDateBucket(endBucket)) / (24 * ONE_HOUR_MS)),
121+
);
122+
123+
if (diffDays <= 1) return 5 * ONE_MINUTE_MS;
124+
if (diffDays <= 7) return 15 * ONE_MINUTE_MS;
125+
return ONE_HOUR_MS;
126+
}
127+
128+
export function buildAnalyticsResultCacheKey(
129+
kind: AnalyticsResultCacheKind,
130+
parts: Record<string, AnalyticsCacheKeyPartValue>,
131+
): string {
132+
return JSON.stringify({
133+
version: ANALYTICS_RESULT_CACHE_VERSION,
134+
kind,
135+
parts: normalizeKeyPart(parts),
136+
});
137+
}
138+
139+
export function createAnalyticsResultCachePersistence(_env: unknown): AnalyticsResultCachePersistence {
140+
return noopAnalyticsResultCachePersistence;
141+
}
142+
143+
export class HistoricalAnalyticsResultMemoryCache {
144+
private readonly entries = new Map<string, AnalyticsResultCacheEntry<unknown>>();
145+
146+
constructor(private readonly maxEntries: number = DEFAULT_MAX_ENTRIES) {}
147+
148+
get<T>(key: string, now = Date.now()): AnalyticsResultCacheEntry<T> | null {
149+
const entry = this.entries.get(key);
150+
if (!entry) return null;
151+
152+
if (entry.expiresAt <= now) {
153+
this.entries.delete(key);
154+
return null;
155+
}
156+
157+
// Reinsert on hit to keep frequently used entries hot.
158+
this.entries.delete(key);
159+
this.entries.set(key, entry);
160+
161+
return entry as AnalyticsResultCacheEntry<T>;
162+
}
163+
164+
set<T>(key: string, entry: AnalyticsResultCacheEntry<T>) {
165+
if (entry.expiresAt <= Date.now()) return;
166+
167+
this.entries.delete(key);
168+
this.entries.set(key, entry as AnalyticsResultCacheEntry<unknown>);
169+
this.trimToMaxEntries();
170+
}
171+
172+
clear() {
173+
this.entries.clear();
174+
}
175+
176+
private trimToMaxEntries() {
177+
while (this.entries.size > this.maxEntries) {
178+
const oldestKey = this.entries.keys().next().value;
179+
if (!oldestKey) return;
180+
this.entries.delete(oldestKey);
181+
}
182+
}
183+
}

core/db/durable/durableObjectClient.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ export async function getMetricsFromDurableObject(
419419

420420
export async function getEventSummaryFromDurableObject(
421421
options: DashboardOptions & {
422+
timezone?: string;
422423
limit?: number;
423424
offset?: number;
424425
search?: string;
@@ -442,6 +443,7 @@ export async function getEventSummaryFromDurableObject(
442443
startDate: options.date?.start,
443444
endDate: options.date?.end,
444445
endDateIsExact: options.date?.endIsExact,
446+
timezone: options.timezone,
445447
limit: options.limit,
446448
offset: options.offset,
447449
search: options.search,

0 commit comments

Comments
 (0)