From 25b002c8383c1ca0e1ac4edb934d085778d67f82 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:01:58 +0300 Subject: [PATCH] perf(api): fewer D1 round trips per admin request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed-in admin API call made about five serial D1 round trips before and inside its handler, and Studio fires 10–13 calls per screen into a database that serves one query at a time. - Better Auth `session.cookieCache` (maxAge 60 s): the session is read from a signed cookie instead of D1. A session revoked elsewhere stays usable for up to 60 s; membership and roles still apply at once (ADR-0010 addendum). - `staffStore.member`: the member and their site roles in one query. - The auth rate limiter (stored in D1) no longer runs on `/get-session`. - Due comment purges are swept at most every 10 s per isolate instead of on every admin request. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/admin-api-round-trips.md | 7 +++++++ docs/adr/0010-auth-better-auth.md | 10 ++++++++++ packages/api/src/auth/instance.ts | 12 +++++++++++- packages/api/src/site-members.ts | 12 ++---------- packages/api/src/staff.ts | 18 +++++++++++++----- packages/cloudflare/src/admin-worker.ts | 20 ++++++++++++++++++-- 6 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 .changeset/admin-api-round-trips.md diff --git a/.changeset/admin-api-round-trips.md b/.changeset/admin-api-round-trips.md new file mode 100644 index 00000000..5614d105 --- /dev/null +++ b/.changeset/admin-api-round-trips.md @@ -0,0 +1,7 @@ +--- +"@cloudhal/api": patch +"@cloudhal/cloudflare": patch +"@cloudhal/cli": patch +--- + +Fewer D1 round trips per admin API request. The session is cached in a signed cookie for 60 seconds, so a session revoked elsewhere can stay usable that long (ADR-0010 addendum); membership and roles still apply at once. A member and their site roles load in one query. `get-session` is no longer rate limited, since the limiter's D1 read and write landed on every session check. Due comment purges are swept at most every 10 seconds per isolate instead of on every request. diff --git a/docs/adr/0010-auth-better-auth.md b/docs/adr/0010-auth-better-auth.md index b3bc7c53..b49e4139 100644 --- a/docs/adr/0010-auth-better-auth.md +++ b/docs/adr/0010-auth-better-auth.md @@ -26,3 +26,13 @@ Adopt **Better Auth 1.7.x** (MIT; Vercel-owned since 2026-07 with an open-source - Positive: one library covers Studio login, tenants, API keys and the MCP authorization server on both runtimes. - Negative: 1.5→1.7 churn (adapter packages split, OIDC plugin removed, tables renamed) and a wide advisory history → pin to 1.7.x, subscribe to GHSAs, keep telemetry off. - Risks: scrypt cost on Workers free plan (override hashing or document paid plan); Vercel governance (mitigated by MIT + data in our DB). + +## Addendum (2026-10-22): session cookie cache +Every signed-in API request reads the session, and on D1 that was a round trip before the handler could start. Studio fires 10–13 calls per screen, and D1 serves one query at a time per database, so these lookups queued behind each other (internal performance review, 2026-10). + +- **Session cookie cache.** Better Auth's `session.cookieCache` is on with `maxAge: 60`: the session is signed into a cookie and read from it for 60 seconds. The trade-off, accepted by the maintainer: + - a session revoked from another device, or by an administrator, stays usable for up to 60 seconds; + - a person's own sign-out clears the cookie at once; + - Better Auth's sensitive endpoints (password change, revoking sessions) do not use the cache; + - membership and roles are still read from D1 on every request, so removing a member or changing a role takes effect immediately. +- **Rate limiter.** It stays on for every auth endpoint except `/get-session`, which is not a brute-force target and which Studio asks for often. Its storage is D1, so each limited call costs a read and a write. diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 89d8f81f..9e17dd65 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -72,6 +72,10 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { autoSignIn: true, }, telemetry: { enabled: false }, + // Every signed-in API request reads the session; signed into a cookie for 60 s, it costs no + // D1 round trip. A revoked session or a sign-out on another device takes effect within 60 s + // (the person's own sign-out clears the cookie at once). + session: { cookieCache: { enabled: true, maxAge: 60 } }, hooks: { // Checked before the endpoint runs (outside Better Auth's write transaction, which would // deadlock a second query on single-connection SQLite). @@ -109,7 +113,13 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { }, ...(options.workers ? { - rateLimit: { enabled: true, storage: "database" as const }, + // Stored in D1, so each limited call costs a read and a write. Reading the session is + // not a brute-force target and Studio asks for it often: it is not limited. + rateLimit: { + enabled: true, + storage: "database" as const, + customRules: { "/get-session": false as const }, + }, advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] }, ...(options.workers.waitUntil diff --git a/packages/api/src/site-members.ts b/packages/api/src/site-members.ts index 0b09f317..194b93c1 100644 --- a/packages/api/src/site-members.ts +++ b/packages/api/src/site-members.ts @@ -28,15 +28,6 @@ export function parseSiteRoles(value: unknown): SiteRoles | undefined { } } -/** One member's assignments (undefined: every site). */ -export async function siteRolesOf(sql: SqlDriver, userId: string): Promise { - const rows = await sql.all<{ site_id: string; role: string }>( - "SELECT site_id, role FROM site_members WHERE user_id = ? ORDER BY site_id", - [userId], - ); - return toRoles(rows); -} - /** Every member's assignments, by user id. */ export async function allSiteRoles(sql: SqlDriver): Promise> { const rows = await sql.all<{ user_id: string; site_id: string; role: string }>( @@ -52,7 +43,8 @@ export async function allSiteRoles(sql: SqlDriver): Promise = {}; for (const r of rows) if (isRole(r.role) && isSiteRole(r.role)) out[r.site_id] = r.role; return Object.keys(out).length > 0 ? out : undefined; diff --git a/packages/api/src/staff.ts b/packages/api/src/staff.ts index 9fdccc44..6cdf50d0 100644 --- a/packages/api/src/staff.ts +++ b/packages/api/src/staff.ts @@ -15,7 +15,7 @@ import { parseSiteRoles, replaceSiteRoles, type SiteRoles, - siteRolesOf, + toRoles, } from "./site-members.ts"; /** The one organization of a deployment (Cloud: one per tenant). */ @@ -99,14 +99,22 @@ export class StaffStore { // ───────────────────────────── members ───────────────────────────── + /** + * One query with the member's site assignments (it runs on every signed-in API request, and a + * second round trip to D1 there slowed every screen). + */ async member(userId: string): Promise { - const row = await this.#sql.get( - `SELECT m."userId", m."role", m."createdAt", u."name", u."email" FROM "member" m JOIN "user" u ON u."id" = m."userId" WHERE m."organizationId" = ? AND m."userId" = ?`, + const rows = await this.#sql.all( + `SELECT m."userId", m."role", m."createdAt", u."name", u."email", s.site_id AS "siteId", s.role AS "siteRole" FROM "member" m JOIN "user" u ON u."id" = m."userId" LEFT JOIN site_members s ON s.user_id = m."userId" WHERE m."organizationId" = ? AND m."userId" = ? ORDER BY s.site_id`, [DEPLOYMENT_ORG, userId], ); - const member = row ? toMember(row) : undefined; + const member = rows[0] ? toMember(rows[0]) : undefined; if (!member) return undefined; - const sites = await siteRolesOf(this.#sql, userId); + const sites = toRoles( + rows + .filter((r) => typeof r.siteId === "string") + .map((r) => ({ site_id: r.siteId as string, role: String(r.siteRole) })), + ); return sites ? { ...member, sites } : member; } diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts index 6708e702..6e6286ae 100644 --- a/packages/cloudflare/src/admin-worker.ts +++ b/packages/cloudflare/src/admin-worker.ts @@ -258,6 +258,22 @@ function required(env: AdminEnv, key: keyof AdminEnv): string { return value; } +/** How often one isolate sweeps due comment purges on an admin request. */ +const COMMENTS_SWEEP_MS = 10_000; +/** When this isolate last swept them; a plain number (ADR-0012: no objects between requests). */ +let commentsSweptAt = 0; + +/** + * Whether this request sweeps due comment purges. Each sweep reads D1, which serves one query at + * a time, so sweeping on every request slowed the screen's own calls; moderation still wakes its + * own flush. + */ +function commentsSweepDue(now = Date.now()): boolean { + if (now - commentsSweptAt <= COMMENTS_SWEEP_MS) return false; + commentsSweptAt = now; + return true; +} + export function createAdminWorker(options: AdminWorkerOptions = {}) { const skills: LoadedSkill[] = (options.skills ?? []).map((s) => skillFromSource(s)); // Checked once per isolate; `cloudhal deploy` refused invalid types before uploading. @@ -357,8 +373,8 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { } : {}), }); - // Every admin request also flushes what is due (Studio polls every 15–60 s). - if (comments) ctx.waitUntil(flush()); + // Every admin request also flushes what is due (Studio polls every 15–60 s), throttled. + if (comments && commentsSweepDue()) ctx.waitUntil(flush()); const store: SiteGraphStore = new SiteGraphStore({ sql, blobs,