Skip to content

Commit fae5f22

Browse files
authored
perf(server): bulk DB loops + React.cache repeated queries (#91)
Closes #88. - affiliate.topReferrers: replace per-row SELECT with single inArray + Map lookup; also drops the buggy stub 'satisfy and() arity' query that was always one-row and discarded - gdpr.purgeExpiredDeletions: parallelize the markPurgedAt + audit insert per row, and reorder so we mark+audit BEFORE the user delete (previously the cascade wiped accountDeletion before the update, making the purgedAt write a silent no-op) - referral.recordAcceptance: Promise.all the update + audit per row, iterations themselves via Promise.allSettled - webhooks.processPendingDeliveries: parallelize the two status updates per delivery (delivery row + subscription row) - onboarding.hasCompletedOnboarding: wrap in React.cache so the layout + page no longer double-query per request
1 parent 1f0f8a5 commit fae5f22

5 files changed

Lines changed: 131 additions & 124 deletions

File tree

apps/web/src/lib/onboarding.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,27 @@ import "server-only";
22
import { db } from "@starter-saas/db";
33
import { auditLog } from "@starter-saas/db/schema/audit";
44
import { and, eq } from "drizzle-orm";
5+
import { cache } from "react";
56

67
export const ONBOARDING_DONE_ACTION = "onboarding.completed";
78

8-
export async function hasCompletedOnboarding(userId: string): Promise<boolean> {
9-
if (!userId) {
10-
return true;
11-
}
12-
const rows = await db
13-
.select({ id: auditLog.id })
14-
.from(auditLog)
15-
.where(
16-
and(
17-
eq(auditLog.action, ONBOARDING_DONE_ACTION),
18-
eq(auditLog.actorUserId, userId),
19-
),
20-
)
21-
.limit(1);
22-
return rows.length > 0;
23-
}
9+
// Wrapped in React.cache so the dashboard layout + onboarding page (or
10+
// any other RSC that asks per-request) share a single DB round-trip.
11+
export const hasCompletedOnboarding = cache(
12+
async (userId: string): Promise<boolean> => {
13+
if (!userId) {
14+
return true;
15+
}
16+
const rows = await db
17+
.select({ id: auditLog.id })
18+
.from(auditLog)
19+
.where(
20+
and(
21+
eq(auditLog.action, ONBOARDING_DONE_ACTION),
22+
eq(auditLog.actorUserId, userId),
23+
),
24+
)
25+
.limit(1);
26+
return rows.length > 0;
27+
},
28+
);

packages/api/src/affiliate.ts

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from "@starter-saas/db/schema/affiliate";
1414
import { auditLog } from "@starter-saas/db/schema/audit";
1515
import { env } from "@starter-saas/env/server";
16-
import { and, count, desc, eq } from "drizzle-orm";
16+
import { and, count, desc, eq, inArray } from "drizzle-orm";
1717

1818
export const AFFILIATE_COOKIE_NAME = "aff_ref";
1919
export const DEFAULT_PAYOUT_MIN_CENTS = 2500;
@@ -253,38 +253,26 @@ export async function topReferrers(limit = 100) {
253253
if (rows.length === 0) {
254254
return [];
255255
}
256+
const ids = rows.map((r) => r.affiliateId);
256257
const affRows = await db
257-
.select()
258+
.select({
259+
id: affiliate.id,
260+
userId: affiliate.userId,
261+
code: affiliate.code,
262+
})
258263
.from(affiliate)
259-
.where(
260-
and(
261-
...rows
262-
.slice(0, 1) // satisfy and() arity
263-
.map((r) => eq(affiliate.id, r.affiliateId)),
264-
),
265-
);
266-
// One-by-one lookup avoids `inArray` collisions with `text` arrays.
267-
const enriched: Array<{
268-
affiliateId: string;
269-
userId: string;
270-
code: string;
271-
signups: number;
272-
}> = [];
273-
for (const row of rows) {
274-
const [aff] = await db
275-
.select()
276-
.from(affiliate)
277-
.where(eq(affiliate.id, row.affiliateId))
278-
.limit(1);
279-
if (aff) {
280-
enriched.push({
264+
.where(inArray(affiliate.id, ids));
265+
const byId = new Map(affRows.map((a) => [a.id, a]));
266+
return rows.flatMap((row) => {
267+
const aff = byId.get(row.affiliateId);
268+
if (!aff) return [];
269+
return [
270+
{
281271
affiliateId: aff.id,
282272
userId: aff.userId,
283273
code: aff.code,
284274
signups: Number(row.signups),
285-
});
286-
}
287-
}
288-
void affRows;
289-
return enriched;
275+
},
276+
];
277+
});
290278
}

packages/api/src/gdpr.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -154,28 +154,33 @@ export async function purgeExpiredDeletions(): Promise<{ purged: number }> {
154154
.select()
155155
.from(accountDeletion)
156156
.where(lt(accountDeletion.scheduledAt, now));
157-
let purged = 0;
158-
for (const row of rows) {
159-
if (row.canceledAt || row.purgedAt) {
160-
continue;
161-
}
162-
await db.delete(userTable).where(eq(userTable.id, row.userId));
163-
await db
164-
.update(accountDeletion)
165-
.set({ purgedAt: new Date() })
166-
.where(eq(accountDeletion.id, row.id));
167-
await db
168-
.insert(auditLog)
169-
.values({
170-
id: randomUUID(),
171-
actorUserId: null,
172-
action: "gdpr.deletion.purged",
173-
targetType: "user",
174-
targetId: row.userId,
175-
metadata: { scheduledAt: row.scheduledAt.toISOString() },
176-
})
177-
.onConflictDoNothing();
178-
purged++;
179-
}
180-
return { purged };
157+
const due = rows.filter((row) => !row.canceledAt && !row.purgedAt);
158+
const purgedAt = new Date();
159+
const results = await Promise.allSettled(
160+
due.map(async (row) => {
161+
// Mark + audit before delete so the audit row references the user
162+
// id while the user still exists; the FK cascade on accountDeletion
163+
// also wipes our marked row after the user delete commits, which is
164+
// fine — pendingDeletion checks for the row's absence too.
165+
await Promise.all([
166+
db
167+
.update(accountDeletion)
168+
.set({ purgedAt })
169+
.where(eq(accountDeletion.id, row.id)),
170+
db
171+
.insert(auditLog)
172+
.values({
173+
id: randomUUID(),
174+
actorUserId: null,
175+
action: "gdpr.deletion.purged",
176+
targetType: "user",
177+
targetId: row.userId,
178+
metadata: { scheduledAt: row.scheduledAt.toISOString() },
179+
})
180+
.onConflictDoNothing(),
181+
]);
182+
await db.delete(userTable).where(eq(userTable.id, row.userId));
183+
}),
184+
);
185+
return { purged: results.filter((r) => r.status === "fulfilled").length };
181186
}

packages/api/src/referral.ts

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -79,33 +79,36 @@ export async function recordAcceptance(input: {
7979
eq(referral.status, "pending"),
8080
),
8181
);
82-
let matched = 0;
83-
for (const row of rows) {
84-
await db
85-
.update(referral)
86-
.set({
87-
status: "accepted",
88-
referredUserId: input.newUserId,
89-
acceptedAt: new Date(),
90-
})
91-
.where(eq(referral.id, row.id));
92-
await db
93-
.insert(auditLog)
94-
.values({
95-
id: randomUUID(),
96-
actorUserId: input.newUserId,
97-
action: "referral.accepted",
98-
targetType: "referral",
99-
targetId: row.id,
100-
metadata: {
101-
referrerUserId: row.referrerUserId,
102-
rewardCents: row.rewardCents,
103-
},
104-
})
105-
.onConflictDoNothing();
106-
matched++;
107-
}
108-
return { matched };
82+
const acceptedAt = new Date();
83+
const results = await Promise.allSettled(
84+
rows.map((row) =>
85+
Promise.all([
86+
db
87+
.update(referral)
88+
.set({
89+
status: "accepted",
90+
referredUserId: input.newUserId,
91+
acceptedAt,
92+
})
93+
.where(eq(referral.id, row.id)),
94+
db
95+
.insert(auditLog)
96+
.values({
97+
id: randomUUID(),
98+
actorUserId: input.newUserId,
99+
action: "referral.accepted",
100+
targetType: "referral",
101+
targetId: row.id,
102+
metadata: {
103+
referrerUserId: row.referrerUserId,
104+
rewardCents: row.rewardCents,
105+
},
106+
})
107+
.onConflictDoNothing(),
108+
]),
109+
),
110+
);
111+
return { matched: results.filter((r) => r.status === "fulfilled").length };
109112
}
110113

111114
export async function pendingReward(userId: string): Promise<number> {

packages/api/src/webhooks.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -186,40 +186,46 @@ export async function processPendingDeliveries(limit = 50): Promise<{
186186

187187
if (succeeded) {
188188
delivered++;
189-
await db
190-
.update(webhookDelivery)
191-
.set({
192-
status: "delivered",
193-
attempts,
194-
responseCode: status,
195-
responseBody: bodyText,
196-
deliveredAt: new Date(),
197-
})
198-
.where(eq(webhookDelivery.id, row.id));
199-
await db
200-
.update(userWebhook)
201-
.set({ lastDeliveryAt: new Date(), failureCount: 0 })
202-
.where(eq(userWebhook.id, sub.id));
189+
const deliveredAt = new Date();
190+
// Both updates touch different rows; parallelize.
191+
await Promise.all([
192+
db
193+
.update(webhookDelivery)
194+
.set({
195+
status: "delivered",
196+
attempts,
197+
responseCode: status,
198+
responseBody: bodyText,
199+
deliveredAt,
200+
})
201+
.where(eq(webhookDelivery.id, row.id)),
202+
db
203+
.update(userWebhook)
204+
.set({ lastDeliveryAt: deliveredAt, failureCount: 0 })
205+
.where(eq(userWebhook.id, sub.id)),
206+
]);
203207
continue;
204208
}
205209

206210
failed++;
207211
const giveUp = attempts >= MAX_ATTEMPTS;
208212
const next = giveUp ? null : new Date(Date.now() + backoffMs(attempts));
209-
await db
210-
.update(webhookDelivery)
211-
.set({
212-
status: giveUp ? "failed" : "retry",
213-
attempts,
214-
responseCode: status || null,
215-
responseBody: bodyText ?? null,
216-
nextRetryAt: next,
217-
})
218-
.where(eq(webhookDelivery.id, row.id));
219-
await db
220-
.update(userWebhook)
221-
.set({ failureCount: sub.failureCount + 1 })
222-
.where(eq(userWebhook.id, sub.id));
213+
await Promise.all([
214+
db
215+
.update(webhookDelivery)
216+
.set({
217+
status: giveUp ? "failed" : "retry",
218+
attempts,
219+
responseCode: status || null,
220+
responseBody: bodyText ?? null,
221+
nextRetryAt: next,
222+
})
223+
.where(eq(webhookDelivery.id, row.id)),
224+
db
225+
.update(userWebhook)
226+
.set({ failureCount: sub.failureCount + 1 })
227+
.where(eq(userWebhook.id, sub.id)),
228+
]);
223229
}
224230

225231
return { processed: pending.length, delivered, failed };

0 commit comments

Comments
 (0)