A social knowledge tool for researchers built on ATProto

formatting and linting

Changed files
+82 -39
.claude
src
modules
+70 -26
.claude/guides/use-case-implementation-patterns.md
··· 3 This guide documents the patterns and best practices for implementing use cases in the Semble application, with full vertical stack integration. 4 5 ## Table of Contents 6 1. [Use Case Structure](#use-case-structure) 7 2. [Repository Patterns](#repository-patterns) 8 3. [Upsert Behavior Pattern](#upsert-behavior-pattern) ··· 100 When you need to find related entities (e.g., note cards for a URL card), add specific repository methods: 101 102 #### Interface Definition 103 ```typescript 104 // src/modules/cards/domain/ICardRepository.ts 105 export interface ICardRepository { 106 findById(id: CardId): Promise<Result<Card | null>>; 107 - findUsersUrlCardByUrl(url: URL, curatorId: CuratorId): Promise<Result<Card | null>>; 108 - findUsersNoteCardByUrl(url: URL, curatorId: CuratorId): Promise<Result<Card | null>>; 109 save(card: Card): Promise<Result<void>>; 110 delete(cardId: CardId): Promise<Result<void>>; 111 } 112 ``` 113 114 #### In-Memory Implementation (for tests) 115 ```typescript 116 async findUsersNoteCardByUrl( 117 url: URL, ··· 132 ``` 133 134 #### Drizzle Implementation 135 ```typescript 136 async findUsersNoteCardByUrl( 137 url: URL, ··· 170 ### Example: AddUrlToLibraryUseCase 171 172 This use case demonstrates modified upsert behavior: 173 - If URL card doesn't exist: Create it 174 - If URL card exists: Reuse it 175 - If note is provided and note card exists: **Update** the note ··· 178 179 ```typescript 180 // Check if note card already exists 181 - const existingNoteCardResult = 182 - await this.cardRepository.findUsersNoteCardByUrl(url, curatorId); 183 if (existingNoteCardResult.isErr()) { 184 return err(AppError.UnexpectedError.create(existingNoteCardResult.error)); 185 } ··· 227 const url = 'https://example.com/existing'; 228 229 // First request creates URL card with note 230 - const firstRequest = { url, note: 'Original note', curatorId: curatorId.value }; 231 const firstResult = await useCase.execute(firstRequest); 232 expect(firstResult.isOk()).toBe(true); 233 const firstResponse = firstResult.unwrap(); 234 235 // Second request updates the note 236 - const secondRequest = { url, note: 'Updated note', curatorId: curatorId.value }; 237 const secondResult = await useCase.execute(secondRequest); 238 expect(secondResult.isOk()).toBe(true); 239 const secondResponse = secondResult.unwrap(); ··· 257 ### Example: UpdateUrlCardAssociationsUseCase 258 259 This use case demonstrates explicit control: 260 - Requires URL card to already exist 261 - Provides separate controls for adding vs removing collections 262 - Can create or update notes ··· 267 url: string; 268 curatorId: string; 269 note?: string; 270 - addToCollections?: string[]; // Explicit add 271 removeFromCollections?: string[]; // Explicit remove 272 } 273 274 export interface UpdateUrlCardAssociationsResponseDTO { 275 urlCardId: string; 276 noteCardId?: string; 277 - addedToCollections: string[]; // What was actually added 278 removedFromCollections: string[]; // What was actually removed 279 } 280 ··· 352 const response = result.unwrap(); 353 expect(response.addedToCollections).toHaveLength(2); 354 expect(response.removedFromCollections).toHaveLength(1); 355 - expect(response.addedToCollections).toContain(collection2.collectionId.getStringValue()); 356 - expect(response.removedFromCollections).toContain(collection1.collectionId.getStringValue()); 357 }); 358 ``` 359 ··· 373 eventPublisher = new FakeEventPublisher(); 374 domainService = new DomainService(repository, eventPublisher); 375 376 - useCase = new YourUseCase( 377 - repository, 378 - domainService, 379 - eventPublisher, 380 - ); 381 }); 382 383 afterEach(() => { ··· 388 describe('Feature group 1', () => { 389 it('should do X when Y', async () => { 390 // Arrange 391 - const request = { /* ... */ }; 392 393 // Act 394 const result = await useCase.execute(request); ··· 401 402 describe('Validation', () => { 403 it('should fail with invalid input', async () => { 404 - const result = await useCase.execute({ /* invalid */ }); 405 406 expect(result.isErr()).toBe(true); 407 if (result.isErr()) { ··· 415 ### Test Coverage Checklist 416 417 For each use case, ensure tests cover: 418 - ✅ Happy path (basic functionality) 419 - ✅ Update existing entities 420 - ✅ Create new entities ··· 591 ## Best Practices 592 593 ### 1. Value Object Validation 594 Always validate and create value objects early in the use case: 595 ```typescript 596 const curatorIdResult = CuratorId.create(request.curatorId); 597 if (curatorIdResult.isErr()) { 598 - return err(new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`)); 599 } 600 const curatorId = curatorIdResult.value; 601 ``` 602 603 ### 2. Error Handling 604 - Use `ValidationError` for business rule violations 605 - Use `AppError.UnexpectedError` for infrastructure errors 606 - Don't fail operations if event publishing fails (log instead) ··· 614 ``` 615 616 ### 3. Domain Services 617 Use domain services for cross-aggregate operations: 618 ```typescript 619 // ✅ Good - uses domain service 620 - await this.cardCollectionService.addCardToCollections(card, collectionIds, curatorId); 621 622 // ❌ Bad - directly manipulating aggregates 623 collection.addCard(card.cardId, curatorId); ··· 625 ``` 626 627 ### 4. Response DTOs 628 Return detailed information about what changed: 629 ```typescript 630 return ok({ 631 id: entity.id.getStringValue(), 632 created: !existingEntity, 633 updated: !!existingEntity, 634 - affectedCollections: updatedCollections.map(c => c.id.getStringValue()), 635 }); 636 ``` 637 638 ### 5. Idempotency 639 Design use cases to be idempotent when possible: 640 - Check existence before creating 641 - Only update if values actually changed 642 - Handle "already exists" gracefully 643 644 ## Common Patterns Summary 645 646 - | Pattern | When to Use | Key Characteristics | 647 - |---------|-------------|---------------------| 648 - | **Upsert** | When user intent is "ensure this state" | - Create if not exists<br/>- Update if exists<br/>- Only add, never remove | 649 - | **Explicit Control** | When user needs precise control | - Separate add/remove operations<br/>- Returns what changed<br/>- Requires entity to exist | 650 - | **Create Only** | When creating new entities | - Fails if already exists<br/>- Simple validation | 651 - | **Update Only** | When modifying existing entities | - Requires entity to exist<br/>- Validates ownership/permissions | 652 653 ## References 654 655 ### Example Implementations 656 - **Upsert**: `src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts:147-234` 657 - **Explicit Control**: `src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts` 658 - **Repository Methods**: `src/modules/cards/domain/ICardRepository.ts:15-18` 659 660 ### Related Guides 661 - Domain-Driven Design patterns 662 - Repository pattern 663 - Event publishing
··· 3 This guide documents the patterns and best practices for implementing use cases in the Semble application, with full vertical stack integration. 4 5 ## Table of Contents 6 + 7 1. [Use Case Structure](#use-case-structure) 8 2. [Repository Patterns](#repository-patterns) 9 3. [Upsert Behavior Pattern](#upsert-behavior-pattern) ··· 101 When you need to find related entities (e.g., note cards for a URL card), add specific repository methods: 102 103 #### Interface Definition 104 + 105 ```typescript 106 // src/modules/cards/domain/ICardRepository.ts 107 export interface ICardRepository { 108 findById(id: CardId): Promise<Result<Card | null>>; 109 + findUsersUrlCardByUrl( 110 + url: URL, 111 + curatorId: CuratorId, 112 + ): Promise<Result<Card | null>>; 113 + findUsersNoteCardByUrl( 114 + url: URL, 115 + curatorId: CuratorId, 116 + ): Promise<Result<Card | null>>; 117 save(card: Card): Promise<Result<void>>; 118 delete(cardId: CardId): Promise<Result<void>>; 119 } 120 ``` 121 122 #### In-Memory Implementation (for tests) 123 + 124 ```typescript 125 async findUsersNoteCardByUrl( 126 url: URL, ··· 141 ``` 142 143 #### Drizzle Implementation 144 + 145 ```typescript 146 async findUsersNoteCardByUrl( 147 url: URL, ··· 180 ### Example: AddUrlToLibraryUseCase 181 182 This use case demonstrates modified upsert behavior: 183 + 184 - If URL card doesn't exist: Create it 185 - If URL card exists: Reuse it 186 - If note is provided and note card exists: **Update** the note ··· 189 190 ```typescript 191 // Check if note card already exists 192 + const existingNoteCardResult = await this.cardRepository.findUsersNoteCardByUrl( 193 + url, 194 + curatorId, 195 + ); 196 if (existingNoteCardResult.isErr()) { 197 return err(AppError.UnexpectedError.create(existingNoteCardResult.error)); 198 } ··· 240 const url = 'https://example.com/existing'; 241 242 // First request creates URL card with note 243 + const firstRequest = { 244 + url, 245 + note: 'Original note', 246 + curatorId: curatorId.value, 247 + }; 248 const firstResult = await useCase.execute(firstRequest); 249 expect(firstResult.isOk()).toBe(true); 250 const firstResponse = firstResult.unwrap(); 251 252 // Second request updates the note 253 + const secondRequest = { 254 + url, 255 + note: 'Updated note', 256 + curatorId: curatorId.value, 257 + }; 258 const secondResult = await useCase.execute(secondRequest); 259 expect(secondResult.isOk()).toBe(true); 260 const secondResponse = secondResult.unwrap(); ··· 278 ### Example: UpdateUrlCardAssociationsUseCase 279 280 This use case demonstrates explicit control: 281 + 282 - Requires URL card to already exist 283 - Provides separate controls for adding vs removing collections 284 - Can create or update notes ··· 289 url: string; 290 curatorId: string; 291 note?: string; 292 + addToCollections?: string[]; // Explicit add 293 removeFromCollections?: string[]; // Explicit remove 294 } 295 296 export interface UpdateUrlCardAssociationsResponseDTO { 297 urlCardId: string; 298 noteCardId?: string; 299 + addedToCollections: string[]; // What was actually added 300 removedFromCollections: string[]; // What was actually removed 301 } 302 ··· 374 const response = result.unwrap(); 375 expect(response.addedToCollections).toHaveLength(2); 376 expect(response.removedFromCollections).toHaveLength(1); 377 + expect(response.addedToCollections).toContain( 378 + collection2.collectionId.getStringValue(), 379 + ); 380 + expect(response.removedFromCollections).toContain( 381 + collection1.collectionId.getStringValue(), 382 + ); 383 }); 384 ``` 385 ··· 399 eventPublisher = new FakeEventPublisher(); 400 domainService = new DomainService(repository, eventPublisher); 401 402 + useCase = new YourUseCase(repository, domainService, eventPublisher); 403 }); 404 405 afterEach(() => { ··· 410 describe('Feature group 1', () => { 411 it('should do X when Y', async () => { 412 // Arrange 413 + const request = { 414 + /* ... */ 415 + }; 416 417 // Act 418 const result = await useCase.execute(request); ··· 425 426 describe('Validation', () => { 427 it('should fail with invalid input', async () => { 428 + const result = await useCase.execute({ 429 + /* invalid */ 430 + }); 431 432 expect(result.isErr()).toBe(true); 433 if (result.isErr()) { ··· 441 ### Test Coverage Checklist 442 443 For each use case, ensure tests cover: 444 + 445 - ✅ Happy path (basic functionality) 446 - ✅ Update existing entities 447 - ✅ Create new entities ··· 618 ## Best Practices 619 620 ### 1. Value Object Validation 621 + 622 Always validate and create value objects early in the use case: 623 + 624 ```typescript 625 const curatorIdResult = CuratorId.create(request.curatorId); 626 if (curatorIdResult.isErr()) { 627 + return err( 628 + new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`), 629 + ); 630 } 631 const curatorId = curatorIdResult.value; 632 ``` 633 634 ### 2. Error Handling 635 + 636 - Use `ValidationError` for business rule violations 637 - Use `AppError.UnexpectedError` for infrastructure errors 638 - Don't fail operations if event publishing fails (log instead) ··· 646 ``` 647 648 ### 3. Domain Services 649 + 650 Use domain services for cross-aggregate operations: 651 + 652 ```typescript 653 // ✅ Good - uses domain service 654 + await this.cardCollectionService.addCardToCollections( 655 + card, 656 + collectionIds, 657 + curatorId, 658 + ); 659 660 // ❌ Bad - directly manipulating aggregates 661 collection.addCard(card.cardId, curatorId); ··· 663 ``` 664 665 ### 4. Response DTOs 666 + 667 Return detailed information about what changed: 668 + 669 ```typescript 670 return ok({ 671 id: entity.id.getStringValue(), 672 created: !existingEntity, 673 updated: !!existingEntity, 674 + affectedCollections: updatedCollections.map((c) => c.id.getStringValue()), 675 }); 676 ``` 677 678 ### 5. Idempotency 679 + 680 Design use cases to be idempotent when possible: 681 + 682 - Check existence before creating 683 - Only update if values actually changed 684 - Handle "already exists" gracefully 685 686 ## Common Patterns Summary 687 688 + | Pattern | When to Use | Key Characteristics | 689 + | -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ | 690 + | **Upsert** | When user intent is "ensure this state" | - Create if not exists<br/>- Update if exists<br/>- Only add, never remove | 691 + | **Explicit Control** | When user needs precise control | - Separate add/remove operations<br/>- Returns what changed<br/>- Requires entity to exist | 692 + | **Create Only** | When creating new entities | - Fails if already exists<br/>- Simple validation | 693 + | **Update Only** | When modifying existing entities | - Requires entity to exist<br/>- Validates ownership/permissions | 694 695 ## References 696 697 ### Example Implementations 698 + 699 - **Upsert**: `src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts:147-234` 700 - **Explicit Control**: `src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts` 701 - **Repository Methods**: `src/modules/cards/domain/ICardRepository.ts:15-18` 702 703 ### Related Guides 704 + 705 - Domain-Driven Design patterns 706 - Repository pattern 707 - Event publishing
+1 -2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
··· 172 } 173 174 // Save updated note card 175 - const saveNoteCardResult = 176 - await this.cardRepository.save(noteCard); 177 if (saveNoteCardResult.isErr()) { 178 return err( 179 AppError.UnexpectedError.create(saveNoteCardResult.error),
··· 172 } 173 174 // Save updated note card 175 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 176 if (saveNoteCardResult.isErr()) { 177 return err( 178 AppError.UnexpectedError.create(saveNoteCardResult.error),
+11 -11
src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts
··· 76 const cardIdResult = CardId.createFromString(request.cardId); 77 if (cardIdResult.isErr()) { 78 return err( 79 - new ValidationError( 80 - `Invalid card ID: ${cardIdResult.error.message}`, 81 - ), 82 ); 83 } 84 const cardId = cardIdResult.value; ··· 103 // Verify it's a URL card 104 if (!urlCard.isUrlCard) { 105 return err( 106 - new ValidationError('Card must be a URL card to update associations.'), 107 ); 108 } 109 110 // Verify ownership 111 if (!urlCard.curatorId.equals(curatorId)) { 112 return err( 113 - new ValidationError('You do not have permission to update this card.'), 114 ); 115 } 116 117 // Get the URL from the card for note operations 118 if (!urlCard.url) { 119 - return err( 120 - new ValidationError('URL card must have a URL property.'), 121 - ); 122 } 123 const url = urlCard.url; 124 ··· 152 } 153 154 // Save updated note card 155 - const saveNoteCardResult = 156 - await this.cardRepository.save(noteCard); 157 if (saveNoteCardResult.isErr()) { 158 return err( 159 AppError.UnexpectedError.create(saveNoteCardResult.error), ··· 284 ); 285 if (removeFromCollectionsResult.isErr()) { 286 if ( 287 - removeFromCollectionsResult.error instanceof AppError.UnexpectedError 288 ) { 289 return err(removeFromCollectionsResult.error); 290 }
··· 76 const cardIdResult = CardId.createFromString(request.cardId); 77 if (cardIdResult.isErr()) { 78 return err( 79 + new ValidationError(`Invalid card ID: ${cardIdResult.error.message}`), 80 ); 81 } 82 const cardId = cardIdResult.value; ··· 101 // Verify it's a URL card 102 if (!urlCard.isUrlCard) { 103 return err( 104 + new ValidationError( 105 + 'Card must be a URL card to update associations.', 106 + ), 107 ); 108 } 109 110 // Verify ownership 111 if (!urlCard.curatorId.equals(curatorId)) { 112 return err( 113 + new ValidationError( 114 + 'You do not have permission to update this card.', 115 + ), 116 ); 117 } 118 119 // Get the URL from the card for note operations 120 if (!urlCard.url) { 121 + return err(new ValidationError('URL card must have a URL property.')); 122 } 123 const url = urlCard.url; 124 ··· 152 } 153 154 // Save updated note card 155 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 156 if (saveNoteCardResult.isErr()) { 157 return err( 158 AppError.UnexpectedError.create(saveNoteCardResult.error), ··· 283 ); 284 if (removeFromCollectionsResult.isErr()) { 285 if ( 286 + removeFromCollectionsResult.error instanceof 287 + AppError.UnexpectedError 288 ) { 289 return err(removeFromCollectionsResult.error); 290 }