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
37 changes: 36 additions & 1 deletion lib/promote/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,45 @@ export type BlendInput = {
posted: Partial<Record<Ownership, number>>;
/** Which classes have a link ready to post right now. */
available: Partial<Record<Ownership, boolean>>;
/**
* Which classes the campaign has an enabled source feeding. A class with no
* inventory *and* no source is not starved — the campaign simply does not
* publish that kind of content.
*/
hasSource?: Partial<Record<Ownership, boolean>>;
fallback: FallbackPolicy;
/** Fallback posts already made today, against maxFallbackItemsPerDay. */
fallbackUsedToday?: number;
};

/**
* Narrow a mix to the classes this campaign can actually supply.
*
* Without this, a campaign of nothing but the user's own pasted links reads its
* 70/30 default as "30% short on shared content", finds none, and posts its own
* links as *fallbacks* — every tick, until maxFallbackItemsPerDay stops the
* campaign dead. A class the campaign has no inventory and no source for is not
* a starved class; it is not part of this campaign's mix at all.
*
* Returns the original mix when nothing qualifies, so the caller still gets a
* ranking to reason about rather than an empty one.
*/
export function effectiveMix(
mix: BlendMix,
available: Partial<Record<Ownership, boolean>>,
hasSource: Partial<Record<Ownership, boolean>> = {},
): BlendMix {
const restricted: BlendMix = { owned: 0, partner: 0, shared: 0 };
let total = 0;
for (const key of OWNERSHIPS) {
if (mix[key] <= 0) continue;
if (!available[key] && !hasSource[key]) continue;
restricted[key] = mix[key];
total += mix[key];
}
return total > 0 ? restricted : mix;
}

/**
* Rank the ownership classes by how far each is below its target share.
* Exported for the preview UI, which shows why a given item is next.
Expand All @@ -124,7 +158,8 @@ export function rankByDeficit(mix: BlendMix, posted: Partial<Record<Ownership, n
* Choose which ownership class the next post draws from.
*/
export function chooseOwnership(input: BlendInput): BlendDecision {
const { mix, posted, available, fallback } = input;
const { posted, available, fallback } = input;
const mix = effectiveMix(input.mix, available, input.hasSource);
const ranked = rankByDeficit(mix, posted);

// The class that is furthest behind and actually has something to post.
Expand Down
16 changes: 16 additions & 0 deletions lib/promote/selectLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,28 @@ export async function selectNextLink(
}),
);

// Which classes this campaign has an enabled source feeding. A class with no
// source and no links is not starved — it is simply not part of the mix, and
// treating it as starved turns every ordinary post into a fallback.
const { data: sourceRows } = await supabase
.from("promo_source")
.select("ownership")
.eq("list_id", list.id)
.eq("enabled", true);

const hasSource: Partial<Record<Ownership, boolean>> = {};
for (const row of (sourceRows ?? []) as Array<{ ownership: string }>) {
const key = row.ownership as Ownership;
if (OWNERSHIPS.includes(key)) hasSource[key] = true;
}

const fallbackUsedToday = await countFallbackToday(supabase, list.id, now);

const decision = chooseOwnership({
mix,
posted,
available,
hasSource,
fallback,
fallbackUsedToday,
});
Expand Down
118 changes: 114 additions & 4 deletions tests/promote/blend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseFallback,
parseMix,
rankByDeficit,
effectiveMix,
DEFAULT_MIX,
type BlendMix,
type FallbackPolicy,
Expand Down Expand Up @@ -102,9 +103,19 @@ describe("chooseOwnership", () => {

describe("when the owned queue is empty", () => {
const available = { owned: false, shared: true };
// The campaign has an owned source; it just has nothing to post from it
// right now. That is what makes owned genuinely starved, rather than
// simply not part of this campaign's mix.
const hasSource = { owned: true, shared: true };

it("uses shared content by default, flagged as a fallback", () => {
const decision = chooseOwnership({ mix, posted: {}, available, fallback: permissive });
const decision = chooseOwnership({
mix,
posted: {},
available,
hasSource,
fallback: permissive,
});
expect(decision.ownership).toBe("shared");
expect(decision.viaFallback).toBe(true);
expect(decision.reason).toBe("fallback");
Expand All @@ -115,6 +126,7 @@ describe("chooseOwnership", () => {
mix,
posted: {},
available,
hasSource,
fallback: { ...permissive, whenOwnedQueueEmpty: "pause" },
});
expect(decision.ownership).toBeNull();
Expand All @@ -124,13 +136,14 @@ describe("chooseOwnership", () => {
it("stops once the daily fallback cap is reached", () => {
const fallback = { ...permissive, maxFallbackItemsPerDay: 3 };
expect(
chooseOwnership({ mix, posted: {}, available, fallback, fallbackUsedToday: 2 })
chooseOwnership({ mix, posted: {}, available, hasSource, fallback, fallbackUsedToday: 2 })
.ownership,
).toBe("shared");
const capped = chooseOwnership({
mix,
posted: {},
available,
hasSource,
fallback,
fallbackUsedToday: 3,
});
Expand All @@ -156,6 +169,7 @@ describe("chooseOwnership", () => {
mix,
posted: { owned: 9, shared: 0 },
available: { owned: true, shared: false },
hasSource: { shared: true },
fallback: permissive,
});
expect(decision.ownership).toBe("owned");
Expand All @@ -164,15 +178,22 @@ describe("chooseOwnership", () => {

it("reaches an unweighted class only under use_any_available", () => {
const onlyPartner = { owned: false, shared: false, partner: true };
const hasSource = { owned: true, shared: true };
expect(
chooseOwnership({ mix, posted: {}, available: onlyPartner, fallback: permissive })
.ownership,
chooseOwnership({
mix,
posted: {},
available: onlyPartner,
hasSource,
fallback: permissive,
}).ownership,
).toBeNull();
expect(
chooseOwnership({
mix,
posted: {},
available: onlyPartner,
hasSource,
fallback: { ...permissive, whenOwnedQueueEmpty: "use_any_available" },
}).ownership,
).toBe("partner");
Expand All @@ -190,6 +211,95 @@ describe("chooseOwnership", () => {
});
});

describe("a campaign that only has its own content", () => {
// Regression, found in production on 2026-08-18. A list of 39 hand-pasted
// owned links carried the 70/30 default mix. The blend read it as "30% short
// on shared content", found none, and posted the user's own links as
// *fallbacks* — six in one tick. maxFallbackItemsPerDay is 3, so the next
// tick would have posted nothing and the campaign would have looked dead.
const mix: BlendMix = { owned: 70, partner: 0, shared: 30 };
const onlyOwned = { owned: true };

it("posts its own content on target, not as a fallback", () => {
const decision = chooseOwnership({
mix,
posted: { owned: 50 },
available: onlyOwned,
fallback: { ...permissive, maxFallbackItemsPerDay: 3 },
});
expect(decision.ownership).toBe("owned");
expect(decision.viaFallback).toBe(false);
expect(decision.reason).toBe("on_target");
});

it("keeps posting indefinitely, never tripping the fallback cap", () => {
const posted: Partial<Record<Ownership, number>> = {};
for (let i = 0; i < 40; i++) {
const decision = chooseOwnership({
mix,
posted,
available: onlyOwned,
fallback: { ...permissive, maxFallbackItemsPerDay: 3 },
// Every previous post was on-target, so nothing accumulates here.
fallbackUsedToday: 0,
});
expect(decision.ownership).toBe("owned");
expect(decision.viaFallback).toBe(false);
posted.owned = (posted.owned ?? 0) + 1;
}
});

it("does start honouring the ratio once a shared source exists", () => {
// Same campaign, now subscribed to a keyword source. Shared is genuinely
// starved, so covering for it IS a fallback and the cap should apply.
const decision = chooseOwnership({
mix,
posted: { owned: 50 },
available: onlyOwned,
hasSource: { shared: true },
fallback: { ...permissive, maxFallbackItemsPerDay: 3 },
});
expect(decision.ownership).toBe("owned");
expect(decision.viaFallback).toBe(true);
});

it("prefers the shared source's content as soon as it has any", () => {
const decision = chooseOwnership({
mix,
posted: { owned: 50 },
available: { owned: true, shared: true },
hasSource: { shared: true },
fallback: permissive,
});
expect(decision.ownership).toBe("shared");
expect(decision.viaFallback).toBe(false);
});
});

describe("effectiveMix", () => {
const mix: BlendMix = { owned: 70, partner: 0, shared: 30 };

it("drops a class the campaign can neither stock nor fetch", () => {
expect(effectiveMix(mix, { owned: true }, {})).toEqual({
owned: 70,
partner: 0,
shared: 0,
});
});

it("keeps a class that has a source but no inventory right now", () => {
expect(effectiveMix(mix, { owned: true }, { shared: true })).toEqual(mix);
});

it("keeps a class that has inventory but no source", () => {
expect(effectiveMix(mix, { owned: true, shared: true }, {})).toEqual(mix);
});

it("returns the original mix when nothing qualifies", () => {
expect(effectiveMix(mix, {}, {})).toEqual(mix);
});
});

describe("rankByDeficit", () => {
it("puts the class furthest below its target first", () => {
const mix: BlendMix = { owned: 70, partner: 0, shared: 30 };
Expand Down
42 changes: 40 additions & 2 deletions tests/promote/select-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ function link(over: Record<string, unknown> = {}) {
}

function db(over: Partial<FakeDb> = {}): FakeDb {
return { promo_link: [], promo_post: [], ...over };
return { promo_link: [], promo_post: [], promo_source: [], ...over };
}

// A campaign only treats a class as *starved* if something is meant to be
// feeding it. Without a source, an empty class is simply not part of the mix —
// which is what stops an all-owned campaign posting its own links as fallbacks.
const sourceFor = (ownership: string) => [
{ id: `src-${ownership}`, list_id: "list-a", ownership, enabled: true },
];

const list = {
id: "list-a",
source_mix: { owned: 70, partner: 0, shared: 30 },
Expand Down Expand Up @@ -102,6 +109,7 @@ describe("selectNextLink", () => {
link({ id: "off", ownership: "owned", enabled: false }),
link({ id: "on", ownership: "shared" }),
],
promo_source: sourceFor("owned"),
}),
);
const selection = await selectNextLink(client, list, NOW);
Expand All @@ -111,7 +119,10 @@ describe("selectNextLink", () => {

it("falls back to shared when the owned queue is empty", async () => {
const { client } = makeFakeSupabase(
db({ promo_link: [link({ id: "shared-1", ownership: "shared" })] }),
db({
promo_link: [link({ id: "shared-1", ownership: "shared" })],
promo_source: sourceFor("owned"),
}),
);
const selection = await selectNextLink(client, list, NOW);
expect(selection.link?.id).toBe("shared-1");
Expand All @@ -123,6 +134,7 @@ describe("selectNextLink", () => {
const { client } = makeFakeSupabase(
db({
promo_link: [link({ id: "shared-1", ownership: "shared" })],
promo_source: sourceFor("owned"),
promo_post: Array.from({ length: 3 }, (_, i) => ({
id: `fb-${i}`,
list_id: "list-a",
Expand All @@ -142,6 +154,7 @@ describe("selectNextLink", () => {
const { client } = makeFakeSupabase(
db({
promo_link: [link({ id: "shared-1", ownership: "shared" })],
promo_source: sourceFor("owned"),
promo_post: Array.from({ length: 3 }, (_, i) => ({
id: `fb-${i}`,
list_id: "list-a",
Expand Down Expand Up @@ -201,6 +214,31 @@ describe("selectNextLink", () => {
expect((await selectNextLink(client, list, NOW)).link?.id).toBe("shared-1");
});

it("does not treat an all-owned campaign's posts as fallbacks", async () => {
// Production regression, 2026-08-18. The "lives" campaign: 39 hand-pasted
// owned links, no sources, carrying the 70/30 default. Every post came out
// flagged via_fallback, and maxFallbackItemsPerDay=3 would have stopped the
// campaign entirely on the following tick.
const { client } = makeFakeSupabase(
db({
promo_link: [link({ id: "owned-1", ownership: "owned" })],
promo_source: [],
promo_post: Array.from({ length: 50 }, (_, i) => ({
id: `legacy-${i}`,
list_id: "list-a",
ownership: "owned",
status: "posted",
via_fallback: false,
created_at: "2026-08-18T10:00:00.000Z",
})),
}),
);
const selection = await selectNextLink(client, list, NOW);
expect(selection.link?.id).toBe("owned-1");
expect(selection.decision.viaFallback).toBe(false);
expect(selection.decision.reason).toBe("on_target");
});

it("ignores another list's history", async () => {
const { client } = makeFakeSupabase(
db({
Expand Down
Loading