xtablo-source/apps/chat-worker/src/lib/supabase.ts
2026-04-11 12:00:25 +02:00

82 lines
2.7 KiB
TypeScript

/**
* Thin PostgREST client using fetch — no Supabase SDK dependency.
* Used by both the Worker (history queries) and the Durable Object (message persistence).
*/
export class PostgREST {
private baseUrl: string;
private serviceRoleKey: string;
constructor(supabaseUrl: string, serviceRoleKey: string) {
this.baseUrl = `${supabaseUrl}/rest/v1`;
this.serviceRoleKey = serviceRoleKey;
}
private headers(): Record<string, string> {
return {
"apikey": this.serviceRoleKey,
"Authorization": `Bearer ${this.serviceRoleKey}`,
"Content-Type": "application/json",
"Prefer": "return=representation",
};
}
/** Insert a row and return the inserted data. */
async insert<T>(table: string, data: Record<string, unknown>): Promise<T[]> {
const res = await fetch(`${this.baseUrl}/${table}`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST insert failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Upsert a row (requires Prefer: resolution=merge-duplicates). */
async upsert<T>(table: string, data: Record<string, unknown>, onConflict: string): Promise<T[]> {
const headers = this.headers();
headers["Prefer"] = "return=representation,resolution=merge-duplicates";
const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, {
method: "POST",
headers,
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST upsert failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Select rows with PostgREST query string. */
async select<T>(table: string, query: string): Promise<T[]> {
const res = await fetch(`${this.baseUrl}/${table}?${query}`, {
method: "GET",
headers: this.headers(),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST select failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Select with exact count header for unread queries. */
async count(table: string, query: string): Promise<number> {
const headers = this.headers();
headers["Prefer"] = "count=exact";
headers["Range-Unit"] = "items";
headers["Range"] = "0-0";
const res = await fetch(`${this.baseUrl}/${table}?${query}`, {
method: "HEAD",
headers,
});
const contentRange = res.headers.get("Content-Range");
if (!contentRange) return 0;
// Content-Range format: "0-0/42" or "*/0"
const total = contentRange.split("/")[1];
return total === "*" ? 0 : parseInt(total, 10);
}
}