Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "AdminAudit_createdAt_id_idx" ON "AdminAudit"("createdAt", "id");
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ model AdminAudit {
@@unique([accountId, idempotencyKey])
@@index([accountId, createdAt])
@@index([actorEmail, createdAt])
@@index([createdAt, id])
}

/// Per-PR agent variant (dev XMTP network only). A named bundle pinning an
Expand Down
50 changes: 49 additions & 1 deletion src/api/v2/credits-admin/audit-repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AdminAudit } from "@prisma/client";
import type { AdminAudit, Prisma } from "@prisma/client";
import { prisma } from "@/utils/prisma";

export type AdminAuditAction = "grant" | "adjust";
Expand Down Expand Up @@ -35,3 +35,51 @@ export const listAdminAuditByAccount = async (
take: limit,
});
};

export type RecentAuditCursor = { createdAt: Date; id: string };

// Delimiter-safe: neither field can contain "|" (ISO timestamp + UUID id).
export const encodeAuditCursor = (r: { createdAt: Date; id: string }): string =>
Buffer.from(`${r.createdAt.toISOString()}|${r.id}`).toString("base64url");

export const decodeAuditCursor = (raw: string): RecentAuditCursor | null => {
try {
const decoded = Buffer.from(raw, "base64url").toString("utf8");
const idx = decoded.indexOf("|");
if (idx < 0) return null;
const createdAt = new Date(decoded.slice(0, idx));
const id = decoded.slice(idx + 1);
if (Number.isNaN(createdAt.getTime()) || !id) return null;
return { createdAt, id };
} catch {
return null;
}
};

export const listRecentAdminAudit = async (args: {
limit?: number;
cursor?: RecentAuditCursor | null;
action?: AdminAuditAction | null;
}): Promise<{ rows: AdminAudit[]; nextCursor: string | null }> => {
const limit = args.limit ?? AUDIT_LIST_LIMIT;
const where: Prisma.AdminAuditWhereInput = {};
if (args.action) where.action = args.action;
if (args.cursor) {
// Keyset (createdAt, id) < (cursor). Prisma has no tuple comparison, so
// OR-expand. The id tiebreaker keeps paging stable when createdAt collides.
where.OR = [
{ createdAt: { lt: args.cursor.createdAt } },
{ createdAt: args.cursor.createdAt, id: { lt: args.cursor.id } },
];
}
const rows = await prisma.adminAudit.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium credits-admin/audit-repository.ts:77

listRecentAdminAudit orders the entire AdminAudit table by (createdAt, id) with no matching index — the schema only indexes (accountId, createdAt) and (actorEmail, createdAt). The first-page query (and each action-filtered first page) therefore scans and sorts the whole audit table before returning 50 rows. As audit history grows, this endpoint becomes progressively slower and more CPU-intensive. Add an index on (createdAt, id) matching the keyset order, and consider a composite index covering the action-filtered access path.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/v2/credits-admin/audit-repository.ts around line 77:

`listRecentAdminAudit` orders the entire `AdminAudit` table by `(createdAt, id)` with no matching index — the schema only indexes `(accountId, createdAt)` and `(actorEmail, createdAt)`. The first-page query (and each `action`-filtered first page) therefore scans and sorts the whole audit table before returning 50 rows. As audit history grows, this endpoint becomes progressively slower and more CPU-intensive. Add an index on `(createdAt, id)` matching the keyset order, and consider a composite index covering the `action`-filtered access path.

take: limit + 1,
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const last = page.at(-1);
const nextCursor = hasMore && last ? encodeAuditCursor(last) : null;
return { rows: page, nextCursor };
};
13 changes: 13 additions & 0 deletions src/api/v2/credits-admin/credits-admin.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { accountViewGetHandler } from "./handlers/account-view-get";
import { adjustPostHandler } from "./handlers/adjust-post";
import { adminPageHandler } from "./handlers/admin-page";
import { auditGetHandler } from "./handlers/audit-get";
import { auditRecentGetHandler } from "./handlers/audit-recent-get";
import { grantPostHandler } from "./handlers/grant-post";
import { searchGetHandler } from "./handlers/search-get";
import { whoamiGetHandler } from "./handlers/whoami-get";
import { attachActorIdentity } from "./middleware/cf-identity";
import { creditsAdminTokenAuth } from "./middleware/token-auth";

Expand All @@ -15,8 +17,19 @@ export const creditsAdminRouter = Router();
creditsAdminRouter.get("/", adminPageHandler);

// Reads — token gate only (no audit write).
creditsAdminRouter.get(
"/whoami",
creditsAdminTokenAuth,
attachActorIdentity,
whoamiGetHandler,
);
creditsAdminRouter.get("/search", creditsAdminTokenAuth, searchGetHandler);
creditsAdminRouter.get("/audit", creditsAdminTokenAuth, auditGetHandler);
creditsAdminRouter.get(
"/audit/recent",
creditsAdminTokenAuth,
auditRecentGetHandler,
);
creditsAdminRouter.get(
"/accounts/:accountId",
creditsAdminTokenAuth,
Expand Down
Loading
Loading