See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix account deletion: exclude deleted users from tracked DID refresh

The 1-second refresh timer was re-reading all users from SQLite without
filtering deleted_at, causing soft-deleted users to be re-added to
trackedDids one second after deletion. This violated spec §6 Flow 1's
promise that account deletion is honored.

Fix: add .whereNull('deleted_at') to the readTrackedDids query in
jetstream_consume.ts so the refresh never re-admits deleted users.

Also adds two regression tests:
- jetstream_consumer.spec.ts: verifies that after refreshTrackedDids()
returns a set that excludes a user (simulating the deleted_at filter),
subsequent likes for that user are dropped
- lucid_schemas.spec.ts: verifies that the .whereNull('deleted_at') SQL
filter actually excludes rows with deleted_at set in real SQLite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+87 -2
+2 -2
apps/web/commands/jetstream_consume.ts
··· 51 51 return new WebSocket(url) 52 52 }, 53 53 54 - // Read tracked DIDs from SQLite users table 54 + // Read tracked DIDs from SQLite users table (exclude soft-deleted users) 55 55 readTrackedDids: async () => { 56 - const rows = await db.from('users').select('did') 56 + const rows = await db.from('users').whereNull('deleted_at').select('did') 57 57 return new Set<string>(rows.map((r: { did: string }) => r.did)) 58 58 }, 59 59
+29
apps/web/tests/unit/lucid_schemas.spec.ts
··· 1 1 import { test } from '@japa/runner' 2 2 import testUtils from '@adonisjs/core/services/test_utils' 3 + import db from '@adonisjs/lucid/services/db' 3 4 import User from '#models/user' 4 5 import JetstreamCursor from '#models/jetstream_cursor' 5 6 import BackfillJob from '#models/backfill_job' ··· 69 70 updatedAt: Date.now(), 70 71 }) 71 72 }) 73 + }) 74 + }) 75 + 76 + test.group('Lucid schemas — User whereNull deleted_at filter', (group) => { 77 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 78 + 79 + test('whereNull deleted_at excludes users with deleted_at set', async ({ assert }) => { 80 + // Insert an active user (no deleted_at) 81 + await User.create({ 82 + did: 'did:plc:activeuser001', 83 + handle: 'activeuser.bsky.social', 84 + firstSeenAt: Date.now(), 85 + }) 86 + 87 + // Insert a deleted user (deleted_at is set) 88 + await User.create({ 89 + did: 'did:plc:deleteduser001', 90 + handle: 'deleteduser.bsky.social', 91 + firstSeenAt: Date.now(), 92 + deletedAt: Date.now(), 93 + }) 94 + 95 + // Run the same query used in jetstream_consume.ts (with the fix applied) 96 + const rows = await db.from('users').whereNull('deleted_at').select('did') 97 + const dids = rows.map((r: { did: string }) => r.did) 98 + 99 + assert.include(dids, 'did:plc:activeuser001', 'active user should be included') 100 + assert.notInclude(dids, 'did:plc:deleteduser001', 'deleted user should be excluded') 72 101 }) 73 102 }) 74 103
+56
apps/web/tests/unit/services/jetstream_consumer.spec.ts
··· 990 990 }) 991 991 } 992 992 ) 993 + 994 + test.group( 995 + 'JetstreamConsumer — refresh excludes users with deleted_at set (seam-level regression)', 996 + () => { 997 + test('after refresh returns empty set (deleted user filtered out), likes for that DID are dropped', async ({ 998 + assert, 999 + }) => { 1000 + // Start with alice tracked in the initial set 1001 + const aliceDid = 'did:plc:alice-refresh-test' 1002 + let currentSet = new Set<string>([aliceDid]) 1003 + 1004 + const fakeWs = new FakeWebSocket() 1005 + const store = makeFakeStore() 1006 + const deps: JetstreamConsumerDeps = { 1007 + createWebSocket(url: string) { 1008 + fakeWs.constructedUrl = url 1009 + return fakeWs 1010 + }, 1011 + // Seam: first call returns alice; subsequent calls return empty set 1012 + // (simulating that alice now has deleted_at set in the DB) 1013 + readTrackedDids: async () => currentSet, 1014 + readCursor: async () => null, 1015 + writeCursor: async (_cursor: bigint) => {}, 1016 + now: () => new Date('2024-09-09T19:46:00.000Z'), 1017 + markUserDeleted: async (_did: string) => {}, 1018 + updateUserHandle: async (_did: string, _handle: string) => {}, 1019 + } 1020 + 1021 + const consumer = new JetstreamConsumer(store, deps) 1022 + await startConsumerInBackground(consumer) 1023 + 1024 + // Confirm alice is tracked: a like should be buffered 1025 + fakeWs.emit(makeLikeEvent(aliceDid, 1725911162329308, 'rkey-before-refresh')) 1026 + await consumer.flushBuffer() 1027 + assert.equal(store.insertCalls.length, 1, 'like before refresh should be buffered') 1028 + 1029 + // Simulate DB filtering out alice (deleted_at is set) — next refresh call returns empty set 1030 + currentSet = new Set<string>() 1031 + 1032 + // Manually trigger a refresh by calling the private method via any-cast 1033 + await (consumer as any).refreshTrackedDids() 1034 + 1035 + // Now a like for alice should be dropped — refresh replaced trackedDids with empty set 1036 + fakeWs.emit(makeLikeEvent(aliceDid, 1725911162339999, 'rkey-after-refresh')) 1037 + await consumer.flushBuffer() 1038 + 1039 + assert.equal( 1040 + store.insertCalls.length, 1041 + 1, 1042 + 'like after refresh should be dropped because alice was filtered out' 1043 + ) 1044 + 1045 + await consumer.shutdown() 1046 + }) 1047 + } 1048 + )