alpha
Login
or
Join now
nandi.uk
/
semble
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
update early testers list
author
Wesley Finck
date
5 months ago
(Feb 9, 2026, 11:59 AM -0800)
commit
dd5ee44f
dd5ee44fef7162391bb1c420d4898cefe9cb1fd2
parent
d76a026b
d76a026bec5e92d2a0798211f59e7e3faf0279f6
+1982
4 changed files
Expand all
Collapse all
Unified
Split
.agent
logs
20260305_following_feed_application_layer.md
20260305_following_feed_spec.md
20260305_removing_feed_saga.md
src
webapp
lib
serverFeatureFlags.ts
+663
.agent/logs/20260305_following_feed_application_layer.md
View file
Reviewed
···
1
1
+
# Following Feed: Application Layer Implementation Guide
2
2
+
3
3
+
## Architectural Decision: Single Use Case with Fan-out
4
4
+
5
5
+
### Decision
6
6
+
7
7
+
**Fan-out logic will be coordinated inside `AddActivityToFeedUseCase`**, not in a separate use case.
8
8
+
9
9
+
## Application Layer Architecture
10
10
+
11
11
+
### Overview
12
12
+
13
13
+
```
14
14
+
Event: CARD_ADDED_TO_LIBRARY / CARD_ADDED_TO_COLLECTION
15
15
+
↓
16
16
+
Event Handler (unchanged interface)
17
17
+
↓
18
18
+
AddActivityToFeedUseCase.execute()
19
19
+
├─ 1. Validate input (existing)
20
20
+
├─ 2. Fetch card metadata (existing)
21
21
+
├─ 3. Create activity via FeedService
22
22
+
│ └─ [Distributed lock + dedup check + insert/update]
23
23
+
├─ 4. Get followers of actor (user) ← NEW
24
24
+
├─ 5. Get followers of collections (if any) ← NEW
25
25
+
├─ 6. Combine & deduplicate follower IDs ← NEW
26
26
+
└─ 7. Fan-out to followers ← NEW
27
27
+
└─ [Idempotent bulk insert via ON CONFLICT DO NOTHING]
28
28
+
```
29
29
+
30
30
+
### Responsibilities
31
31
+
32
32
+
**Use Case (`AddActivityToFeedUseCase`):**
33
33
+
34
34
+
- Orchestrates the complete flow: validation → activity creation → fan-out
35
35
+
- Coordinates calls to multiple repositories
36
36
+
- Implements business logic
37
37
+
- Handles errors with appropriate strategies
38
38
+
39
39
+
---
40
40
+
41
41
+
## Repository Interfaces
42
42
+
43
43
+
### New Repository: `IFollowsRepository`
44
44
+
45
45
+
**Purpose:** Query following relationships to determine fan-out targets.
46
46
+
47
47
+
**Note:** This repository is **read-only** for the purpose of feed fan-out. Follow/unfollow operations (write) are out of scope for this implementation.
48
48
+
49
49
+
```typescript
50
50
+
export interface Follow {
51
51
+
followerId: string; // DID of user who is following
52
52
+
targetId: string; // User DID or Collection UUID being followed
53
53
+
targetType: 'USER' | 'COLLECTION';
54
54
+
}
55
55
+
56
56
+
export interface IFollowsRepository {
57
57
+
/**
58
58
+
* Get all followers of a specific user.
59
59
+
*
60
60
+
* @param targetId - User DID being followed
61
61
+
* @param targetType - Must be 'USER' for this method
62
62
+
* @returns Array of Follow records (can be empty if no followers)
63
63
+
*
64
64
+
* Example:
65
65
+
* getFollowers('did:plc:alice123', 'USER')
66
66
+
* → [{ followerId: 'did:plc:bob456', targetId: 'did:plc:alice123', targetType: 'USER' }]
67
67
+
*/
68
68
+
getFollowers(targetId: string, targetType: 'USER'): Promise<Result<Follow[]>>;
69
69
+
70
70
+
/**
71
71
+
* Get all followers of multiple collections (combined, deduplicated).
72
72
+
*
73
73
+
* @param collectionIds - Array of collection UUIDs
74
74
+
* @returns Array of Follow records (can be empty)
75
75
+
*
76
76
+
* Example:
77
77
+
* getFollowersOfCollections(['uuid-1', 'uuid-2'])
78
78
+
* → [
79
79
+
* { followerId: 'did:plc:bob456', targetId: 'uuid-1', targetType: 'COLLECTION' },
80
80
+
* { followerId: 'did:plc:carol789', targetId: 'uuid-2', targetType: 'COLLECTION' }
81
81
+
* ]
82
82
+
*
83
83
+
* Notes:
84
84
+
* - Returns empty array if collectionIds is empty
85
85
+
* - Results may include duplicates if a user follows multiple input collections
86
86
+
* (deduplication happens at use case level)
87
87
+
*/
88
88
+
getFollowersOfCollections(collectionIds: string[]): Promise<Result<Follow[]>>;
89
89
+
}
90
90
+
```
91
91
+
92
92
+
### Updated Repository: `IFeedRepository`
93
93
+
94
94
+
**Add these methods to the existing interface:**
95
95
+
96
96
+
```typescript
97
97
+
export interface IFeedRepository {
98
98
+
// ... existing methods (addActivity, updateActivity, etc.) ...
99
99
+
100
100
+
/**
101
101
+
* Fan-out an activity to multiple followers' following feeds.
102
102
+
*
103
103
+
* @param activityId - Activity to distribute
104
104
+
* @param followerIds - User DIDs to receive this activity (deduplicated by caller)
105
105
+
* @param createdAt - Activity timestamp (denormalized for sorting)
106
106
+
* @returns Success or error
107
107
+
*
108
108
+
* Idempotency guarantee:
109
109
+
* - Uses ON CONFLICT DO NOTHING on primary key (user_id, activity_id)
110
110
+
* - Safe to call multiple times with same inputs
111
111
+
* - Retries are silent (no error on duplicate)
112
112
+
*
113
113
+
* Performance:
114
114
+
* - Bulk insert operation (single query)
115
115
+
* - Returns immediately if followerIds is empty (no-op)
116
116
+
*
117
117
+
* Example:
118
118
+
* fanOutActivityToFollowers(
119
119
+
* activityId,
120
120
+
* ['did:plc:bob456', 'did:plc:carol789'],
121
121
+
* new Date('2026-03-05T10:00:00Z')
122
122
+
* )
123
123
+
* → Inserts 2 rows into following_feed_items
124
124
+
*/
125
125
+
fanOutActivityToFollowers(
126
126
+
activityId: ActivityId,
127
127
+
followerIds: string[],
128
128
+
createdAt: Date,
129
129
+
): Promise<Result<void>>;
130
130
+
131
131
+
/**
132
132
+
* Get a user's following feed (paginated).
133
133
+
*
134
134
+
* @param userId - User DID whose feed to fetch
135
135
+
* @param options - Pagination, filters (urlType, source, beforeActivityId)
136
136
+
* @returns Paginated feed activities
137
137
+
*
138
138
+
* Query pattern:
139
139
+
* - Filters by user_id on following_feed_items
140
140
+
* - JOINs to feed_activities for full activity data
141
141
+
* - Supports same filters as global feed (urlType, source)
142
142
+
* - Cursor-based pagination via beforeActivityId
143
143
+
*
144
144
+
* Example:
145
145
+
* getFollowingFeed('did:plc:bob456', { page: 1, limit: 20, urlType: 'article' })
146
146
+
* → Returns up to 20 activities from Bob's following feed
147
147
+
*/
148
148
+
getFollowingFeed(
149
149
+
userId: string,
150
150
+
options: FeedQueryOptions,
151
151
+
): Promise<Result<PaginatedFeedResult>>;
152
152
+
}
153
153
+
```
154
154
+
155
155
+
**Interface Contract:**
156
156
+
157
157
+
- `fanOutActivityToFollowers`:
158
158
+
- **Idempotent** - Safe to retry with same inputs
159
159
+
- **Bulk operation** - Single query for all follower IDs
160
160
+
- **No-op if empty** - Returns `ok(undefined)` immediately if `followerIds.length === 0`
161
161
+
- **Denormalized timestamp** - Stores `createdAt` for efficient sorting (no JOIN needed)
162
162
+
163
163
+
- `getFollowingFeed`:
164
164
+
- **Same query options as global feed** - Reuses `FeedQueryOptions` type
165
165
+
- **Same result format** - Returns `PaginatedFeedResult` (consistent with other feed queries)
166
166
+
- **Efficient JOIN** - Single query with proper index usage
167
167
+
168
168
+
---
169
169
+
170
170
+
## Use Case Orchestration
171
171
+
172
172
+
### Updated `AddActivityToFeedUseCase`
173
173
+
174
174
+
#### Constructor Dependencies
175
175
+
176
176
+
```typescript
177
177
+
export class AddActivityToFeedUseCase implements UseCase<...> {
178
178
+
constructor(
179
179
+
private feedService: FeedService, // Existing
180
180
+
private cardRepository: ICardRepository, // Existing
181
181
+
private followsRepository: IFollowsRepository, // NEW - for fetching followers
182
182
+
private feedRepository: IFeedRepository, // NEW - for fan-out operation
183
183
+
) {}
184
184
+
}
185
185
+
```
186
186
+
187
187
+
#### Execution Flow
188
188
+
189
189
+
```typescript
190
190
+
async execute(request: AddActivityToFeedDTO): Promise<Result<...>> {
191
191
+
try {
192
192
+
// ========================================
193
193
+
// PHASE 1: VALIDATION (Existing)
194
194
+
// ========================================
195
195
+
196
196
+
// Validate actorId, cardId, collectionIds
197
197
+
// (existing code, unchanged)
198
198
+
199
199
+
// ========================================
200
200
+
// PHASE 2: FETCH CARD METADATA (Existing)
201
201
+
// ========================================
202
202
+
203
203
+
// Fetch card to get urlType and source
204
204
+
// (existing code, unchanged)
205
205
+
206
206
+
// ========================================
207
207
+
// PHASE 3: CREATE ACTIVITY (Existing)
208
208
+
// ========================================
209
209
+
210
210
+
// Call FeedService with distributed locking
211
211
+
const activityResult = await this.feedService.addCardCollectedActivity(
212
212
+
actorId,
213
213
+
cardId,
214
214
+
collectionIds,
215
215
+
urlType,
216
216
+
source,
217
217
+
createdAt,
218
218
+
);
219
219
+
220
220
+
if (activityResult.isErr()) {
221
221
+
return err(new ValidationError(activityResult.error.message));
222
222
+
}
223
223
+
224
224
+
const activity = activityResult.value;
225
225
+
226
226
+
// ========================================
227
227
+
// PHASE 4: GET FOLLOWERS (NEW)
228
228
+
// ========================================
229
229
+
230
230
+
// 4a. Get followers of the actor (user who created activity)
231
231
+
const userFollowersResult = await this.followsRepository.getFollowers(
232
232
+
actorId.value,
233
233
+
'USER'
234
234
+
);
235
235
+
236
236
+
const userFollowers = userFollowersResult.isOk()
237
237
+
? userFollowersResult.value.map(f => f.followerId)
238
238
+
: [];
239
239
+
240
240
+
// 4b. Get followers of collections (if any)
241
241
+
let collectionFollowers: string[] = [];
242
242
+
if (collectionIds && collectionIds.length > 0) {
243
243
+
const collectionIdStrings = collectionIds.map(id => id.getStringValue());
244
244
+
const collectionFollowersResult =
245
245
+
await this.followsRepository.getFollowersOfCollections(collectionIdStrings);
246
246
+
247
247
+
collectionFollowers = collectionFollowersResult.isOk()
248
248
+
? collectionFollowersResult.value.map(f => f.followerId)
249
249
+
: [];
250
250
+
}
251
251
+
252
252
+
// 4c. Combine and deduplicate follower IDs
253
253
+
const allFollowerIds = new Set([...userFollowers, ...collectionFollowers]);
254
254
+
255
255
+
// ========================================
256
256
+
// PHASE 5: FAN-OUT (NEW)
257
257
+
// ========================================
258
258
+
259
259
+
if (allFollowerIds.size > 0) {
260
260
+
const fanOutResult = await this.feedRepository.fanOutActivityToFollowers(
261
261
+
activity.activityId,
262
262
+
Array.from(allFollowerIds),
263
263
+
activity.createdAt,
264
264
+
);
265
265
+
266
266
+
// Error handling: Log but don't fail the use case
267
267
+
// Activity already exists in global feed
268
268
+
// Event retries will eventually distribute it
269
269
+
if (fanOutResult.isErr()) {
270
270
+
console.error('Fan-out failed (will retry on event retry):', fanOutResult.error);
271
271
+
// Note: We do NOT return err here - see Error Handling section
272
272
+
}
273
273
+
}
274
274
+
275
275
+
// ========================================
276
276
+
// RETURN SUCCESS
277
277
+
// ========================================
278
278
+
279
279
+
return ok({
280
280
+
activityId: activity.activityId.getStringValue(),
281
281
+
});
282
282
+
283
283
+
} catch (error) {
284
284
+
return err(AppError.UnexpectedError.create(error));
285
285
+
}
286
286
+
}
287
287
+
```
288
288
+
289
289
+
### Orchestration Principles
290
290
+
291
291
+
1. **Sequential execution** - Each phase depends on previous phase results
292
292
+
2. **Graceful degradation** - Follower lookup errors don't fail the use case (returns empty array)
293
293
+
3. **Deduplication at use case level** - Combine user + collection followers using `Set`
294
294
+
4. **Skip empty fan-out** - Don't call repository if no followers
295
295
+
5. **Business logic lives here** - Future conditional fan-out rules (e.g., "don't fan-out Margin library activities") implemented in this orchestration layer
296
296
+
297
297
+
---
298
298
+
299
299
+
## Integration with Event Handlers
300
300
+
301
301
+
### No Changes Required
302
302
+
303
303
+
Event handlers remain unchanged:
304
304
+
305
305
+
```typescript
306
306
+
export class CardAddedToLibraryEventHandler
307
307
+
implements IEventHandler<CardAddedToLibraryEvent>
308
308
+
{
309
309
+
constructor(private addActivityToFeedUseCase: AddActivityToFeedUseCase) {}
310
310
+
311
311
+
async handle(event: CardAddedToLibraryEvent): Promise<Result<void>> {
312
312
+
const result = await this.addActivityToFeedUseCase.execute({
313
313
+
type: ActivityTypeEnum.CARD_COLLECTED,
314
314
+
actorId: event.curatorId.value,
315
315
+
cardId: event.cardId.getStringValue(),
316
316
+
collectionIds: undefined,
317
317
+
createdAt: event.addedAt,
318
318
+
});
319
319
+
320
320
+
if (result.isErr()) {
321
321
+
console.error('Failed to add library activity to feed:', result.error);
322
322
+
return err(result.error);
323
323
+
}
324
324
+
325
325
+
return ok(undefined);
326
326
+
}
327
327
+
}
328
328
+
```
329
329
+
330
330
+
## Dependency Injection Updates
331
331
+
332
332
+
### Factory Changes Required
333
333
+
334
334
+
Update the factory/composition root to inject new dependencies:
335
335
+
336
336
+
```typescript
337
337
+
// UseCaseFactory or similar
338
338
+
export class UseCaseFactory {
339
339
+
createAddActivityToFeedUseCase(): AddActivityToFeedUseCase {
340
340
+
return new AddActivityToFeedUseCase(
341
341
+
this.feedService, // Existing
342
342
+
this.cardRepository, // Existing
343
343
+
this.repositoryFactory.followsRepository, // NEW
344
344
+
this.repositoryFactory.feedRepository, // NEW (already exists, just inject)
345
345
+
);
346
346
+
}
347
347
+
}
348
348
+
```
349
349
+
350
350
+
```typescript
351
351
+
// RepositoryFactory
352
352
+
export class RepositoryFactory {
353
353
+
// Existing
354
354
+
get feedRepository(): IFeedRepository { ... }
355
355
+
get cardRepository(): ICardRepository { ... }
356
356
+
357
357
+
// NEW
358
358
+
get followsRepository(): IFollowsRepository {
359
359
+
return this._followsRepository ??= new DrizzleFollowsRepository(this.db);
360
360
+
}
361
361
+
}
362
362
+
```
363
363
+
364
364
+
---
365
365
+
366
366
+
## Database Schema
367
367
+
368
368
+
### New Tables
369
369
+
370
370
+
```sql
371
371
+
-- Following relationships (polymorphic - supports users AND collections)
372
372
+
CREATE TABLE follows (
373
373
+
follower_id TEXT NOT NULL, -- DID of user who is following
374
374
+
target_id TEXT NOT NULL, -- ID of what's being followed (user DID or collection UUID)
375
375
+
target_type TEXT NOT NULL, -- 'USER' or 'COLLECTION'
376
376
+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
377
377
+
PRIMARY KEY (follower_id, target_id, target_type)
378
378
+
);
379
379
+
380
380
+
CREATE INDEX idx_follows_follower ON follows(follower_id);
381
381
+
CREATE INDEX idx_follows_target ON follows(target_id, target_type);
382
382
+
383
383
+
-- Following feed items (fan-out target)
384
384
+
CREATE TABLE following_feed_items (
385
385
+
user_id TEXT NOT NULL, -- DID of feed owner
386
386
+
activity_id UUID NOT NULL REFERENCES feed_activities(id) ON DELETE CASCADE,
387
387
+
created_at TIMESTAMP NOT NULL, -- Denormalized from activity for sorting
388
388
+
PRIMARY KEY (user_id, activity_id)
389
389
+
);
390
390
+
391
391
+
CREATE INDEX idx_following_feed_user_time ON following_feed_items(user_id, created_at DESC);
392
392
+
```
393
393
+
394
394
+
### New Repository: IFollowsRepository
395
395
+
396
396
+
**Note:** This repository only defines methods needed for feed fan-out. Follow/unfollow functionality is out of scope for this document.
397
397
+
398
398
+
```typescript
399
399
+
export interface Follow {
400
400
+
followerId: string;
401
401
+
targetId: string;
402
402
+
targetType: 'USER' | 'COLLECTION';
403
403
+
}
404
404
+
405
405
+
export interface IFollowsRepository {
406
406
+
// Get all followers of a specific user
407
407
+
getFollowers(targetId: string, targetType: 'USER'): Promise<Result<Follow[]>>;
408
408
+
409
409
+
// Get all followers of multiple collections (combined)
410
410
+
getFollowersOfCollections(collectionIds: string[]): Promise<Result<Follow[]>>;
411
411
+
}
412
412
+
```
413
413
+
414
414
+
---
415
415
+
416
416
+
### New Repository Implementation: DrizzleFollowsRepository
417
417
+
418
418
+
```typescript
419
419
+
export class DrizzleFollowsRepository implements IFollowsRepository {
420
420
+
constructor(private db: PostgresJsDatabase) {}
421
421
+
422
422
+
async getFollowers(
423
423
+
targetId: string,
424
424
+
targetType: 'USER',
425
425
+
): Promise<Result<Follow[]>> {
426
426
+
try {
427
427
+
const results = await this.db
428
428
+
.select()
429
429
+
.from(follows)
430
430
+
.where(
431
431
+
and(
432
432
+
eq(follows.target_id, targetId),
433
433
+
eq(follows.target_type, targetType),
434
434
+
),
435
435
+
);
436
436
+
437
437
+
return ok(
438
438
+
results.map((r) => ({
439
439
+
followerId: r.follower_id,
440
440
+
targetId: r.target_id,
441
441
+
targetType: r.target_type as 'USER' | 'COLLECTION',
442
442
+
})),
443
443
+
);
444
444
+
} catch (error) {
445
445
+
return err(error as Error);
446
446
+
}
447
447
+
}
448
448
+
449
449
+
async getFollowersOfCollections(
450
450
+
collectionIds: string[],
451
451
+
): Promise<Result<Follow[]>> {
452
452
+
try {
453
453
+
if (collectionIds.length === 0) {
454
454
+
return ok([]);
455
455
+
}
456
456
+
457
457
+
const results = await this.db
458
458
+
.select()
459
459
+
.from(follows)
460
460
+
.where(
461
461
+
and(
462
462
+
sql`${follows.target_id} = ANY(${collectionIds}::text[])`,
463
463
+
eq(follows.target_type, 'COLLECTION'),
464
464
+
),
465
465
+
);
466
466
+
467
467
+
return ok(
468
468
+
results.map((r) => ({
469
469
+
followerId: r.follower_id,
470
470
+
targetId: r.target_id,
471
471
+
targetType: r.target_type as 'USER' | 'COLLECTION',
472
472
+
})),
473
473
+
);
474
474
+
} catch (error) {
475
475
+
return err(error as Error);
476
476
+
}
477
477
+
}
478
478
+
}
479
479
+
```
480
480
+
481
481
+
---
482
482
+
483
483
+
### Updated FeedRepository: Add Fan-out Method
484
484
+
485
485
+
```typescript
486
486
+
// Add to IFeedRepository interface
487
487
+
export interface IFeedRepository {
488
488
+
// ... existing methods ...
489
489
+
490
490
+
fanOutActivityToFollowers(
491
491
+
activityId: ActivityId,
492
492
+
followerIds: string[],
493
493
+
createdAt: Date,
494
494
+
): Promise<Result<void>>;
495
495
+
}
496
496
+
497
497
+
// DrizzleFeedRepository implementation
498
498
+
async fanOutActivityToFollowers(
499
499
+
activityId: ActivityId,
500
500
+
followerIds: string[],
501
501
+
createdAt: Date,
502
502
+
): Promise<Result<void>> {
503
503
+
try {
504
504
+
if (followerIds.length === 0) {
505
505
+
return ok(undefined);
506
506
+
}
507
507
+
508
508
+
const values = followerIds.map(userId => ({
509
509
+
user_id: userId,
510
510
+
activity_id: activityId.getStringValue(),
511
511
+
created_at: createdAt,
512
512
+
}));
513
513
+
514
514
+
await this.db
515
515
+
.insert(followingFeedItems)
516
516
+
.values(values)
517
517
+
.onConflictDoNothing(); // Idempotent (handles retries)
518
518
+
519
519
+
return ok(undefined);
520
520
+
} catch (error) {
521
521
+
return err(error as Error);
522
522
+
}
523
523
+
}
524
524
+
```
525
525
+
526
526
+
---
527
527
+
528
528
+
### Idempotency Handling
529
529
+
530
530
+
Both activity creation and fan-out use `ON CONFLICT DO NOTHING`:
531
531
+
532
532
+
```typescript
533
533
+
// FeedService.addCardCollectedActivity() uses distributed locking (ILockService) to prevent
534
534
+
// race conditions during check-and-insert. Lock key: feed:activity:{actorId}:{cardId}
535
535
+
// This ensures that concurrent events for the same card+actor are serialized.
536
536
+
537
537
+
// Additionally, activity lookup uses findRecentCardCollectedActivity (2-minute window)
538
538
+
539
539
+
// Fan-out is idempotent
540
540
+
await this.db.insert(followingFeedItems).values(values).onConflictDoNothing(); // Primary key (user_id, activity_id) prevents duplicates
541
541
+
```
542
542
+
543
543
+
**Result:** If event handler retries:
544
544
+
545
545
+
1. FeedService tries to acquire lock → either:
546
546
+
- Gets lock immediately (first attempt), or
547
547
+
- Waits for lock, then finds existing activity (created by previous attempt) → returns it
548
548
+
2. Activity creation finds existing activity via findRecentCardCollectedActivity → returns it
549
549
+
3. Fan-out silently skips duplicate rows (ON CONFLICT DO NOTHING) → succeeds
550
550
+
4. Use case completes successfully with idempotent behavior
551
551
+
552
552
+
---
553
553
+
554
554
+
## Query Pattern: Getting Following Feed
555
555
+
556
556
+
```typescript
557
557
+
async getFollowingFeed(
558
558
+
userId: string,
559
559
+
options: FeedQueryOptions,
560
560
+
): Promise<Result<PaginatedFeedResult>> {
561
561
+
try {
562
562
+
const { page, limit, beforeActivityId } = options;
563
563
+
const offset = (page - 1) * limit;
564
564
+
565
565
+
// Build where conditions
566
566
+
const whereConditions = [
567
567
+
eq(followingFeedItems.user_id, userId)
568
568
+
];
569
569
+
570
570
+
if (options.urlType) {
571
571
+
whereConditions.push(eq(feedActivities.urlType, options.urlType));
572
572
+
}
573
573
+
574
574
+
if (options.source) {
575
575
+
if (options.source === ActivitySource.SEMBLE) {
576
576
+
whereConditions.push(sql`${feedActivities.source} IS NULL`);
577
577
+
} else {
578
578
+
whereConditions.push(eq(feedActivities.source, options.source));
579
579
+
}
580
580
+
}
581
581
+
582
582
+
// Cursor-based pagination
583
583
+
if (beforeActivityId) {
584
584
+
const beforeActivity = await this.db
585
585
+
.select({ createdAt: followingFeedItems.created_at })
586
586
+
.from(followingFeedItems)
587
587
+
.where(
588
588
+
and(
589
589
+
eq(followingFeedItems.user_id, userId),
590
590
+
eq(followingFeedItems.activity_id, beforeActivityId.getStringValue())
591
591
+
)
592
592
+
)
593
593
+
.limit(1);
594
594
+
595
595
+
if (beforeActivity.length > 0) {
596
596
+
whereConditions.push(
597
597
+
lt(followingFeedItems.created_at, beforeActivity[0].createdAt)
598
598
+
);
599
599
+
}
600
600
+
}
601
601
+
602
602
+
// Main query with JOIN
603
603
+
const activitiesResult = await this.db
604
604
+
.select({
605
605
+
id: feedActivities.id,
606
606
+
actorId: feedActivities.actorId,
607
607
+
cardId: feedActivities.cardId,
608
608
+
type: feedActivities.type,
609
609
+
metadata: feedActivities.metadata,
610
610
+
urlType: feedActivities.urlType,
611
611
+
source: feedActivities.source,
612
612
+
createdAt: followingFeedItems.created_at, // Use denormalized timestamp
613
613
+
})
614
614
+
.from(followingFeedItems)
615
615
+
.innerJoin(
616
616
+
feedActivities,
617
617
+
eq(feedActivities.id, followingFeedItems.activity_id)
618
618
+
)
619
619
+
.where(and(...whereConditions))
620
620
+
.orderBy(
621
621
+
desc(followingFeedItems.created_at),
622
622
+
desc(followingFeedItems.activity_id)
623
623
+
)
624
624
+
.limit(limit)
625
625
+
.offset(offset);
626
626
+
627
627
+
// Count total (with same filters)
628
628
+
const totalCountResult = await this.db
629
629
+
.select({ count: count() })
630
630
+
.from(followingFeedItems)
631
631
+
.innerJoin(
632
632
+
feedActivities,
633
633
+
eq(feedActivities.id, followingFeedItems.activity_id)
634
634
+
)
635
635
+
.where(and(...whereConditions));
636
636
+
637
637
+
const totalCount = totalCountResult[0]?.count || 0;
638
638
+
639
639
+
// Map to domain objects (same as existing feeds)
640
640
+
const activities = await this.mapToDomainActivities(activitiesResult);
641
641
+
642
642
+
const hasMore = offset + activities.length < totalCount;
643
643
+
const nextCursor = hasMore && activities.length > 0
644
644
+
? activities[activities.length - 1].activityId
645
645
+
: undefined;
646
646
+
647
647
+
return ok({
648
648
+
activities,
649
649
+
totalCount,
650
650
+
hasMore,
651
651
+
nextCursor,
652
652
+
});
653
653
+
} catch (error) {
654
654
+
return err(error as Error);
655
655
+
}
656
656
+
}
657
657
+
```
658
658
+
659
659
+
**Index Usage:**
660
660
+
661
661
+
- `idx_following_feed_user_time` handles WHERE + ORDER BY efficiently
662
662
+
- JOIN to `feed_activities` uses primary key (fast)
663
663
+
- Additional filters (urlType, source) use existing indexes on `feed_activities`
+801
.agent/logs/20260305_following_feed_spec.md
View file
Reviewed
···
1
1
+
# Following Feed Implementation Specification
2
2
+
3
3
+
**Date:** 2026-03-05
4
4
+
**Status:** Planning
5
5
+
**Architecture:** Application-level fan-out
6
6
+
7
7
+
**Scope:** This document covers how to add activities to following feeds and how to fetch them. Follow/unfollow functionality is **out of scope** and will be handled separately.
8
8
+
9
9
+
---
10
10
+
11
11
+
## Overview
12
12
+
13
13
+
The following feed extends the existing event-driven feed activity system. Activities are already created asynchronously via `CARD_ADDED_TO_LIBRARY` and `CARD_ADDED_TO_COLLECTION` events. We will perform **application-level synchronous fan-out** after activity creation.
14
14
+
15
15
+
**Key Design:** Event handlers call `AddActivityToFeedUseCase` directly (no saga). The use case coordinates:
16
16
+
17
17
+
1. Activity creation (with distributed locking via `ILockService` to prevent race conditions)
18
18
+
2. Following feed fan-out (fetching followers and inserting into following feed)
19
19
+
20
20
+
**Following Targets:** Users can follow both **users** AND **collections**, so fan-out must consider:
21
21
+
22
22
+
- Followers of the actor (user who created the activity)
23
23
+
- Followers of the collections (if the activity includes collections)
24
24
+
25
25
+
---
26
26
+
27
27
+
## Database Schema
28
28
+
29
29
+
### New Tables
30
30
+
31
31
+
```sql
32
32
+
-- Following relationships (polymorphic - supports users AND collections)
33
33
+
CREATE TABLE follows (
34
34
+
follower_id TEXT NOT NULL, -- DID of user who is following
35
35
+
target_id TEXT NOT NULL, -- ID of what's being followed (user DID or collection UUID)
36
36
+
target_type TEXT NOT NULL, -- 'USER' or 'COLLECTION'
37
37
+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
38
38
+
PRIMARY KEY (follower_id, target_id, target_type)
39
39
+
);
40
40
+
41
41
+
CREATE INDEX idx_follows_follower ON follows(follower_id);
42
42
+
CREATE INDEX idx_follows_target ON follows(target_id, target_type);
43
43
+
44
44
+
-- Following feed items (fan-out target)
45
45
+
CREATE TABLE following_feed_items (
46
46
+
user_id TEXT NOT NULL, -- DID of feed owner
47
47
+
activity_id UUID NOT NULL REFERENCES feed_activities(id) ON DELETE CASCADE,
48
48
+
created_at TIMESTAMP NOT NULL, -- Denormalized from activity for sorting
49
49
+
PRIMARY KEY (user_id, activity_id)
50
50
+
);
51
51
+
52
52
+
CREATE INDEX idx_following_feed_user_time ON following_feed_items(user_id, created_at DESC);
53
53
+
```
54
54
+
55
55
+
### Why This Schema?
56
56
+
57
57
+
1. **`follows` table (Polymorphic):**
58
58
+
- Stores who follows what (users OR collections)
59
59
+
- `target_type` distinguishes between following a user vs a collection
60
60
+
- Single table keeps queries simple (one lookup for all followers)
61
61
+
- Alternative: separate `user_follows` and `collection_follows` tables (more normalized, but requires 2 queries)
62
62
+
63
63
+
2. **`following_feed_items` table:**
64
64
+
- Stores the fan-out results
65
65
+
- Each row = "this activity should appear in this user's following feed"
66
66
+
- Same structure regardless of whether activity came from following a user or collection
67
67
+
68
68
+
3. **Denormalized `created_at`:**
69
69
+
- Avoids JOIN to `feed_activities` for sorting queries
70
70
+
- Small storage cost for massive read performance gain
71
71
+
72
72
+
4. **ON DELETE CASCADE:**
73
73
+
- When an activity is deleted, automatically remove from all following feeds
74
74
+
75
75
+
---
76
76
+
77
77
+
## Flow from Event to Following Feed
78
78
+
79
79
+
### Current Flow (After Saga Removal)
80
80
+
81
81
+
```
82
82
+
Event: CARD_ADDED_TO_LIBRARY
83
83
+
↓
84
84
+
CardAddedToLibraryEventHandler
85
85
+
↓
86
86
+
AddActivityToFeedUseCase.execute()
87
87
+
↓
88
88
+
FeedService.addCardCollectedActivity()
89
89
+
├─ [ACQUIRE DISTRIBUTED LOCK via ILockService]
90
90
+
├─ [CHECK for recent activity]
91
91
+
├─ [INSERT or UPDATE feed_activities]
92
92
+
└─ [RELEASE DISTRIBUTED LOCK]
93
93
+
↓
94
94
+
DrizzleFeedRepository.addActivity()
95
95
+
↓
96
96
+
INSERT INTO feed_activities (...)
97
97
+
```
98
98
+
99
99
+
### New Flow (With Following Feed)
100
100
+
101
101
+
```
102
102
+
Event: CARD_ADDED_TO_LIBRARY
103
103
+
↓
104
104
+
CardAddedToLibraryEventHandler
105
105
+
↓
106
106
+
AddActivityToFeedUseCase.execute()
107
107
+
├─ FeedService.addCardCollectedActivity()
108
108
+
│ ├─ [ACQUIRE DISTRIBUTED LOCK]
109
109
+
│ ├─ [CHECK for recent activity]
110
110
+
│ └─ [INSERT or UPDATE feed_activities]
111
111
+
│
112
112
+
├─ FollowsRepository.getFollowers(actorId, 'USER')
113
113
+
│
114
114
+
├─ FollowsRepository.getFollowersOfCollections(collectionIds)
115
115
+
│
116
116
+
├─ Combine & deduplicate follower lists
117
117
+
│
118
118
+
└─ FeedRepository.fanOutActivityToFollowers()
119
119
+
└─ INSERT INTO following_feed_items (user_id, activity_id, created_at)
120
120
+
VALUES (...follower_ids..., activity_id, created_at)
121
121
+
ON CONFLICT DO NOTHING
122
122
+
```
123
123
+
124
124
+
**Key Points:**
125
125
+
126
126
+
- ✅ Activity creation is protected by distributed lock (prevents race conditions)
127
127
+
- ✅ No additional async layer needed (already in event handler)
128
128
+
- ✅ Fan-out happens at application level after activity creation
129
129
+
- ✅ If either activity creation or fan-out fails, event handler retries entire use case
130
130
+
- ✅ Both operations are idempotent (ON CONFLICT DO NOTHING) for retry safety
131
131
+
132
132
+
---
133
133
+
134
134
+
## Application-Level Fan-out Design
135
135
+
136
136
+
Fan-out happens in `AddActivityToFeedUseCase.execute()` by orchestrating multiple repository calls.
137
137
+
138
138
+
```typescript
139
139
+
// AddActivityToFeedUseCase.execute()
140
140
+
async execute(request: AddActivityToFeedDTO): Promise<Result<...>> {
141
141
+
// ... validation (existing code) ...
142
142
+
143
143
+
// 1. Create activity WITHOUT fan-out
144
144
+
const activityResult = await this.feedService.addCardCollectedActivity(
145
145
+
actorId,
146
146
+
cardId,
147
147
+
collectionIds,
148
148
+
urlType,
149
149
+
source,
150
150
+
createdAt,
151
151
+
);
152
152
+
153
153
+
if (activityResult.isErr()) {
154
154
+
return err(new ValidationError(activityResult.error.message));
155
155
+
}
156
156
+
157
157
+
const activity = activityResult.value;
158
158
+
159
159
+
// 2. Get followers of the actor (user)
160
160
+
const userFollowersResult = await this.followsRepository.getFollowers(
161
161
+
actorId.value,
162
162
+
'USER'
163
163
+
);
164
164
+
165
165
+
const userFollowers = userFollowersResult.isOk()
166
166
+
? userFollowersResult.value
167
167
+
: [];
168
168
+
169
169
+
// 3. Get followers of collections (if any)
170
170
+
let collectionFollowers: string[] = [];
171
171
+
if (collectionIds && collectionIds.length > 0) {
172
172
+
const collectionIdStrings = collectionIds.map(id => id.getStringValue());
173
173
+
const collectionFollowersResult =
174
174
+
await this.followsRepository.getFollowersOfCollections(collectionIdStrings);
175
175
+
176
176
+
collectionFollowers = collectionFollowersResult.isOk()
177
177
+
? collectionFollowersResult.value
178
178
+
: [];
179
179
+
}
180
180
+
181
181
+
// 4. Combine and dedupe
182
182
+
const allFollowers = new Set([...userFollowers, ...collectionFollowers]);
183
183
+
184
184
+
// 5. Fan-out to all followers
185
185
+
if (allFollowers.size > 0) {
186
186
+
await this.feedRepository.fanOutActivityToFollowers(
187
187
+
activity.activityId,
188
188
+
Array.from(allFollowers),
189
189
+
activity.createdAt,
190
190
+
);
191
191
+
}
192
192
+
193
193
+
return ok({
194
194
+
activityId: activity.activityId.getStringValue(),
195
195
+
});
196
196
+
}
197
197
+
```
198
198
+
199
199
+
**Why Application-Level:**
200
200
+
201
201
+
- ✅ Proper separation of concerns (use case orchestrates, repositories handle data)
202
202
+
- ✅ Full context available for business decisions (can add conditional logic based on source, type, etc.)
203
203
+
- ✅ Easier to test (can mock each repository call independently)
204
204
+
- ✅ Repositories stay simple (data access only, no business logic)
205
205
+
206
206
+
**Handling Non-Atomicity:**
207
207
+
208
208
+
We mitigate this with:
209
209
+
210
210
+
1. **Idempotent operations:** Both activity creation and fan-out use `ON CONFLICT DO NOTHING`
211
211
+
2. **Event retries:** If fan-out fails, BullMQ retries the entire use case
212
212
+
3. **Distributed locking:** FeedService uses ILockService to prevent race conditions during activity creation
213
213
+
214
214
+
**Result:** If event handler retries:
215
215
+
216
216
+
1. Activity creation finds existing activity via distributed lock + 2-minute window check → returns it
217
217
+
2. Fan-out silently skips duplicate rows (ON CONFLICT DO NOTHING) → succeeds
218
218
+
3. Use case completes successfully with idempotent behavior
219
219
+
220
220
+
---
221
221
+
222
222
+
## Application-Level Implementation
223
223
+
224
224
+
### Updated Use Case: AddActivityToFeedUseCase
225
225
+
226
226
+
```typescript
227
227
+
export class AddActivityToFeedUseCase implements UseCase<...> {
228
228
+
constructor(
229
229
+
private feedService: FeedService,
230
230
+
private cardRepository: ICardRepository,
231
231
+
private followsRepository: IFollowsRepository, // NEW
232
232
+
private feedRepository: IFeedRepository, // NEW (for fan-out)
233
233
+
) {}
234
234
+
235
235
+
async execute(request: AddActivityToFeedDTO): Promise<Result<...>> {
236
236
+
try {
237
237
+
// ... existing validation (actorId, cardId, collectionIds) ...
238
238
+
239
239
+
// ... existing card fetch logic ...
240
240
+
241
241
+
// 1. Create activity in global feed (no fan-out yet)
242
242
+
// Note: FeedService internally uses distributed locking (via ILockService) during check-and-insert
243
243
+
// to prevent race conditions when multiple events arrive for the same card+actor
244
244
+
const activityResult = await this.feedService.addCardCollectedActivity(
245
245
+
actorId,
246
246
+
cardId,
247
247
+
collectionIds,
248
248
+
urlType,
249
249
+
source,
250
250
+
createdAt,
251
251
+
);
252
252
+
253
253
+
if (activityResult.isErr()) {
254
254
+
return err(new ValidationError(activityResult.error.message));
255
255
+
}
256
256
+
257
257
+
const activity = activityResult.value;
258
258
+
259
259
+
// 2. Get followers of the actor (user who created activity)
260
260
+
const userFollowersResult = await this.followsRepository.getFollowers(
261
261
+
actorId.value,
262
262
+
'USER'
263
263
+
);
264
264
+
265
265
+
const userFollowers = userFollowersResult.isOk()
266
266
+
? userFollowersResult.value.map(f => f.followerId)
267
267
+
: [];
268
268
+
269
269
+
// 3. Get followers of collections (if any)
270
270
+
let collectionFollowers: string[] = [];
271
271
+
if (collectionIds && collectionIds.length > 0) {
272
272
+
const collectionIdStrings = collectionIds.map(id => id.getStringValue());
273
273
+
const collectionFollowersResult =
274
274
+
await this.followsRepository.getFollowersOfCollections(collectionIdStrings);
275
275
+
276
276
+
collectionFollowers = collectionFollowersResult.isOk()
277
277
+
? collectionFollowersResult.value.map(f => f.followerId)
278
278
+
: [];
279
279
+
}
280
280
+
281
281
+
// 4. Combine and deduplicate
282
282
+
const allFollowerIds = new Set([...userFollowers, ...collectionFollowers]);
283
283
+
284
284
+
// 5. Fan-out to all followers
285
285
+
if (allFollowerIds.size > 0) {
286
286
+
const fanOutResult = await this.feedRepository.fanOutActivityToFollowers(
287
287
+
activity.activityId,
288
288
+
Array.from(allFollowerIds),
289
289
+
activity.createdAt,
290
290
+
);
291
291
+
292
292
+
if (fanOutResult.isErr()) {
293
293
+
// Log error - event handler will retry entire use case if critical
294
294
+
// Distributed lock in FeedService ensures no duplicate activities
295
295
+
console.error('Fan-out failed:', fanOutResult.error);
296
296
+
}
297
297
+
}
298
298
+
299
299
+
return ok({
300
300
+
activityId: activity.activityId.getStringValue(),
301
301
+
});
302
302
+
} catch (error) {
303
303
+
return err(AppError.UnexpectedError.create(error));
304
304
+
}
305
305
+
}
306
306
+
}
307
307
+
```
308
308
+
309
309
+
---
310
310
+
311
311
+
### New Repository: IFollowsRepository
312
312
+
313
313
+
**Note:** This repository only defines methods needed for feed fan-out. Follow/unfollow functionality is out of scope for this document.
314
314
+
315
315
+
```typescript
316
316
+
export interface Follow {
317
317
+
followerId: string;
318
318
+
targetId: string;
319
319
+
targetType: 'USER' | 'COLLECTION';
320
320
+
}
321
321
+
322
322
+
export interface IFollowsRepository {
323
323
+
// Get all followers of a specific user
324
324
+
getFollowers(targetId: string, targetType: 'USER'): Promise<Result<Follow[]>>;
325
325
+
326
326
+
// Get all followers of multiple collections (combined)
327
327
+
getFollowersOfCollections(collectionIds: string[]): Promise<Result<Follow[]>>;
328
328
+
}
329
329
+
```
330
330
+
331
331
+
---
332
332
+
333
333
+
### New Repository Implementation: DrizzleFollowsRepository
334
334
+
335
335
+
```typescript
336
336
+
export class DrizzleFollowsRepository implements IFollowsRepository {
337
337
+
constructor(private db: PostgresJsDatabase) {}
338
338
+
339
339
+
async getFollowers(
340
340
+
targetId: string,
341
341
+
targetType: 'USER',
342
342
+
): Promise<Result<Follow[]>> {
343
343
+
try {
344
344
+
const results = await this.db
345
345
+
.select()
346
346
+
.from(follows)
347
347
+
.where(
348
348
+
and(
349
349
+
eq(follows.target_id, targetId),
350
350
+
eq(follows.target_type, targetType),
351
351
+
),
352
352
+
);
353
353
+
354
354
+
return ok(
355
355
+
results.map((r) => ({
356
356
+
followerId: r.follower_id,
357
357
+
targetId: r.target_id,
358
358
+
targetType: r.target_type as 'USER' | 'COLLECTION',
359
359
+
})),
360
360
+
);
361
361
+
} catch (error) {
362
362
+
return err(error as Error);
363
363
+
}
364
364
+
}
365
365
+
366
366
+
async getFollowersOfCollections(
367
367
+
collectionIds: string[],
368
368
+
): Promise<Result<Follow[]>> {
369
369
+
try {
370
370
+
if (collectionIds.length === 0) {
371
371
+
return ok([]);
372
372
+
}
373
373
+
374
374
+
const results = await this.db
375
375
+
.select()
376
376
+
.from(follows)
377
377
+
.where(
378
378
+
and(
379
379
+
sql`${follows.target_id} = ANY(${collectionIds}::text[])`,
380
380
+
eq(follows.target_type, 'COLLECTION'),
381
381
+
),
382
382
+
);
383
383
+
384
384
+
return ok(
385
385
+
results.map((r) => ({
386
386
+
followerId: r.follower_id,
387
387
+
targetId: r.target_id,
388
388
+
targetType: r.target_type as 'USER' | 'COLLECTION',
389
389
+
})),
390
390
+
);
391
391
+
} catch (error) {
392
392
+
return err(error as Error);
393
393
+
}
394
394
+
}
395
395
+
}
396
396
+
```
397
397
+
398
398
+
---
399
399
+
400
400
+
### Updated FeedRepository: Add Fan-out Method
401
401
+
402
402
+
```typescript
403
403
+
// Add to IFeedRepository interface
404
404
+
export interface IFeedRepository {
405
405
+
// ... existing methods ...
406
406
+
407
407
+
fanOutActivityToFollowers(
408
408
+
activityId: ActivityId,
409
409
+
followerIds: string[],
410
410
+
createdAt: Date,
411
411
+
): Promise<Result<void>>;
412
412
+
}
413
413
+
414
414
+
// DrizzleFeedRepository implementation
415
415
+
async fanOutActivityToFollowers(
416
416
+
activityId: ActivityId,
417
417
+
followerIds: string[],
418
418
+
createdAt: Date,
419
419
+
): Promise<Result<void>> {
420
420
+
try {
421
421
+
if (followerIds.length === 0) {
422
422
+
return ok(undefined);
423
423
+
}
424
424
+
425
425
+
const values = followerIds.map(userId => ({
426
426
+
user_id: userId,
427
427
+
activity_id: activityId.getStringValue(),
428
428
+
created_at: createdAt,
429
429
+
}));
430
430
+
431
431
+
await this.db
432
432
+
.insert(followingFeedItems)
433
433
+
.values(values)
434
434
+
.onConflictDoNothing(); // Idempotent (handles retries)
435
435
+
436
436
+
return ok(undefined);
437
437
+
} catch (error) {
438
438
+
return err(error as Error);
439
439
+
}
440
440
+
}
441
441
+
```
442
442
+
443
443
+
---
444
444
+
445
445
+
### Idempotency Handling
446
446
+
447
447
+
Both activity creation and fan-out use `ON CONFLICT DO NOTHING`:
448
448
+
449
449
+
```typescript
450
450
+
// FeedService.addCardCollectedActivity() uses distributed locking (ILockService) to prevent
451
451
+
// race conditions during check-and-insert. Lock key: feed:activity:{actorId}:{cardId}
452
452
+
// This ensures that concurrent events for the same card+actor are serialized.
453
453
+
454
454
+
// Additionally, activity lookup uses findRecentCardCollectedActivity (2-minute window)
455
455
+
456
456
+
// Fan-out is idempotent
457
457
+
await this.db.insert(followingFeedItems).values(values).onConflictDoNothing(); // Primary key (user_id, activity_id) prevents duplicates
458
458
+
```
459
459
+
460
460
+
**Result:** If event handler retries:
461
461
+
462
462
+
1. FeedService tries to acquire lock → either:
463
463
+
- Gets lock immediately (first attempt), or
464
464
+
- Waits for lock, then finds existing activity (created by previous attempt) → returns it
465
465
+
2. Activity creation finds existing activity via findRecentCardCollectedActivity → returns it
466
466
+
3. Fan-out silently skips duplicate rows (ON CONFLICT DO NOTHING) → succeeds
467
467
+
4. Use case completes successfully with idempotent behavior
468
468
+
469
469
+
---
470
470
+
471
471
+
## Query Pattern: Getting Following Feed
472
472
+
473
473
+
```typescript
474
474
+
async getFollowingFeed(
475
475
+
userId: string,
476
476
+
options: FeedQueryOptions,
477
477
+
): Promise<Result<PaginatedFeedResult>> {
478
478
+
try {
479
479
+
const { page, limit, beforeActivityId } = options;
480
480
+
const offset = (page - 1) * limit;
481
481
+
482
482
+
// Build where conditions
483
483
+
const whereConditions = [
484
484
+
eq(followingFeedItems.user_id, userId)
485
485
+
];
486
486
+
487
487
+
if (options.urlType) {
488
488
+
whereConditions.push(eq(feedActivities.urlType, options.urlType));
489
489
+
}
490
490
+
491
491
+
if (options.source) {
492
492
+
if (options.source === ActivitySource.SEMBLE) {
493
493
+
whereConditions.push(sql`${feedActivities.source} IS NULL`);
494
494
+
} else {
495
495
+
whereConditions.push(eq(feedActivities.source, options.source));
496
496
+
}
497
497
+
}
498
498
+
499
499
+
// Cursor-based pagination
500
500
+
if (beforeActivityId) {
501
501
+
const beforeActivity = await this.db
502
502
+
.select({ createdAt: followingFeedItems.created_at })
503
503
+
.from(followingFeedItems)
504
504
+
.where(
505
505
+
and(
506
506
+
eq(followingFeedItems.user_id, userId),
507
507
+
eq(followingFeedItems.activity_id, beforeActivityId.getStringValue())
508
508
+
)
509
509
+
)
510
510
+
.limit(1);
511
511
+
512
512
+
if (beforeActivity.length > 0) {
513
513
+
whereConditions.push(
514
514
+
lt(followingFeedItems.created_at, beforeActivity[0].createdAt)
515
515
+
);
516
516
+
}
517
517
+
}
518
518
+
519
519
+
// Main query with JOIN
520
520
+
const activitiesResult = await this.db
521
521
+
.select({
522
522
+
id: feedActivities.id,
523
523
+
actorId: feedActivities.actorId,
524
524
+
cardId: feedActivities.cardId,
525
525
+
type: feedActivities.type,
526
526
+
metadata: feedActivities.metadata,
527
527
+
urlType: feedActivities.urlType,
528
528
+
source: feedActivities.source,
529
529
+
createdAt: followingFeedItems.created_at, // Use denormalized timestamp
530
530
+
})
531
531
+
.from(followingFeedItems)
532
532
+
.innerJoin(
533
533
+
feedActivities,
534
534
+
eq(feedActivities.id, followingFeedItems.activity_id)
535
535
+
)
536
536
+
.where(and(...whereConditions))
537
537
+
.orderBy(
538
538
+
desc(followingFeedItems.created_at),
539
539
+
desc(followingFeedItems.activity_id)
540
540
+
)
541
541
+
.limit(limit)
542
542
+
.offset(offset);
543
543
+
544
544
+
// Count total (with same filters)
545
545
+
const totalCountResult = await this.db
546
546
+
.select({ count: count() })
547
547
+
.from(followingFeedItems)
548
548
+
.innerJoin(
549
549
+
feedActivities,
550
550
+
eq(feedActivities.id, followingFeedItems.activity_id)
551
551
+
)
552
552
+
.where(and(...whereConditions));
553
553
+
554
554
+
const totalCount = totalCountResult[0]?.count || 0;
555
555
+
556
556
+
// Map to domain objects (same as existing feeds)
557
557
+
const activities = await this.mapToDomainActivities(activitiesResult);
558
558
+
559
559
+
const hasMore = offset + activities.length < totalCount;
560
560
+
const nextCursor = hasMore && activities.length > 0
561
561
+
? activities[activities.length - 1].activityId
562
562
+
: undefined;
563
563
+
564
564
+
return ok({
565
565
+
activities,
566
566
+
totalCount,
567
567
+
hasMore,
568
568
+
nextCursor,
569
569
+
});
570
570
+
} catch (error) {
571
571
+
return err(error as Error);
572
572
+
}
573
573
+
}
574
574
+
```
575
575
+
576
576
+
**Index Usage:**
577
577
+
578
578
+
- `idx_following_feed_user_time` handles WHERE + ORDER BY efficiently
579
579
+
- JOIN to `feed_activities` uses primary key (fast)
580
580
+
- Additional filters (urlType, source) use existing indexes on `feed_activities`
581
581
+
582
582
+
---
583
583
+
584
584
+
## Performance Analysis
585
585
+
586
586
+
### Write Performance (Fan-out)
587
587
+
588
588
+
**Scenario:** User with 1,000 followers posts an activity
589
589
+
590
590
+
| Approach | Database Operations | Estimated Time |
591
591
+
| ------------------------ | ---------------------------------------- | -------------- |
592
592
+
| **Option A (2 queries)** | 1 INSERT + 1 SELECT + 1 INSERT (1k rows) | ~30-50ms |
593
593
+
| **Option B (CTE)** | 1 combined query | ~20-40ms |
594
594
+
595
595
+
**Bottleneck:** Not the DB, but finding follower IDs. With proper index (`idx_follows_following`), this is O(1) lookup.
596
596
+
597
597
+
**Scaling:**
598
598
+
599
599
+
- 100 followers: ~10ms
600
600
+
- 1,000 followers: ~40ms
601
601
+
- 10,000 followers: ~300ms (still acceptable for async event handler)
602
602
+
- 100,000 followers: ~3s (need optimization - see below)
603
603
+
604
604
+
### Read Performance (Query Following Feed)
605
605
+
606
606
+
**Scenario:** User queries their following feed (page 1, 20 items)
607
607
+
608
608
+
| Operation | Strategy | Estimated Time |
609
609
+
| ---------------------- | ------------------------------------------------ | -------------- |
610
610
+
| **Filter by user** | `idx_following_feed_user_time` (index-only scan) | ~1ms |
611
611
+
| **Sort by time** | Already in index order | 0ms |
612
612
+
| **JOIN to activities** | Primary key lookup (20 items) | ~1ms |
613
613
+
| **Total** | | **~2-5ms** |
614
614
+
615
615
+
**Comparison to other feeds:**
616
616
+
617
617
+
- Global feed: ~2-5ms (same)
618
618
+
- Gems feed: ~5-10ms (JSONB filtering slower)
619
619
+
- Following feed: ~2-5ms ✅
620
620
+
621
621
+
---
622
622
+
623
623
+
## Edge Cases & Considerations
624
624
+
625
625
+
### 1. **What if user has no followers?**
626
626
+
627
627
+
```sql
628
628
+
SELECT follower_id FROM follows WHERE target_id = ? AND target_type = 'USER'
629
629
+
-- Returns empty result set
630
630
+
-- INSERT following_feed_items skipped (no rows)
631
631
+
```
632
632
+
633
633
+
✅ Works fine, no fan-out occurs.
634
634
+
635
635
+
### 2. **What if fan-out operation fails?**
636
636
+
637
637
+
If fan-out fails (e.g., DB connection lost):
638
638
+
639
639
+
1. Activity is already created in global feed
640
640
+
2. Event handler throws error
641
641
+
3. BullMQ retries the event
642
642
+
4. Activity creation finds existing activity (via distributed lock + 2-minute window)
643
643
+
5. Fan-out retries and succeeds (idempotent with ON CONFLICT DO NOTHING)
644
644
+
645
645
+
✅ Eventual consistency with retries.
646
646
+
647
647
+
### 3. **Duplicate activities in following feed?**
648
648
+
649
649
+
Can't happen:
650
650
+
651
651
+
- Primary key `(user_id, activity_id)` prevents duplicates
652
652
+
- Fan-out uses `ON CONFLICT DO NOTHING` for idempotency
653
653
+
654
654
+
```typescript
655
655
+
await tx.insert(followingFeedItems).values(fanOutValues).onConflictDoNothing();
656
656
+
```
657
657
+
658
658
+
---
659
659
+
660
660
+
## Optimizations for High Follower Counts
661
661
+
662
662
+
### When user has > 10,000 followers:
663
663
+
664
664
+
**Problem:** Fan-out takes > 1 second, blocks event handler
665
665
+
666
666
+
**Solution: Hybrid approach**
667
667
+
668
668
+
```typescript
669
669
+
const SYNC_FANOUT_THRESHOLD = 5000;
670
670
+
671
671
+
async addActivity(activity: FeedActivity): Promise<Result<void>> {
672
672
+
await this.db.transaction(async (tx) => {
673
673
+
// 1. Always insert activity
674
674
+
await tx.insert(feedActivities).values(...);
675
675
+
676
676
+
// 2. Count followers
677
677
+
const followerCount = await tx
678
678
+
.select({ count: count() })
679
679
+
.from(follows)
680
680
+
.where(eq(follows.following_id, dto.actorId));
681
681
+
682
682
+
if (followerCount[0].count < SYNC_FANOUT_THRESHOLD) {
683
683
+
// 3a. Small follower count: fan-out synchronously
684
684
+
await tx.execute(sql`INSERT INTO following_feed_items ...`);
685
685
+
} else {
686
686
+
// 3b. Large follower count: queue background job
687
687
+
await this.queueFanoutJob(dto.id, dto.actorId);
688
688
+
}
689
689
+
});
690
690
+
}
691
691
+
```
692
692
+
693
693
+
**For medium scale (< 100k users):** Sync approach is fine. This optimization can wait.
694
694
+
695
695
+
---
696
696
+
697
697
+
## Summary: Implementation Changes Required
698
698
+
699
699
+
### Database Changes
700
700
+
701
701
+
**New Tables (2):**
702
702
+
703
703
+
1. **`follows`** - Polymorphic table for following users AND collections
704
704
+
- `follower_id` (TEXT) - User doing the following
705
705
+
- `target_id` (TEXT) - User DID or Collection UUID being followed
706
706
+
- `target_type` (TEXT) - 'USER' or 'COLLECTION'
707
707
+
- Primary key: `(follower_id, target_id, target_type)`
708
708
+
709
709
+
2. **`following_feed_items`** - Fan-out target table
710
710
+
- `user_id` (TEXT) - Feed owner
711
711
+
- `activity_id` (UUID) - Reference to feed_activities
712
712
+
- `created_at` (TIMESTAMP) - Denormalized for sorting
713
713
+
- Primary key: `(user_id, activity_id)`
714
714
+
715
715
+
### Application Layer Changes
716
716
+
717
717
+
**New Repository (1):**
718
718
+
719
719
+
- **`IFollowsRepository` / `DrizzleFollowsRepository`**
720
720
+
- `getFollowers(targetId, targetType)` - Get followers of a user
721
721
+
- `getFollowersOfCollections(collectionIds[])` - Get followers of multiple collections
722
722
+
723
723
+
**Updated Repository (1):**
724
724
+
725
725
+
- **`IFeedRepository` / `DrizzleFeedRepository`**
726
726
+
- Add: `fanOutActivityToFollowers(activityId, followerIds[], createdAt)` - Insert into following_feed_items
727
727
+
- Add: `getFollowingFeed(userId, options)` - Query following feed (similar to getGemsFeed)
728
728
+
729
729
+
**Updated Use Case (1):**
730
730
+
731
731
+
- **`AddActivityToFeedUseCase`**
732
732
+
- Add `followsRepository` to constructor
733
733
+
- Add `feedRepository` to constructor (for fan-out)
734
734
+
- After creating activity:
735
735
+
1. Get followers of actor (user)
736
736
+
2. Get followers of collections (if any)
737
737
+
3. Combine and dedupe
738
738
+
4. Call `fanOutActivityToFollowers()`
739
739
+
740
740
+
### Key Design Principles
741
741
+
742
742
+
1. ✅ **Application-level fan-out** - Use case orchestrates, repositories handle data
743
743
+
2. ✅ **Distributed locking** - FeedService uses ILockService to prevent race conditions during activity creation
744
744
+
3. ✅ **Idempotency** - Both activity creation and fan-out use `ON CONFLICT DO NOTHING` for retry safety
745
745
+
4. ✅ **Polymorphic follows** - Single table supports both user and collection follows
746
746
+
5. ✅ **Eventual consistency** - Event retries handle failures gracefully
747
747
+
748
748
+
---
749
749
+
750
750
+
## Implementation Steps
751
751
+
752
752
+
### Phase 1: Database Schema
753
753
+
754
754
+
1. **Create migration** for `follows` and `following_feed_items` tables
755
755
+
2. **Update** `createTestSchema.ts` with new tables and indexes
756
756
+
3. **Generate migration** with `npm run db:generate`
757
757
+
4. **Apply migration** to development database
758
758
+
759
759
+
### Phase 2: Repositories
760
760
+
761
761
+
1. **Create** `IFollowsRepository` interface with `getFollowers()` and `getFollowersOfCollections()`
762
762
+
2. **Implement** `DrizzleFollowsRepository`
763
763
+
3. **Update** `IFeedRepository` with `fanOutActivityToFollowers()` and `getFollowingFeed()`
764
764
+
4. **Implement** new methods in `DrizzleFeedRepository`
765
765
+
5. **Add** repository instances to `RepositoryFactory`
766
766
+
767
767
+
### Phase 3: Use Case Updates
768
768
+
769
769
+
1. **Update** `AddActivityToFeedUseCase` constructor to include `followsRepository` and `feedRepository`
770
770
+
2. **Add** fan-out logic after activity creation (as shown in implementation section)
771
771
+
3. **Update** `UseCaseFactory` to inject new dependencies
772
772
+
773
773
+
### Phase 4: Feed Query API
774
774
+
775
775
+
1. **Create** `GetFollowingFeedUseCase` (similar to existing feed use cases)
776
776
+
2. **Add** HTTP route and controller for following feed query
777
777
+
778
778
+
### Phase 5: Testing
779
779
+
780
780
+
1. **Unit tests** for `DrizzleFollowsRepository`
781
781
+
2. **Unit tests** for fan-out logic in `AddActivityToFeedUseCase`
782
782
+
3. **Integration tests** for complete flow (event → activity → fan-out)
783
783
+
4. **Performance tests** with various follower counts (100, 1k, 10k)
784
784
+
5. **Test retry scenarios** (ensure idempotency works)
785
785
+
6. **Run** `npm run build:check` to verify no type errors
786
786
+
787
787
+
---
788
788
+
789
789
+
## Open Questions
790
790
+
791
791
+
1. **Following feed filters:** Should following feed support same filters as global feed?
792
792
+
- `urlType` filter (show only articles, videos, etc.)
793
793
+
- `source` filter (show only Semble, Margin, etc.)
794
794
+
795
795
+
2. **Conditional fan-out:** Should certain activities NOT be fanned out?
796
796
+
- Example: "Don't fan-out Margin activities to collection followers" (only to user followers)
797
797
+
- Application-level approach makes this easy to implement
798
798
+
799
799
+
3. **Performance optimization threshold:** At what follower count should we implement async fan-out?
800
800
+
- Current plan: Sync fan-out for all users
801
801
+
- Future consideration: Hybrid approach for users with > 10k followers
+515
.agent/logs/20260305_removing_feed_saga.md
View file
Reviewed
···
1
1
+
# Removing SAGA Pattern from Feed Event Processing
2
2
+
3
3
+
**Date**: 2026-03-05
4
4
+
**Status**: Design Document
5
5
+
**Author**: System Design
6
6
+
7
7
+
## Executive Summary
8
8
+
9
9
+
This document outlines the plan to remove the `CardCollectionSaga` pattern from feed event processing. The saga adds unnecessary complexity for handling `CardAddedToLibrary` and `CardAddedToCollection` events. We will replace it with direct event handling using distributed locking at the service layer to prevent race conditions.
10
10
+
11
11
+
---
12
12
+
13
13
+
## 1. Current State
14
14
+
15
15
+
### Event Flow (With SAGA)
16
16
+
17
17
+
```
18
18
+
CardAddedToLibraryEvent → CardAddedToLibraryEventHandler
19
19
+
↓
20
20
+
CardCollectionSaga (3-second aggregation)
21
21
+
↓
22
22
+
AddActivityToFeedUseCase
23
23
+
↓
24
24
+
FeedService (2-minute deduplication)
25
25
+
↓
26
26
+
DrizzleFeedRepository
27
27
+
28
28
+
CardAddedToCollectionEvent → CardAddedToCollectionEventHandler
29
29
+
↓
30
30
+
CardCollectionSaga (3-second aggregation)
31
31
+
↓
32
32
+
AddActivityToFeedUseCase
33
33
+
↓
34
34
+
FeedService (2-minute deduplication)
35
35
+
↓
36
36
+
DrizzleFeedRepository
37
37
+
```
38
38
+
39
39
+
### What CardCollectionSaga Does
40
40
+
41
41
+
**File**: `src/modules/feeds/application/sagas/CardCollectionSaga.ts`
42
42
+
43
43
+
1. **Short-term event aggregation (3 seconds)**: Merges events that happen nearly simultaneously
44
44
+
2. **Distributed locking via Redis**: Prevents race conditions when multiple workers process events
45
45
+
- Lock key pattern: `saga:feed:lock:{cardId}-{actorId}`
46
46
+
- State key pattern: `saga:feed:pending:{cardId}-{actorId}`
47
47
+
- Lock TTL: 3-8 seconds (aggregation window + 5 seconds)
48
48
+
- Retry: 15 attempts with exponential backoff (100ms base, capped at 2s)
49
49
+
3. **Timestamp preservation**: Tracks earliest `addedAt` timestamp from all events
50
50
+
4. **Deferred execution**: Batches events before hitting the database
51
51
+
52
52
+
### What FeedService Does
53
53
+
54
54
+
**File**: `src/modules/feeds/domain/services/FeedService.ts`
55
55
+
56
56
+
1. **Medium-term deduplication (2 minutes)**: Checks for recent activities via `findRecentCardCollectedActivity`
57
57
+
2. **Collection merging**: Updates existing activities with new collection IDs
58
58
+
3. **Activity creation**: Creates new activities when no recent one exists
59
59
+
60
60
+
### Why It Was Built
61
61
+
62
62
+
The saga was designed to handle the case where:
63
63
+
64
64
+
- A user clicks "save" on a card
65
65
+
- System generates `CardAddedToLibraryEvent`
66
66
+
- System also generates `CardAddedToCollectionEvent` (if added to collections)
67
67
+
- Both events arrive nearly simultaneously at different workers
68
68
+
- Without coordination, this could create 2 separate activities instead of 1 merged activity
69
69
+
70
70
+
---
71
71
+
72
72
+
## 2. Why Remove It
73
73
+
74
74
+
### Reasons for Removal
75
75
+
76
76
+
1. **Unnecessary Complexity**: Two layers of deduplication (3-second saga + 2-minute service) is overkill
77
77
+
2. **Cognitive Overhead**: Understanding saga pattern adds mental load for future developers
78
78
+
3. **Inflexibility**: Eventually we want to handle events differently:
79
79
+
- Following feed (only from followed users/collections)
80
80
+
- Global feed (all activities)
81
81
+
- Different aggregation rules per feed type
82
82
+
4. **Redundancy**: FeedService already handles the core use case via 2-minute window
83
83
+
5. **Maintenance Burden**: More code to maintain, debug, and test
84
84
+
6. **Over-Engineering**: The 3-second aggregation is a micro-optimization that adds macro-complexity
85
85
+
86
86
+
### What We're Keeping
87
87
+
88
88
+
- FeedService's 2-minute deduplication window (good enough)
89
89
+
- Time-based activity merging at insert level
90
90
+
- Clean separation between event handling and business logic
91
91
+
92
92
+
---
93
93
+
94
94
+
## 3. New Architecture
95
95
+
96
96
+
### Simplified Event Flow (Without SAGA)
97
97
+
98
98
+
```
99
99
+
CardAddedToLibraryEvent → CardAddedToLibraryEventHandler
100
100
+
↓
101
101
+
AddActivityToFeedUseCase
102
102
+
↓
103
103
+
FeedService (with locking + 2-minute deduplication)
104
104
+
↓
105
105
+
DrizzleFeedRepository
106
106
+
107
107
+
CardAddedToCollectionEvent → CardAddedToCollectionEventHandler
108
108
+
↓
109
109
+
AddActivityToFeedUseCase
110
110
+
↓
111
111
+
FeedService (with locking + 2-minute deduplication)
112
112
+
↓
113
113
+
DrizzleFeedRepository
114
114
+
```
115
115
+
116
116
+
### Key Changes
117
117
+
118
118
+
1. **Event handlers call use case directly** (no saga intermediary)
119
119
+
2. **Locking moves to FeedService layer** (during check-and-insert)
120
120
+
3. **Each event processed independently** (simpler to reason about)
121
121
+
4. **Future flexibility** for different feed types
122
122
+
123
123
+
---
124
124
+
125
125
+
## 4. Locking Strategy
126
126
+
127
127
+
### Why We Need Locking
128
128
+
129
129
+
Without locking, race conditions can occur:
130
130
+
131
131
+
```
132
132
+
Timeline without locking:
133
133
+
T0: Worker A receives CardAddedToLibraryEvent
134
134
+
T1: Worker B receives CardAddedToCollectionEvent (same card+actor)
135
135
+
T2: Worker A queries: findRecentCardCollectedActivity → finds nothing
136
136
+
T3: Worker B queries: findRecentCardCollectedActivity → finds nothing
137
137
+
T4: Worker A inserts new activity
138
138
+
T5: Worker B inserts new activity (DUPLICATE!)
139
139
+
```
140
140
+
141
141
+
With locking:
142
142
+
143
143
+
```
144
144
+
Timeline with locking:
145
145
+
T0: Worker A receives CardAddedToLibraryEvent
146
146
+
T1: Worker B receives CardAddedToCollectionEvent
147
147
+
T2: Worker A acquires lock for "card123-actor456"
148
148
+
T3: Worker B tries to acquire same lock → WAITS
149
149
+
T4: Worker A queries → finds nothing → inserts activity → releases lock
150
150
+
T5: Worker B acquires lock
151
151
+
T6: Worker B queries → finds Worker A's activity → merges collections → releases lock
152
152
+
```
153
153
+
154
154
+
### Implementation: Use Existing RedisLockService
155
155
+
156
156
+
**Files**:
157
157
+
158
158
+
- Interface: `src/shared/infrastructure/locking/ILockService.ts`
159
159
+
- Implementation: `src/shared/infrastructure/locking/RedisLockService.ts`
160
160
+
161
161
+
The codebase already has a production-ready distributed locking service using **Redlock** algorithm.
162
162
+
163
163
+
**Characteristics**:
164
164
+
165
165
+
- Uses `redlock` npm package (industry standard)
166
166
+
- 3 retry attempts with exponential backoff (200ms base + 200ms jitter)
167
167
+
- Configurable lock TTL
168
168
+
- Handles container shutdown gracefully (SIGTERM)
169
169
+
170
170
+
### Lock Configuration
171
171
+
172
172
+
```typescript
173
173
+
// Lock key pattern
174
174
+
const lockKey = `feed:activity:${actorId}:${cardId}`;
175
175
+
176
176
+
// Lock TTL: 10 seconds (generous for database operation)
177
177
+
const lockTTL = 10000; // milliseconds
178
178
+
179
179
+
// Lock during FeedService.addCardCollectedActivity()
180
180
+
await lockService.withLock(lockKey, lockTTL, async () => {
181
181
+
// Check for recent activity
182
182
+
// Insert or update activity
183
183
+
});
184
184
+
```
185
185
+
186
186
+
### Handling Lock Failures
187
187
+
188
188
+
If lock acquisition fails after retries:
189
189
+
190
190
+
- Log warning (for monitoring)
191
191
+
- Proceed with operation anyway (accept small risk of duplicate)
192
192
+
- Rationale: Better to process event than drop it
193
193
+
194
194
+
**Why this is acceptable**:
195
195
+
196
196
+
- Lock failures are rare with Redlock
197
197
+
- 2-minute deduplication window catches most duplicates anyway
198
198
+
- Better to have occasional duplicate than lose user actions
199
199
+
200
200
+
---
201
201
+
202
202
+
## 5. Implementation Steps
203
203
+
204
204
+
### Step 1: Modify Event Handlers
205
205
+
206
206
+
**Files to Modify**:
207
207
+
208
208
+
- `src/modules/feeds/application/eventHandlers/CardAddedToLibraryEventHandler.ts`
209
209
+
- `src/modules/feeds/application/eventHandlers/CardAddedToCollectionEventHandler.ts`
210
210
+
211
211
+
**Changes**:
212
212
+
213
213
+
```typescript
214
214
+
// BEFORE (with saga)
215
215
+
export class CardAddedToLibraryEventHandler
216
216
+
implements IEventHandler<CardAddedToLibraryEvent>
217
217
+
{
218
218
+
constructor(private cardCollectionSaga: CardCollectionSaga) {}
219
219
+
220
220
+
async handle(event: CardAddedToLibraryEvent): Promise<Result<void>> {
221
221
+
return this.cardCollectionSaga.handleCardEvent(event);
222
222
+
}
223
223
+
}
224
224
+
225
225
+
// AFTER (direct to use case)
226
226
+
export class CardAddedToLibraryEventHandler
227
227
+
implements IEventHandler<CardAddedToLibraryEvent>
228
228
+
{
229
229
+
constructor(private addActivityToFeedUseCase: AddActivityToFeedUseCase) {}
230
230
+
231
231
+
async handle(event: CardAddedToLibraryEvent): Promise<Result<void>> {
232
232
+
const result = await this.addActivityToFeedUseCase.execute({
233
233
+
type: ActivityTypeEnum.CARD_COLLECTED,
234
234
+
actorId: event.curatorId.value,
235
235
+
cardId: event.cardId.getStringValue(),
236
236
+
collectionIds: undefined, // Library event has no collections
237
237
+
createdAt: event.addedAt,
238
238
+
});
239
239
+
240
240
+
if (result.isErr()) {
241
241
+
console.error('Failed to add library activity to feed:', result.error);
242
242
+
return err(result.error);
243
243
+
}
244
244
+
245
245
+
return ok(undefined);
246
246
+
}
247
247
+
}
248
248
+
```
249
249
+
250
250
+
### Step 2: Add Locking to FeedService
251
251
+
252
252
+
**File to Modify**: `src/modules/feeds/domain/services/FeedService.ts`
253
253
+
254
254
+
**Changes**:
255
255
+
256
256
+
```typescript
257
257
+
export class FeedService implements DomainService {
258
258
+
constructor(
259
259
+
private feedRepository: IFeedRepository,
260
260
+
private lockService: ILockService, // ADD THIS
261
261
+
) {}
262
262
+
263
263
+
async addCardCollectedActivity(
264
264
+
actorId: CuratorId,
265
265
+
cardId: CardId,
266
266
+
collectionIds?: CollectionId[],
267
267
+
urlType?: UrlType,
268
268
+
source?: string,
269
269
+
createdAt?: Date,
270
270
+
): Promise<Result<FeedActivity, FeedServiceError>> {
271
271
+
const lockKey = `feed:activity:${actorId.value}:${cardId.getStringValue()}`;
272
272
+
const lockTTL = 10000; // 10 seconds
273
273
+
274
274
+
try {
275
275
+
// Acquire distributed lock
276
276
+
return await this.lockService.withLock(lockKey, lockTTL, async () => {
277
277
+
// Existing logic: check for recent activity, insert or update
278
278
+
const recentActivityResult =
279
279
+
await this.feedRepository.findRecentCardCollectedActivity(
280
280
+
actorId,
281
281
+
cardId,
282
282
+
2, // 2 minutes
283
283
+
);
284
284
+
285
285
+
if (recentActivityResult.isErr()) {
286
286
+
return err(
287
287
+
new FeedServiceError(
288
288
+
`Failed to check for recent activity: ${recentActivityResult.error.message}`,
289
289
+
),
290
290
+
);
291
291
+
}
292
292
+
293
293
+
const recentActivity = recentActivityResult.value;
294
294
+
295
295
+
if (recentActivity && collectionIds && collectionIds.length > 0) {
296
296
+
// Update existing activity by merging collections
297
297
+
recentActivity.mergeCollections(collectionIds);
298
298
+
299
299
+
const updateResult =
300
300
+
await this.feedRepository.updateActivity(recentActivity);
301
301
+
302
302
+
if (updateResult.isErr()) {
303
303
+
return err(
304
304
+
new FeedServiceError(
305
305
+
`Failed to update activity: ${updateResult.error.message}`,
306
306
+
),
307
307
+
);
308
308
+
}
309
309
+
310
310
+
return ok(recentActivity);
311
311
+
} else if (recentActivity) {
312
312
+
// Recent activity exists but no new collections to add, return existing
313
313
+
return ok(recentActivity);
314
314
+
}
315
315
+
316
316
+
// No recent activity found, create new one
317
317
+
const activityResult = FeedActivity.createCardCollected(
318
318
+
actorId,
319
319
+
cardId,
320
320
+
collectionIds,
321
321
+
urlType,
322
322
+
source,
323
323
+
createdAt,
324
324
+
);
325
325
+
326
326
+
if (activityResult.isErr()) {
327
327
+
return err(new FeedServiceError(activityResult.error.message));
328
328
+
}
329
329
+
330
330
+
const activity = activityResult.value;
331
331
+
const saveResult = await this.feedRepository.addActivity(activity);
332
332
+
333
333
+
if (saveResult.isErr()) {
334
334
+
return err(
335
335
+
new FeedServiceError(
336
336
+
`Failed to save activity: ${saveResult.error.message}`,
337
337
+
),
338
338
+
);
339
339
+
}
340
340
+
341
341
+
return ok(activity);
342
342
+
});
343
343
+
} catch (error) {
344
344
+
// Lock acquisition failed after retries
345
345
+
console.warn(
346
346
+
`Failed to acquire lock for ${lockKey}, proceeding without lock`,
347
347
+
error,
348
348
+
);
349
349
+
350
350
+
// Proceed with operation anyway (fallback behavior)
351
351
+
// ... (duplicate the lock body here, or extract to a method)
352
352
+
}
353
353
+
}
354
354
+
}
355
355
+
```
356
356
+
357
357
+
### Step 3: Update FeedWorkerProcess
358
358
+
359
359
+
**File to Modify**: `src/shared/infrastructure/processes/FeedWorkerProcess.ts`
360
360
+
361
361
+
**Changes**:
362
362
+
363
363
+
```typescript
364
364
+
// BEFORE
365
365
+
protected async registerHandlers(
366
366
+
subscriber: IEventSubscriber,
367
367
+
services: WorkerServices,
368
368
+
repositories: Repositories,
369
369
+
): Promise<void> {
370
370
+
const useCases = UseCaseFactory.createForWorker(repositories, services);
371
371
+
372
372
+
// Create saga with proper use case dependency and state store from services
373
373
+
const cardCollectionSaga = new CardCollectionSaga(
374
374
+
useCases.addActivityToFeedUseCase,
375
375
+
services.sagaStateStore,
376
376
+
);
377
377
+
378
378
+
const cardAddedToLibraryHandler = new CardAddedToLibraryEventHandler(
379
379
+
cardCollectionSaga,
380
380
+
);
381
381
+
const cardAddedToCollectionHandler = new CardAddedToCollectionEventHandler(
382
382
+
cardCollectionSaga,
383
383
+
);
384
384
+
385
385
+
await subscriber.subscribe(
386
386
+
EventNames.CARD_ADDED_TO_LIBRARY,
387
387
+
cardAddedToLibraryHandler,
388
388
+
);
389
389
+
390
390
+
await subscriber.subscribe(
391
391
+
EventNames.CARD_ADDED_TO_COLLECTION,
392
392
+
cardAddedToCollectionHandler,
393
393
+
);
394
394
+
}
395
395
+
396
396
+
// AFTER
397
397
+
protected async registerHandlers(
398
398
+
subscriber: IEventSubscriber,
399
399
+
services: WorkerServices,
400
400
+
repositories: Repositories,
401
401
+
): Promise<void> {
402
402
+
const useCases = UseCaseFactory.createForWorker(repositories, services);
403
403
+
404
404
+
const cardAddedToLibraryHandler = new CardAddedToLibraryEventHandler(
405
405
+
useCases.addActivityToFeedUseCase,
406
406
+
);
407
407
+
const cardAddedToCollectionHandler = new CardAddedToCollectionEventHandler(
408
408
+
useCases.addActivityToFeedUseCase,
409
409
+
);
410
410
+
411
411
+
await subscriber.subscribe(
412
412
+
EventNames.CARD_ADDED_TO_LIBRARY,
413
413
+
cardAddedToLibraryHandler,
414
414
+
);
415
415
+
416
416
+
await subscriber.subscribe(
417
417
+
EventNames.CARD_ADDED_TO_COLLECTION,
418
418
+
cardAddedToCollectionHandler,
419
419
+
);
420
420
+
}
421
421
+
```
422
422
+
423
423
+
### Step 4: Update ServiceFactory
424
424
+
425
425
+
**File to Modify**: `src/shared/infrastructure/http/factories/ServiceFactory.ts`
426
426
+
427
427
+
**Changes**:
428
428
+
429
429
+
- Inject `lockService` into `FeedService` constructor
430
430
+
- Lock service already created in `createForWorker()`
431
431
+
432
432
+
```typescript
433
433
+
// In createForWorker() method
434
434
+
const feedService = new FeedService(
435
435
+
feedRepository,
436
436
+
services.lockService, // ADD THIS
437
437
+
);
438
438
+
```
439
439
+
440
440
+
### Step 5: Remove Saga Files
441
441
+
442
442
+
**Files to Remove**:
443
443
+
444
444
+
- `src/modules/feeds/application/sagas/CardCollectionSaga.ts`
445
445
+
446
446
+
**Files to Keep** (still used by CardNotificationSaga):
447
447
+
448
448
+
- `src/modules/feeds/application/sagas/ISagaStateStore.ts`
449
449
+
- `src/modules/feeds/infrastructure/RedisSagaStateStore.ts`
450
450
+
- `src/modules/feeds/infrastructure/InMemorySagaStateStore.ts`
451
451
+
452
452
+
### Step 6: Update Tests
453
453
+
454
454
+
**Files to Update**:
455
455
+
456
456
+
- Event handler tests
457
457
+
- FeedService tests (add lock service mock)
458
458
+
- Integration tests for feed worker
459
459
+
460
460
+
---
461
461
+
462
462
+
## 6. Files Summary
463
463
+
464
464
+
### Files to Modify
465
465
+
466
466
+
| File | Changes |
467
467
+
| -------------------------------------- | ------------------------------------------------------ |
468
468
+
| `CardAddedToLibraryEventHandler.ts` | Inject `AddActivityToFeedUseCase`, call directly |
469
469
+
| `CardAddedToCollectionEventHandler.ts` | Inject `AddActivityToFeedUseCase`, call directly |
470
470
+
| `FeedService.ts` | Add `ILockService` dependency, wrap operations in lock |
471
471
+
| `FeedWorkerProcess.ts` | Remove saga instantiation, pass use case to handlers |
472
472
+
| `ServiceFactory.ts` | Inject lock service into FeedService |
473
473
+
474
474
+
### Files to Remove
475
475
+
476
476
+
| File | Reason |
477
477
+
| ----------------------- | ---------------- |
478
478
+
| `CardCollectionSaga.ts` | No longer needed |
479
479
+
480
480
+
### Files to Keep (Used by Other Features)
481
481
+
482
482
+
| File | Reason |
483
483
+
| --------------------------- | ---------------------------- |
484
484
+
| `ISagaStateStore.ts` | Used by CardNotificationSaga |
485
485
+
| `RedisSagaStateStore.ts` | Used by CardNotificationSaga |
486
486
+
| `InMemorySagaStateStore.ts` | Used by CardNotificationSaga |
487
487
+
488
488
+
---
489
489
+
490
490
+
## 11. Conclusion
491
491
+
492
492
+
Removing `CardCollectionSaga` simplifies the architecture while maintaining correctness and performance. The existing FeedService deduplication logic, combined with distributed locking, provides sufficient protection against race conditions. This change enables future flexibility for implementing different feed types without the constraints of the saga pattern.
493
493
+
494
494
+
**Key Takeaway**: Keep it simple. The 2-minute deduplication window + distributed locking is good enough. The 3-second saga aggregation was premature optimization.
495
495
+
496
496
+
---
497
497
+
498
498
+
## Appendix: Code References
499
499
+
500
500
+
### Current Implementation
501
501
+
502
502
+
- **CardCollectionSaga**: `src/modules/feeds/application/sagas/CardCollectionSaga.ts:21-236`
503
503
+
- **FeedService deduplication**: `src/modules/feeds/domain/services/FeedService.ts:29-66`
504
504
+
- **DrizzleFeedRepository.findRecentCardCollectedActivity**: `src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts:362-408`
505
505
+
506
506
+
### Locking Infrastructure
507
507
+
508
508
+
- **ILockService**: `src/shared/infrastructure/locking/ILockService.ts`
509
509
+
- **RedisLockService**: `src/shared/infrastructure/locking/RedisLockService.ts`
510
510
+
- **Redlock configuration**: Uses `redlock` npm package with 3 retries, 200ms delays
511
511
+
512
512
+
### Worker Configuration
513
513
+
514
514
+
- **FeedWorkerProcess**: `src/shared/infrastructure/processes/FeedWorkerProcess.ts:18-66`
515
515
+
- **ServiceFactory**: `src/shared/infrastructure/http/factories/ServiceFactory.ts`
+3
src/webapp/lib/serverFeatureFlags.ts
View file
Reviewed
···
10
10
'tgoerke.bsky.social',
11
11
'psingletary.com',
12
12
'hilarybaumann.com',
13
13
+
'cosmik.network',
14
14
+
'semble.so',
15
15
+
'atproto.science',
13
16
]);
14
17
15
18
export async function getServerFeatureFlags() {