From 576588b70dddea1417614001309a62b2c6f819c0 Mon Sep 17 00:00:00 2001 From: DangerouslyShip Date: Thu, 12 Mar 2026 08:14:35 -0700 Subject: [PATCH 1/2] perf: denormalize latestVersionSummary into skillSearchDigest Eliminates ~9MB of skillVersions reads per listPublicPageV2 call by copying latestVersionSummary from skills into the digest via the existing trigger. Old rows without the field fall back to fetching the full version doc. Co-Authored-By: Claude Opus 4.6 --- convex/lib/public.ts | 1 + convex/lib/skillSearchDigest.test.ts | 2 +- convex/lib/skillSearchDigest.ts | 1 + convex/schema.ts | 11 +++++++++++ convex/skills.listPublicPageV2.test.ts | 6 ++++++ convex/skills.ts | 6 +----- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/convex/lib/public.ts b/convex/lib/public.ts index 33ed405aa..b3b019a0e 100644 --- a/convex/lib/public.ts +++ b/convex/lib/public.ts @@ -41,6 +41,7 @@ export type HydratableSkill = Pick< | 'canonicalSkillId' | 'forkOf' | 'latestVersionId' + | 'latestVersionSummary' | 'tags' | 'badges' | 'stats' diff --git a/convex/lib/skillSearchDigest.test.ts b/convex/lib/skillSearchDigest.test.ts index ef2c366e6..0cd3f988c 100644 --- a/convex/lib/skillSearchDigest.test.ts +++ b/convex/lib/skillSearchDigest.test.ts @@ -102,7 +102,7 @@ describe('extractDigestFields', () => { expect(digest).not.toHaveProperty('moderationEvidence') expect(digest).not.toHaveProperty('quality') - expect(digest).not.toHaveProperty('latestVersionSummary') + expect(digest).toHaveProperty('latestVersionSummary') expect(digest).not.toHaveProperty('moderationNotes') expect(digest).not.toHaveProperty('moderationSummary') expect(digest).not.toHaveProperty('resourceId') diff --git a/convex/lib/skillSearchDigest.ts b/convex/lib/skillSearchDigest.ts index 0b6e1d906..958e775e9 100644 --- a/convex/lib/skillSearchDigest.ts +++ b/convex/lib/skillSearchDigest.ts @@ -19,6 +19,7 @@ const SHARED_KEYS = [ 'canonicalSkillId', 'forkOf', 'latestVersionId', + 'latestVersionSummary', 'tags', 'badges', 'stats', diff --git a/convex/schema.ts b/convex/schema.ts index 41f202249..d1e494387 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -448,6 +448,17 @@ const skillSearchDigest = defineTable({ canonicalSkillId: v.optional(v.id('skills')), forkOf: forkOfValidator, latestVersionId: v.optional(v.id('skillVersions')), + latestVersionSummary: v.optional( + v.object({ + version: v.string(), + createdAt: v.number(), + changelog: v.string(), + changelogSource: v.optional( + v.union(v.literal('auto'), v.literal('user')), + ), + clawdis: v.optional(v.any()), + }), + ), tags: v.record(v.string(), v.id('skillVersions')), badges: badgesValidator, stats: statsValidator, diff --git a/convex/skills.listPublicPageV2.test.ts b/convex/skills.listPublicPageV2.test.ts index 4bc8c533e..3e24e57ce 100644 --- a/convex/skills.listPublicPageV2.test.ts +++ b/convex/skills.listPublicPageV2.test.ts @@ -333,6 +333,12 @@ function makeSkill( canonicalSkillId: undefined, forkOf: undefined, latestVersionId, + latestVersionSummary: { + version: '1.0.0', + createdAt: 1, + changelog: '', + changelogSource: 'user' as const, + }, tags: {}, badges: {}, stats: { diff --git a/convex/skills.ts b/convex/skills.ts index dfe656b0e..aa4bb6f63 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -958,11 +958,7 @@ async function buildPublicSkillEntries( const entries = await Promise.all( skills.map(async (skill) => { // Use denormalized summary when available to avoid reading the full ~6KB version doc. - // HydratableSkill (from digest rows) won't have latestVersionSummary. - const summary = - 'latestVersionSummary' in skill - ? (skill as Doc<'skills'>).latestVersionSummary - : undefined + const summary = skill.latestVersionSummary const hasSummary = includeVersion && summary const [latestVersionDoc, ownerInfo] = await Promise.all([ includeVersion && !hasSummary && skill.latestVersionId From b32a499774c710846ce24977a80f9170261d2848 Mon Sep 17 00:00:00 2001 From: DangerouslyShip Date: Thu, 12 Mar 2026 08:28:44 -0700 Subject: [PATCH 2/2] test: add coverage for fallback when latestVersionSummary is absent Verifies that old digest rows without latestVersionSummary correctly fall back to ctx.db.get(latestVersionId) for version data. Co-Authored-By: Claude Opus 4.6 --- convex/skills.listPublicPageV2.test.ts | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/convex/skills.listPublicPageV2.test.ts b/convex/skills.listPublicPageV2.test.ts index 3e24e57ce..d7c88d197 100644 --- a/convex/skills.listPublicPageV2.test.ts +++ b/convex/skills.listPublicPageV2.test.ts @@ -287,6 +287,48 @@ describe('skills.listPublicPageV2', () => { ) }) + it('falls back to db.get(latestVersionId) when latestVersionSummary is absent', async () => { + const oldRow = makeSkill('skills:old', 'old', 'users:1', 'skillVersions:1') + // Simulate a pre-backfill digest row without latestVersionSummary + delete (oldRow as Record).latestVersionSummary + + const paginateMock = vi.fn().mockResolvedValue({ + page: [oldRow], + continueCursor: 'next-cursor', + isDone: false, + pageStatus: null, + splitCursor: null, + }) + const getMock = vi.fn(async (id: string) => { + if (id.startsWith('users:')) return makeUser(id) + if (id.startsWith('skillVersions:')) return makeVersion(id) + return null + }) + const ctx = { + db: { + query: vi.fn(() => ({ + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ paginate: paginateMock })), + })), + })), + get: getMock, + }, + } + + const result = await listPublicPageV2Handler(ctx, { + paginationOpts: { cursor: null, numItems: 25 }, + sort: 'downloads', + dir: 'desc', + highlightedOnly: false, + nonSuspiciousOnly: false, + }) + + expect(result.page).toHaveLength(1) + expect(result.page[0]?.skill.slug).toBe('old') + // Should have fetched the version doc via db.get + expect(getMock).toHaveBeenCalledWith('skillVersions:1') + }) + it('does not swallow non-cursor paginate errors', async () => { const paginateMock = vi.fn().mockRejectedValue(new Error('database unavailable')) const ctx = {