feat(staging): switch to local postgres

This commit is contained in:
Jesse_Chen
2026-07-22 11:04:36 +08:00
parent f5c57efeba
commit a1b8eaec36
30 changed files with 1011 additions and 141 deletions
@@ -0,0 +1,43 @@
create schema if not exists auth authorization schema_owner;
grant usage on schema public to anon, authenticated, service_role;
grant usage on schema auth to anon, authenticated, service_role;
revoke all on schema auth from public;
create table if not exists auth.users (
id uuid primary key,
email text not null,
raw_user_meta_data jsonb not null default '{}'::jsonb,
email_confirmed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists auth_users_email_canonical_key
on auth.users (lower(btrim(email)));
create or replace function auth.uid()
returns uuid
language sql
stable
as $$
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
$$;
create or replace function auth.jwt()
returns jsonb
language sql
stable
as $$
select jsonb_build_object(
'sub', nullif(current_setting('request.jwt.claim.sub', true), ''),
'email', nullif(current_setting('request.jwt.claim.email', true), '')
)
$$;
revoke all on table auth.users from public, anon, authenticated, service_role;
grant select, insert, update, delete on table auth.users to service_role;
revoke all on function auth.uid() from public, anon;
revoke all on function auth.jwt() from public, anon;
grant execute on function auth.uid() to authenticated, service_role;
grant execute on function auth.jwt() to authenticated, service_role;
@@ -0,0 +1,65 @@
create or replace function identity.sync_user_to_business_auth()
returns trigger
language plpgsql
security definer
set search_path = pg_catalog, auth
as $$
begin
if tg_op = 'DELETE' then
delete from auth.users where id = old.id;
return old;
end if;
insert into auth.users (
id,
email,
raw_user_meta_data,
email_confirmed_at,
created_at,
updated_at
) values (
new.id,
new.email,
jsonb_build_object('full_name', new.name),
case when new.email_verified then coalesce(new.email_verified_at, now()) end,
new.created_at,
new.updated_at
)
on conflict (id) do update set
email = excluded.email,
raw_user_meta_data = excluded.raw_user_meta_data,
email_confirmed_at = excluded.email_confirmed_at,
updated_at = excluded.updated_at;
return new;
end;
$$;
drop trigger if exists identity_user_business_auth_sync on identity.users;
create trigger identity_user_business_auth_sync
after insert or update or delete on identity.users
for each row execute function identity.sync_user_to_business_auth();
insert into auth.users (
id,
email,
raw_user_meta_data,
email_confirmed_at,
created_at,
updated_at
)
select
id,
email,
jsonb_build_object('full_name', name),
case when email_verified then coalesce(email_verified_at, now()) end,
created_at,
updated_at
from identity.users
on conflict (id) do update set
email = excluded.email,
raw_user_meta_data = excluded.raw_user_meta_data,
email_confirmed_at = excluded.email_confirmed_at,
updated_at = excluded.updated_at;
revoke all on function identity.sync_user_to_business_auth() from public;
+55 -22
View File
@@ -9,34 +9,56 @@ const migrationFilenamePattern = /^\d{14}_[a-z0-9_]+\.sql$/;
class SafeMigrationError extends Error {}
async function loadMigrationFiles(migrationsDirectory) {
let entries;
try {
entries = await readdir(migrationsDirectory, { withFileTypes: true });
} catch {
throw new SafeMigrationError("unable to read migrations directory");
async function loadMigrationFiles(migrationsDirectories) {
const directories = Array.isArray(migrationsDirectories)
? migrationsDirectories
: [migrationsDirectories];
const entriesByDirectory = [];
for (const migrationsDirectory of directories) {
try {
entriesByDirectory.push({
migrationsDirectory,
entries: await readdir(migrationsDirectory, { withFileTypes: true }),
});
} catch {
throw new SafeMigrationError("unable to read migrations directory");
}
}
const malformedSqlEntry = entries.find(
(entry) =>
entry.isFile() &&
entry.name.endsWith(".sql") &&
!migrationFilenamePattern.test(entry.name),
);
const malformedSqlEntry = entriesByDirectory
.flatMap(({ entries }) => entries)
.find(
(entry) =>
entry.isFile() &&
entry.name.endsWith(".sql") &&
!migrationFilenamePattern.test(entry.name),
);
if (malformedSqlEntry) {
throw new SafeMigrationError(
`invalid migration filename: ${malformedSqlEntry.name}`,
);
}
const migrationEntries = entriesByDirectory.flatMap(
({ migrationsDirectory, entries }) =>
entries
.filter(
(entry) => entry.isFile() && migrationFilenamePattern.test(entry.name),
)
.map((entry) => ({ migrationsDirectory, filename: entry.name })),
);
const duplicate = migrationEntries.find(
(entry, index) =>
migrationEntries.findIndex((candidate) => candidate.filename === entry.filename) !== index,
);
if (duplicate) {
throw new SafeMigrationError(`duplicate migration filename: ${duplicate.filename}`);
}
return Promise.all(
entries
.filter(
(entry) => entry.isFile() && migrationFilenamePattern.test(entry.name),
)
.map((entry) => entry.name)
.sort()
.map(async (filename) => {
migrationEntries
.sort((left, right) => left.filename.localeCompare(right.filename))
.map(async ({ migrationsDirectory, filename }) => {
const bytes = await readFile(resolve(migrationsDirectory, filename));
return {
filename,
@@ -76,10 +98,11 @@ function assertLedgerFilesPresent(ledger, files) {
export async function runMigrations({
connectionString,
migrationsDirectory,
migrationsDirectories,
logger = console,
check = false,
}) {
const files = await loadMigrationFiles(migrationsDirectory);
const files = await loadMigrationFiles(migrationsDirectories ?? migrationsDirectory);
const client = new Client({ connectionString });
let locked = false;
@@ -193,11 +216,21 @@ if (invokedPath === import.meta.url) {
dirname(fileURLToPath(import.meta.url)),
"../db/migrations",
);
const supabaseCompatibilityDirectory = resolve(
dirname(fileURLToPath(import.meta.url)),
"../supabase/migrations",
);
try {
const status = await runMigrations({
connectionString: requireSchemaDatabaseUrl(process.env),
migrationsDirectory:
process.env.MIGRATIONS_DIRECTORY?.trim() || defaultDirectory,
...(process.env.MIGRATIONS_DIRECTORY?.trim()
? { migrationsDirectory: process.env.MIGRATIONS_DIRECTORY.trim() }
: {
migrationsDirectories: [
defaultDirectory,
supabaseCompatibilityDirectory,
],
}),
check: process.argv.slice(2).includes("--check"),
});
process.exitCode = status;
+2
View File
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
import { isAdminEmail } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children;
+3 -1
View File
@@ -57,7 +57,7 @@ export async function GET() {
// an older case. A concurrently created case simply appears on refresh.
const { data: profile, error } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
.select("credits,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_time,active_birth_time,birth_time_status,rectification_case_id")
.eq("id", userId)
.single();
@@ -72,6 +72,8 @@ export async function GET() {
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
credits: profile.credits,
profile,
authProvider: process.env.AUTH_PROVIDER?.trim() === "self-hosted" ? "self-hosted" : "supabase",
isAdmin: isAdminEmail(user.email),
rectificationPriceCredits,
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
+11 -2
View File
@@ -64,10 +64,19 @@ export async function GET() {
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment();
const truthSourceIdentity = getTruthSourceRuntimeIdentity();
const selfHosted = process.env.AUTH_PROVIDER?.trim() === "self-hosted";
const databaseChecks: Record<string, Check> = selfHosted
? {
localBusinessDatabase: envCheck(["APP_DATABASE_URL", "ADMIN_DATABASE_URL"]),
localIdentityDatabase: envCheck(["IDENTITY_DATABASE_URL"]),
}
: {
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
};
const checks = {
web: { status: "ok" } satisfies Check,
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
...databaseChecks,
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
jyotishApi: await jyotishApiCheck(),
researchTruthSource: {
+16 -4
View File
@@ -1,21 +1,33 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { chatSessionWriteSchema } from "@/lib/chat-session-write-contract";
import {
chatSessionModelPatchSchema,
chatSessionWriteSchema,
} from "@/lib/chat-session-write-contract";
type RouteContext = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
const parsed = chatSessionWriteSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const payload = await request.json().catch(() => null);
const fullWrite = chatSessionWriteSchema.safeParse(payload);
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
let values: Record<string, unknown>;
if (fullWrite.success) {
values = fullWrite.data;
} else if (modelPatch.success) {
values = modelPatch.data;
} else {
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.update(parsed.data)
.update(values)
.eq("id", id)
.eq("user_id", user.id)
.select("id")
+20
View File
@@ -3,6 +3,26 @@ import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
return NextResponse.json({ sessions: data ?? [] });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const supabase = await createServerSupabaseClient();
+51 -55
View File
@@ -89,6 +89,7 @@ import {
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { selfHostedOtpActions } from "@/modules/identity/client";
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
const BirthTimeRectification = dynamic(
@@ -158,6 +159,8 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number };
type Account = {
user: { id: string; email: string | null };
credits: number;
profile: unknown;
authProvider: "supabase" | "self-hosted";
isAdmin: boolean;
rectificationPriceCredits: number;
hasConfirmedBirthTime: boolean;
@@ -738,6 +741,32 @@ async function fetchModelCatalog(signal?: AbortSignal) {
return parsePublicModelCatalog(payload);
}
async function fetchSessions(signal?: AbortSignal) {
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
const payload = await response.json().catch(() => null) as { sessions?: unknown; error?: string } | null;
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
return payload?.sessions ?? [];
}
async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
credentials: "same-origin",
signal,
body: JSON.stringify({ model_id: modelId }),
});
const payload = await response.json().catch(() => null);
return {
found: response.ok,
error: response.ok ? null : payloadMessage(payload, "模型选择暂时无法同步"),
};
}
export default function Home() {
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
@@ -1075,6 +1104,8 @@ export default function Home() {
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
credits: 8,
profile: previewProfile,
authProvider: "self-hosted",
isAdmin: false,
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
@@ -1101,16 +1132,7 @@ export default function Home() {
return;
}
const supabase = createBrowserSupabaseClient();
const { data: authData, error: authError } = await supabase.auth.getSession();
if (authError) throw authError;
if (controller.signal.aborted) return;
if (!authData.session) {
window.location.assign("/login");
return;
}
const [nextAccount, modelCatalogResult] = await Promise.all([
const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([
fetchAccount(controller.signal),
fetchModelCatalog(controller.signal)
.then((catalog) => ({ catalog, unavailable: false }))
@@ -1118,50 +1140,30 @@ export default function Home() {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return { catalog: null, unavailable: true };
}),
fetchSessions(controller.signal),
]);
const nextModelCatalog = modelCatalogResult.catalog;
const [profileResult, sessionsResult] = await Promise.all([
supabase
.from("profiles")
.select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", nextAccount.user.id)
.abortSignal(controller.signal)
.maybeSingle(),
supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
.abortSignal(controller.signal)
.order("updated_at", { ascending: false }),
]);
if (profileResult.error) throw profileResult.error;
if (sessionsResult.error) throw sessionsResult.error;
const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog);
const parsedSessions = readSessions(sessionsPayload, nextModelCatalog);
let nextSessions = parsedSessions.sessions;
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession(nextModelCatalog?.defaultModelId ?? "");
const { error } = await supabase
.from("chat_sessions")
.insert({
id: initialSession.id,
user_id: nextAccount.user.id,
if (initialSession.modelId) {
await writeChatSession(initialSession.id, {
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId || null,
model_id: initialSession.modelId,
messages: initialSession.messages,
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
updated_at: new Date(initialSession.updatedAt).toISOString(),
})
.abortSignal(controller.signal);
if (error) throw error;
}, "create");
}
nextSessions = [initialSession];
}
if (controller.signal.aborted) return;
const nextProfile = readProfile(profileResult.data);
const nextProfile = readProfile(nextAccount.profile);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
@@ -1178,13 +1180,9 @@ export default function Home() {
setAccountError("");
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
const { error } = await supabase
.from("chat_sessions")
.update({ model_id: nextModelCatalog.defaultModelId })
.eq("user_id", nextAccount.user.id)
.in("id", parsedSessions.fallbackSessionIds)
.abortSignal(controller.signal);
if (error && !controller.signal.aborted) {
const results = await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) =>
patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal)));
if (results.some((result) => result.error) && !controller.signal.aborted) {
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
}
}
@@ -1491,14 +1489,8 @@ export default function Home() {
if (process.env.NODE_ENV === "development" && uiPreview.current) {
return { found: true, error: null };
}
const { data, error } = await createBrowserSupabaseClient()
.from("chat_sessions")
.update(values)
.eq("id", sessionId)
.eq("user_id", ownerId)
.select("id")
.maybeSingle();
return { found: Boolean(data), error: error?.message ?? null };
void ownerId;
return patchSessionModel(sessionId, values.model_id);
},
userId,
nextSession.id,
@@ -1834,8 +1826,12 @@ export default function Home() {
setSigningOut(true);
setAccountError("");
try {
const { error } = await createBrowserSupabaseClient().auth.signOut();
if (error) throw error;
if (account?.authProvider === "self-hosted") {
await selfHostedOtpActions.signOut();
} else {
const { error } = await createBrowserSupabaseClient().auth.signOut();
if (error) throw error;
}
window.location.assign("/login");
} catch (caught) {
const message = caught instanceof Error ? caught.message : "退出失败";
@@ -27,6 +27,10 @@ export const chatSessionCreateSchema = chatSessionWriteSchema.extend({
id: z.string().uuid(),
}).strict();
export const chatSessionModelPatchSchema = z.object({
model_id: z.string().trim().min(1).max(64),
}).strict();
export type ChatSessionWrite = Readonly<{
title: string;
theme: "career" | "marriage" | "wealth" | "timing" | "general";
@@ -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);
}
@@ -0,0 +1,7 @@
import "server-only";
export {
LocalPostgresDataClient,
createLocalPostgresDataClient,
type LocalDatabaseRole,
} from "./local-postgres-client-core";
+10 -1
View File
@@ -1,12 +1,21 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
import { readDatabaseUrl } from "@/lib/db/config";
import {
getSupabaseUrl,
SupabaseConfigurationError,
} from "./config";
export function createAdminSupabaseClient() {
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
return createLocalPostgresDataClient(
readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
null,
"service_role",
) as unknown as SupabaseClient;
}
const url = getSupabaseUrl();
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!serviceRoleKey) {
+22 -1
View File
@@ -1,10 +1,31 @@
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { SupabaseClient } from "@supabase/supabase-js";
import { cookies, headers } from "next/headers";
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
import { readDatabaseUrl } from "@/lib/db/config";
import { getIdentityAuthServices } from "@/modules/identity/auth";
import { readIdentitySession } from "@/modules/identity/session";
import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
import { resolveIdentitySurface } from "@/modules/identity/host";
import { getSupabasePublicConfig } from "./config";
export async function createServerSupabaseClient() {
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const requestHeaders = new Headers(await headers());
const services = getIdentityAuthServices();
const surface = resolveIdentitySurface(
requestHeaders.get("host"),
readSelfHostedIdentityConfig(process.env),
);
const auth = surface === "admin" ? services.admin : services.user;
const session = await readIdentitySession(auth.api, requestHeaders);
return createLocalPostgresDataClient(
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
session ? { id: session.user.id, email: session.user.email } : null,
) as unknown as SupabaseClient;
}
const { url, anonKey } = getSupabasePublicConfig();
const cookieStore = await cookies();
+7
View File
@@ -19,11 +19,13 @@ export interface SelfHostedOtpClient {
otp: string;
}): Promise<OtpClientResult>;
};
signOut?(): Promise<OtpClientResult>;
}
export interface SelfHostedOtpActions {
send(email: string): Promise<void>;
verify(email: string, otp: string): Promise<void>;
signOut(): Promise<void>;
}
export function createSelfHostedOtpActions(
@@ -48,6 +50,11 @@ export function createSelfHostedOtpActions(
throw new Error("验证码错误或已过期,请重新获取");
}
},
async signOut() {
if (!client.signOut) throw new Error("退出失败,请稍后再试");
const result = await client.signOut();
if (result.error) throw new Error("退出失败,请稍后再试");
},
};
}
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
test("local PostgreSQL applies the reviewed business schema and serves authenticated business calls", async () => {
const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl(
"schema_owner",
"schema-owner-test-password",
);
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: schemaUrl,
},
});
assert.equal(migration.status, 0, migration.stderr);
assert.match(migration.stdout, /applied 20260715000000_account_credits\.sql/);
assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/);
assert.equal(
fixture.psql(`
select string_agg(tablename, ',' order by tablename)
from pg_tables
where schemaname = 'public'
`),
[
"birth_time_rectification_action_receipts",
"birth_time_rectification_billing",
"birth_time_rectification_cases",
"birth_time_rectification_dynamic_state",
"birth_time_rectification_event_evidence",
"birth_time_rectification_handoff_attach_receipts",
"birth_time_rectification_handoff_settlements",
"birth_time_rectification_question_handoffs",
"birth_time_rectification_scoring_jobs",
"birth_time_rectification_turns",
"chart_profiles",
"chat_sessions",
"consultation_requests",
"credit_request_cancellations",
"credit_transactions",
"profiles",
"redemption_codes",
"synastry_reports",
].join(","),
);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`
insert into identity.users (name, email, email_verified, email_verified_at)
values ('Local User', 'local-user@example.com', true, now())
`,
);
const userId = fixture.psql(
"select id from identity.users where email = 'local-user@example.com'",
);
assert.equal(
fixture.psql(`select email from auth.users where id = '${userId}'`),
"local-user@example.com",
);
assert.equal(
fixture.psql(`select email || ':' || credits from public.profiles where id = '${userId}'`),
"local-user@example.com:0",
);
assert.equal(
fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
`set role authenticated;
select set_config('request.jwt.claim.sub', '${userId}', true);
select email from public.profiles where id = '${userId}'`,
),
`SET\n${userId}\nlocal-user@example.com`,
);
const local = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
{ id: userId, email: "local-user@example.com" },
);
const profile = await local.from("profiles")
.select("id,email,credits")
.eq("id", userId)
.single();
assert.equal(profile.error, null);
assert.deepEqual(profile.data, {
id: userId,
email: "local-user@example.com",
credits: 0,
});
const admin = createLocalPostgresDataClient(
fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"),
null,
"service_role",
);
const adminProfile = await admin.from("profiles")
.select("id")
.eq("id", userId)
.single();
assert.equal(adminProfile.error, null);
assert.deepEqual(adminProfile.data, { id: userId });
const sessionId = "11111111-1111-4111-8111-111111111111";
const inserted = await local.from("chat_sessions").insert({
id: sessionId,
user_id: userId,
title: "Local conversation",
theme: "general",
model_id: "test-model",
messages: [],
session_type: "consultation",
rectification_case_id: null,
updated_at: new Date().toISOString(),
}).select("id").single();
assert.equal(inserted.error, null);
assert.deepEqual(inserted.data, { id: sessionId });
fixture.psql(`
insert into public.redemption_codes (code_hash, code_mask, credits)
values ('${"a".repeat(64)}', 'JYOTISH-****-TEST', 3)
`);
const redeemed = await local.rpc("redeem_code", {
p_code_hash: "a".repeat(64),
});
assert.equal(redeemed.error, null);
assert.deepEqual(redeemed.data, [{ success: true, credits: 3, error_code: null }]);
} finally {
fixture.stop();
}
});
+8
View File
@@ -33,6 +33,14 @@ test("database roles have no cluster privileges", () => {
"schema_owner:f:f:f:f:f:f",
].join("\n"),
);
assert.equal(
fixture.psql(`
select rolcanlogin || ':' || rolbypassrls
from pg_roles
where rolname = 'service_role'
`),
"f:true",
);
assert.equal(
fixture.psql(
"select nspowner::regrole::text from pg_namespace where nspname = 'public'",
+10 -4
View File
@@ -156,7 +156,7 @@ test("server compose defaults to local images without removing either build", ()
}
});
test("staging Caddy isolates the public and identity-only admin hosts", () => {
test("staging Caddy isolates the business admin surface from the public host", () => {
const caddy = readFileSync(
new URL("../../deploy/Caddyfile.staging", import.meta.url),
"utf8",
@@ -168,7 +168,9 @@ test("staging Caddy isolates the public and identity-only admin hosts", () => {
/\{\$ADMIN_SITE_ADDRESS:https:\/\/admin\.staging\.jyotisha\.chat\}/,
);
assert.match(caddy, /reverse_proxy web:3000/);
assert.match(caddy, /@identity path \/login \/api\/auth\/\*/);
assert.match(caddy, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/);
assert.match(caddy, /redir @adminRoot \/admin\/codes 302/);
assert.match(caddy, /@adminSurface path \/login \/admin \/admin\/\* \/api\/admin\/\* \/api\/auth\/\*/);
assert.match(caddy, /respond "Not found" 404/);
assert.doesNotMatch(caddy, /www\.jyotisha\.chat/);
});
@@ -234,15 +236,19 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
"CADDYFILE_PATH=./Caddyfile.staging",
"SITE_ADDRESS=https://staging.jyotisha.chat",
"ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat",
"AUTH_PROVIDER=supabase",
"AUTH_PROVIDER=self-hosted",
"SELF_HOSTED_IDENTITY_ENABLED=true",
"AUTH_USER_ORIGIN=https://staging.jyotisha.chat",
"AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat",
"IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha",
"APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha",
"ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-runtime-test-password@postgres:5432/jyotisha",
"BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long",
"BETTER_AUTH_ADMIN_SECRET=admin-secret-that-is-at-least-32-bytes-long",
"RESEND_API_KEY=re_test_key_that_must_not_be_printed",
"RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat>",
"ADMIN_EMAILS=admin@example.com",
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes",
];
const run = () =>
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
@@ -325,7 +331,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
writeEnv(
validSelectors.map((line) =>
line.startsWith("AUTH_PROVIDER=")
? "AUTH_PROVIDER=self-hosted"
? "AUTH_PROVIDER=supabase"
: line,
),
);
@@ -72,6 +72,7 @@ test("quality gate validates relevant changes once and publishes a digest manife
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
assert.match(workflow, /name: staging-image-manifest-\$\{\{ github\.sha \}\}/);
assert.match(workflow, /uses: actions\/upload-artifact@v4/);
assert.doesNotMatch(workflow, /STAGING_SUPABASE|NEXT_PUBLIC_SUPABASE/);
assert.doesNotMatch(workflow, /(?:^|:)latest$/m);
});
@@ -337,6 +338,11 @@ test("manual migration uses only PostgreSQL and the digest-pinned migrator", ()
assert.match(runner, /docker pull "\$WEB_IMAGE"/);
assert.match(runner, /up -d --no-build --pull never --wait postgres/);
assert.match(runner, /-f deploy\/docker-compose\.postgres\.yml/);
assertOrder(runner, [
"up -d --no-build --pull never --wait postgres",
"002-ensure-business-compatibility-roles.sql",
"--profile migration run --rm migrator",
]);
assert.match(runner, /--profile migration run --rm migrator/);
assert.match(runner, /select filename from migration\.schema_migrations order by filename/);
assert.doesNotMatch(runner, /docker-compose\.server\.yml/);