feat(staging): switch to local postgres
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
|
||||
type LocalIdentity = Readonly<{ id: string; email: string | null }> | null;
|
||||
export type LocalDatabaseRole = "authenticated" | "service_role";
|
||||
type PostgresError = Error & { code?: string };
|
||||
type QueryError = Readonly<{ message: string; code?: string }>;
|
||||
type QueryResult = Readonly<{
|
||||
data: unknown;
|
||||
error: QueryError | null;
|
||||
count?: number | null;
|
||||
}>;
|
||||
|
||||
type Filter =
|
||||
| Readonly<{ kind: "eq"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "in"; column: string; value: readonly unknown[] }>
|
||||
| Readonly<{ kind: "is"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>;
|
||||
|
||||
type Mutation =
|
||||
| Readonly<{ kind: "insert"; rows: readonly Record<string, unknown>[] }>
|
||||
| Readonly<{ kind: "update"; values: Record<string, unknown> }>
|
||||
| Readonly<{ kind: "upsert"; rows: readonly Record<string, unknown>[]; conflict: readonly string[] }>
|
||||
| Readonly<{ kind: "delete"; exactCount: boolean }>;
|
||||
|
||||
const identifierPattern = /^[a-z_][a-z0-9_]*$/;
|
||||
|
||||
function identifier(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!identifierPattern.test(normalized)) throw new Error("unsafe database identifier");
|
||||
return `"${normalized}"`;
|
||||
}
|
||||
|
||||
function queryError(error: unknown): QueryError {
|
||||
const value = error as PostgresError;
|
||||
return {
|
||||
message: value instanceof Error ? value.message : "database request failed",
|
||||
...(typeof value?.code === "string" ? { code: value.code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function records(value: Record<string, unknown> | readonly Record<string, unknown>[]) {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
const poolGlobal = globalThis as typeof globalThis & {
|
||||
jyotishaLocalDataPools?: Map<string, Pool>;
|
||||
};
|
||||
|
||||
function localDataPool(connectionString: string): Pool {
|
||||
poolGlobal.jyotishaLocalDataPools ??= new Map();
|
||||
let pool = poolGlobal.jyotishaLocalDataPools.get(connectionString);
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
allowExitOnIdle: true,
|
||||
application_name: "jyotisha-business",
|
||||
});
|
||||
poolGlobal.jyotishaLocalDataPools.set(connectionString, pool);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function inBusinessTransaction<T>(
|
||||
pool: Pool,
|
||||
identity: LocalIdentity,
|
||||
role: LocalDatabaseRole,
|
||||
run: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
await client.query(`set local role ${role}`);
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
const result = await run(client);
|
||||
await client.query("commit");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function columnTypes(
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const result = await client.query<{ column_name: string; udt_name: string }>(
|
||||
`
|
||||
select column_name, udt_name
|
||||
from information_schema.columns
|
||||
where table_schema = 'public' and table_name = $1
|
||||
`,
|
||||
[table],
|
||||
);
|
||||
return new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
|
||||
}
|
||||
|
||||
function databaseValue(type: string | undefined, value: unknown): unknown {
|
||||
if ((type === "json" || type === "jsonb") && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
private selectedColumns: string[] | null = null;
|
||||
private mutation: Mutation | null = null;
|
||||
private readonly filters: Filter[] = [];
|
||||
private ordering: Readonly<{ column: string; ascending: boolean }> | null = null;
|
||||
private rowLimit: number | null = null;
|
||||
private abort: AbortSignal | null = null;
|
||||
private cardinality: "many" | "single" | "maybeSingle" = "many";
|
||||
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole,
|
||||
private readonly table: string,
|
||||
) {
|
||||
identifier(table);
|
||||
}
|
||||
|
||||
select(columns = "*") {
|
||||
this.selectedColumns = columns === "*"
|
||||
? ["*"]
|
||||
: columns.split(",").map((column) => column.trim()).filter(Boolean);
|
||||
for (const column of this.selectedColumns) {
|
||||
if (column !== "*") identifier(column);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
insert(value: Record<string, unknown> | readonly Record<string, unknown>[]) {
|
||||
this.mutation = { kind: "insert", rows: records(value) };
|
||||
return this;
|
||||
}
|
||||
|
||||
upsert(
|
||||
value: Record<string, unknown> | readonly Record<string, unknown>[],
|
||||
options: { onConflict: string },
|
||||
) {
|
||||
const conflict = options.onConflict.split(",").map((column) => column.trim());
|
||||
conflict.forEach(identifier);
|
||||
this.mutation = { kind: "upsert", rows: records(value), conflict };
|
||||
return this;
|
||||
}
|
||||
|
||||
update(values: Record<string, unknown>) {
|
||||
this.mutation = { kind: "update", values };
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(options?: { count?: string }) {
|
||||
this.mutation = { kind: "delete", exactCount: options?.count === "exact" };
|
||||
return this;
|
||||
}
|
||||
|
||||
eq(column: string, value: unknown) {
|
||||
identifier(column);
|
||||
this.filters.push({ kind: "eq", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
in(column: string, value: readonly unknown[]) {
|
||||
identifier(column);
|
||||
this.filters.push({ kind: "in", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
is(column: string, value: unknown) {
|
||||
identifier(column);
|
||||
this.filters.push({ kind: "is", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
not(column: string, operator: string, value: unknown) {
|
||||
identifier(column);
|
||||
if (operator !== "cs") throw new Error("unsupported not filter");
|
||||
this.filters.push({ kind: "notContains", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
order(column: string, options: { ascending?: boolean } = {}) {
|
||||
identifier(column);
|
||||
this.ordering = { column, ascending: options.ascending !== false };
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(value: number) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid row limit");
|
||||
this.rowLimit = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
abortSignal(signal: AbortSignal) {
|
||||
this.abort = signal;
|
||||
return this;
|
||||
}
|
||||
|
||||
single() {
|
||||
this.cardinality = "single";
|
||||
return this.execute();
|
||||
}
|
||||
|
||||
maybeSingle() {
|
||||
this.cardinality = "maybeSingle";
|
||||
return this.execute();
|
||||
}
|
||||
|
||||
then<TResult1 = QueryResult, TResult2 = never>(
|
||||
onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): PromiseLike<TResult1 | TResult2> {
|
||||
return this.execute().then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
private returningClause(): string {
|
||||
if (!this.selectedColumns) return "";
|
||||
return ` returning ${this.selectedColumns.map((column) => column === "*" ? "*" : identifier(column)).join(", ")}`;
|
||||
}
|
||||
|
||||
private filterClause(parameters: unknown[], types: Map<string, string>): string {
|
||||
if (this.filters.length === 0) return "";
|
||||
const parts = this.filters.map((filter) => {
|
||||
const column = identifier(filter.column);
|
||||
if (filter.kind === "is") {
|
||||
if (filter.value === null) return `${column} is null`;
|
||||
if (filter.value === true) return `${column} is true`;
|
||||
if (filter.value === false) return `${column} is false`;
|
||||
throw new Error("unsupported is filter");
|
||||
}
|
||||
if (filter.kind === "in") {
|
||||
if (filter.value.length === 0) return "false";
|
||||
const placeholders = filter.value.map((value) => {
|
||||
parameters.push(databaseValue(types.get(filter.column), value));
|
||||
return `$${parameters.length}`;
|
||||
});
|
||||
return `${column} in (${placeholders.join(", ")})`;
|
||||
}
|
||||
if (filter.kind === "notContains") {
|
||||
parameters.push(databaseValue(types.get(filter.column), filter.value));
|
||||
return `not (${column} @> $${parameters.length})`;
|
||||
}
|
||||
parameters.push(databaseValue(types.get(filter.column), filter.value));
|
||||
return `${column} = $${parameters.length}`;
|
||||
});
|
||||
return ` where ${parts.join(" and ")}`;
|
||||
}
|
||||
|
||||
private async execute(): Promise<QueryResult> {
|
||||
if (this.abort?.aborted) {
|
||||
return { data: null, error: { message: "AbortError" } };
|
||||
}
|
||||
try {
|
||||
return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => {
|
||||
const types = await columnTypes(client, this.table);
|
||||
const parameters: unknown[] = [];
|
||||
let sql: string;
|
||||
|
||||
if (!this.mutation) {
|
||||
const selected = (this.selectedColumns ?? ["*"])
|
||||
.map((column) => column === "*" ? "*" : identifier(column))
|
||||
.join(", ");
|
||||
sql = `select ${selected} from public.${identifier(this.table)}`;
|
||||
sql += this.filterClause(parameters, types);
|
||||
if (this.ordering) {
|
||||
sql += ` order by ${identifier(this.ordering.column)} ${this.ordering.ascending ? "asc" : "desc"}`;
|
||||
}
|
||||
if (this.rowLimit !== null) sql += ` limit ${this.rowLimit}`;
|
||||
} else if (this.mutation.kind === "insert" || this.mutation.kind === "upsert") {
|
||||
const rows = this.mutation.rows;
|
||||
if (rows.length === 0) return { data: this.selectedColumns ? [] : null, error: null };
|
||||
const columns = Object.keys(rows[0] ?? {});
|
||||
if (columns.length === 0 || rows.some((row) => Object.keys(row).join("\0") !== columns.join("\0"))) {
|
||||
throw new Error("inconsistent insert rows");
|
||||
}
|
||||
columns.forEach(identifier);
|
||||
const valueGroups = rows.map((row) => `(${columns.map((column) => {
|
||||
parameters.push(databaseValue(types.get(column), row[column]));
|
||||
return `$${parameters.length}`;
|
||||
}).join(", ")})`);
|
||||
sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`;
|
||||
if (this.mutation.kind === "upsert") {
|
||||
const updates = columns.filter((column) => !this.mutation || this.mutation.kind !== "upsert" || !this.mutation.conflict.includes(column));
|
||||
sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${updates.length === 0
|
||||
? "nothing"
|
||||
: `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}`}`;
|
||||
}
|
||||
sql += this.returningClause();
|
||||
} else if (this.mutation.kind === "update") {
|
||||
const columns = Object.keys(this.mutation.values);
|
||||
if (columns.length === 0) throw new Error("empty update");
|
||||
const assignments = columns.map((column) => {
|
||||
identifier(column);
|
||||
parameters.push(databaseValue(types.get(column), this.mutation && this.mutation.kind === "update" ? this.mutation.values[column] : null));
|
||||
return `${identifier(column)} = $${parameters.length}`;
|
||||
});
|
||||
sql = `update public.${identifier(this.table)} set ${assignments.join(", ")}`;
|
||||
sql += this.filterClause(parameters, types);
|
||||
sql += this.returningClause();
|
||||
} else {
|
||||
sql = `delete from public.${identifier(this.table)}`;
|
||||
sql += this.filterClause(parameters, types);
|
||||
sql += this.returningClause();
|
||||
}
|
||||
|
||||
const result = await client.query(sql, parameters);
|
||||
const rows = result.rows;
|
||||
let data: unknown = this.selectedColumns ? rows : null;
|
||||
if (this.cardinality !== "many") {
|
||||
if (rows.length > 1 || (this.cardinality === "single" && rows.length !== 1)) {
|
||||
return { data: null, error: { code: "PGRST116", message: "unexpected row count" } };
|
||||
}
|
||||
data = rows[0] ?? null;
|
||||
}
|
||||
return {
|
||||
data,
|
||||
error: null,
|
||||
...(this.mutation?.kind === "delete" && this.mutation.exactCount
|
||||
? { count: result.rowCount ?? 0 }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { data: null, error: queryError(error), count: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FunctionMetadata = Readonly<{
|
||||
proretset: boolean;
|
||||
return_type: string;
|
||||
argument_names: string[] | null;
|
||||
argument_types: string[];
|
||||
}>;
|
||||
|
||||
async function functionMetadata(
|
||||
client: PoolClient,
|
||||
functionName: string,
|
||||
argumentNames: readonly string[],
|
||||
): Promise<FunctionMetadata> {
|
||||
const result = await client.query<FunctionMetadata>(
|
||||
`
|
||||
select
|
||||
p.proretset,
|
||||
format_type(p.prorettype, null) as return_type,
|
||||
p.proargnames as argument_names,
|
||||
array(
|
||||
select format_type(argument_type, null)
|
||||
from unnest(p.proargtypes) argument_type
|
||||
) as argument_types
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
and p.proname = $1
|
||||
and $2::text[] <@ coalesce(p.proargnames, '{}'::text[])
|
||||
order by cardinality(p.proargtypes) asc
|
||||
limit 1
|
||||
`,
|
||||
[functionName, argumentNames],
|
||||
);
|
||||
const metadata = result.rows[0];
|
||||
if (!metadata) throw new Error("database function not found");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function castType(value: string): string {
|
||||
if (!/^[a-z0-9_ .\[\]]+$/.test(value)) throw new Error("unsafe database type");
|
||||
return value;
|
||||
}
|
||||
|
||||
export class LocalPostgresDataClient {
|
||||
readonly auth: Readonly<{
|
||||
getUser: () => Promise<Readonly<{
|
||||
data: { user: LocalIdentity };
|
||||
error: null;
|
||||
}>>;
|
||||
}>;
|
||||
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(
|
||||
connectionString: string,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole,
|
||||
) {
|
||||
if (role !== "authenticated" && role !== "service_role") {
|
||||
throw new Error("unsupported database role");
|
||||
}
|
||||
this.pool = localDataPool(connectionString);
|
||||
this.auth = {
|
||||
getUser: async () => ({ data: { user: this.identity }, error: null }),
|
||||
};
|
||||
}
|
||||
|
||||
from(table: string) {
|
||||
return new LocalPostgresQueryBuilder(this.pool, this.identity, this.role, table);
|
||||
}
|
||||
|
||||
async rpc(functionName: string, args: Readonly<Record<string, unknown>> = {}) {
|
||||
try {
|
||||
identifier(functionName);
|
||||
return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => {
|
||||
const names = Object.keys(args);
|
||||
names.forEach(identifier);
|
||||
const metadata = await functionMetadata(client, functionName, names);
|
||||
const typeByName = new Map(
|
||||
(metadata.argument_names ?? []).map((name, index) => [name, metadata.argument_types[index]]),
|
||||
);
|
||||
const parameters = names.map((name) =>
|
||||
databaseValue(typeByName.get(name), args[name]));
|
||||
const call = names.map((name, index) =>
|
||||
`${identifier(name)} => $${index + 1}::${castType(typeByName.get(name) ?? "text")}`,
|
||||
).join(", ");
|
||||
const sql = metadata.proretset
|
||||
? `select * from public.${identifier(functionName)}(${call})`
|
||||
: `select public.${identifier(functionName)}(${call}) as value`;
|
||||
const result = await client.query(sql, parameters);
|
||||
return {
|
||||
data: metadata.proretset ? result.rows : result.rows[0]?.value ?? null,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { data: null, error: queryError(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalPostgresDataClient(
|
||||
connectionString: string,
|
||||
identity: LocalIdentity = null,
|
||||
role: LocalDatabaseRole = "authenticated",
|
||||
) {
|
||||
return new LocalPostgresDataClient(connectionString, identity, role);
|
||||
}
|
||||
Reference in New Issue
Block a user