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
32 changes: 32 additions & 0 deletions lib/ads/candidates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { AdFormatId } from "./creative";

// A page size, never a cap on the auction. The old LIMIT 100 excluded newer
// campaigns entirely once a format had more than 100 ready creatives.
const PAGE_SIZE = 500;
const COLUMNS = "id, campaign_id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color, font_family, ad_campaigns!inner(id, owner_id, status, ref_slug, destination_url, daily_budget_cents, spend_today_cents, spend_date, bid_credits)";

export async function loadServingCreatives(sb: SupabaseClient, format: AdFormatId) {
const rows = [];
let after: string | undefined;
for (;;) {
let query = sb.from("ad_creatives")
.select(COLUMNS)
.eq("format", format)
.eq("status", "ready")
.in("ad_campaigns.status", ["active", "exhausted"])
.order("id", { ascending: true })
.limit(PAGE_SIZE);
if (after) query = query.gt("id", after);
const { data, error } = await query;
if (error) {
// Never run an auction against a silently incomplete candidate pool.
console.error("[ads] creative inventory unavailable", error.message);
return [];
}
if (!data?.length) return rows;
rows.push(...data);
if (data.length < PAGE_SIZE) return rows;
after = data[data.length - 1].id as string;
}
}
11 changes: 2 additions & 9 deletions lib/ads/serve.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import { loadServingCreatives } from "./candidates";
import { serviceClient } from "@/lib/supabase/service";
import { env } from "@/lib/env";
import {
Expand Down Expand Up @@ -221,15 +222,7 @@ export async function serveAd(
// Campaigns with a ready creative in this format. 'exhausted' is a legacy
// status no longer written by ad_charge_click — rows still carrying it are
// live campaigns that ran dry, and belong on the free tier rather than dark.
const { data: creatives } = await sb
.from("ad_creatives")
.select(
"id, campaign_id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color, font_family, ad_campaigns!inner(id, owner_id, status, ref_slug, destination_url, daily_budget_cents, spend_today_cents, spend_date, bid_credits)",
)
.eq("format", format)
.eq("status", "ready")
.in("ad_campaigns.status", ["active", "exhausted"])
.limit(100);
const creatives = await loadServingCreatives(sb, format);

// No paid creative for this format → default CrawlProof house ad.
if (!creatives || creatives.length === 0) return houseFill(format, theme);
Expand Down
61 changes: 57 additions & 4 deletions tests/contract/ads-self-deal.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Regression: a self-owned campaign (same profile owns the slot and the
// campaign) used to be filtered out of the candidate list entirely. That is
Expand All @@ -22,6 +22,7 @@ const H = vi.hoisted(() => {
slotOwner: OWNER as string | null,
campaignOwners: [OWNER] as string[],
credits: 9999,
inventoryErrorAfter: false,
inserted: [] as Record<string, unknown>[],
},
};
Expand Down Expand Up @@ -87,10 +88,26 @@ vi.mock("@/lib/supabase/service", () => ({
});
}
if (table === "ad_creatives") {
return chain({
data: state.campaignOwners.map((o, i) => creativeFor(o, i)),
error: null,
let limit = 1000; // PostgREST's default response cap.
let after = "";
const query: unknown = new Proxy({}, {
get(_target, prop) {
if (prop === "then") {
const rows = state.campaignOwners.map((o, i) => creativeFor(o, i))
.sort((a, b) => a.id.localeCompare(b.id))
.filter((r) => r.id > after).slice(0, limit);
const result = after && state.inventoryErrorAfter
? { data: null, error: { message: "inventory timeout" } }
: { data: rows, error: null };
const promise = Promise.resolve(result);
return promise.then.bind(promise);
}
if (prop === "limit") return (n: number) => { limit = n; return query; };
if (prop === "gt") return (_key: string, id: string) => { after = id; return query; };
return () => query;
},
});
return query;
}
if (table === "profiles") {
return chain({
Expand Down Expand Up @@ -187,3 +204,39 @@ describe("self-deal still loses to a real advertiser", () => {
expect(state.inserted.some((r) => r.tier === "paid")).toBe(true);
});
});


describe("inventory beyond the first page", () => {
afterEach(() => { vi.restoreAllMocks(); state.inventoryErrorAfter = false; });

it.each([374, 500, 1201])("allows the last of %i campaigns to win and records its impression", async (count) => {
vi.resetModules();
state.slotOwner = OWNER;
state.campaignOwners = Array(count).fill(OWNER);
state.credits = 9999;
state.inserted = [];
// Select the last candidate in the ordered pool, past both the former
// 100-row cutoff and (in the larger fixture) PostgREST's 1000-row cap.
vi.spyOn(Math, "random").mockReturnValue(0.999999);
const last = state.campaignOwners.map((owner, i) => creativeFor(owner, i))
.sort((a, b) => a.id.localeCompare(b.id)).at(-1)!;
const fill = await serve();
expect(fill?.campaignId).toBe(last.campaign_id);
expect(fill?.tier).toBe("free");
expect(state.inserted).toEqual([expect.objectContaining({ campaign_id: last.campaign_id })]);
});

it("does not auction a partial pool when a later inventory page fails", async () => {
vi.resetModules();
state.slotOwner = OWNER;
state.campaignOwners = Array(501).fill(OWNER);
state.inventoryErrorAfter = true;
state.inserted = [];
const log = vi.spyOn(console, "error").mockImplementation(() => {});
const fill = await serve();
expect(fill?.campaignId).toBe("house");
expect(state.inserted).toEqual([]);
expect(log).toHaveBeenCalledWith("[ads] creative inventory unavailable", "inventory timeout");
});

});
Loading