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
30 changes: 22 additions & 8 deletions src/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getAnalyticsCached } from "@/lib/analytics/cache";
import { getAnalyticsCached, getAnalyticsForVersion } from "@/lib/analytics/cache";
import type { Granularity } from "@/lib/analytics/types";
import { requireStore } from "@/lib/auth/tenant";
import { NoMirrorDataError, loadSnapshot } from "@/lib/store/snapshot";
Expand Down Expand Up @@ -31,14 +31,28 @@ export async function GET(request: Request) {
? granularityParam
: undefined;

try {
const snapshot = await loadSnapshot(store, { refresh: params.get("refresh") === "1" });
const refresh = params.get("refresh") === "1";

const result = await getAnalyticsCached(snapshot, {
range: from && to ? { from, to } : undefined,
allTime,
granularity,
});
try {
/*
* The fast path: the store's URL and its last-sync timestamp are the
* entire cache key (see getAnalyticsForVersion's doc comment) and cost
* nothing to obtain -- requireStore already resolved them. On a cache
* hit this never touches loadSnapshot() at all, which is the whole
* point: that function's Postgres reassembly measured at 188MB / over a
* minute on a real store, and this route is the one polled most (every
* dashboard load, every range change). An explicit ?refresh=1 always
* takes the slow path deliberately -- it's a request to bypass caching,
* not just to see fresh data.
*/
const analyticsOpts = { range: from && to ? { from, to } : undefined, allTime, granularity };
const result = refresh
? await getAnalyticsCached(await loadSnapshot(store, { refresh: true }), analyticsOpts)
: await getAnalyticsForVersion(
{ storeUrl: store.url, fetchedAt: store.lastSyncAt ?? "" },
() => loadSnapshot(store),
analyticsOpts,
);

/*
* This payload runs to ~1.4MB on a real store (uncapped customer rows by
Expand Down
42 changes: 29 additions & 13 deletions src/app/api/cron/sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { getAnalyticsForVersion } from "@/lib/analytics/cache";
import { db } from "@/lib/db/client";
import { forgetSnapshot, readSnapshot } from "@/lib/woo/mirror";
import { syncStore } from "@/lib/woo/sync";
Expand Down Expand Up @@ -77,21 +78,36 @@ export async function POST(request: Request) {
});
forgetSnapshot(store.id);
/*
* Warms the Redis snapshot cache with this store's fresh data right
* here, once, in the sync job -- rather than leaving the first
* Warms the shared analytics cache (Postgres bytea, see
* analytics/shared-cache.ts) with this store's default-range figures
* right here, once, in the sync job -- rather than leaving the first
* dashboard/inbox/product-search request after this sync to pay for
* it. This is the one place in the app a full Postgres reassembly is
* actually expected: once per store per cron cycle, not once per
* user-facing request. A failure here (e.g. no history yet on a
* store's very first sync) must not fail the sync itself.
* it. This used to warm a whole-snapshot Redis cache instead; that
* design didn't survive contact with a real store (188MB serialized on
* a 22,000-order store, timing out on every write) and warming the
* derived analytics result is both what dashboard/inbox actually read
* and, at ~1.4MB gzipped, well within what a cache entry should be.
* `lastSyncAt` has to be re-read fresh rather than reused from the row
* fetched before this loop started -- syncStore() is what just updated
* it, and a stale value here would key the cache entry under a
* timestamp no reader will ever ask for again. A failure here (e.g. no
* history yet on a store's very first sync) must not fail the sync
* itself.
*/
await readSnapshot({
id: store.id,
url: store.url,
name: store.name,
historyMonths: store.history_months,
lastSyncAt: null,
}).catch(() => {});
const [{ last_sync_at: freshLastSyncAt }] = await db()<{ last_sync_at: Date | null }[]>`
select last_sync_at from stores where id = ${store.id}
`;
await getAnalyticsForVersion(
{ storeUrl: store.url, fetchedAt: freshLastSyncAt?.toISOString() ?? "" },
() =>
readSnapshot({
id: store.id,
url: store.url,
name: store.name,
historyMonths: store.history_months,
lastSyncAt: freshLastSyncAt?.toISOString() ?? null,
}),
).catch(() => {});
results.push({ store: store.url, ok: true, ...result });
} catch (error) {
// One unreachable store must not stop the rest: a merchant who revoked
Expand Down
26 changes: 16 additions & 10 deletions src/app/api/reports/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getAnalytics } from "@/lib/analytics/cache";
import { getAnalyticsForVersion } from "@/lib/analytics/cache";
import { sheetsToCsv, sheetToCsv } from "@/lib/export/csv";
import { buildSheet, REPORT_IDS, type ReportId, type Sheet } from "@/lib/export/datasets";
import { buildPdf } from "@/lib/export/pdf";
Expand Down Expand Up @@ -49,15 +49,21 @@ export async function POST(request: Request) {
const { format, reports, from, to, granularity, limit } = parsed.data;

try {
const snapshot = await loadSnapshot(store);
const result = getAnalytics(snapshot, {
range: from && to ? { from, to } : undefined,
granularity,
// Exports are the complete record — the UI row caps don't apply.
maxOrderRows: Number.MAX_SAFE_INTEGER,
maxCustomerRows: Number.MAX_SAFE_INTEGER,
includeHistory: true,
});
// Same fast-path-first pattern as /api/analytics — the store's URL and
// last-sync timestamp are the whole cache key, so a repeat export of the
// same range never has to pay for reassembling the raw mirror.
const result = await getAnalyticsForVersion(
{ storeUrl: store.url, fetchedAt: store.lastSyncAt ?? "" },
() => loadSnapshot(store),
{
range: from && to ? { from, to } : undefined,
granularity,
// Exports are the complete record — the UI row caps don't apply.
maxOrderRows: Number.MAX_SAFE_INTEGER,
maxCustomerRows: Number.MAX_SAFE_INTEGER,
includeHistory: true,
},
);

const sheets: Sheet[] = (reports as ReportId[]).map((id) => {
const sheet = buildSheet(id, result);
Expand Down
35 changes: 34 additions & 1 deletion src/app/api/sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { getAnalyticsForVersion } from "@/lib/analytics/cache";
import { requireStore, requireWrite } from "@/lib/auth/tenant";
import { forgetSnapshot, syncStatus } from "@/lib/woo/mirror";
import { db } from "@/lib/db/client";
import { forgetSnapshot, readSnapshot, syncStatus } from "@/lib/woo/mirror";
import { syncStore } from "@/lib/woo/sync";

export const runtime = "nodejs";
Expand Down Expand Up @@ -51,6 +53,37 @@ export async function POST(request: Request) {
// The memo is keyed on the store, not on the data, so a completed sync has
// to drop it or the next read serves what was there before.
forgetSnapshot(store.id);
/*
* Warms the shared analytics cache right here too, not just from the
* cron route -- this is the "just connected" / "manual re-sync" path per
* this route's own doc comment, and it's exactly the moment someone is
* about to look at their dashboard. Without this, the cache stays cold
* until the next cron cycle, and that first dashboard/inbox view pays
* for a full Postgres reassembly instead of a cache hit.
*
* `store.lastSyncAt` above is from before syncStore() ran -- re-read it
* fresh so the cache entry is keyed under the timestamp syncStore() just
* wrote, not the one before it. This used to call readSnapshot() alone
* to warm a whole-snapshot Redis cache; found by testing against a real
* store connection that that design doesn't survive real scale (188MB
* serialized, timing out on every write) -- warming the derived
* analytics result instead is both what the dashboard/inbox actually
* read and small enough (~1.4MB gzipped) to actually cache.
*/
const [{ last_sync_at: freshLastSyncAt }] = await db()<{ last_sync_at: Date | null }[]>`
select last_sync_at from stores where id = ${store.id}
`;
await getAnalyticsForVersion(
{ storeUrl: store.url, fetchedAt: freshLastSyncAt?.toISOString() ?? "" },
() =>
readSnapshot({
id: store.id,
url: store.url,
name: store.name,
historyMonths: store.historyMonths,
lastSyncAt: freshLastSyncAt?.toISOString() ?? null,
}),
).catch(() => {});

return NextResponse.json({ ok: true, ...result });
} catch (error) {
Expand Down
34 changes: 22 additions & 12 deletions src/app/api/whatsapp/chats/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { getAnalytics } from "@/lib/analytics/cache";
import { getAnalyticsForVersion } from "@/lib/analytics/cache";
import { requireStore, type TenantStore } from "@/lib/auth/tenant";
import { loadSnapshot } from "@/lib/store/snapshot";
import { WhatsAppApiError, WhatsAppClient, type WhatsAppChat } from "@/lib/whatsapp/client";
import { readWhatsAppConfig } from "@/lib/whatsapp/config";
import { phoneMapFromSnapshot } from "@/lib/whatsapp/recipients";
import { normalisePhone } from "@/lib/whatsapp/phone";
import { readPhoneByCustomerKey } from "@/lib/woo/mirror";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -48,13 +48,12 @@ export async function GET(request: Request) {
const chats = await new WhatsAppClient(config).listChats(80);
const enriched = await withCustomerNames(store, chats, config.defaultDialCode);
/*
* The gateway call is cheap; the customer-name enrichment above is not —
* it pays a full readSnapshot() (the whole order/customer/product mirror,
* tens of MB on a real store) on every uncached hit. This route is
* polled every 60s by any open Inbox tab, so without a cache that cost
* repeats indefinitely for as long as the tab stays open. Same
* private + Vary: Cookie treatment as /api/analytics and /api/customers,
* for the same tenant-cache-poisoning reason.
* The gateway call is cheap; the customer-name enrichment above is
* cheaper than it used to be but still real work (an analytics lookup
* plus a phone-map query) on every uncached hit, and this route is
* polled every 60s by any open Inbox tab. Same private + Vary: Cookie
* treatment as /api/analytics and /api/customers, for the same
* tenant-cache-poisoning reason.
*/
return NextResponse.json(
{ chats: enriched },
Expand Down Expand Up @@ -88,9 +87,20 @@ async function withCustomerNames(
});

const lookup = (async () => {
const snapshot = await loadSnapshot(store);
const analytics = getAnalytics(snapshot);
const phoneByKey = phoneMapFromSnapshot(snapshot);
/*
* Two independent, much cheaper reads instead of one loadSnapshot() --
* neither needs the whole order/customer/product mirror. The analytics
* result comes from the shared cache on every request except the one
* right after a sync (getAnalyticsForVersion only pays for a full
* reassembly on an actual miss), and the phone lookup is a narrow
* three-column SQL query rather than the full `raw` order documents.
*/
const [analytics, phoneByKey] = await Promise.all([
getAnalyticsForVersion({ storeUrl: store.url, fetchedAt: store.lastSyncAt ?? "" }, () =>
loadSnapshot(store),
),
readPhoneByCustomerKey(store.id),
]);

// Index customers by the same E.164 form a chat id reduces to, so the two
// sides match regardless of how the number was typed at checkout.
Expand Down
36 changes: 17 additions & 19 deletions src/app/api/whatsapp/products/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { isNotConnected } from "@/lib/store/errors";
import { requireStore } from "@/lib/auth/tenant";
import { loadSnapshot } from "@/lib/store/snapshot";
import { readMostRecentCurrency, readProducts } from "@/lib/woo/mirror";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -10,10 +9,12 @@ export const maxDuration = 120;
/**
* Catalogue search, for picking the product a campaign is about.
*
* Served from the cached snapshot rather than WooCommerce, so typing in the
* picker costs nothing upstream. Only the fields a message can use are
* returned — a search box has no business shipping stock levels or ratings to
* the browser.
* Reads the products mirror directly, not the full store snapshot — the
* picker only ever needs the catalogue, and pulling in the whole order and
* customer history alongside it (readSnapshot()'s cost, tens to hundreds of
* MB on a real store) to answer a question about fifty-odd products would be
* pure waste. Only the fields a message can use are returned — a search box
* has no business shipping stock levels or ratings to the browser.
*/
export async function GET(request: Request) {
const resolved = await requireStore(request);
Expand All @@ -25,9 +26,12 @@ export async function GET(request: Request) {
const limit = Math.min(Number(params.get("limit")) || 20, 50);

try {
const snapshot = await loadSnapshot(store);
const [products, currency] = await Promise.all([
readProducts(store.id),
readMostRecentCurrency(store.id),
]);

const matches = snapshot.products
const matches = products
.filter((product) => {
if (product.status && product.status !== "publish") return false;
if (!query) return true;
Expand All @@ -51,22 +55,16 @@ export async function GET(request: Request) {
}));

/*
* Hit on every 250ms-debounced keystroke in the picker with no caching
* previously — each request still pays the full loadSnapshot() cost (the
* whole order/customer/product mirror) on a cold instance, regardless of
* how narrow the search is, since it's the same underlying snapshot load
* before any client-side filtering happens. Products don't change often
* enough to need fresher than a couple of minutes; same private +
* Vary: Cookie treatment as the other tenant-scoped cached routes.
* Hit on every 250ms-debounced keystroke in the picker. Products don't
* change often enough to need fresher than a couple of minutes; same
* private + Vary: Cookie treatment as the other tenant-scoped cached
* routes.
*/
return NextResponse.json(
{ products: matches, currency: snapshot.currency },
{ products: matches, currency },
{ headers: { "Cache-Control": "private, max-age=120", Vary: "Cookie" } },
);
} catch (error) {
if (isNotConnected(error)) {
return NextResponse.json({ error: error.message, code: "not_connected" }, { status: 409 });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Could not read the catalogue." },
{ status: 500 },
Expand Down
63 changes: 62 additions & 1 deletion src/lib/analytics/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ const MAX_ENTRIES = 8;
/** Insertion-ordered, so the first key is the oldest — Map guarantees this. */
const cache = new Map<string, AnalyticsResult>();

function keyFor(snapshot: StoreSnapshot, opts: AnalyticsOptions): string {
/** Everything the cache key is built from, without the rest of the snapshot. */
export interface SnapshotVersion {
storeUrl: string;
fetchedAt: string;
}

function keyFor(snapshot: SnapshotVersion, opts: AnalyticsOptions): string {
const { range, granularity, allTime } = opts;
return [
snapshot.storeUrl,
Expand Down Expand Up @@ -159,6 +165,61 @@ export async function getAnalyticsCached(
return result;
}

/**
* Same job as `getAnalyticsCached`, but for a caller that hasn't paid for a
* snapshot yet and would rather not, if it can avoid it.
*
* `version` needs only the store's URL and its last-sync timestamp — both
* already on hand wherever a `TenantStore` is, no query required — because
* that pair is everything the cache key is built from (see `keyFor` and
* `sharedKey`, which read the identical two fields off a real snapshot
* today). `loadSnapshot` is the expensive path (readSnapshot()'s Postgres
* reassembly, confirmed at 188MB / over a minute on a real 22,000-order
* store) and is only invoked on an actual miss.
*
* The mirror only changes once per sync cycle (~10 minutes), so between
* syncs every request after the first should hit the local map or the
* shared Postgres-bytea cache and never touch the raw order/customer/product
* tables at all — this is what makes /api/analytics, /api/reports/export and
* similar routes cheap on a warm cache instead of paying a full reassembly
* on every cold serverless instance.
*/
export async function getAnalyticsForVersion(
version: SnapshotVersion,
loadSnapshot: () => Promise<StoreSnapshot>,
opts: AnalyticsOptions = {},
): Promise<AnalyticsResult> {
const key = keyFor(version, opts);

const local = cache.get(key);
if (local) {
cache.delete(key);
cache.set(key, local);
return local;
}

const sharedCacheKey = sharedKey({
storeUrl: version.storeUrl,
fetchedAt: version.fetchedAt,
from: opts.range?.from,
to: opts.range?.to,
granularity: opts.granularity,
allTime: opts.allTime,
});

const shared = await readShared(sharedCacheKey);
if (shared) {
remember(key, shared);
return shared;
}

const snapshot = await loadSnapshot();
const result = computeAnalytics(snapshot, opts);
remember(key, result);
void writeShared(sharedCacheKey, result);
return result;
}

/**
* Drops everything derived from a store.
*
Expand Down
Loading
Loading