Barazo AppView backend barazo.forum

feat(scripts): add backfill script for accountCreatedAt from PLC directory (#109)

CLI script that bulk-resolves account creation dates for existing users
with did:plc:* DIDs where accountCreatedAt is NULL. Queries the PLC
directory audit log, processes users with rate-limiting (200ms delay),
and reports progress/summary.

Run: pnpm db:backfill-account-ages

authored by

Guido X Jansen and committed by
GitHub
7fc9d388 7ed404a2

+404
+2
package.json
··· 25 25 "test:integration": "vitest run --config vitest.config.integration.ts", 26 26 "db:generate": "node --import tsx node_modules/drizzle-kit/bin.cjs generate", 27 27 "db:migrate": "tsx scripts/migrate.ts", 28 + "db:backfill-account-ages": "tsx scripts/backfill-account-created-at.ts", 28 29 "db:studio": "drizzle-kit studio", 29 30 "format": "prettier --write .", 30 31 "format:check": "prettier --check .", ··· 50 51 "ioredis": "5.10.0", 51 52 "isomorphic-dompurify": "3.0.0", 52 53 "multiformats": "13.4.2", 54 + "pino": "10.3.1", 53 55 "postgres": "3.4.8", 54 56 "sharp": "0.34.5", 55 57 "zod": "4.3.6"
+3
pnpm-lock.yaml
··· 96 96 multiformats: 97 97 specifier: 13.4.2 98 98 version: 13.4.2 99 + pino: 100 + specifier: 10.3.1 101 + version: 10.3.1 99 102 postgres: 100 103 specifier: 3.4.8 101 104 version: 3.4.8
+152
scripts/backfill-account-created-at.ts
··· 1 + import { eq, and, isNull, like } from 'drizzle-orm' 2 + import pino from 'pino' 3 + import { createDb } from '../src/db/index.js' 4 + import type { Database } from '../src/db/index.js' 5 + import { users } from '../src/db/schema/index.js' 6 + import { createAccountAgeService } from '../src/services/account-age.js' 7 + import type { AccountAgeService } from '../src/services/account-age.js' 8 + import type { Logger } from '../src/lib/logger.js' 9 + 10 + // --------------------------------------------------------------------------- 11 + // Types 12 + // --------------------------------------------------------------------------- 13 + 14 + export interface BackfillDeps { 15 + db: Database 16 + accountAgeService: AccountAgeService 17 + logger: Logger 18 + batchSize: number 19 + delayMs: number 20 + } 21 + 22 + export interface BackfillResult { 23 + total: number 24 + resolved: number 25 + skipped: number 26 + failed: number 27 + } 28 + 29 + // --------------------------------------------------------------------------- 30 + // Constants 31 + // --------------------------------------------------------------------------- 32 + 33 + const DEFAULT_BATCH_SIZE = 50 34 + const DEFAULT_DELAY_MS = 200 35 + const PROGRESS_INTERVAL = 10 36 + 37 + // --------------------------------------------------------------------------- 38 + // Core logic (testable) 39 + // --------------------------------------------------------------------------- 40 + 41 + export async function backfillAccountCreatedAt(deps: BackfillDeps): Promise<BackfillResult> { 42 + const { db, accountAgeService, logger, delayMs } = deps 43 + 44 + const pendingUsers = await db 45 + .select({ did: users.did }) 46 + .from(users) 47 + .where(and(isNull(users.accountCreatedAt), like(users.did, 'did:plc:%'))) 48 + 49 + if (pendingUsers.length === 0) { 50 + logger.info('No users need account age backfilling') 51 + return { total: 0, resolved: 0, skipped: 0, failed: 0 } 52 + } 53 + 54 + logger.info({ count: pendingUsers.length }, 'Starting account age backfill') 55 + 56 + const result: BackfillResult = { total: pendingUsers.length, resolved: 0, skipped: 0, failed: 0 } 57 + 58 + for (let i = 0; i < pendingUsers.length; i++) { 59 + const user = pendingUsers[i] 60 + if (!user) continue 61 + 62 + try { 63 + const createdAt = await accountAgeService.resolveCreationDate(user.did) 64 + 65 + if (createdAt) { 66 + await db.update(users).set({ accountCreatedAt: createdAt }).where(eq(users.did, user.did)) 67 + result.resolved++ 68 + } else { 69 + logger.warn({ did: user.did }, 'Could not resolve account creation date, skipping') 70 + result.skipped++ 71 + } 72 + } catch (err: unknown) { 73 + logger.error({ err, did: user.did }, 'Failed to process user during backfill') 74 + result.failed++ 75 + } 76 + 77 + // Log progress every PROGRESS_INTERVAL users 78 + const processed = i + 1 79 + if (processed % PROGRESS_INTERVAL === 0) { 80 + logger.info( 81 + { 82 + processed, 83 + total: pendingUsers.length, 84 + resolved: result.resolved, 85 + skipped: result.skipped, 86 + failed: result.failed, 87 + }, 88 + `Processed ${String(processed)}/${String(pendingUsers.length)} users` 89 + ) 90 + } 91 + 92 + // Rate-limit between PLC directory requests 93 + if (delayMs > 0 && i < pendingUsers.length - 1) { 94 + await new Promise((resolve) => setTimeout(resolve, delayMs)) 95 + } 96 + } 97 + 98 + return result 99 + } 100 + 101 + // --------------------------------------------------------------------------- 102 + // CLI entry point 103 + // --------------------------------------------------------------------------- 104 + 105 + async function main(): Promise<void> { 106 + const logger = pino({ level: 'info' }) 107 + 108 + const databaseUrl = process.env['DATABASE_URL'] 109 + if (!databaseUrl) { 110 + logger.fatal('DATABASE_URL environment variable is required') 111 + process.exit(1) 112 + } 113 + 114 + const { db, client } = createDb(databaseUrl) 115 + const accountAgeService = createAccountAgeService(logger) 116 + 117 + try { 118 + const result = await backfillAccountCreatedAt({ 119 + db, 120 + accountAgeService, 121 + logger, 122 + batchSize: DEFAULT_BATCH_SIZE, 123 + delayMs: DEFAULT_DELAY_MS, 124 + }) 125 + 126 + logger.info( 127 + { 128 + total: result.total, 129 + resolved: result.resolved, 130 + skipped: result.skipped, 131 + failed: result.failed, 132 + }, 133 + 'Backfill complete' 134 + ) 135 + } finally { 136 + await client.end() 137 + } 138 + 139 + process.exit(0) 140 + } 141 + 142 + // Only run main when executed directly via tsx (not imported by Vitest) 143 + const isDirectExecution = 144 + process.argv[1]?.endsWith('backfill-account-created-at.ts') === true && 145 + typeof process.env['VITEST'] === 'undefined' 146 + if (isDirectExecution) { 147 + main().catch((err: unknown) => { 148 + // eslint-disable-next-line no-console -- CLI fallback for fatal errors before logger setup 149 + console.error('Backfill failed:', err) 150 + process.exit(1) 151 + }) 152 + }
+247
tests/unit/scripts/backfill-account-created-at.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest' 2 + import { backfillAccountCreatedAt } from '../../../scripts/backfill-account-created-at.js' 3 + import type { BackfillDeps, BackfillResult } from '../../../scripts/backfill-account-created-at.js' 4 + 5 + // --------------------------------------------------------------------------- 6 + // Standalone mock functions (avoids @typescript-eslint/unbound-method) 7 + // --------------------------------------------------------------------------- 8 + 9 + const resolveCreationDateFn = vi.fn<(did: string) => Promise<Date | null>>() 10 + const determineTrustStatusFn = vi.fn() 11 + 12 + const dbSelectFn = vi.fn() 13 + const dbUpdateFn = vi.fn() 14 + 15 + const logInfoFn = vi.fn() 16 + const logWarnFn = vi.fn() 17 + const logErrorFn = vi.fn() 18 + 19 + // --------------------------------------------------------------------------- 20 + // Helpers 21 + // --------------------------------------------------------------------------- 22 + 23 + function createMockLogger() { 24 + return { 25 + info: logInfoFn, 26 + warn: logWarnFn, 27 + error: logErrorFn, 28 + debug: vi.fn(), 29 + fatal: vi.fn(), 30 + trace: vi.fn(), 31 + child: vi.fn().mockReturnThis(), 32 + level: 'info', 33 + silent: vi.fn(), 34 + } 35 + } 36 + 37 + function createMockDeps(overrides?: Partial<BackfillDeps>): BackfillDeps { 38 + return { 39 + db: { 40 + select: dbSelectFn, 41 + update: dbUpdateFn, 42 + } as unknown as BackfillDeps['db'], 43 + accountAgeService: { 44 + resolveCreationDate: resolveCreationDateFn, 45 + determineTrustStatus: determineTrustStatusFn, 46 + }, 47 + logger: createMockLogger() as unknown as BackfillDeps['logger'], 48 + batchSize: 50, 49 + delayMs: 0, // No delay in tests 50 + ...overrides, 51 + } 52 + } 53 + 54 + /** Build a mock user row with only the fields the backfill reads. */ 55 + function mockUser(did: string) { 56 + return { did } 57 + } 58 + 59 + /** Select chain mock refs for assertions. */ 60 + const selectFromFn = vi.fn() 61 + const selectWhereFn = vi.fn() 62 + 63 + /** 64 + * Wire up the mock DB so `db.select().from().where()` resolves to `rows`. 65 + */ 66 + function stubSelectUsers(rows: Array<{ did: string }>) { 67 + selectFromFn.mockReturnThis() 68 + selectWhereFn.mockResolvedValue(rows) 69 + dbSelectFn.mockReturnValue({ from: selectFromFn, where: selectWhereFn }) 70 + } 71 + 72 + /** Update chain mock refs for assertions. */ 73 + const updateSetFn = vi.fn() 74 + const updateWhereFn = vi.fn() 75 + 76 + /** 77 + * Wire up the mock DB so `db.update().set().where()` resolves. 78 + */ 79 + function stubUpdateUser() { 80 + updateSetFn.mockReturnThis() 81 + updateWhereFn.mockResolvedValue(undefined) 82 + dbUpdateFn.mockReturnValue({ set: updateSetFn, where: updateWhereFn }) 83 + } 84 + 85 + // --------------------------------------------------------------------------- 86 + // Tests 87 + // --------------------------------------------------------------------------- 88 + 89 + describe('backfillAccountCreatedAt', () => { 90 + let deps: BackfillDeps 91 + 92 + beforeEach(() => { 93 + vi.resetAllMocks() 94 + deps = createMockDeps() 95 + }) 96 + 97 + it('fetches users where accountCreatedAt IS NULL and DID starts with did:plc:', async () => { 98 + stubSelectUsers([]) 99 + 100 + await backfillAccountCreatedAt(deps) 101 + 102 + expect(dbSelectFn).toHaveBeenCalled() 103 + }) 104 + 105 + it('calls resolveCreationDate for each user', async () => { 106 + const users = [mockUser('did:plc:abc'), mockUser('did:plc:def'), mockUser('did:plc:ghi')] 107 + stubSelectUsers(users) 108 + stubUpdateUser() 109 + 110 + const createdAt = new Date('2025-06-01T00:00:00Z') 111 + resolveCreationDateFn.mockResolvedValue(createdAt) 112 + 113 + await backfillAccountCreatedAt(deps) 114 + 115 + expect(resolveCreationDateFn).toHaveBeenCalledTimes(3) 116 + expect(resolveCreationDateFn).toHaveBeenCalledWith('did:plc:abc') 117 + expect(resolveCreationDateFn).toHaveBeenCalledWith('did:plc:def') 118 + expect(resolveCreationDateFn).toHaveBeenCalledWith('did:plc:ghi') 119 + }) 120 + 121 + it('updates the DB with the resolved date', async () => { 122 + const users = [mockUser('did:plc:abc')] 123 + stubSelectUsers(users) 124 + stubUpdateUser() 125 + 126 + const createdAt = new Date('2025-06-01T00:00:00Z') 127 + resolveCreationDateFn.mockResolvedValue(createdAt) 128 + 129 + await backfillAccountCreatedAt(deps) 130 + 131 + expect(dbUpdateFn).toHaveBeenCalled() 132 + expect(updateSetFn).toHaveBeenCalledWith({ accountCreatedAt: createdAt }) 133 + }) 134 + 135 + it('skips users where resolution returns null and logs a warning', async () => { 136 + const users = [mockUser('did:plc:abc')] 137 + stubSelectUsers(users) 138 + stubUpdateUser() 139 + 140 + resolveCreationDateFn.mockResolvedValue(null) 141 + 142 + const result = await backfillAccountCreatedAt(deps) 143 + 144 + expect(dbUpdateFn).not.toHaveBeenCalled() 145 + expect(logWarnFn).toHaveBeenCalledWith( 146 + { did: 'did:plc:abc' }, 147 + 'Could not resolve account creation date, skipping' 148 + ) 149 + expect(result.skipped).toBe(1) 150 + }) 151 + 152 + it('respects batch size configuration', async () => { 153 + deps = createMockDeps({ batchSize: 2 }) 154 + 155 + const users = [mockUser('did:plc:a'), mockUser('did:plc:b'), mockUser('did:plc:c')] 156 + stubSelectUsers(users) 157 + stubUpdateUser() 158 + 159 + const createdAt = new Date('2025-06-01T00:00:00Z') 160 + resolveCreationDateFn.mockResolvedValue(createdAt) 161 + 162 + await backfillAccountCreatedAt(deps) 163 + 164 + // All 3 users should still be processed regardless of batch size 165 + expect(resolveCreationDateFn).toHaveBeenCalledTimes(3) 166 + }) 167 + 168 + it('reports correct summary counts', async () => { 169 + const users = [ 170 + mockUser('did:plc:resolved1'), 171 + mockUser('did:plc:resolved2'), 172 + mockUser('did:plc:skipped'), 173 + ] 174 + stubSelectUsers(users) 175 + stubUpdateUser() 176 + 177 + const createdAt = new Date('2025-06-01T00:00:00Z') 178 + resolveCreationDateFn 179 + .mockResolvedValueOnce(createdAt) 180 + .mockResolvedValueOnce(createdAt) 181 + .mockResolvedValueOnce(null) // skipped 182 + 183 + const result = await backfillAccountCreatedAt(deps) 184 + 185 + expect(result).toEqual<BackfillResult>({ 186 + total: 3, 187 + resolved: 2, 188 + skipped: 1, 189 + failed: 0, 190 + }) 191 + }) 192 + 193 + it('counts failed resolutions when resolveCreationDate throws', async () => { 194 + const users = [mockUser('did:plc:good'), mockUser('did:plc:bad')] 195 + stubSelectUsers(users) 196 + stubUpdateUser() 197 + 198 + const createdAt = new Date('2025-06-01T00:00:00Z') 199 + resolveCreationDateFn 200 + .mockResolvedValueOnce(createdAt) 201 + .mockRejectedValueOnce(new Error('Unexpected failure')) 202 + 203 + const result = await backfillAccountCreatedAt(deps) 204 + 205 + expect(result.resolved).toBe(1) 206 + expect(result.failed).toBe(1) 207 + expect(logErrorFn).toHaveBeenCalled() 208 + }) 209 + 210 + it('returns zeroes when no users need backfilling', async () => { 211 + stubSelectUsers([]) 212 + 213 + const result = await backfillAccountCreatedAt(deps) 214 + 215 + expect(result).toEqual<BackfillResult>({ 216 + total: 0, 217 + resolved: 0, 218 + skipped: 0, 219 + failed: 0, 220 + }) 221 + expect(logInfoFn).toHaveBeenCalledWith('No users need account age backfilling') 222 + }) 223 + 224 + it('logs progress at configured intervals', async () => { 225 + deps = createMockDeps({ batchSize: 50 }) 226 + 227 + // Create 12 users to trigger at least one progress log (every 10) 228 + const users = Array.from({ length: 12 }, (_, i) => mockUser(`did:plc:user${String(i)}`)) 229 + stubSelectUsers(users) 230 + stubUpdateUser() 231 + 232 + const createdAt = new Date('2025-06-01T00:00:00Z') 233 + resolveCreationDateFn.mockResolvedValue(createdAt) 234 + 235 + await backfillAccountCreatedAt(deps) 236 + 237 + // Should have logged progress after 10th user 238 + const progressLog = logInfoFn.mock.calls.find( 239 + (call: unknown[]) => 240 + typeof call[0] === 'object' && 241 + call[0] !== null && 242 + 'processed' in (call[0] as Record<string, unknown>) && 243 + (call[0] as Record<string, unknown>)['processed'] === 10 244 + ) 245 + expect(progressLog).toBeDefined() 246 + }) 247 + })