perf(db): composite index (account_id, name) + hide archived groups

Two related follow-ups for the 3 000+ groups-per-account scale path:

1. New B-tree index on whatsapp_groups (account_id, name) (migration
   0014). Covers the groups list page's
   `WHERE account_id=? ORDER BY name ASC LIMIT 200` query so PG
   streams pre-sorted from the index instead of pulling all rows
   then sorting. The unique (account_id, wa_group_jid) was the only
   prior B-tree on this table; it backed the WHERE prefix but not
   the ORDER BY.

2. listGroupsForAccount now filters `is_archived = false` in both
   the search and the no-search branch. Soft-archived groups
   (set when group-sync sees them disappear from the live
   participant list, or when an operator unpairs the account) used
   to leak into the wizard picker, letting operators pick a group
   the bot can no longer reach. Archived rows still exist in DB so
   reminders that target them keep working; a re-pair flips them
   back via the on-conflict upsert.

README "Deferred" entry for the composite index removed (it's
shipped). Search-as-you-type in the wizard picker stays deferred.

482 web + 88 bot tests still green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 21:57:17 +08:00
parent c906a9fa3a
commit ea7d07b2c8
6 changed files with 1122 additions and 5 deletions

View File

@ -200,9 +200,5 @@ Set `NO_SUDO=1` if your user is in the docker group (recommended).
groups per account the picker still loads the alphabetical
top-200; operators with >200 groups need to use the list page's
search to find anything past 'L'.
- **Composite index on `(account_id, name)`** for the groups list
page's `ORDER BY name LIMIT 200` query — currently a sort + limit;
the GIN trigram on `name` plus the unique on `(account_id,
wa_group_jid)` already cover most cases.
- **Self-service password reset** (email link, etc.) — out of scope
for v1; admins use the Users page.

View File

@ -82,11 +82,19 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
const account = await getAccount(operatorId, accountId);
if (!account) return null;
const trimmed = (q ?? "").trim();
// Hide archived groups from the picker by default. They're rows
// that disappeared from the live participant list (group deleted,
// bot kicked, etc.) but still have reminder_targets pointing at
// them — see the soft-archive flow in apps/bot/src/whatsapp/
// group-sync.ts. Surfacing archived rows here would let an
// operator pick a group the bot can't actually reach.
const rows = trimmed
? await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups
WHERE account_id = ${accountId} AND name % ${trimmed}
WHERE account_id = ${accountId}
AND is_archived = false
AND name % ${trimmed}
ORDER BY similarity(name, ${trimmed}) DESC
LIMIT 50
`)
@ -94,6 +102,7 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups
WHERE account_id = ${accountId}
AND is_archived = false
ORDER BY name ASC
LIMIT 200
`);

View File

@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS "whatsapp_groups_account_name_idx" ON "whatsapp_groups" USING btree ("account_id","name");

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,13 @@
"when": 1778464004000,
"tag": "0013_tricky_yellowjacket",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1778464005000,
"tag": "0014_lame_puck",
"breakpoints": true
}
]
}

View File

@ -10,6 +10,7 @@ import {
jsonb,
primaryKey,
uniqueIndex,
index,
inet,
} from "drizzle-orm/pg-core";
@ -80,6 +81,16 @@ export const whatsappGroups = pgTable(
},
(t) => ({
accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid),
// Backs `WHERE account_id=? ORDER BY name ASC LIMIT 200` on the
// groups list page. Without this, PG falls back to the unique
// (account_id, wa_group_jid) index for the WHERE clause and then
// does an explicit sort on `name` — fine at small scale, slow
// when an operator has 3 000+ groups. Drizzle import is `index`,
// declared in this same file's import block.
accountNameIdx: index("whatsapp_groups_account_name_idx").on(
t.accountId,
t.name,
),
}),
);