From f7c3aa648276f01feb0fcb6aa9c8caaeba702e92 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 14:06:13 -0400 Subject: [PATCH] feat(github): index connections by installation + status mutation --- packages/backend/convex/github.ts | 26 +++++++++ packages/backend/convex/schema.ts | 3 +- .../tests/unit/git-connection-status.test.ts | 58 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/backend/tests/unit/git-connection-status.test.ts diff --git a/packages/backend/convex/github.ts b/packages/backend/convex/github.ts index c7886f1..918d724 100644 --- a/packages/backend/convex/github.ts +++ b/packages/backend/convex/github.ts @@ -138,6 +138,32 @@ export const upsertConnectionForUser = internalMutation({ }, }); +export const setConnectionStatusByInstallation = internalMutation({ + args: { + installationId: v.string(), + status: v.union( + v.literal('active'), + v.literal('needs_reauth'), + v.literal('revoked'), + ), + }, + handler: async (ctx, { installationId, status }) => { + const connections = await ctx.db + .query('gitConnections') + .withIndex('by_installation', (q) => + q.eq('installationId', installationId), + ) + .collect(); + + const now = Date.now(); + for (const connection of connections) { + await ctx.db.patch(connection._id, { status, updatedAt: now }); + } + + return { updated: connections.length }; + }, +}); + export const createForkSpoonRecord = internalMutation({ args: { ownerId: v.id('users'), diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 241fd0b..69d596b 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -53,7 +53,8 @@ const applicationTables = { }) .index('by_user', ['userId']) .index('by_user_provider', ['userId', 'provider']) - .index('by_status', ['status']), + .index('by_status', ['status']) + .index('by_installation', ['installationId']), spoons: defineTable({ ownerId: v.id('users'), name: v.string(), diff --git a/packages/backend/tests/unit/git-connection-status.test.ts b/packages/backend/tests/unit/git-connection-status.test.ts new file mode 100644 index 0000000..ba75ddb --- /dev/null +++ b/packages/backend/tests/unit/git-connection-status.test.ts @@ -0,0 +1,58 @@ +import { convexTest } from 'convex-test'; +import { describe, expect, test } from 'vitest'; + +import { internal } from '../../convex/_generated/api.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +const seedConnection = async ( + t: ReturnType, + installationId: string, +) => + await t.run(async (ctx) => { + const now = Date.now(); + const userId = await ctx.db.insert('users', { + email: 'a@b.c', + name: 'A', + }); + return await ctx.db.insert('gitConnections', { + userId, + provider: 'github', + displayName: `GitHub installation ${installationId}`, + installationId, + status: 'active', + connectedAt: now, + updatedAt: now, + }); + }); + +describe('setConnectionStatusByInstallation', () => { + test('flips the matching connection status', async () => { + const t = convexTest(schema, modules); + const connectionId = await seedConnection(t, '42'); + + const result = await t.mutation( + internal.github.setConnectionStatusByInstallation, + { installationId: '42', status: 'needs_reauth' }, + ); + + expect(result).toEqual({ updated: 1 }); + const connection = await t.run(async (ctx) => await ctx.db.get(connectionId)); + expect(connection?.status).toBe('needs_reauth'); + }); + + test('is a no-op when no connection matches', async () => { + const t = convexTest(schema, modules); + const connectionId = await seedConnection(t, '42'); + + const result = await t.mutation( + internal.github.setConnectionStatusByInstallation, + { installationId: '999', status: 'revoked' }, + ); + + expect(result).toEqual({ updated: 0 }); + const connection = await t.run(async (ctx) => await ctx.db.get(connectionId)); + expect(connection?.status).toBe('active'); + }); +});