From 68dc7bb4ee80d16c4bf2531932f0c72f56a81d04 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 14:09:55 -0400 Subject: [PATCH] feat(spoons): find owned spoons by pushed repo (indexed) --- packages/backend/convex/schema.ts | 3 +- packages/backend/convex/spoons.ts | 41 ++++- .../tests/unit/spoons-for-repo.test.ts | 165 ++++++++++++++++++ 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 packages/backend/tests/unit/spoons-for-repo.test.ts diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 69d596b..1b70718 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -135,7 +135,8 @@ const applicationTables = { .index('by_owner', ['ownerId']) .index('by_owner_status', ['ownerId', 'status']) .index('by_owner_provider', ['ownerId', 'provider']) - .index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']), + .index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']) + .index('by_fork', ['provider', 'forkOwner', 'forkRepo']), spoonRepositoryStates: defineTable({ spoonId: v.id('spoons'), ownerId: v.id('users'), diff --git a/packages/backend/convex/spoons.ts b/packages/backend/convex/spoons.ts index 6d1ac74..b215281 100644 --- a/packages/backend/convex/spoons.ts +++ b/packages/backend/convex/spoons.ts @@ -1,6 +1,6 @@ import { ConvexError, v } from 'convex/values'; -import type { Doc } from './_generated/dataModel'; +import type { Doc, Id } from './_generated/dataModel'; import { internalMutation, internalQuery, @@ -221,6 +221,45 @@ export const getOwnedForAction = internalQuery({ }, }); +export const findForRepo = internalQuery({ + args: { owner: v.string(), repo: v.string() }, + handler: async ( + ctx, + { owner, repo }, + ): Promise<{ spoonId: Id<'spoons'>; ownerId: Id<'users'> }[]> => { + const [upstreamMatches, forkMatches] = await Promise.all([ + ctx.db + .query('spoons') + .withIndex('by_upstream', (q) => + q + .eq('provider', 'github') + .eq('upstreamOwner', owner) + .eq('upstreamRepo', repo), + ) + .collect(), + ctx.db + .query('spoons') + .withIndex('by_fork', (q) => + q + .eq('provider', 'github') + .eq('forkOwner', owner) + .eq('forkRepo', repo), + ) + .collect(), + ]); + + const seen = new Set>(); + const results: { spoonId: Id<'spoons'>; ownerId: Id<'users'> }[] = []; + for (const spoon of [...upstreamMatches, ...forkMatches]) { + if (spoon.status === 'archived') continue; + if (seen.has(spoon._id)) continue; + seen.add(spoon._id); + results.push({ spoonId: spoon._id, ownerId: spoon.ownerId }); + } + return results; + }, +}); + export const createManual = mutation({ args: { name: v.string(), diff --git a/packages/backend/tests/unit/spoons-for-repo.test.ts b/packages/backend/tests/unit/spoons-for-repo.test.ts new file mode 100644 index 0000000..db9ca51 --- /dev/null +++ b/packages/backend/tests/unit/spoons-for-repo.test.ts @@ -0,0 +1,165 @@ +import { convexTest } from 'convex-test'; +import { describe, expect, test } from 'vitest'; + +import type { Id } from '../../convex/_generated/dataModel.js'; +import { internal } from '../../convex/_generated/api.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +const createUser = async (t: ReturnType, email: string) => + await t.mutation(async (ctx) => { + return await ctx.db.insert('users', { email, name: email }); + }); + +type SeedSpoon = { + ownerId: Id<'users'>; + upstreamOwner: string; + upstreamRepo: string; + forkOwner?: string; + forkRepo?: string; + status?: 'draft' | 'active' | 'needs_connection' | 'paused' | 'archived'; +}; + +const seedSpoon = async (t: ReturnType, spoon: SeedSpoon) => + (await t.mutation(async (ctx) => { + const now = Date.now(); + return await ctx.db.insert('spoons', { + ownerId: spoon.ownerId, + name: `${spoon.upstreamOwner}/${spoon.upstreamRepo}`, + provider: 'github', + upstreamOwner: spoon.upstreamOwner, + upstreamRepo: spoon.upstreamRepo, + upstreamDefaultBranch: 'main', + upstreamUrl: `https://github.com/${spoon.upstreamOwner}/${spoon.upstreamRepo}`, + forkOwner: spoon.forkOwner, + forkRepo: spoon.forkRepo, + forkUrl: spoon.forkOwner + ? `https://github.com/${spoon.forkOwner}/${spoon.forkRepo}` + : undefined, + visibility: 'public', + maintenanceMode: 'watch', + syncCadence: 'daily', + productionRefStrategy: 'default_branch', + status: spoon.status ?? 'active', + createdAt: now, + updatedAt: now, + }); + })) as Id<'spoons'>; + +describe('spoons.findForRepo', () => { + test('matches on the upstream side', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + const upstreamSpoon = await seedSpoon(t, { + ownerId, + upstreamOwner: 'up', + upstreamRepo: 'x', + forkOwner: 'me', + forkRepo: 'x-fork', + }); + await seedSpoon(t, { + ownerId, + upstreamOwner: 'other', + upstreamRepo: 'y', + forkOwner: 'me', + forkRepo: 'y-fork', + }); + + const result = await t.query(internal.spoons.findForRepo, { + owner: 'up', + repo: 'x', + }); + + expect(result).toEqual([{ spoonId: upstreamSpoon, ownerId }]); + }); + + test('matches on the fork side', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + await seedSpoon(t, { + ownerId, + upstreamOwner: 'up', + upstreamRepo: 'x', + forkOwner: 'me', + forkRepo: 'x-fork', + }); + const forkSpoon = await seedSpoon(t, { + ownerId, + upstreamOwner: 'other', + upstreamRepo: 'y', + forkOwner: 'me', + forkRepo: 'x-fork-2', + }); + + const result = await t.query(internal.spoons.findForRepo, { + owner: 'me', + repo: 'x-fork-2', + }); + + expect(result).toEqual([{ spoonId: forkSpoon, ownerId }]); + }); + + test('returns empty for an unknown repo', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + await seedSpoon(t, { + ownerId, + upstreamOwner: 'up', + upstreamRepo: 'x', + forkOwner: 'me', + forkRepo: 'x-fork', + }); + + const result = await t.query(internal.spoons.findForRepo, { + owner: 'nobody', + repo: 'nothing', + }); + + expect(result).toEqual([]); + }); + + test('dedups a spoon whose upstream and fork are the same repo', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + const dupSpoon = await seedSpoon(t, { + ownerId, + upstreamOwner: 'dup', + upstreamRepo: 'z', + forkOwner: 'dup', + forkRepo: 'z', + }); + + const result = await t.query(internal.spoons.findForRepo, { + owner: 'dup', + repo: 'z', + }); + + expect(result).toEqual([{ spoonId: dupSpoon, ownerId }]); + }); + + test('excludes archived spoons', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + await seedSpoon(t, { + ownerId, + upstreamOwner: 'up', + upstreamRepo: 'x', + forkOwner: 'me', + forkRepo: 'x-fork', + status: 'archived', + }); + + const upstreamResult = await t.query(internal.spoons.findForRepo, { + owner: 'up', + repo: 'x', + }); + const forkResult = await t.query(internal.spoons.findForRepo, { + owner: 'me', + repo: 'x-fork', + }); + + expect(upstreamResult).toEqual([]); + expect(forkResult).toEqual([]); + }); +});