diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eddaf07 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + # No lockfile is tracked (lock files are gitignored), so pnpm resolves + # versions from the workspace manifests. + run: pnpm install + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck diff --git a/apps/access-api/package.json b/apps/access-api/package.json index 5851f82..7efb38b 100644 --- a/apps/access-api/package.json +++ b/apps/access-api/package.json @@ -9,6 +9,7 @@ "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit", + "lint": "eslint .", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "test": "tsx --test test/**/*.test.ts", diff --git a/apps/access-api/prisma/schema.prisma b/apps/access-api/prisma/schema.prisma index 15be37e..55799b1 100644 --- a/apps/access-api/prisma/schema.prisma +++ b/apps/access-api/prisma/schema.prisma @@ -17,6 +17,7 @@ model ProcessedEvent { status String @default("processed") // e.g., processed, reverted eventType String data Json + previousState Json? // pre-application state snapshot (used for exact rollback) fencingToken Int @default(0) // Monotonically increasing leader generation createdAt DateTime @default(now()) @@ -26,6 +27,23 @@ model ProcessedEvent { @@index([blockHash]) } +/// Dead-letter queue: logs that failed processing, retryable via the backfill CLI. +model FailedEvent { + id String @id @default(uuid()) + contractAddress String + blockHash String + blockNumber BigInt + transactionHash String + logIndex Int + eventType String + error String + data Json + retryCount Int @default(0) + createdAt DateTime @default(now()) + + @@index([contractAddress]) +} + model Membership { id String @id @default(uuid()) diff --git a/apps/access-api/scripts/backfill.ts b/apps/access-api/scripts/backfill.ts index 6bfe10c..025cd58 100644 --- a/apps/access-api/scripts/backfill.ts +++ b/apps/access-api/scripts/backfill.ts @@ -317,4 +317,5 @@ async function main() { } } -main(); +// main() handles its own errors (exit codes) — no await needed at top level +void main(); diff --git a/apps/access-api/src/index.ts b/apps/access-api/src/index.ts index 0a36f80..98c7910 100644 --- a/apps/access-api/src/index.ts +++ b/apps/access-api/src/index.ts @@ -117,8 +117,8 @@ async function main() { process.exit(0); }; - process.on("SIGINT", () => shutdown("SIGINT")); - process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); // ── Start indexing ───────────────────────────────────────────────────── await indexer.start(); diff --git a/apps/access-api/src/utils/backfill-lock.ts b/apps/access-api/src/utils/backfill-lock.ts index 3a48300..0f032b6 100644 --- a/apps/access-api/src/utils/backfill-lock.ts +++ b/apps/access-api/src/utils/backfill-lock.ts @@ -15,7 +15,7 @@ * a stale lock was erroneously held (e.g. after a crash). */ -import { PrismaClient } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; /** How long (ms) before a lock heartbeat is considered stale. */ const LOCK_TTL_MS = 60_000; // 1 minute @@ -171,7 +171,7 @@ export class BackfillLock { /** Returns all currently-held (possibly stale) locks. */ async listLocks(): Promise { const rows = await this.prisma.backfillLock.findMany(); - return rows.map((r: { holder: string; acquiredAt: Date; liveHead: Date | null }) => ({ + return rows.map((r: { holder: string; acquiredAt: Date; liveHead: bigint | null }) => ({ holder: r.holder as LockHolder, acquiredAt: r.acquiredAt, liveHead: r.liveHead ?? undefined, diff --git a/apps/access-api/src/utils/leader-election.ts b/apps/access-api/src/utils/leader-election.ts index cdfbce1..50af047 100644 --- a/apps/access-api/src/utils/leader-election.ts +++ b/apps/access-api/src/utils/leader-election.ts @@ -18,7 +18,7 @@ * - All coordination is database-backed — no external service required. */ -import { PrismaClient } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; import { randomUUID } from "node:crypto"; // ─── Configuration ─────────────────────────────────────────────────────────── @@ -346,28 +346,27 @@ export class LeaderElectionService { private startRenewLoop(): void { this.clearTimers(); - this.renewTimer = setInterval(async () => { - try { - await this.renewLease(); - } catch (err) { + this.renewTimer = setInterval(() => { + void this.renewLease().catch((err) => { console.error("[LeaderElection] Lease renewal error:", err); - } + }); }, this.renewIntervalMs); } private startPollLoop(): void { this.clearTimers(); - this.pollTimer = setInterval(async () => { - try { - const became = await this.tryBecomeLeader(); - if (became) { - // Switch from polling to renewing - this.clearTimers(); - this.startRenewLoop(); - } - } catch (err) { - console.error("[LeaderElection] Poll error:", err); - } + this.pollTimer = setInterval(() => { + void this.tryBecomeLeader() + .then((became) => { + if (became) { + // Switch from polling to renewing + this.clearTimers(); + this.startRenewLoop(); + } + }) + .catch((err) => { + console.error("[LeaderElection] Poll error:", err); + }); }, this.standbyPollIntervalMs); } diff --git a/apps/access-api/src/workers/indexer.ts b/apps/access-api/src/workers/indexer.ts index 306cba8..e02811e 100644 --- a/apps/access-api/src/workers/indexer.ts +++ b/apps/access-api/src/workers/indexer.ts @@ -7,9 +7,9 @@ import { type Log, } from "viem"; import { mainnet } from "viem/chains"; -import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaClient, type Prisma } from "@prisma/client"; import { MEMBERSHIP_ABI, MEMBERSHIP_EVENTS } from "@guildpass/contracts"; -import { LeaderElectionService } from "../utils/leader-election.js"; +import type { LeaderElectionService } from "../utils/leader-election.js"; export interface IndexerConfig { rpcUrl: string; @@ -279,21 +279,50 @@ export class IndexerCore { } } - private async applyEventApplication(decoded: any, tx: any) { + private async applyEventApplication( + decoded: any, + tx: any, + ): Promise<{ wallet: string; passId: string; status: number } | undefined> { const { eventName, args } = decoded; if (eventName === MEMBERSHIP_EVENTS.MembershipCreated) { + const previous = await tx.membership.findUnique({ + where: { wallet_passId: { wallet: args.member, passId: args.passId } }, + }); await tx.membership.upsert({ where: { wallet_passId: { wallet: args.member, passId: args.passId } }, update: { status: 1 }, create: { wallet: args.member, passId: args.passId, status: 1 }, }); - } else if (eventName === MEMBERSHIP_EVENTS.MembershipUpdated) { + // Snapshot the pre-application state so a reorg can roll back exactly + // (passId is BigInt in the DB, so serialise it for JSON storage). + return previous + ? { + wallet: previous.wallet, + passId: previous.passId.toString(), + status: previous.status, + } + : undefined; + } + + if (eventName === MEMBERSHIP_EVENTS.MembershipUpdated) { + const previous = await tx.membership.findUnique({ + where: { wallet_passId: { wallet: args.member, passId: args.passId } }, + }); await tx.membership.update({ where: { wallet_passId: { wallet: args.member, passId: args.passId } }, data: { status: args.newStatus }, }); + return previous + ? { + wallet: previous.wallet, + passId: previous.passId.toString(), + status: previous.status, + } + : undefined; } + + return undefined; } private async revertEventApplication(event: any, tx: any) { diff --git a/apps/dashboard/app/activity/page.tsx b/apps/dashboard/app/activity/page.tsx index 43981f0..9689d79 100644 --- a/apps/dashboard/app/activity/page.tsx +++ b/apps/dashboard/app/activity/page.tsx @@ -6,7 +6,6 @@ import WalletAddressText from "@/components/WalletAddressText"; import { getActivityRefreshConfig } from "@/lib/env"; import { formatRelativeTime } from "@/lib/format-relative-time"; import { useActivityFeed } from "@/lib/hooks/useActivityFeed"; -import { convertToCsv, downloadCsv, } from "@/lib/csv-export"; import { type ActivityEventEntity, type ActivityEventSeverity, @@ -221,51 +220,6 @@ function ActivityPageContent() { guildId, }); - const handleExportCsv = () => { - const exportData = events.map((activity) => ({ - type: activity.type, - description: activity.description, - timestamp: new Date(activity.timestamp).toISOString(), - actor: - activity.actor.name || - activity.actor.wallet || - "System", - entity: activity.entity - ? `${activity.entity.type}: ${ - activity.entity.name || activity.entity.id - }` - : "", - source: activity.source, - severity: activity.severity, - })); - - const columns: { - key: - | "type" - | "description" - | "timestamp" - | "actor" - | "entity" - | "source" - | "severity"; - label: string; -}[] = [ - { key: "type", label: "Type" }, - { key: "description", label: "Description" }, - { key: "timestamp", label: "Timestamp" }, - { key: "actor", label: "Actor" }, - { key: "entity", label: "Entity" }, - { key: "source", label: "Source" }, - { key: "severity", label: "Severity" }, -]; - - const csv = convertToCsv(exportData, columns); - - const date = new Date().toISOString().split("T")[0]; - - downloadCsv(csv, `activity-log-${date}.csv`); -}; - const hasActiveFilters = Boolean(type || source || severity || entityType || actor.trim() || from || sort !== "newest" || limit !== 10); const clearFilters = () => { @@ -294,7 +248,7 @@ function ActivityPageContent() { diff --git a/apps/dashboard/app/passes/page.tsx b/apps/dashboard/app/passes/page.tsx index 53d1807..bbb9b02 100644 --- a/apps/dashboard/app/passes/page.tsx +++ b/apps/dashboard/app/passes/page.tsx @@ -138,7 +138,7 @@ export default function PassesPage() { } } - load(); + void load(); return () => { mounted = false; }; @@ -203,13 +203,46 @@ export default function PassesPage() { }; const handleDeactivate = (id: string) => { - updateMutation.mutate({ id, data: { status: "inactive" } }); + // Errors are surfaced via onError (alert); avoid unhandled rejection + updateMutation.mutate({ id, data: { status: "inactive" } }).catch(() => {}); }; const handleEdit = (id: string) => { const name = prompt("Enter new name:"); if (name?.trim()) { - updateMutation.mutate({ id, data: { name: name.trim() } }); + // Errors are surfaced via onError (alert); avoid unhandled rejection + updateMutation.mutate({ id, data: { name: name.trim() } }).catch(() => {}); + } + }; + + const handleCreate = async () => { + if (!form.name.trim()) return alert("Pass name is required."); + if (!form.description.trim()) return alert("Description is required."); + + try { + setCreateLoading(true); + const res = await guildFetch("/api/passes", guildId, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: form.name.trim(), + description: form.description.trim(), + price: form.price ? Number(form.price) : undefined, + maxSupply: form.maxSupply ? Number(form.maxSupply) : null, + status: "draft", + currentSupply: 0, + }), + }); + const newPass = await readApiResult(res); + setPasses((prev) => [newPass, ...prev].slice(0, pagination.limit)); + setPagination((prev) => ({ ...prev, total: prev.total + 1 })); + invalidateAfterMutation("pass", guildId); + setIsCreateOpen(false); + setForm({ name: "", description: "", price: "", maxSupply: "" }); + } catch (error: unknown) { + alert(error instanceof Error ? error.message : "Failed to create pass."); + } finally { + setCreateLoading(false); } }; @@ -286,36 +319,7 @@ export default function PassesPage() { diff --git a/apps/dashboard/app/settings/page.tsx b/apps/dashboard/app/settings/page.tsx index 3ff468e..017574a 100644 --- a/apps/dashboard/app/settings/page.tsx +++ b/apps/dashboard/app/settings/page.tsx @@ -98,7 +98,7 @@ export default function SettingsPage() { )} -
+ void handleSave(event)}> {/* General Settings and Profile */}
{/* General Settings */} diff --git a/apps/dashboard/components/ReconcilePanel.tsx b/apps/dashboard/components/ReconcilePanel.tsx index acb97be..7463ea5 100644 --- a/apps/dashboard/components/ReconcilePanel.tsx +++ b/apps/dashboard/components/ReconcilePanel.tsx @@ -50,7 +50,7 @@ export default function ReconcilePanel() {