Files
waseel/lib/db.ts
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

135 lines
4.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lookup, setDefaultResultOrder } from "dns";
import { Pool, type QueryResultRow } from "pg";
// Neon's host resolves to both IPv6 (AAAA) and IPv4 (A). This host has no IPv6
// route, so an IPv6-first connect fails instantly with ENETUNREACH and only
// then falls back to IPv4 — wasting a round-trip on every fresh connection and
// racing Neon's wake. Force IPv4 first so the working path is tried first.
setDefaultResultOrder("ipv4first");
// Neon free-tier scales compute to zero when idle; the first connection after a
// cold wake can take 1030s to establish. A 10s connect timeout 500s every
// request during that wake window, so allow 30s. Once a ride is active the
// driver-location + call polls keep the DB warm, so this only bites the first
// poll after a long idle.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 30_000,
});
// A pooled client can die out from under us (Neon recycle, network blip).
// Without this handler Node logs an unhandled "idle client error" and the
// pool just drops the client; log it so a flaky connection is visible.
pool.on("error", (err) => {
console.error("[DB_POOL_ERROR]: ", err.message);
});
// Connection errors that are safe to retry on a fresh pool client. Neon's
// direct endpoint intermittently ETIMEDOUTs while the compute wakes; a single
// retry a second later almost always succeeds once the endpoint is warm.
const RETRY_CODES = new Set([
"ETIMEDOUT",
"ECONNRESET",
"ENETUNREACH",
"EHOSTUNREACH",
"EPIPE",
"08000",
"08006",
"08001",
"08004",
"57P03",
]);
const isRetryable = (err: unknown): boolean => {
const e = err as { code?: string };
return Boolean(e && typeof e.code === "string" && RETRY_CODES.has(e.code));
};
// Retry a pool.query a couple of times on transient connection errors. The
// query itself is idempotent from the pool's perspective: a connect failure
// means no statement ran, and pg removes the dead client before the next
// attempt, so we never double-execute a committed statement.
const queryWithRetry = async <R extends QueryResultRow = QueryResultRow>(
text: string,
values: SqlValue[],
): Promise<R[]> => {
let lastErr: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const result = await pool.query<R>(text, values);
return result.rows;
} catch (err) {
lastErr = err;
if (!isRetryable(err) || attempt === 2) throw err;
// Back off ~1s, ~2s; Neon wake completes within a few seconds.
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw lastErr;
};
export type SqlValue = string | number | boolean | null | Date;
export async function sql<R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
): Promise<R[]> {
const text = strings.reduce(
(acc, chunk, i) =>
acc + chunk + (i < values.length ? `$${i + 1}` : ""),
"",
);
return queryWithRetry<R>(text, values);
}
export async function query<R extends QueryResultRow = QueryResultRow>(
text: string,
values: SqlValue[] = [],
): Promise<R[]> {
return queryWithRetry<R>(text, values);
}
export async function transaction<T>(
callback: (
tx: <R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
) => Promise<R[]>,
) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const tx = async <R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
) => {
const text = strings.reduce(
(acc, chunk, i) =>
acc + chunk + (i < values.length ? `$${i + 1}` : ""),
"",
);
const result = await client.query<R>(text, values);
return result.rows;
};
const out = await callback(tx);
await client.query("COMMIT");
return out;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}