384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
/**
|
|
* Compatibility shim that preserves the historical Supabase client
|
|
* query-builder API surface (`.from().select()...`) for the legacy
|
|
* public-storefront and admin pages that still call into it. The SaaS
|
|
* rebuild no longer uses Supabase as a backend — server actions and
|
|
* API routes connect directly to Postgres via `pg` / `withDb` from
|
|
* `@/db/client`. This shim exists so the build keeps passing while
|
|
* the remaining legacy call sites are migrated to Drizzle / raw `pg`
|
|
* queries.
|
|
*
|
|
* IMPORTANT: This shim does NOT talk to a real database. It returns
|
|
* empty result sets. Legacy call sites that need real data must be
|
|
* rewritten against `pool` / `withDb` / `withBrand`.
|
|
*
|
|
* The query-builder API surface supported here is intentionally narrow:
|
|
* - .from(table).select(cols?).eq(col, val).eq(...).is(col, null).
|
|
* order(col, opts?).limit(n).range(min, max).single() → returns
|
|
* `{ data, error }` like the Supabase client did.
|
|
* - .from(table).insert(payload).select().single() for legacy inserts
|
|
* - .from(table).upsert(payload)
|
|
* - .from(table).update(payload).eq(col, val)
|
|
* - .from(table).delete().eq(col, val)
|
|
* - .rpc(fnName, params) — returns `{ data, error }` (data is null)
|
|
* - .auth.{getSession, getUser, signInWithPassword, signOut,
|
|
* updateUser, onAuthStateChange} — all return null / no-ops
|
|
* - .storage.from(bucket).{upload, download, remove, list}
|
|
* - .channel().on().subscribe()
|
|
*
|
|
* If a call site needs more than that, migrate the call site.
|
|
*/
|
|
|
|
type FilterOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "is" | "like" | "ilike" | "in";
|
|
type Filter = { column: string; value: unknown; op: FilterOp };
|
|
|
|
// Result types for the mock query builder
|
|
interface MockQueryResult<T> {
|
|
data: T[] | null;
|
|
error: null;
|
|
}
|
|
|
|
interface MockSingleResult<T> {
|
|
data: T | null;
|
|
error: null;
|
|
}
|
|
|
|
class MockQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
private tableName: string;
|
|
private filters: Filter[] = [];
|
|
private selectColumns: string = "*";
|
|
private orderColumn?: string;
|
|
private orderDirection?: "asc" | "desc";
|
|
private limitValue?: number;
|
|
private rangeMin?: number;
|
|
private rangeMax?: number;
|
|
private mode: "select" | "update" | "delete" = "select";
|
|
private mutationData: Record<string, unknown> | null = null;
|
|
|
|
constructor(tableName: string) {
|
|
this.tableName = tableName;
|
|
// No mock data - return empty results
|
|
}
|
|
|
|
select(columns: string = "*", _opts?: Record<string, unknown>) {
|
|
this.selectColumns = columns;
|
|
return this;
|
|
}
|
|
|
|
eq(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "eq" });
|
|
return this;
|
|
}
|
|
|
|
neq(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "neq" });
|
|
return this;
|
|
}
|
|
|
|
gt(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "gt" });
|
|
return this;
|
|
}
|
|
|
|
gte(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "gte" });
|
|
return this;
|
|
}
|
|
|
|
lt(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "lt" });
|
|
return this;
|
|
}
|
|
|
|
lte(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "lte" });
|
|
return this;
|
|
}
|
|
|
|
is(column: string, value: unknown) {
|
|
this.filters.push({ column, value, op: "is" });
|
|
return this;
|
|
}
|
|
|
|
like(column: string, value: string) {
|
|
this.filters.push({ column, value, op: "like" });
|
|
return this;
|
|
}
|
|
|
|
ilike(column: string, value: string) {
|
|
this.filters.push({ column, value, op: "ilike" });
|
|
return this;
|
|
}
|
|
|
|
in(column: string, values: unknown[]) {
|
|
this.filters.push({ column, value: values, op: "in" });
|
|
return this;
|
|
}
|
|
|
|
order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) {
|
|
this.orderColumn = column;
|
|
this.orderDirection = options?.ascending === false ? "desc" : "asc";
|
|
return this;
|
|
}
|
|
|
|
limit(count: number) {
|
|
this.limitValue = count;
|
|
return this;
|
|
}
|
|
|
|
range(min: number, max: number) {
|
|
this.rangeMin = min;
|
|
this.rangeMax = max;
|
|
return this;
|
|
}
|
|
|
|
// The legacy Supabase client returns a thenable from .single() so
|
|
// callers can write `.single().then(({ data, error }) => ...)` as
|
|
// well as `const { data, error } = await ....single()`. We return a
|
|
// proper Promise<MockSingleResult<T>> so destructured binding
|
|
// patterns in callers work under `--strict` (no implicit `any`).
|
|
single(): Promise<MockSingleResult<T>> {
|
|
return Promise.resolve(this.executeSingle());
|
|
}
|
|
|
|
maybeSingle(): Promise<MockSingleResult<T>> {
|
|
return Promise.resolve(this.executeSingle());
|
|
}
|
|
|
|
then<TResult1 = MockQueryResult<T>, TResult2 = never>(
|
|
onfulfilled?: ((value: MockQueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
): PromiseLike<TResult1 | TResult2> {
|
|
const result = this.execute();
|
|
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
|
|
}
|
|
|
|
// Insert / update / delete mutators — these are mostly used by legacy
|
|
// auth flows and the AI preferences action. We capture the data and
|
|
// short-circuit to a successful no-op response so the call sites
|
|
// don't blow up. Real writes must go through server actions.
|
|
executeSingle(): MockSingleResult<T> {
|
|
const result = this.execute();
|
|
if (Array.isArray(result.data) && result.data.length > 0) {
|
|
return { data: result.data[0] as T, error: null };
|
|
}
|
|
return { data: null, error: null };
|
|
}
|
|
|
|
execute(): MockQueryResult<T> {
|
|
if (this.mode === "update" || this.mode === "delete") {
|
|
return this.runMutation();
|
|
}
|
|
// No mock data - return empty array
|
|
let filtered: unknown[] = [];
|
|
|
|
for (const filter of this.filters) {
|
|
const inSet = filter.op === "in" && Array.isArray(filter.value)
|
|
? new Set(filter.value as unknown[])
|
|
: null;
|
|
const likeNeedle = filter.op === "like" && typeof filter.value === "string"
|
|
? (filter.value as string).replace(/%/g, "")
|
|
: null;
|
|
const ilikeNeedle = filter.op === "ilike" && typeof filter.value === "string"
|
|
? (filter.value as string).replace(/%/g, "").toLowerCase()
|
|
: null;
|
|
filtered = filtered.filter((row) => {
|
|
const r = row as Record<string, unknown>;
|
|
const rowValue = r[filter.column];
|
|
switch (filter.op) {
|
|
case "eq":
|
|
return rowValue === filter.value;
|
|
case "neq":
|
|
return rowValue !== filter.value;
|
|
case "gt":
|
|
return (rowValue as number) > (filter.value as number);
|
|
case "gte":
|
|
return (rowValue as number) >= (filter.value as number);
|
|
case "lt":
|
|
return (rowValue as number) < (filter.value as number);
|
|
case "lte":
|
|
return (rowValue as number) <= (filter.value as number);
|
|
case "is":
|
|
if (filter.value === null) return rowValue === null;
|
|
if (filter.value === undefined) return rowValue === undefined;
|
|
return rowValue === filter.value;
|
|
case "like":
|
|
return (
|
|
typeof rowValue === "string" &&
|
|
likeNeedle !== null &&
|
|
likeNeedle.length > 0 &&
|
|
rowValue.includes(likeNeedle)
|
|
);
|
|
case "ilike":
|
|
return (
|
|
typeof rowValue === "string" &&
|
|
ilikeNeedle !== null &&
|
|
ilikeNeedle.length > 0 &&
|
|
rowValue.toLowerCase().includes(ilikeNeedle)
|
|
);
|
|
case "in":
|
|
return inSet !== null && inSet.has(rowValue);
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (this.orderColumn) {
|
|
filtered.sort((a: unknown, b: unknown) => {
|
|
const aVal = (a as Record<string, unknown>)[this.orderColumn!] as string | number;
|
|
const bVal = (b as Record<string, unknown>)[this.orderColumn!] as string | number;
|
|
if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
|
|
if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
if (this.rangeMin !== undefined && this.rangeMax !== undefined) {
|
|
filtered = filtered.slice(this.rangeMin, this.rangeMax + 1);
|
|
} else if (this.limitValue !== undefined) {
|
|
filtered = filtered.slice(0, this.limitValue);
|
|
}
|
|
|
|
return { data: filtered as T[], error: null };
|
|
}
|
|
|
|
private runMutation(): MockQueryResult<T> {
|
|
if (this.mode === "delete") {
|
|
return { data: null, error: null };
|
|
}
|
|
if (this.mutationData) {
|
|
const items = [this.mutationData];
|
|
const returning = items.map((item, i) => ({
|
|
...item,
|
|
id: (item as Record<string, unknown>).id ?? `generated-${Date.now()}-${i}`,
|
|
}));
|
|
return { data: returning as unknown as T[], error: null };
|
|
}
|
|
return { data: null, error: null };
|
|
}
|
|
}
|
|
|
|
interface MockMutationResult<T> {
|
|
data: T[] | null;
|
|
error: null;
|
|
}
|
|
|
|
class MockMutationBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
private tableName: string;
|
|
private data: unknown;
|
|
|
|
constructor(tableName: string, data: unknown) {
|
|
this.tableName = tableName;
|
|
this.data = data;
|
|
}
|
|
|
|
select() {
|
|
return new MockQueryBuilder<T>(this.tableName);
|
|
}
|
|
|
|
eq() {
|
|
return this;
|
|
}
|
|
|
|
then<TResult1 = MockMutationResult<T>, TResult2 = never>(
|
|
onfulfilled?: ((value: MockMutationResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
): PromiseLike<TResult1 | TResult2> {
|
|
const result: MockMutationResult<T> = {
|
|
data: Array.isArray(this.data)
|
|
? this.data.map((item: Record<string, unknown>, i: number) => ({
|
|
...item,
|
|
id: item?.id ?? `generated-${Date.now()}-${i}`,
|
|
})) as unknown as T[]
|
|
: this.data
|
|
? [{ ...(this.data as Record<string, unknown>), id: (this.data as Record<string, unknown>).id ?? `generated-${Date.now()}` }] as unknown as T[]
|
|
: null,
|
|
error: null,
|
|
};
|
|
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
|
|
}
|
|
}
|
|
|
|
class MockDeleteBuilder {
|
|
eq(_column: string, _value: unknown) {
|
|
return this;
|
|
}
|
|
in(_column: string, _values: unknown[]) {
|
|
return this;
|
|
}
|
|
then(resolve: (value: unknown) => void) {
|
|
resolve({ data: null, error: null });
|
|
}
|
|
}
|
|
|
|
class MockStorageBuilder {
|
|
from(_bucket: string) {
|
|
return {
|
|
upload: async (_path: string, _file: unknown) => ({ data: { path: _path }, error: null }),
|
|
download: async (_path: string) => ({ data: new Blob(), error: null }),
|
|
remove: async (_paths: string[]) => ({ data: { paths: _paths }, error: null }),
|
|
list: async () => ({ data: [], error: null }),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Auth credential types
|
|
interface SignInCredentials {
|
|
email?: string;
|
|
password?: string;
|
|
}
|
|
|
|
interface UserAttributes {
|
|
email?: string;
|
|
data?: Record<string, unknown>;
|
|
}
|
|
|
|
function createMockClient() {
|
|
// The query builder is mutable, so we can't simply return a class
|
|
// instance + spread mutation methods. The cleanest way to support
|
|
// both `.from(t).select(...)...` (read) and `.from(t).update(d).eq(...)`
|
|
// (write) in a single chain is to delegate everything to one object
|
|
// and inspect `this.mode` lazily. We build that object via
|
|
// `Object.assign` to keep the TypeScript inference happy.
|
|
function makeFrom<T extends Record<string, unknown> = Record<string, unknown>>(table: string) {
|
|
const qb = new MockQueryBuilder<T>(table);
|
|
return Object.assign(qb, {
|
|
insert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
|
update: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
|
upsert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
|
delete: () => new MockDeleteBuilder(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
from: makeFrom,
|
|
storage: new MockStorageBuilder(),
|
|
auth: {
|
|
getSession: async () => ({ data: { session: null }, error: null }),
|
|
getUser: async () => ({ data: { user: null }, error: null }),
|
|
signInWithPassword: async (_creds: SignInCredentials) => ({
|
|
data: { user: null, session: null },
|
|
error: null,
|
|
}),
|
|
signOut: async () => ({ error: null }),
|
|
updateUser: async (_attrs: UserAttributes): Promise<{ data: { user: null }; error: null }> => ({
|
|
data: { user: null },
|
|
error: null,
|
|
}),
|
|
onAuthStateChange: () => ({
|
|
data: { subscription: { unsubscribe: () => {} } },
|
|
}),
|
|
},
|
|
rpc: async (_name: string, _params?: Record<string, unknown>): Promise<{ data: null; error: null }> => ({
|
|
data: null,
|
|
error: null,
|
|
}),
|
|
channel: () => ({
|
|
on: () => ({ subscribe: () => ({}) }),
|
|
subscribe: () => ({}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
export const supabase = createMockClient();
|