Skip to content

Commit c600997

Browse files
committed
use hashed rkeys to prevent duplicate contributions
1 parent 77d58a5 commit c600997

4 files changed

Lines changed: 64 additions & 23 deletions

File tree

src/contributions/client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,33 @@ export class ContributionClient {
104104
}
105105
}
106106

107+
async getRecord(collection: string, rkey: string): Promise<Record<string, unknown> | null> {
108+
if (!this.session) {
109+
throw new Error('Not authenticated — call restore() first')
110+
}
111+
112+
const response = await fetch(
113+
`${this.instanceUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(this.session.did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`,
114+
{ headers: { 'X-Client-Key': this.clientKey } },
115+
)
116+
117+
if (response.status === 404 || response.status === 400) {
118+
return null
119+
}
120+
121+
if (!response.ok) {
122+
const body = await response.text()
123+
throw new Error(`getRecord returned ${response.status}: ${body}`)
124+
}
125+
126+
return await response.json() as Record<string, unknown>
127+
}
128+
107129
async createContribution(params: {
108130
contributionType: 'correction' | 'addition' | 'newGame'
109131
changes: Record<string, unknown>
110132
subject?: string
133+
rkey?: string
111134
}): Promise<ContributionResult> {
112135
if (!this.session) {
113136
throw new Error('Not authenticated — call restore() first')
@@ -120,6 +143,9 @@ export class ContributionClient {
120143
if (params.subject) {
121144
body.subject = params.subject
122145
}
146+
if (params.rkey) {
147+
body.rkey = params.rkey
148+
}
123149

124150
const response = await this.session.fetchHandler(
125151
`${this.instanceUrl}/xrpc/games.gamesgamesgamesgames.createContribution`,

src/contributions/handler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { ContributionClient } from './client.js'
22
import { diffGameRecord } from './diff.js'
3+
import { contentHashRkey } from './hash.js'
4+
5+
const CONTRIBUTION_COLLECTION = 'games.gamesgamesgamesgames.contribution'
36

47
export interface ContributionSyncResult {
58
action: 'created' | 'skipped'
@@ -22,9 +25,16 @@ export async function syncGameViaContribution(
2225
const existing = await contributionClient.getGameByIgdbId(igdbId)
2326

2427
if (!existing) {
28+
const rkey = contentHashRkey(mappedRecord)
29+
const existingRecord = await contributionClient.getRecord(CONTRIBUTION_COLLECTION, rkey)
30+
if (existingRecord) {
31+
return { action: 'skipped' }
32+
}
33+
2534
const result = await contributionClient.createContribution({
2635
contributionType: 'newGame',
2736
changes: mappedRecord,
37+
rkey,
2838
})
2939
return { action: 'created', contributionType: 'newGame', uri: result.uri }
3040
}
@@ -35,11 +45,18 @@ export async function syncGameViaContribution(
3545
return { action: 'skipped' }
3646
}
3747

48+
const rkey = contentHashRkey(changes)
49+
const existingRecord = await contributionClient.getRecord(CONTRIBUTION_COLLECTION, rkey)
50+
if (existingRecord) {
51+
return { action: 'skipped' }
52+
}
53+
3854
const subjectUri = existing.uri
3955
const result = await contributionClient.createContribution({
4056
contributionType: 'correction',
4157
changes,
4258
subject: subjectUri,
59+
rkey,
4360
})
4461

4562
if (existing.redirectedFrom) {

src/contributions/hash.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createHash } from 'node:crypto'
2+
3+
function stableStringify(value: unknown): string {
4+
if (value === null || value === undefined) return 'null'
5+
if (typeof value !== 'object') return JSON.stringify(value)
6+
7+
if (Array.isArray(value)) {
8+
return '[' + value.map(stableStringify).join(',') + ']'
9+
}
10+
11+
const sorted = Object.keys(value as Record<string, unknown>).sort()
12+
const entries = sorted.map(
13+
(k) => JSON.stringify(k) + ':' + stableStringify((value as Record<string, unknown>)[k]),
14+
)
15+
return '{' + entries.join(',') + '}'
16+
}
17+
18+
export function contentHashRkey(changes: Record<string, unknown>): string {
19+
const serialized = stableStringify(changes)
20+
return createHash('sha256').update(serialized).digest('hex').slice(0, 32)
21+
}

src/sync.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
import 'dotenv/config'
1515

16-
import Database from 'better-sqlite3'
1716
import { IGDBClient } from './igdb/client.js'
1817
import { AtprotoClient } from './atproto/client.js'
1918
import { StateManager } from './state.js'
@@ -97,28 +96,6 @@ async function main() {
9796
console.log(`Started at ${new Date().toISOString()}`)
9897
console.log()
9998

100-
// Validate state vs PDS to prevent duplicate creation
101-
const pdsStorePath = process.env.PDS_STORE_PATH ?? '/pds/actors/29/did:web:gamesgamesgamesgames.games/store.sqlite'
102-
try {
103-
const pdsDb = new Database(pdsStorePath, { readonly: true })
104-
const pdsRow = pdsDb.prepare(
105-
"SELECT COUNT(*) as count FROM record WHERE collection = 'games.gamesgamesgamesgames.game'",
106-
).get() as { count: number }
107-
pdsDb.close()
108-
109-
const stateCount = state.getEntityCount('game')
110-
console.log(`[sync] PDS game records: ${pdsRow.count}, State entities: ${stateCount}`)
111-
112-
if (pdsRow.count > 0 && stateCount < pdsRow.count * 0.9) {
113-
console.error(`[sync] ABORT: state.sqlite is out of sync with PDS (state=${stateCount}, PDS=${pdsRow.count}).`)
114-
console.error('[sync] This would cause duplicate record creation. Run cleanup first.')
115-
process.exit(1)
116-
}
117-
} catch (err) {
118-
console.warn(`[sync] Could not validate PDS store at ${pdsStorePath}: ${(err as Error).message}`)
119-
console.warn('[sync] Continuing without validation (set PDS_STORE_PATH if running on server)')
120-
}
121-
12299
// Sync in dependency order
123100

124101
// // 1. Platform Families

0 commit comments

Comments
 (0)