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:
parent
c906a9fa3a
commit
ea7d07b2c8
@ -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.
|
||||
|
||||
@ -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
|
||||
`);
|
||||
|
||||
1
packages/db/migrations/0014_lame_puck.sql
Normal file
1
packages/db/migrations/0014_lame_puck.sql
Normal file
@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS "whatsapp_groups_account_name_idx" ON "whatsapp_groups" USING btree ("account_id","name");
|
||||
1093
packages/db/migrations/meta/0014_snapshot.json
Normal file
1093
packages/db/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -99,6 +99,13 @@
|
||||
"when": 1778464004000,
|
||||
"tag": "0013_tricky_yellowjacket",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1778464005000,
|
||||
"tag": "0014_lame_puck",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user