Skip to content

Commit f80fa90

Browse files
committed
fix(seed): harden SoulHub auto-seed
1 parent 0cc0bdc commit f80fa90

File tree

4 files changed

+79
-12
lines changed

4 files changed

+79
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Fixed
1212
- Web: stabilize skill OG image generation on server runtimes.
1313
- Web: prevent skill OG text overflow outside the card.
14+
- Registry: make SoulHub auto-seed idempotent and non-user-owned.
1415

1516
## 0.1.0 - 2026-01-07
1617

convex/seed.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { Doc } from './_generated/dataModel'
3+
import { decideSeedStart } from './seed'
4+
5+
function seedState(cursor: string, updatedAt: number) {
6+
return { cursor, updatedAt } as unknown as Doc<'githubBackupSyncState'>
7+
}
8+
9+
describe('decideSeedStart', () => {
10+
it('returns done when done', () => {
11+
expect(decideSeedStart(seedState('done', Date.now()), Date.now())).toEqual({
12+
started: false,
13+
reason: 'done',
14+
})
15+
})
16+
17+
it('returns running when lock fresh', () => {
18+
const now = Date.now()
19+
expect(decideSeedStart(seedState('running', now), now + 1000)).toEqual({
20+
started: false,
21+
reason: 'running',
22+
})
23+
})
24+
25+
it('starts when lock stale', () => {
26+
const now = Date.now()
27+
const stale = now - 10 * 60 * 1000 - 1
28+
expect(decideSeedStart(seedState('running', stale), now)).toEqual({
29+
started: true,
30+
reason: 'patched',
31+
})
32+
})
33+
34+
it('starts when missing', () => {
35+
expect(decideSeedStart(null, Date.now())).toEqual({ started: true, reason: 'inserted' })
36+
})
37+
})

convex/seed.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { v } from 'convex/values'
22
import { internal } from './_generated/api'
33
import type { Doc, Id } from './_generated/dataModel'
4-
import type { ActionCtx, DatabaseReader } from './_generated/server'
4+
import type { ActionCtx, DatabaseReader, DatabaseWriter } from './_generated/server'
55
import { action, internalMutation, internalQuery } from './_generated/server'
66
import { publishSoulVersionForUser } from './lib/soulPublish'
77
import { SOUL_SEED_DISPLAY_NAME, SOUL_SEED_HANDLE, SOUL_SEED_KEY, SOUL_SEEDS } from './seedSouls'
@@ -10,6 +10,11 @@ const SEED_LOCK_STALE_MS = 10 * 60 * 1000
1010

1111
type SeedStateDoc = Doc<'githubBackupSyncState'>
1212

13+
type SeedStartDecision = {
14+
started: boolean
15+
reason: 'done' | 'running' | 'patched' | 'inserted'
16+
}
17+
1318
async function getSeedState(ctx: { db: DatabaseReader }): Promise<SeedStateDoc | null> {
1419
const entries = (await ctx.db
1520
.query('githubBackupSyncState')
@@ -19,6 +24,28 @@ async function getSeedState(ctx: { db: DatabaseReader }): Promise<SeedStateDoc |
1924
return entries[0] ?? null
2025
}
2126

27+
async function cleanupSeedState(ctx: { db: DatabaseWriter }, keepId: Id<'githubBackupSyncState'>) {
28+
const entries = (await ctx.db
29+
.query('githubBackupSyncState')
30+
.withIndex('by_key', (q) => q.eq('key', SOUL_SEED_KEY))
31+
.order('desc')
32+
.take(50)) as SeedStateDoc[]
33+
34+
for (const entry of entries) {
35+
if (entry._id === keepId) continue
36+
await ctx.db.delete(entry._id)
37+
}
38+
}
39+
40+
export function decideSeedStart(existing: SeedStateDoc | null, now: number): SeedStartDecision {
41+
const cursor = existing?.cursor ?? null
42+
if (cursor === 'done') return { started: false, reason: 'done' }
43+
if (cursor === 'running' && existing && now - existing.updatedAt < SEED_LOCK_STALE_MS) {
44+
return { started: false, reason: 'running' }
45+
}
46+
return existing ? { started: true, reason: 'patched' } : { started: true, reason: 'inserted' }
47+
}
48+
2249
export const getSoulSeedStateInternal = internalQuery({
2350
args: {},
2451
handler: async (ctx) => getSeedState(ctx),
@@ -31,13 +58,16 @@ export const setSoulSeedStateInternal = internalMutation({
3158
const now = Date.now()
3259
if (existing) {
3360
await ctx.db.patch(existing._id, { cursor: args.status, updatedAt: now })
61+
await cleanupSeedState(ctx, existing._id)
3462
return existing._id
3563
}
36-
return ctx.db.insert('githubBackupSyncState', {
64+
const id = await ctx.db.insert('githubBackupSyncState', {
3765
key: SOUL_SEED_KEY,
3866
cursor: args.status,
3967
updatedAt: now,
4068
})
69+
await cleanupSeedState(ctx, id)
70+
return id
4171
},
4272
})
4373

@@ -46,23 +76,22 @@ export const tryStartSoulSeedInternal = internalMutation({
4676
handler: async (ctx) => {
4777
const now = Date.now()
4878
const existing = await getSeedState(ctx)
49-
const cursor = existing?.cursor ?? null
79+
const decision = decideSeedStart(existing, now)
5080

51-
if (cursor === 'done') return { started: false, reason: 'done' as const }
52-
if (cursor === 'running' && existing && now - existing.updatedAt < SEED_LOCK_STALE_MS) {
53-
return { started: false, reason: 'running' as const }
54-
}
81+
if (!decision.started) return decision
5582

5683
if (existing) {
5784
await ctx.db.patch(existing._id, { cursor: 'running', updatedAt: now })
85+
await cleanupSeedState(ctx, existing._id)
5886
return { started: true, reason: 'patched' as const }
5987
}
6088

61-
await ctx.db.insert('githubBackupSyncState', {
89+
const id = await ctx.db.insert('githubBackupSyncState', {
6290
key: SOUL_SEED_KEY,
6391
cursor: 'running',
6492
updatedAt: now,
6593
})
94+
await cleanupSeedState(ctx, id)
6695
return { started: true, reason: 'inserted' as const }
6796
},
6897
})

convex/seedSouls.ts

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)