data layer

This commit is contained in:
2026-05-25 08:38:26 +07:00
parent 4e8c11d545
commit a428170fef
81 changed files with 3941 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
// Thin fetch wrapper for the analytics API. Throws ApiError on non-2xx.
export class ApiError extends Error {
status: number;
field?: string;
constructor(status: number, message: string, field?: string) {
super(message);
this.status = status;
this.field = field;
}
}
const BASE = import.meta.env.VITE_ANALYTICS_BASE_URL ?? '/api/analytics';
async function request<T>(path: string, workspaceID: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
'content-type': 'application/json',
'X-Workspace-Id': workspaceID,
...(init?.headers ?? {}),
},
});
const text = await res.text();
const data = text ? safeJSON(text) : undefined;
if (!res.ok) {
const msg = (data as { error?: string })?.error ?? res.statusText;
const field = (data as { field?: string })?.field;
throw new ApiError(res.status, msg, field);
}
return data as T;
}
function safeJSON(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
// ---------------------------------------------------------------------------
// Common shapes
// ---------------------------------------------------------------------------
export interface QueryResult {
columns: string[];
rows: unknown[][];
row_count: number;
duration_ms: number;
cache_hit: boolean;
meta?: Record<string, unknown>;
}
export interface Profile {
id: string;
workspace_id: string;
user_id?: string;
anonymous_ids?: string[];
traits?: Record<string, unknown>;
first_seen_at: string;
last_seen_at: string;
}
export interface SavedQuery {
id: string;
workspace_id: string;
owner_id?: string;
name: string;
kind: 'events' | 'sql' | 'funnel' | 'retention' | 'session';
spec: Record<string, unknown>;
created_at: string;
updated_at: string;
}
// ---------------------------------------------------------------------------
// Endpoints (current workspace passed by each caller)
// ---------------------------------------------------------------------------
export const analytics = (workspaceID: string) => ({
health: () => request<{ status: string }>('/health', workspaceID),
ready: () => request<{ status: string }>('/ready', workspaceID),
queryEvents: (body: {
table: 'events_track' | 'events_identify' | 'events_page' | 'events_group';
from: string; to: string;
user_id?: string; anonymous_id?: string; event?: string;
limit?: number; offset?: number;
}) => request<QueryResult>('/query/events', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
querySQL: (body: { sql: string }) =>
request<QueryResult>('/query/sql', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
queryFunnel: (body: { steps: string[]; from: string; to: string; window_seconds: number }) =>
request<QueryResult>('/query/funnel', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
queryRetention: (body: {
initial_event: string; return_event: string;
from: string; to: string; periods?: number;
}) => request<QueryResult>('/query/retention', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
querySession: (body: {
from: string; to: string;
timeout_seconds?: number; user_id?: string;
limit?: number; offset?: number;
}) => request<QueryResult>('/query/session', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
getProfile: (id: string) =>
request<Profile>(`/profiles/${id}`, workspaceID),
getProfileTimeline: (id: string, limit = 100, offset = 0) =>
request<QueryResult>(`/profiles/${id}/events?limit=${limit}&offset=${offset}`, workspaceID),
listSavedQueries: () =>
request<{ items: SavedQuery[]; limit: number; offset: number }>('/queries', workspaceID),
createSavedQuery: (body: { name: string; kind: SavedQuery['kind']; spec: Record<string, unknown> }) =>
request<SavedQuery>('/queries', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
deleteSavedQuery: (id: string) =>
request<void>(`/queries/${id}`, workspaceID, { method: 'DELETE' }),
});