Skip to content

perf(credshare): list a team's vaults in one query instead of two per vault - #128

Merged
ralyodio merged 1 commit into
masterfrom
fix/credshare-vaults-n1
Aug 4, 2026
Merged

perf(credshare): list a team's vaults in one query instead of two per vault#128
ralyodio merged 1 commit into
masterfrom
fix/credshare-vaults-n1

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

logicsrc teams pull took ~24 seconds to fetch a ten-key vault. This is where it went.

Why

GET /api/credshare/teams/:slug/vaults built its response in a loop:

for (const v of vaults) {
  const grant = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, user.id]);
  const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
  ...
}

libSQL is remote, so each get() is a network round trip, not a local read — the endpoint cost 2N+1 of them.

Measured against production on a team with 176 vaults:

endpoint TTFB
/api/credshare/me 0.27s
/api/credshare/teams 0.32s
/api/credshare/teams/:slug/vaults 10.58s

connect was 33ms in every case, so this is entirely server-side. And teams pull calls the endpoint twice — once in createCredentialSyncPlaninspect, once in runCredentialSyncreadValues, each going through resolveVaultId — which is the ~24s.

Nearly all of it is spent listing 175 vaults the command has no interest in, to resolve one name to one id.

What

One SELECT with two correlated subqueries. Both are covered by primary keys that already exist — credshare_secrets is keyed (vault_id, name) and credshare_vault_grants (vault_id, user_id) — so the per-vault work becomes an index probe inside the database rather than a round trip across the network. No schema change, no new index.

Seeded a local libSQL to match that team (176 vaults × 17 secrets):

vaults returned : 176
secretCount[0]  : 17  hasAccess: true
DB round trips  : 3        (was 355)

I chose correlated subqueries over LEFT JOIN … GROUP BY deliberately: joining both children multiplies rows, and COUNT(s.id) then silently reports the wrong number if a vault ever matches more than one grant row. The subquery form cannot express that bug. There's a test for it.

The response shape is unchanged — hasAccess is still a real boolean rather than the 0/1 SQLite returns, and secretCount still a number.

Tests

Five, splitting behaviour from cost.

The four behavioural cases (per-vault counts, grant/no-grant, non-member 403, unknown-team 404) pass against both the old loop and the new query. That's intentional — it's the evidence that only the round-trip count changed.

The guard asserts the query count for 3 vaults equals the count for 30, rather than matching a magic number. Any future rewrite that reintroduces per-vault I/O fails it regardless of what the constant part costs. Against the old loop it reports 3 vaults took 9 queries, 30 took 63 — exactly 2N+3.

Full apps/pwa suite: 26 pass, 0 fail.

Not fixed here

/teams/:slug/members and /vaults/:id/grants have the same shape — publicKeyFor() once per member. Neither is on the pull path, and both scale with member count rather than vault count, so they aren't causing user-visible pain yet. Worth a follow-up.

This also doesn't address the client fetching all 176 vaults twice to resolve one name. That's now ~0.1s a call instead of ~10.6s, so it stopped mattering — but a ?name= filter on the endpoint would make resolveVaultId O(1) in vault count.

🤖 Generated with Claude Code

… vault

GET /api/credshare/teams/:slug/vaults built its response in a loop, asking
the database for a grant row and a secret count once per vault. libSQL is
remote, so each of those is a network round trip, and the endpoint cost
2N+1 of them.

On a team with 176 vaults that is 353 round trips and ~10.6s of server
time. `logicsrc teams pull` resolves the vault id twice -- once planning
the sync, once reading values -- so a pull of a single ten-key vault took
~24s, nearly all of it spent listing vaults the command does not want.

Replaced with one SELECT carrying two correlated subqueries. Both are
covered by existing primary keys (credshare_secrets is keyed
(vault_id, name), credshare_vault_grants (vault_id, user_id)), so the
per-vault work becomes an index probe inside the database instead of a
round trip across the network. No schema or index change.

Measured on a local libSQL seeded to match that team -- 176 vaults, 17
secrets each -- the endpoint goes from 355 round trips to 3, and returns
identical rows.

The response shape is unchanged: hasAccess is still a real boolean rather
than the 0/1 SQLite hands back, and secretCount is still a number.

Tests pin behaviour and cost separately. The behavioural cases pass
against both the old loop and the new query, which is the point -- only
the round-trip count changed. The regression guard asserts the query
count for 3 vaults EQUALS the count for 30 rather than matching a magic
number, so any future rewrite that reintroduces per-vault I/O fails no
matter what the constant part costs. Against the old loop it reports
9 vs 63.

Two sibling endpoints have the same shape -- /teams/:slug/members and
/vaults/:id/grants both call publicKeyFor() per member. Neither is on the
pull path and both scale with member count rather than vault count, so
they are left alone here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

55 finding(s)

HIGH/CRITICAL: 30 | MEDIUM: 25

Severity Rule Location
HIGH secret-openai-key plugins/credential-sharing/src/vault-encryption.test.ts:21
HIGH manifest-typosquat apps/commandboard-web/package.json:20
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:392
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:410
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:426
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:428
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:433
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:471
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:477
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:499
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:516
HIGH secret-generic-credential apps/logicsrc-web/contract/logicsrc-web.contract.test.ts:528
HIGH manifest-typosquat apps/logicsrc-web/package.json:28
HIGH js-ssrf-outbound-request apps/logicsrc-web/src/lib/coinpay.ts:50
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH secret-generic-credential apps/pwa/test/appbar.test.mjs:10
HIGH secret-generic-credential packages/account-core/src/index.test.ts:104
HIGH secret-generic-credential packages/agentad/src/exchange.test.ts:13
HIGH secret-generic-credential plugins/agentgit/src/index.ts:12
HIGH secret-generic-credential plugins/c0mpute/src/index.ts:12
HIGH secret-generic-credential plugins/coinpay/src/index.ts:12
HIGH secret-generic-credential plugins/credential-sharing/src/engine.test.ts:69
HIGH secret-generic-credential plugins/credential-sharing/src/engine.test.ts:134
HIGH secret-database-url plugins/credential-sharing/src/rekey.test.ts:33
HIGH secret-generic-credential plugins/credential-sharing/src/vault-encryption.test.ts:21
HIGH js-ssrf-outbound-request plugins/feed-discovery/src/providers/itunes-podcast.ts:37
HIGH js-ssrf-outbound-request plugins/feed-discovery/src/providers/podcastindex.ts:43
HIGH secret-generic-credential plugins/sh1pt/src/index.ts:11
HIGH secret-generic-credential plugins/ugig/src/index.ts:11
MEDIUM js-unescaped-html-sink apps/commandboard-web/src/main.ts:19
MEDIUM js-unescaped-html-sink apps/logicsrc-web/src/app/[[...slug]]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/logicsrc-web/src/app/blog/[slug]/page.tsx:120
MEDIUM js-unescaped-html-sink apps/logicsrc-web/src/app/docs/[slug]/page.tsx:55
MEDIUM js-unescaped-html-sink apps/logicsrc-web/src/app/layout.tsx:83
MEDIUM js-unescaped-html-sink apps/logicsrc-web/src/app/pricing/page.tsx:46
MEDIUM js-unescaped-html-sink apps/logicsrc-web/src/components/site-shell.tsx:46
MEDIUM sql-template-interpolation packages/cli/src/index.ts:812
MEDIUM sql-template-interpolation packages/cli/src/index.ts:814
MEDIUM sql-template-interpolation packages/cli/src/index.ts:821
MEDIUM sql-template-interpolation packages/cli/src/teams.ts:284
MEDIUM redos-nested-quantifier packages/openontology/src/ids.ts:20
MEDIUM redos-nested-quantifier packages/openprd/src/validate.ts:41
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-account-audit-event.schema.json:23
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-account-grant.schema.json:23
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-account-grant.schema.json:35
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-account-provider.schema.json:20
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-connected-account.schema.json:30
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-credential-audit-event.schema.json:14
MEDIUM redos-nested-quantifier packages/schemas/schemas/logicsrc-openontology-manifest.schema.json:28

…and 5 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 4, 2026 05:20
@ralyodio
ralyodio merged commit fd253b0 into master Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant