Skip to content
Merged
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
20 changes: 20 additions & 0 deletions db/migrations/009_perf_indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Performance indexes for hot read paths.
--
-- ledger_entries(user_id): every balance lookup runs
-- SELECT SUM(delta) FROM ledger_entries WHERE user_id = $1
-- The existing ux_ledger_market_user_reason index leads with market_id, so it
-- can't serve a user_id lookup. Without this, balance is a sequential scan.
--
-- markets(group_id): the leaderboard resolves a group's markets with
-- WHERE group_id = $1
-- and the markets-list endpoint filters the same way. markets had no index here.
--
-- Tables are small at current scale, so a plain (non-CONCURRENT) build is fine
-- and keeps the migration transactional like the others. If these tables grow
-- large, rebuild these with CREATE INDEX CONCURRENTLY outside a transaction.
BEGIN;

CREATE INDEX IF NOT EXISTS ix_ledger_entries_user_id ON ledger_entries (user_id);
CREATE INDEX IF NOT EXISTS ix_markets_group_id ON markets (group_id);

COMMIT;
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"migrate": "node --env-file-if-exists=.env.local server/migrations/run_migrations.js",
"test": "node --env-file-if-exists=.env.local test/resolve.integration.test.js",
"test:db": "node --env-file-if-exists=.env.local test/database-url.test.js",
"test:dburl": "node --env-file-if-exists=.env.local test/database-url.test.js"
"test:dburl": "node --env-file-if-exists=.env.local test/database-url.test.js",
"test:perf": "vitest run",
"test:perf:watch": "vitest"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1039.0",
Expand All @@ -22,5 +24,9 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"remotion": "^4.0.454"
},
"devDependencies": {
"dotenv": "^17.4.2",
"vitest": "^4.1.7"
}
}
68 changes: 3 additions & 65 deletions pages/api/groups/[id]/leaderboard.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getUserFromRequest } from '../../../../lib/auth';
import { applyCors } from '../../../../server/cors';
import { query } from '../../../../server/db';
import { getLeaderboard } from '../../../../server/queries/leaderboard';

export default async function handler(req, res) {
if (applyCors(req, res)) return;
Expand All @@ -16,70 +16,8 @@ export default async function handler(req, res) {
if (!groupId) return res.status(400).json({ error: 'group id is required' });

try {
const { rows: memberRows } = await query(
'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1',
[groupId, user.id]
);
if (memberRows.length === 0) {
return res.status(403).json({ error: 'forbidden' });
}

const { rows: marketRows } = await query(
'SELECT id FROM markets WHERE group_id = $1',
[groupId]
);
const marketIds = marketRows.map((row) => row.id).filter(Boolean);

let ledgerRows = [];
if (marketIds.length > 0) {
const result = await query(
'SELECT user_id, market_id, delta, reason, created_at FROM ledger_entries WHERE market_id = ANY($1)',
[marketIds]
);
ledgerRows = result.rows;
}

const scores = new Map();
const history = new Map();
for (const row of ledgerRows) {
scores.set(row.user_id, (scores.get(row.user_id) || 0) + (row.delta || 0));
if (!history.has(row.user_id)) history.set(row.user_id, []);
history.get(row.user_id).push({
delta: row.delta || 0,
reason: row.reason,
created_at: row.created_at,
});
}

const { rows: groupMemberRows } = await query(
'SELECT user_id FROM group_members WHERE group_id = $1',
[groupId]
);

const scoredUserIds = [...new Set(groupMemberRows.map((row) => row.user_id).filter(Boolean))];
if (scoredUserIds.length === 0) return res.status(200).json([]);

const { rows: userRows } = await query(
'SELECT id, email, display_name, starting_points FROM users WHERE id = ANY($1)',
[scoredUserIds]
);
const userMap = new Map(userRows.map((u) => [u.id, u]));

const leaderboard = [...scores.entries()]
.map(([userId, score]) => {
const u = userMap.get(userId);
const display_name = u?.display_name ?? (u?.email ? u.email.split('@')[0] : userId);
const balance = (u?.starting_points ?? 2000) + score;
const recent = (history.get(userId) || [])
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 5);
const trendWindow = recent.slice(0, 3).reduce((sum, row) => sum + row.delta, 0);
const trend = trendWindow > 0 ? 'up' : (trendWindow < 0 ? 'down' : 'flat');
return { user_id: userId, display_name, score: balance, raw_delta: score, last_deltas: recent, trend };
})
.sort((left, right) => right.score - left.score);

return res.status(200).json(leaderboard);
const result = await getLeaderboard(groupId, user.id);
return res.status(result.status).json(result.body);
} catch (err) {
console.error('leaderboard error', err);
return res.status(500).json({ error: 'internal server error' });
Expand Down
77 changes: 3 additions & 74 deletions pages/api/markets/[id]/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { applyCors } from '../../../../server/cors';
import { query } from '../../../../server/db';
import { getUserFromRequest } from '../../../../lib/auth';
import { getMarketDetail } from '../../../../server/queries/marketDetail';

export default async function handler(req, res) {
if (applyCors(req, res)) return;
Expand All @@ -12,80 +12,9 @@ export default async function handler(req, res) {
const user = await getUserFromRequest(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });

const marketId = req.query.id;

try {
const { rows: marketRows } = await query(
`SELECT id, group_id, creator_id, title, type, state, resolve_by, resolution, created_at
FROM markets WHERE id = $1 LIMIT 1`,
[marketId]
);
const market = marketRows[0];
if (!market) {
return res.status(404).json({ error: 'market not found' });
}

const { rows: memberRows } = await query(
'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1',
[market.group_id, user.id]
);
if (memberRows.length === 0) {
return res.status(403).json({ error: 'forbidden' });
}

const { rows: predictionRows } = await query(
'SELECT choice FROM predictions WHERE market_id = $1',
[marketId]
);

const { rows: mySettlementRows } = await query(
'SELECT delta, reason, created_at FROM ledger_entries WHERE market_id = $1 AND user_id = $2',
[marketId, user.id]
);

const { rows: myPredictionRows } = await query(
'SELECT stake_points, choice, created_at FROM predictions WHERE market_id = $1 AND user_id = $2 LIMIT 1',
[marketId, user.id]
);
const myPredictionRow = myPredictionRows[0] || null;

const { rows: userRows } = await query(
'SELECT starting_points FROM users WHERE id = $1 LIMIT 1',
[user.id]
);
const userRow = userRows[0];

const { rows: allLedgerRows } = await query(
'SELECT delta FROM ledger_entries WHERE user_id = $1',
[user.id]
);

const predictionCount = predictionRows.length;
const yesCount = predictionRows.filter((row) => row.choice === true).length;
const noCount = predictionRows.filter((row) => row.choice === false).length;

const settlementBreakdown = {};
let settlementDelta = 0;
for (const row of mySettlementRows) {
settlementBreakdown[row.reason] = (settlementBreakdown[row.reason] || 0) + (row.delta || 0);
settlementDelta += row.delta || 0;
}
const userBalance = (userRow?.starting_points ?? 2000) + allLedgerRows.reduce((sum, row) => sum + (row.delta || 0), 0);

return res.status(200).json({
market: {
...market,
prediction_count: predictionCount,
yes_count: yesCount,
no_count: noCount,
my_settlement: {
total_delta: settlementDelta,
breakdown: settlementBreakdown,
},
my_prediction: myPredictionRow,
my_balance: userBalance,
},
});
const result = await getMarketDetail(req.query.id, user.id);
return res.status(result.status).json(result.body);
} catch (e) {
console.error('market detail error', e);
return res.status(500).json({ error: 'internal' });
Expand Down
14 changes: 1 addition & 13 deletions pages/api/markets/[id]/predictions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getUserFromRequest } from '../../../../lib/auth';
import { applyCors } from '../../../../server/cors';
import { query } from '../../../../server/db';
import { getUserBalance } from '../../../../server/queries/balance';
import { getIdempotentResponse, storeIdempotentResponse } from '../../../../server/idempotency';

async function loadMarketWithMembership(marketId, userId) {
Expand Down Expand Up @@ -51,19 +52,6 @@ async function listPredictions(marketId, userId) {
return { status: 200, body: enriched };
}

async function getUserBalance(userId) {
const { rows: userRows } = await query(
'SELECT starting_points FROM users WHERE id = $1 LIMIT 1',
[userId]
);
const { rows: ledgerRows } = await query(
'SELECT delta FROM ledger_entries WHERE user_id = $1',
[userId]
);
const ledgerTotal = ledgerRows.reduce((sum, row) => sum + (row.delta || 0), 0);
return (userRows[0]?.starting_points ?? 2000) + ledgerTotal;
}

async function createPrediction(marketId, userId, userEmail, choice, stakePoints) {
const marketStatus = await loadMarketWithMembership(marketId, userId);
if (marketStatus.notFound) return { status: 404, body: { error: 'market not found' } };
Expand Down
18 changes: 18 additions & 0 deletions server/queries/balance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const { query } = require('../db');

// A user's balance is their starting_points plus the sum of all their ledger
// deltas. Computed in a single aggregate round-trip rather than fetching every
// ledger row and summing in JS. Defaults starting_points to 2000 if the user
// row doesn't exist yet (matches prior handler behavior).
async function getUserBalance(userId, q = query) {
const { rows } = await q(
`SELECT (
COALESCE((SELECT starting_points FROM users WHERE id = $1), 2000)
+ COALESCE((SELECT SUM(delta) FROM ledger_entries WHERE user_id = $1), 0)
)::int AS balance`,
[userId]
);
return rows[0].balance;
}

module.exports = { getUserBalance };
67 changes: 67 additions & 0 deletions server/queries/leaderboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const { query } = require('../db');

// Group leaderboard. Returns { status, body }.
// Scores are aggregated in SQL (SUM per user) and the per-user "recent activity"
// is bounded to the latest 5 rows via a window function, instead of pulling
// every ledger row for the group and summing in JS. Only users with ledger
// activity appear (matches prior behavior).
async function getLeaderboard(groupId, userId, q = query) {
const { rows: memberRows } = await q(
'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1',
[groupId, userId]
);
if (memberRows.length === 0) return { status: 403, body: { error: 'forbidden' } };

// One pass over the group's ledger: total delta per user (full SUM) plus the
// 5 most recent entries per user. raw_delta is the same on every row for a
// given user (window SUM over the whole partition).
const { rows: ledgerRows } = await q(
`SELECT user_id, delta, reason, created_at, raw_delta FROM (
SELECT user_id, delta, reason, created_at,
SUM(delta) OVER (PARTITION BY user_id)::int AS raw_delta,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM ledger_entries
WHERE market_id IN (SELECT id FROM markets WHERE group_id = $1)
) t
WHERE rn <= 5
ORDER BY user_id, created_at DESC`,
[groupId]
);

if (ledgerRows.length === 0) return { status: 200, body: [] };

const perUser = new Map();
for (const row of ledgerRows) {
if (!perUser.has(row.user_id)) {
perUser.set(row.user_id, { raw_delta: row.raw_delta || 0, recent: [] });
}
perUser.get(row.user_id).recent.push({
delta: row.delta || 0,
reason: row.reason,
created_at: row.created_at,
});
}

const userIds = [...perUser.keys()];
const { rows: userRows } = await q(
'SELECT id, email, display_name, starting_points FROM users WHERE id = ANY($1)',
[userIds]
);
const userMap = new Map(userRows.map((u) => [u.id, u]));

const leaderboard = userIds
.map((id) => {
const { raw_delta, recent } = perUser.get(id);
const u = userMap.get(id);
const display_name = u?.display_name ?? (u?.email ? u.email.split('@')[0] : id);
const score = (u?.starting_points ?? 2000) + raw_delta;
const trendWindow = recent.slice(0, 3).reduce((sum, r) => sum + r.delta, 0);
const trend = trendWindow > 0 ? 'up' : trendWindow < 0 ? 'down' : 'flat';
return { user_id: id, display_name, score, raw_delta, last_deltas: recent, trend };
})
.sort((left, right) => right.score - left.score);

return { status: 200, body: leaderboard };
}

module.exports = { getLeaderboard };
70 changes: 70 additions & 0 deletions server/queries/marketDetail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const { query } = require('../db');
const { getUserBalance } = require('./balance');

// Market detail for a single viewer. Returns { status, body }.
// The market lookup and membership check are sequential (membership needs the
// market's group_id, and both gate access). Everything after that is
// independent, so it runs concurrently. Counts and balance are aggregated in
// SQL rather than fetched row-by-row and summed in JS.
async function getMarketDetail(marketId, userId, q = query) {
const { rows: marketRows } = await q(
`SELECT id, group_id, creator_id, title, type, state, resolve_by, resolution, created_at
FROM markets WHERE id = $1 LIMIT 1`,
[marketId]
);
const market = marketRows[0];
if (!market) return { status: 404, body: { error: 'market not found' } };

const { rows: memberRows } = await q(
'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1',
[market.group_id, userId]
);
if (memberRows.length === 0) return { status: 403, body: { error: 'forbidden' } };

const [countsResult, settlementResult, myPredictionResult, userBalance] = await Promise.all([
q(
`SELECT COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE choice = true)::int AS yes,
COUNT(*) FILTER (WHERE choice = false)::int AS no
FROM predictions WHERE market_id = $1`,
[marketId]
),
q(
'SELECT delta, reason, created_at FROM ledger_entries WHERE market_id = $1 AND user_id = $2',
[marketId, userId]
),
q(
'SELECT stake_points, choice, created_at FROM predictions WHERE market_id = $1 AND user_id = $2 LIMIT 1',
[marketId, userId]
),
getUserBalance(userId, q),
]);

const counts = countsResult.rows[0];
const settlementBreakdown = {};
let settlementDelta = 0;
for (const row of settlementResult.rows) {
settlementBreakdown[row.reason] = (settlementBreakdown[row.reason] || 0) + (row.delta || 0);
settlementDelta += row.delta || 0;
}

return {
status: 200,
body: {
market: {
...market,
prediction_count: counts.total,
yes_count: counts.yes,
no_count: counts.no,
my_settlement: {
total_delta: settlementDelta,
breakdown: settlementBreakdown,
},
my_prediction: myPredictionResult.rows[0] || null,
my_balance: userBalance,
},
},
};
}

module.exports = { getMarketDetail };
Loading
Loading