diff --git a/packages/backend/convex/maintenanceDedup.ts b/packages/backend/convex/maintenanceDedup.ts new file mode 100644 index 0000000..e8ba281 --- /dev/null +++ b/packages/backend/convex/maintenanceDedup.ts @@ -0,0 +1,21 @@ +/** + * Pure, deterministic dedup key for maintenance threads. + * + * Derives a stable key from the merge-base + head SHAs so that repeated + * scheduled sync runs against the same upstream state collapse onto a single + * maintenance thread instead of creating a new one each run. NEVER involves + * Date.now()/random — the key must be identical for identical inputs. + * + * Returns `null` when the head SHA is missing/empty (unresolvable head → the + * caller cannot dedup and should SKIP thread creation rather than mint a + * unique key). + */ +export const maintenanceDedupKey = (input: { + mergeBaseSha?: string; + headSha?: string; +}): string | null => { + const head = input.headSha?.trim(); + if (!head) return null; + const base = input.mergeBaseSha?.trim() || 'nobase'; + return `${base}:${head}`; +}; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 824c8c0..241fd0b 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -797,6 +797,7 @@ const applicationTables = { ), ignoredCommitShas: v.optional(v.array(v.string())), ignoredReason: v.optional(v.string()), + dedupKey: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), resolvedAt: v.optional(v.number()), @@ -805,7 +806,8 @@ const applicationTables = { .index('by_owner_status', ['ownerId', 'status']) .index('by_spoon', ['spoonId']) .index('by_source', ['ownerId', 'source']) - .index('by_created', ['createdAt']), + .index('by_created', ['createdAt']) + .index('by_spoon_dedup', ['spoonId', 'dedupKey']), threadMessages: defineTable({ threadId: v.id('threads'), ownerId: v.id('users'), diff --git a/packages/backend/tests/unit/maintenance-dedup.test.ts b/packages/backend/tests/unit/maintenance-dedup.test.ts new file mode 100644 index 0000000..af08a83 --- /dev/null +++ b/packages/backend/tests/unit/maintenance-dedup.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest'; + +import { maintenanceDedupKey } from '../../convex/maintenanceDedup'; + +describe('maintenanceDedupKey', () => { + test('combines mergeBase and head into a stable key', () => { + expect(maintenanceDedupKey({ mergeBaseSha: 'a', headSha: 'b' })).toBe('a:b'); + }); + + test('falls back to nobase when mergeBase is missing', () => { + expect(maintenanceDedupKey({ headSha: 'b' })).toBe('nobase:b'); + }); + + test('returns null when head is missing', () => { + expect(maintenanceDedupKey({ mergeBaseSha: 'a' })).toBeNull(); + }); + + test('returns null when head is empty', () => { + expect(maintenanceDedupKey({ mergeBaseSha: 'a', headSha: '' })).toBeNull(); + }); + + test('returns null when head is only whitespace', () => { + expect(maintenanceDedupKey({ mergeBaseSha: 'a', headSha: ' ' })).toBeNull(); + }); + + test('is deterministic across repeated calls (no Date.now/random)', () => { + const input = { mergeBaseSha: 'base123', headSha: 'head456' }; + const first = maintenanceDedupKey(input); + const second = maintenanceDedupKey(input); + expect(first).toBe(second); + expect(first).toBe('base123:head456'); + }); + + test('different heads produce different keys', () => { + const a = maintenanceDedupKey({ mergeBaseSha: 'base', headSha: 'h1' }); + const b = maintenanceDedupKey({ mergeBaseSha: 'base', headSha: 'h2' }); + expect(a).not.toBe(b); + }); + + test('trims surrounding whitespace on inputs', () => { + expect( + maintenanceDedupKey({ mergeBaseSha: ' a ', headSha: ' b ' }), + ).toBe('a:b'); + }); +});