diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 6c1fe54..336dbe6 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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"; @@ -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 diff --git a/src/app/api/cron/sync/route.ts b/src/app/api/cron/sync/route.ts index cb5cbc9..f897bd6 100644 --- a/src/app/api/cron/sync/route.ts +++ b/src/app/api/cron/sync/route.ts @@ -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"; @@ -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 diff --git a/src/app/api/reports/export/route.ts b/src/app/api/reports/export/route.ts index bb24b7b..80f8295 100644 --- a/src/app/api/reports/export/route.ts +++ b/src/app/api/reports/export/route.ts @@ -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"; @@ -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); diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts index d872a29..9d54a1f 100644 --- a/src/app/api/sync/route.ts +++ b/src/app/api/sync/route.ts @@ -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"; @@ -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) { diff --git a/src/app/api/whatsapp/chats/route.ts b/src/app/api/whatsapp/chats/route.ts index 35c20eb..2e67b2f 100644 --- a/src/app/api/whatsapp/chats/route.ts +++ b/src/app/api/whatsapp/chats/route.ts @@ -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"; @@ -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 }, @@ -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. diff --git a/src/app/api/whatsapp/products/route.ts b/src/app/api/whatsapp/products/route.ts index 39911a6..5f15235 100644 --- a/src/app/api/whatsapp/products/route.ts +++ b/src/app/api/whatsapp/products/route.ts @@ -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"; @@ -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); @@ -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; @@ -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 }, diff --git a/src/lib/analytics/cache.ts b/src/lib/analytics/cache.ts index c23486b..b30bbb6 100644 --- a/src/lib/analytics/cache.ts +++ b/src/lib/analytics/cache.ts @@ -41,7 +41,13 @@ const MAX_ENTRIES = 8; /** Insertion-ordered, so the first key is the oldest — Map guarantees this. */ const cache = new Map(); -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, @@ -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, + opts: AnalyticsOptions = {}, +): Promise { + 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. * diff --git a/src/lib/storage/snapshot-cache.ts b/src/lib/storage/snapshot-cache.ts deleted file mode 100644 index 34ae0cb..0000000 --- a/src/lib/storage/snapshot-cache.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { StoreSnapshot } from "@/lib/woo/types"; - -/** - * A read-through cache for the full store snapshot, on Upstash Redis. - * - * The reason this exists: `readSnapshot()` in src/lib/woo/mirror.ts reassembles - * a whole store's orders/customers/products from Postgres on every cache miss - * — tens of megabytes on a real store, per that file's own comment on what - * `pg_stat_statements` showed. The in-process memo there helps within one - * warm serverless instance, but a fresh instance (the common case on Vercel, - * not the exception) gets none of that benefit and pays the full Postgres - * read again -- which is exactly what drove a real Supabase egress-quota - * restriction (service outage) this project hit. - * - * Upstash's free tier needs no card at all and is confirmed against their - * own pricing page (256MB storage, 500K commands/month, 10GB bandwidth/month) - * — comfortably enough for a JSON blob a few MB in size, written once per - * store per 10-minute sync cycle and read on every dashboard/inbox/product - * request. R2 was the first choice (also zero egress) but requires a - * payment method to enable even on the free tier, which this project can't - * use -- Upstash doesn't. - * - * Plain REST calls rather than the @upstash/redis SDK: Upstash's REST API is - * two HTTP calls (GET/SET with a bearer token), and pulling in a client - * library for that is more than this needs. - * - * This is a cache, not the source of truth -- Postgres still is. A miss here - * (first sync of a new store, Redis unreachable, env vars unset) falls back - * to the existing Postgres reassembly in mirror.ts exactly as before. - */ - -const TTL_SECONDS = 15 * 60; // Generous relative to the 10-min sync cycle that refreshes it. - -function credentials(): { url: string; token: string } | null { - const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); - const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); - if (!url || !token) return null; - return { url, token }; -} - -function keyFor(storeId: string): string { - return `snapshot:${storeId}`; -} - -/** Reads the cached snapshot, or null on any miss/failure — never throws. */ -export async function getCachedSnapshot(storeId: string): Promise { - const creds = credentials(); - if (!creds) return null; - - try { - const res = await fetch(`${creds.url}/get/${keyFor(storeId)}`, { - headers: { Authorization: `Bearer ${creds.token}` }, - cache: "no-store", - }); - if (!res.ok) return null; - const body = (await res.json()) as { result: string | null }; - if (!body.result) return null; - return JSON.parse(body.result) as StoreSnapshot; - } catch { - // Network error, malformed JSON, whatever -- fall back to Postgres, same - // as a cold cache always has. - return null; - } -} - -/** - * Invalidates the cache entry — called from mirror.ts's forgetSnapshot, - * which several call sites use without a guaranteed follow-up read (a store - * disconnect, a manual re-sync trigger). Without this, stale data could sit - * here for up to TTL_SECONDS after the thing that made it stale. Best-effort - * and fire-and-forget for the same reason as the rest of this file: a failed - * delete just means the entry expires on its own schedule instead of - * immediately, not a correctness break for anything that reads through this - * cache. - */ -export async function deleteCachedSnapshot(storeId: string): Promise { - const creds = credentials(); - if (!creds) return; - - try { - await fetch(`${creds.url}/del/${keyFor(storeId)}`, { - method: "POST", - headers: { Authorization: `Bearer ${creds.token}` }, - }); - } catch { - // Best-effort, see above. - } -} - -/** - * Writes the snapshot back, best-effort. Never throws -- a failed cache write - * must not fail the sync or the request that triggered a Postgres reassembly; - * the next reader just falls back to Postgres again, same as today. - */ -export async function putCachedSnapshot(storeId: string, snapshot: StoreSnapshot): Promise { - const creds = credentials(); - if (!creds) return; - - try { - await fetch(`${creds.url}/set/${keyFor(storeId)}?EX=${TTL_SECONDS}`, { - method: "POST", - headers: { Authorization: `Bearer ${creds.token}`, "Content-Type": "text/plain" }, - body: JSON.stringify(snapshot), - }); - } catch { - // Best-effort, see the doc comment above. - } -} diff --git a/src/lib/woo/mirror.ts b/src/lib/woo/mirror.ts index 2b0ce4b..9627f45 100644 --- a/src/lib/woo/mirror.ts +++ b/src/lib/woo/mirror.ts @@ -1,6 +1,5 @@ import { db } from "@/lib/db/client"; import type { TenantStore } from "@/lib/auth/tenant"; -import { deleteCachedSnapshot, getCachedSnapshot, putCachedSnapshot } from "@/lib/storage/snapshot-cache"; import type { StoreSnapshot, WooCustomer, WooOrder, WooProduct } from "./types"; /** @@ -17,6 +16,17 @@ import type { StoreSnapshot, WooCustomer, WooOrder, WooProduct } from "./types"; * where the data comes from: three tiers of cache in front of a multi-minute * WooCommerce pull, replaced by three indexed queries against local tables. * + * A full reassembly is still not cheap on a real store -- 188MB of raw JSON + * on a 22,000-order store, confirmed by testing against one, which rules out + * caching the *whole snapshot* as a single blob anywhere with a practical + * size limit (Redis, an HTTP response, ...). Consumers that only need part of + * the mirror should call a narrower reader below (`readProducts`, + * `readPhoneByCustomerKey`) instead of this function, and consumers that need + * derived results (analytics) should go through a cache keyed on the store's + * version -- see `getAnalyticsForVersion` in `@/lib/analytics/cache` -- rather + * than force a full readSnapshot() just to check whether the answer is + * already cached. + * * ─── The remaining in-process cache ──────────────────────────────────────── * * One memo per instance, so a single dashboard session's several requests — @@ -67,12 +77,6 @@ export async function readSnapshot(store: SnapshotSource): Promise Date.now()) return hit.snapshot; - const cached = await getCachedSnapshot(store.id); - if (cached) { - memo.set(store.id, { snapshot: cached, expiresAt: Date.now() + MEMO_TTL_MS }); - return cached; - } - const since = new Date(); since.setMonth(since.getMonth() - (store.historyMonths || 24)); @@ -118,24 +122,82 @@ export async function readSnapshot(store: SnapshotSource): Promise { + const rows = await db()<{ raw: WooProduct }[]>` + select raw from woo_products where store_id = ${storeId} + `; + return rows.map((r) => r.raw); +} + +/** + * The currency of the store's most recent order — same value readSnapshot() + * derives as `orders[0]?.raw?.currency`, since readSnapshot's orders are + * sorted `date_created desc`. WooCommerce has no store-level currency field + * to read this from directly, so, same as there, it's read off an order; one + * indexed row rather than the whole order history. + */ +export async function readMostRecentCurrency(storeId: string): Promise { + const [row] = await db()<{ currency: string | null }[]>` + select currency from woo_orders + where store_id = ${storeId} + order by date_created desc + limit 1 + `; + return row?.currency ?? "USD"; +} + +/** + * Each customer's most recent billing phone, keyed the same way + * `customerKey()` (src/lib/analytics/helpers.ts) keys a customer: a real + * `customer_id` wins, falling back to the billing email, falling back to the + * order id for a guest order with neither. Reimplemented in SQL rather than + * calling customerKey() itself because the point is to never pull the full + * order objects into the app at all -- this selects three narrow columns + * instead of the `raw` jsonb blob that carries every line item, and orders + * by date so, same as phoneMapFromSnapshot(), the most recent order *that + * carried a phone* wins for a customer who has ordered more than once -- + * filtering out phone-less orders before picking "most recent" rather than + * after, so a later order with no phone on file doesn't blank out an earlier + * one that had it. + */ +export async function readPhoneByCustomerKey(storeId: string): Promise> { + const rows = await db()<{ key: string; phone: string }[]>` + select distinct on (key) key, phone from ( + select + case + when customer_id > 0 then 'id:' || customer_id + when nullif(trim(billing_email), '') is not null then 'email:' || lower(trim(billing_email)) + else 'order:' || id + end as key, + trim(raw -> 'billing' ->> 'phone') as phone, + date_created + from woo_orders + where store_id = ${storeId} + and nullif(trim(raw -> 'billing' ->> 'phone'), '') is not null + ) t + order by key, date_created desc + `; + + return new Map(rows.map((r) => [r.key, r.phone])); } /* ── Queries that do not need the whole snapshot ────────────────────────── diff --git a/supabase/migrations/20260907090000_order_phone_index.sql b/supabase/migrations/20260907090000_order_phone_index.sql new file mode 100644 index 0000000..0eeeaec --- /dev/null +++ b/supabase/migrations/20260907090000_order_phone_index.sql @@ -0,0 +1,12 @@ +-- readPhoneByCustomerKey() (src/lib/woo/mirror.ts) reads a customer's most +-- recent billing phone straight from woo_orders instead of paying for a full +-- readSnapshot() reassembly -- but without an index on the expression it +-- filters and sorts by, Postgres still has to detoast and parse every +-- order's `raw` jsonb to answer it: ~8s on a real 22,000-order store versus +-- ~1.7s with this index in place, confirmed by testing against one. +-- +-- Partial: only orders that actually carry a billing phone are worth +-- indexing, and that's also exactly the filter the query already applies. +create index if not exists woo_orders_billing_phone_idx + on public.woo_orders (store_id, (trim(raw -> 'billing' ->> 'phone')), date_created desc) + where nullif(trim(raw -> 'billing' ->> 'phone'), '') is not null;