Files
cfw-autumn/server/experiments/diffV07VsV22.ts
2026-05-14 10:19:12 +01:00

427 lines
11 KiB
TypeScript

import {
type AppEnv,
type CusProductStatus,
type FullCustomer,
type ListCustomersV2Params,
RELEVANT_STATUSES,
} from "@autumn/shared";
import chalk from "chalk";
import { sql } from "drizzle-orm";
import { initDrizzle } from "../src/db/initDrizzle";
import { getCursorPaginatedFullCusQuery } from "../src/internal/customers/cursorPaginatedFullCusQuery";
import { getPaginatedFullCusQuery } from "../src/internal/customers/getFullCusQuery";
import {
type FlattenedCustomerRow,
reassembleFlattenedCustomer,
} from "../src/internal/customers/reassembleFlattenedCustomer";
import { loadLocalEnv } from "../src/utils/envUtils";
loadLocalEnv();
const ORGS: { id: string; env: AppEnv; label: string }[] = [
{ id: "biu9vSF7vghBLSKW1UTDwxHBAivjnPaK", env: "live" as AppEnv, label: "firecrawl" },
{ id: "r7pwHiekGsqt32qGqcVku6thWFh5aHh8", env: "live" as AppEnv, label: "runable" },
];
const LIMIT = parseInt(process.env.DIFF_LIMIT ?? "50", 10);
const DEEP_OFFSET = parseInt(process.env.DIFF_DEEP_OFFSET ?? "500", 10);
const CUS_PRODUCT_LIMIT = 15;
const STATEMENT_TIMEOUT_MS = 30_000;
type ResolvedParams = Partial<ListCustomersV2Params> & {
offset?: number;
cursor?: { t: number; id: string };
};
type CaseContext = { db: DB; orgId: string; env: AppEnv };
type Case = {
name: string;
build: (ctx: CaseContext) => Promise<ResolvedParams | null>;
};
type DB = ReturnType<typeof initDrizzle>["db"];
const runReadOnly = async <T,>(db: DB, fn: (tx: any) => Promise<T>): Promise<T> => {
return await db.transaction(async (tx) => {
await tx.execute(
sql.raw(`SET LOCAL statement_timeout = ${STATEMENT_TIMEOUT_MS}`),
);
await tx.execute(sql.raw("SET LOCAL transaction_read_only = on"));
return fn(tx);
});
};
const v22NormalizeTimestamp = (value: any): number => {
if (typeof value === "string") {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? Date.now() : parsed;
}
return typeof value === "number" ? value : Date.now();
};
const v22Normalize = (raw: any): any => {
const out = {
...raw,
created_at: v22NormalizeTimestamp(raw.created_at),
};
if (raw.customer_products && Array.isArray(raw.customer_products)) {
out.customer_products = raw.customer_products.map((cp: any) => ({
...cp,
created_at: v22NormalizeTimestamp(cp.created_at),
starts_at: cp.starts_at
? v22NormalizeTimestamp(cp.starts_at)
: v22NormalizeTimestamp(cp.created_at),
canceled_at: cp.canceled_at ? v22NormalizeTimestamp(cp.canceled_at) : null,
ended_at: cp.ended_at ? v22NormalizeTimestamp(cp.ended_at) : null,
trial_ends_at: cp.trial_ends_at
? v22NormalizeTimestamp(cp.trial_ends_at)
: null,
quantity: cp.quantity ? parseInt(cp.quantity, 10) || 1 : 1,
options: cp.options || [],
collection_method: cp.collection_method || "charge_automatically",
subscription_ids: cp.subscription_ids || [],
scheduled_ids: cp.scheduled_ids || [],
customer_entitlements: (cp.customer_entitlements || []).map((ce: any) => ({
...ce,
created_at: v22NormalizeTimestamp(ce.created_at),
next_reset_at: ce.next_reset_at
? v22NormalizeTimestamp(ce.next_reset_at)
: null,
balance: ce.balance ? parseFloat(ce.balance) || 0 : 0,
adjustment: ce.adjustment ? parseFloat(ce.adjustment) || 0 : 0,
})),
}));
}
return out;
};
const fetchV22 = async (
db: DB,
orgId: string,
env: AppEnv,
params: ResolvedParams,
) => {
const { subscription_status, plans, search, processors, offset = 0 } = params;
const query = getPaginatedFullCusQuery({
orgId,
env,
inStatuses: subscription_status
? [subscription_status as unknown as CusProductStatus]
: RELEVANT_STATUSES,
includeInvoices: false,
withEntities: false,
withTrialsUsed: false,
withSubs: true,
limit: LIMIT,
offset,
search,
plans,
processors,
cusProductLimit: CUS_PRODUCT_LIMIT,
});
const rows = await runReadOnly(db, (tx) => tx.execute(query));
return (rows as any[]).map(v22Normalize) as FullCustomer[];
};
const fetchV23 = async (
db: DB,
orgId: string,
env: AppEnv,
params: ResolvedParams,
) => {
const { subscription_status, plans, search, processors, cursor } = params;
const query = getCursorPaginatedFullCusQuery({
orgId,
env,
inStatuses: subscription_status
? [subscription_status as unknown as CusProductStatus]
: RELEVANT_STATUSES,
withSubs: true,
limit: LIMIT,
search,
plans,
processors,
cursor: cursor ? { v: 0 as const, t: cursor.t, id: cursor.id } : undefined,
cusProductLimit: CUS_PRODUCT_LIMIT,
});
const rows = (await runReadOnly(db, (tx) => tx.execute(query))) as any[];
const flat = (rows[0] ?? {}) as unknown as FlattenedCustomerRow;
const all = reassembleFlattenedCustomer(flat);
return all.slice(0, LIMIT);
};
const resolveCursorAtOffset = async ({
db,
orgId,
env,
offset,
inStatuses,
plans,
processors,
search,
}: {
db: DB;
orgId: string;
env: AppEnv;
offset: number;
inStatuses?: CusProductStatus[];
plans?: ListCustomersV2Params["plans"];
processors?: ListCustomersV2Params["processors"];
search?: string;
}): Promise<{ t: number; id: string } | null> => {
const query = getPaginatedFullCusQuery({
orgId,
env,
inStatuses: inStatuses ?? RELEVANT_STATUSES,
includeInvoices: false,
withEntities: false,
withTrialsUsed: false,
withSubs: false,
limit: 1,
offset: offset - 1,
search,
plans,
processors,
cusProductLimit: 1,
});
const rows = (await runReadOnly(db, (tx) => tx.execute(query))) as any[];
if (rows.length === 0) return null;
const row = rows[0] as { id: string; created_at: number | string };
return { t: v22NormalizeTimestamp(row.created_at), id: row.id };
};
const resolveFirstPlanId = async ({
db,
orgId,
env,
}: {
db: DB;
orgId: string;
env: AppEnv;
}): Promise<string | null> => {
const rows = (await runReadOnly(
db,
(tx) => tx.execute(sql`
SELECT p.id
FROM products p
JOIN customer_products cp ON cp.internal_product_id = p.internal_id
WHERE p.org_id = ${orgId} AND p.env = ${env}
GROUP BY p.id
ORDER BY COUNT(*) DESC
LIMIT 1
`),
)) as any[];
return rows[0]?.id ?? null;
};
const resolveSearchSubstring = async ({
db,
orgId,
env,
}: {
db: DB;
orgId: string;
env: AppEnv;
}): Promise<string | null> => {
const rows = (await runReadOnly(
db,
(tx) => tx.execute(sql`
SELECT c.email
FROM customers c
WHERE c.org_id = ${orgId} AND c.env = ${env}
AND c.email IS NOT NULL
AND length(c.email) >= 5
LIMIT 1
`),
)) as any[];
const email = rows[0]?.email as string | undefined;
if (!email) return null;
const at = email.indexOf("@");
if (at < 3) return email.slice(0, 3);
return email.slice(0, Math.min(at, 4));
};
const CASES: Case[] = [
{
name: "page 1 / no filters",
build: async () => ({}),
},
{
name: "page 1 / subscription_status=active",
build: async () => ({ subscription_status: "active" }),
},
{
name: "page 1 / processors=revenuecat",
build: async () => ({ processors: ["revenuecat"] }),
},
{
name: "page 1 / search",
build: async (ctx) => {
const search = await resolveSearchSubstring(ctx);
if (!search) return null;
return { search };
},
},
{
name: "page 1 / plans",
build: async (ctx) => {
const planId = await resolveFirstPlanId(ctx);
if (!planId) return null;
return { plans: [{ id: planId }] };
},
},
{
name: `deep / offset=${DEEP_OFFSET} / no filters`,
build: async (ctx) => {
const cursor = await resolveCursorAtOffset({
db: ctx.db,
orgId: ctx.orgId,
env: ctx.env,
offset: DEEP_OFFSET,
});
if (!cursor) return null;
return { offset: DEEP_OFFSET, cursor };
},
},
];
const findDiff = (a: any, b: any, path = "$"): string | null => {
if (a === b) return null;
if (a === null || b === null || a === undefined || b === undefined) {
return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
}
if (typeof a !== typeof b) {
return `${path}: type ${typeof a} vs ${typeof b}`;
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b)) {
return `${path}: array vs non-array`;
}
if (a.length !== b.length) {
return `${path}.length: ${a.length} vs ${b.length}`;
}
for (let i = 0; i < a.length; i++) {
const d = findDiff(a[i], b[i], `${path}[${i}]`);
if (d) return d;
}
return null;
}
if (typeof a === "object") {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const d = findDiff(a[k], b[k], `${path}.${k}`);
if (d) return d;
}
return null;
}
return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
};
const summarize = (label: string, list: FullCustomer[]) => {
const cps = list.reduce((n, c) => n + (c.customer_products?.length ?? 0), 0);
const subs = list.reduce(
(n, c) => n + (c.subscriptions?.length ?? 0),
0,
);
const extras = list.reduce(
(n, c) => n + (c.extra_customer_entitlements?.length ?? 0),
0,
);
return `${label}: ${list.length} cus / ${cps} cps / ${subs} subs / ${extras} loose_ces`;
};
const runOrg = async ({
db,
orgId,
env,
label,
}: {
db: DB;
orgId: string;
env: AppEnv;
label: string;
}) => {
console.log(
chalk.magentaBright(
`\n========= ${label} (${orgId}) env=${env} limit=${LIMIT} =========\n`,
),
);
let total = 0;
let passes = 0;
for (const c of CASES) {
total++;
console.log(chalk.cyan(`\n--- ${c.name} ---`));
const params = await c.build({ db, orgId, env });
if (!params) {
console.log(chalk.yellow(" ⊘ skipped (no data to build params)"));
passes++;
continue;
}
console.log(
chalk.gray(
` params: ${JSON.stringify(params, (_k, v) => (typeof v === "bigint" ? String(v) : v))}`,
),
);
const [v22, v23] = await Promise.all([
fetchV22(db, orgId, env, params),
fetchV23(db, orgId, env, params),
]);
console.log(chalk.gray(` ${summarize("V2.2", v22)}`));
console.log(chalk.gray(` ${summarize("V2.3", v23)}`));
const diff = findDiff(v22, v23);
if (diff) {
console.log(chalk.red(` ❌ DIFF at ${diff}`));
const idx = diff.match(/\$\[(\d+)\]/);
if (idx) {
const i = parseInt(idx[1]!, 10);
console.log(chalk.gray(` --- V2.2 [${i}] ---`));
console.log(chalk.gray(JSON.stringify(v22[i], null, 2).slice(0, 4000)));
console.log(chalk.gray(` --- V2.3 [${i}] ---`));
console.log(chalk.gray(JSON.stringify(v23[i], null, 2).slice(0, 4000)));
}
} else {
console.log(chalk.green(` ✓ MATCH`));
passes++;
}
}
console.log(
chalk.magentaBright(`\n ${label}: ${passes}/${total} cases match`),
);
return { total, passes };
};
const main = async () => {
const { db, client } = initDrizzle();
try {
let grandTotal = 0;
let grandPasses = 0;
for (const org of ORGS) {
try {
const { total, passes } = await runOrg({
db,
orgId: org.id,
env: org.env,
label: org.label,
});
grandTotal += total;
grandPasses += passes;
} catch (err) {
console.error(chalk.red(`\n❌ Org ${org.label} failed:`), err);
}
}
console.log(
chalk.magentaBright(
`\n========= TOTAL: ${grandPasses}/${grandTotal} cases match =========\n`,
),
);
} catch (error) {
console.error(chalk.red("\n❌ Diff failed:"));
console.error(error);
process.exit(1);
} finally {
await client.end();
process.exit(0);
}
};
await main();