This repository has no description
0

Configure Feed

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

formatting

+136 -205
+8 -5
.agent/logs/20251223_card_url_type_migration.md
··· 1 1 # Card URL Type Migration Plan 2 2 3 3 **Date:** 2025-12-23 4 - **Status:** Planning Phase 4 + **Status:** Planning Phase 5 5 6 6 ## Overview 7 7 ··· 10 10 ## Approach: Incremental Migration 11 11 12 12 ### Phase 1: Add Optional Field (Immediate) 13 + 13 14 - Add `url_type` TEXT column to cards table (nullable) 14 15 - Update CardMapper to populate urlType for new cards 15 16 - Add indexes for performance 16 17 - New cards will be immediately queryable by type 17 18 18 19 ### Phase 2: Hybrid Queries (Temporary) 20 + 19 21 - Update query services to handle mixed data: 20 22 - Fast indexed lookup for new cards (urlType field) 21 23 - Fallback JSON query for old cards (contentData.metadata.type) 22 24 - Use OR conditions to support both data states 23 25 24 26 ### Phase 3: Backfill Migration (Later) 27 + 25 28 - Run SQL update to populate urlType for existing cards 26 29 - Remove fallback JSON queries once migration complete 27 30 - Simplify query logic to use only indexed field ··· 31 34 ✅ **Zero downtime** - No breaking changes 32 35 ✅ **Immediate value** - New cards queryable by type right away 33 36 ✅ **Gradual migration** - Can backfill when convenient 34 - ✅ **Performance** - Indexed queries for new data 37 + ✅ **Performance** - Indexed queries for new data 35 38 36 39 ## Implementation Notes 37 40 ··· 42 45 ## Migration SQL (for Phase 3) 43 46 44 47 ```sql 45 - UPDATE cards 48 + UPDATE cards 46 49 SET url_type = content_data #>> '{metadata,type}' 47 - WHERE type = 'URL' 48 - AND url_type IS NULL 50 + WHERE type = 'URL' 51 + AND url_type IS NULL 49 52 AND content_data #>> '{metadata,type}' IS NOT NULL; 50 53 ``` 51 54
+4 -1
src/modules/cards/infrastructure/repositories/mappers/CardMapper.ts
··· 346 346 type: card.type.value, 347 347 contentData, 348 348 url: card.url?.value, 349 - urlType: content.type === CardTypeEnum.URL ? content.urlContent?.metadata?.type : undefined, 349 + urlType: 350 + content.type === CardTypeEnum.URL 351 + ? content.urlContent?.metadata?.type 352 + : undefined, 350 353 parentCardId: card.parentCardId?.getStringValue(), 351 354 viaCardId: card.viaCardId?.getStringValue(), 352 355 libraryCount: card.libraryCount,
+1 -1
src/modules/cards/infrastructure/repositories/query-services/CollectionCardQueryService.ts
··· 54 54 eq(collectionCards.collectionId, collectionId), 55 55 eq(cards.type, CardTypeEnum.URL), 56 56 ]; 57 - 57 + 58 58 if (options.urlType) { 59 59 whereConditions.push(eq(cards.urlType, options.urlType)); 60 60 }
+1 -1
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 37 37 eq(cards.authorId, userId), 38 38 eq(cards.type, CardTypeEnum.URL), 39 39 ]; 40 - 40 + 41 41 if (options.urlType) { 42 42 whereConditions.push(eq(cards.urlType, options.urlType)); 43 43 }
+5 -8
src/modules/cards/tests/utils/InMemoryCardQueryRepository.ts
··· 32 32 try { 33 33 // Get all cards and filter by user's library membership 34 34 const allCards = this.cardRepository.getAllCards(); 35 - let userCards = allCards 36 - .filter( 37 - (card) => 38 - card.isUrlCard && 39 - card.isInLibrary(CuratorId.create(userId).unwrap()), 40 - ); 35 + let userCards = allCards.filter( 36 + (card) => 37 + card.isUrlCard && card.isInLibrary(CuratorId.create(userId).unwrap()), 38 + ); 41 39 42 40 // Filter by urlType if specified 43 41 if (options.urlType) { ··· 244 242 ); 245 243 let collectionCards = allCards.filter( 246 244 (card) => 247 - collectionCardIds.has(card.cardId.getStringValue()) && 248 - card.isUrlCard, 245 + collectionCardIds.has(card.cardId.getStringValue()) && card.isUrlCard, 249 246 ); 250 247 251 248 // Filter by urlType if specified
+21 -12
src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts
··· 69 69 .limit(1); 70 70 71 71 if (beforeActivity.length > 0) { 72 - const conditions = [lt(feedActivities.createdAt, beforeActivity[0]!.createdAt), ...whereConditions]; 72 + const conditions = [ 73 + lt(feedActivities.createdAt, beforeActivity[0]!.createdAt), 74 + ...whereConditions, 75 + ]; 73 76 activitiesResult = await this.db 74 77 .select() 75 78 .from(feedActivities) ··· 82 85 } 83 86 } else { 84 87 // Regular pagination without cursor 85 - const query = this.db 86 - .select() 87 - .from(feedActivities); 88 - 88 + const query = this.db.select().from(feedActivities); 89 + 89 90 if (whereConditions.length > 0) { 90 - query.where(whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]); 91 + query.where( 92 + whereConditions.length > 1 93 + ? and(...whereConditions) 94 + : whereConditions[0], 95 + ); 91 96 } 92 - 97 + 93 98 activitiesResult = await query 94 99 .orderBy(desc(feedActivities.createdAt), desc(feedActivities.id)) 95 100 .limit(limit) ··· 100 105 const countQuery = this.db 101 106 .select({ count: count() }) 102 107 .from(feedActivities); 103 - 108 + 104 109 if (whereConditions.length > 0) { 105 - countQuery.where(whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]); 110 + countQuery.where( 111 + whereConditions.length > 1 112 + ? and(...whereConditions) 113 + : whereConditions[0], 114 + ); 106 115 } 107 - 116 + 108 117 const totalCountResult = await countQuery; 109 118 110 119 const totalCount = totalCountResult[0]?.count || 0; ··· 206 215 const conditions = [ 207 216 lt(feedActivities.createdAt, beforeActivity[0]!.createdAt), 208 217 jsonArrayCondition, 209 - ...whereConditions 218 + ...whereConditions, 210 219 ]; 211 220 activitiesResult = await this.db 212 221 .select() ··· 235 244 if (options.urlType) { 236 245 conditions.push(eq(feedActivities.urlType, options.urlType)); 237 246 } 238 - 247 + 239 248 const totalCountResult = await this.db 240 249 .select({ count: count() }) 241 250 .from(feedActivities)
+43 -22
src/modules/feeds/infrastructure/repositories/schema/feedActivity.sql.ts
··· 1 - import { pgTable, text, timestamp, jsonb, uuid, index } from 'drizzle-orm/pg-core'; 1 + import { 2 + pgTable, 3 + text, 4 + timestamp, 5 + jsonb, 6 + uuid, 7 + index, 8 + } from 'drizzle-orm/pg-core'; 2 9 3 - export const feedActivities = pgTable('feed_activities', { 4 - id: uuid('id').primaryKey(), 5 - actorId: text('actor_id').notNull(), // The DID of the user who performed the activity 6 - type: text('type').notNull(), // The type of activity (e.g., 'CARD_COLLECTED') 7 - metadata: jsonb('metadata').notNull(), // Activity-specific metadata 8 - urlType: text('url_type'), // Optional URL type from the card 9 - createdAt: timestamp('created_at').notNull().defaultNow(), 10 - }, (table) => ({ 11 - // Index for filtering by activity type 12 - typeIdx: index('feed_activities_type_idx').on(table.type), 13 - // Index for filtering by URL type 14 - urlTypeIdx: index('feed_activities_url_type_idx').on(table.urlType), 15 - // Index for sorting by creation date (most recent first) 16 - createdAtIdx: index('feed_activities_created_at_idx').on(table.createdAt.desc()), 17 - // Composite index for common query patterns (type + createdAt) 18 - typeCreatedAtIdx: index('feed_activities_type_created_at_idx').on(table.type, table.createdAt.desc()), 19 - // Composite index for URL type filtering with date sorting 20 - urlTypeCreatedAtIdx: index('feed_activities_url_type_created_at_idx').on(table.urlType, table.createdAt.desc()), 21 - // Composite index for filtering by both type and URL type with date sorting 22 - typeUrlTypeCreatedAtIdx: index('feed_activities_type_url_type_created_at_idx').on(table.type, table.urlType, table.createdAt.desc()), 23 - })); 10 + export const feedActivities = pgTable( 11 + 'feed_activities', 12 + { 13 + id: uuid('id').primaryKey(), 14 + actorId: text('actor_id').notNull(), // The DID of the user who performed the activity 15 + type: text('type').notNull(), // The type of activity (e.g., 'CARD_COLLECTED') 16 + metadata: jsonb('metadata').notNull(), // Activity-specific metadata 17 + urlType: text('url_type'), // Optional URL type from the card 18 + createdAt: timestamp('created_at').notNull().defaultNow(), 19 + }, 20 + (table) => ({ 21 + // Index for filtering by activity type 22 + typeIdx: index('feed_activities_type_idx').on(table.type), 23 + // Index for filtering by URL type 24 + urlTypeIdx: index('feed_activities_url_type_idx').on(table.urlType), 25 + // Index for sorting by creation date (most recent first) 26 + createdAtIdx: index('feed_activities_created_at_idx').on( 27 + table.createdAt.desc(), 28 + ), 29 + // Composite index for common query patterns (type + createdAt) 30 + typeCreatedAtIdx: index('feed_activities_type_created_at_idx').on( 31 + table.type, 32 + table.createdAt.desc(), 33 + ), 34 + // Composite index for URL type filtering with date sorting 35 + urlTypeCreatedAtIdx: index('feed_activities_url_type_created_at_idx').on( 36 + table.urlType, 37 + table.createdAt.desc(), 38 + ), 39 + // Composite index for filtering by both type and URL type with date sorting 40 + typeUrlTypeCreatedAtIdx: index( 41 + 'feed_activities_type_url_type_created_at_idx', 42 + ).on(table.type, table.urlType, table.createdAt.desc()), 43 + }), 44 + );
+26 -77
src/shared/infrastructure/database/migrations/meta/0010_snapshot.json
··· 251 251 "name": "cards_parent_card_id_cards_id_fk", 252 252 "tableFrom": "cards", 253 253 "tableTo": "cards", 254 - "columnsFrom": [ 255 - "parent_card_id" 256 - ], 257 - "columnsTo": [ 258 - "id" 259 - ], 254 + "columnsFrom": ["parent_card_id"], 255 + "columnsTo": ["id"], 260 256 "onDelete": "no action", 261 257 "onUpdate": "no action" 262 258 }, ··· 264 260 "name": "cards_via_card_id_cards_id_fk", 265 261 "tableFrom": "cards", 266 262 "tableTo": "cards", 267 - "columnsFrom": [ 268 - "via_card_id" 269 - ], 270 - "columnsTo": [ 271 - "id" 272 - ], 263 + "columnsFrom": ["via_card_id"], 264 + "columnsTo": ["id"], 273 265 "onDelete": "no action", 274 266 "onUpdate": "no action" 275 267 }, ··· 277 269 "name": "cards_published_record_id_published_records_id_fk", 278 270 "tableFrom": "cards", 279 271 "tableTo": "published_records", 280 - "columnsFrom": [ 281 - "published_record_id" 282 - ], 283 - "columnsTo": [ 284 - "id" 285 - ], 272 + "columnsFrom": ["published_record_id"], 273 + "columnsTo": ["id"], 286 274 "onDelete": "no action", 287 275 "onUpdate": "no action" 288 276 } ··· 414 402 "name": "collection_cards_collection_id_collections_id_fk", 415 403 "tableFrom": "collection_cards", 416 404 "tableTo": "collections", 417 - "columnsFrom": [ 418 - "collection_id" 419 - ], 420 - "columnsTo": [ 421 - "id" 422 - ], 405 + "columnsFrom": ["collection_id"], 406 + "columnsTo": ["id"], 423 407 "onDelete": "cascade", 424 408 "onUpdate": "no action" 425 409 }, ··· 427 411 "name": "collection_cards_card_id_cards_id_fk", 428 412 "tableFrom": "collection_cards", 429 413 "tableTo": "cards", 430 - "columnsFrom": [ 431 - "card_id" 432 - ], 433 - "columnsTo": [ 434 - "id" 435 - ], 414 + "columnsFrom": ["card_id"], 415 + "columnsTo": ["id"], 436 416 "onDelete": "cascade", 437 417 "onUpdate": "no action" 438 418 }, ··· 440 420 "name": "collection_cards_via_card_id_cards_id_fk", 441 421 "tableFrom": "collection_cards", 442 422 "tableTo": "cards", 443 - "columnsFrom": [ 444 - "via_card_id" 445 - ], 446 - "columnsTo": [ 447 - "id" 448 - ], 423 + "columnsFrom": ["via_card_id"], 424 + "columnsTo": ["id"], 449 425 "onDelete": "no action", 450 426 "onUpdate": "no action" 451 427 }, ··· 453 429 "name": "collection_cards_published_record_id_published_records_id_fk", 454 430 "tableFrom": "collection_cards", 455 431 "tableTo": "published_records", 456 - "columnsFrom": [ 457 - "published_record_id" 458 - ], 459 - "columnsTo": [ 460 - "id" 461 - ], 432 + "columnsFrom": ["published_record_id"], 433 + "columnsTo": ["id"], 462 434 "onDelete": "no action", 463 435 "onUpdate": "no action" 464 436 } ··· 498 470 "name": "collection_collaborators_collection_id_collections_id_fk", 499 471 "tableFrom": "collection_collaborators", 500 472 "tableTo": "collections", 501 - "columnsFrom": [ 502 - "collection_id" 503 - ], 504 - "columnsTo": [ 505 - "id" 506 - ], 473 + "columnsFrom": ["collection_id"], 474 + "columnsTo": ["id"], 507 475 "onDelete": "cascade", 508 476 "onUpdate": "no action" 509 477 } ··· 619 587 "name": "collections_published_record_id_published_records_id_fk", 620 588 "tableFrom": "collections", 621 589 "tableTo": "published_records", 622 - "columnsFrom": [ 623 - "published_record_id" 624 - ], 625 - "columnsTo": [ 626 - "id" 627 - ], 590 + "columnsFrom": ["published_record_id"], 591 + "columnsTo": ["id"], 628 592 "onDelete": "no action", 629 593 "onUpdate": "no action" 630 594 } ··· 723 687 "name": "library_memberships_card_id_cards_id_fk", 724 688 "tableFrom": "library_memberships", 725 689 "tableTo": "cards", 726 - "columnsFrom": [ 727 - "card_id" 728 - ], 729 - "columnsTo": [ 730 - "id" 731 - ], 690 + "columnsFrom": ["card_id"], 691 + "columnsTo": ["id"], 732 692 "onDelete": "cascade", 733 693 "onUpdate": "no action" 734 694 }, ··· 736 696 "name": "library_memberships_published_record_id_published_records_id_fk", 737 697 "tableFrom": "library_memberships", 738 698 "tableTo": "published_records", 739 - "columnsFrom": [ 740 - "published_record_id" 741 - ], 742 - "columnsTo": [ 743 - "id" 744 - ], 699 + "columnsFrom": ["published_record_id"], 700 + "columnsTo": ["id"], 745 701 "onDelete": "no action", 746 702 "onUpdate": "no action" 747 703 } ··· 749 705 "compositePrimaryKeys": { 750 706 "library_memberships_card_id_user_id_pk": { 751 707 "name": "library_memberships_card_id_user_id_pk", 752 - "columns": [ 753 - "card_id", 754 - "user_id" 755 - ] 708 + "columns": ["card_id", "user_id"] 756 709 } 757 710 }, 758 711 "uniqueConstraints": {}, ··· 1106 1059 "name": "auth_refresh_tokens_user_did_users_id_fk", 1107 1060 "tableFrom": "auth_refresh_tokens", 1108 1061 "tableTo": "users", 1109 - "columnsFrom": [ 1110 - "user_did" 1111 - ], 1112 - "columnsTo": [ 1113 - "id" 1114 - ], 1062 + "columnsFrom": ["user_did"], 1063 + "columnsTo": ["id"], 1115 1064 "onDelete": "no action", 1116 1065 "onUpdate": "no action" 1117 1066 } ··· 1171 1120 "schemas": {}, 1172 1121 "tables": {} 1173 1122 } 1174 - } 1123 + }
+26 -77
src/shared/infrastructure/database/migrations/meta/0011_snapshot.json
··· 251 251 "name": "cards_parent_card_id_cards_id_fk", 252 252 "tableFrom": "cards", 253 253 "tableTo": "cards", 254 - "columnsFrom": [ 255 - "parent_card_id" 256 - ], 257 - "columnsTo": [ 258 - "id" 259 - ], 254 + "columnsFrom": ["parent_card_id"], 255 + "columnsTo": ["id"], 260 256 "onDelete": "no action", 261 257 "onUpdate": "no action" 262 258 }, ··· 264 260 "name": "cards_via_card_id_cards_id_fk", 265 261 "tableFrom": "cards", 266 262 "tableTo": "cards", 267 - "columnsFrom": [ 268 - "via_card_id" 269 - ], 270 - "columnsTo": [ 271 - "id" 272 - ], 263 + "columnsFrom": ["via_card_id"], 264 + "columnsTo": ["id"], 273 265 "onDelete": "no action", 274 266 "onUpdate": "no action" 275 267 }, ··· 277 269 "name": "cards_published_record_id_published_records_id_fk", 278 270 "tableFrom": "cards", 279 271 "tableTo": "published_records", 280 - "columnsFrom": [ 281 - "published_record_id" 282 - ], 283 - "columnsTo": [ 284 - "id" 285 - ], 272 + "columnsFrom": ["published_record_id"], 273 + "columnsTo": ["id"], 286 274 "onDelete": "no action", 287 275 "onUpdate": "no action" 288 276 } ··· 414 402 "name": "collection_cards_collection_id_collections_id_fk", 415 403 "tableFrom": "collection_cards", 416 404 "tableTo": "collections", 417 - "columnsFrom": [ 418 - "collection_id" 419 - ], 420 - "columnsTo": [ 421 - "id" 422 - ], 405 + "columnsFrom": ["collection_id"], 406 + "columnsTo": ["id"], 423 407 "onDelete": "cascade", 424 408 "onUpdate": "no action" 425 409 }, ··· 427 411 "name": "collection_cards_card_id_cards_id_fk", 428 412 "tableFrom": "collection_cards", 429 413 "tableTo": "cards", 430 - "columnsFrom": [ 431 - "card_id" 432 - ], 433 - "columnsTo": [ 434 - "id" 435 - ], 414 + "columnsFrom": ["card_id"], 415 + "columnsTo": ["id"], 436 416 "onDelete": "cascade", 437 417 "onUpdate": "no action" 438 418 }, ··· 440 420 "name": "collection_cards_via_card_id_cards_id_fk", 441 421 "tableFrom": "collection_cards", 442 422 "tableTo": "cards", 443 - "columnsFrom": [ 444 - "via_card_id" 445 - ], 446 - "columnsTo": [ 447 - "id" 448 - ], 423 + "columnsFrom": ["via_card_id"], 424 + "columnsTo": ["id"], 449 425 "onDelete": "no action", 450 426 "onUpdate": "no action" 451 427 }, ··· 453 429 "name": "collection_cards_published_record_id_published_records_id_fk", 454 430 "tableFrom": "collection_cards", 455 431 "tableTo": "published_records", 456 - "columnsFrom": [ 457 - "published_record_id" 458 - ], 459 - "columnsTo": [ 460 - "id" 461 - ], 432 + "columnsFrom": ["published_record_id"], 433 + "columnsTo": ["id"], 462 434 "onDelete": "no action", 463 435 "onUpdate": "no action" 464 436 } ··· 498 470 "name": "collection_collaborators_collection_id_collections_id_fk", 499 471 "tableFrom": "collection_collaborators", 500 472 "tableTo": "collections", 501 - "columnsFrom": [ 502 - "collection_id" 503 - ], 504 - "columnsTo": [ 505 - "id" 506 - ], 473 + "columnsFrom": ["collection_id"], 474 + "columnsTo": ["id"], 507 475 "onDelete": "cascade", 508 476 "onUpdate": "no action" 509 477 } ··· 619 587 "name": "collections_published_record_id_published_records_id_fk", 620 588 "tableFrom": "collections", 621 589 "tableTo": "published_records", 622 - "columnsFrom": [ 623 - "published_record_id" 624 - ], 625 - "columnsTo": [ 626 - "id" 627 - ], 590 + "columnsFrom": ["published_record_id"], 591 + "columnsTo": ["id"], 628 592 "onDelete": "no action", 629 593 "onUpdate": "no action" 630 594 } ··· 723 687 "name": "library_memberships_card_id_cards_id_fk", 724 688 "tableFrom": "library_memberships", 725 689 "tableTo": "cards", 726 - "columnsFrom": [ 727 - "card_id" 728 - ], 729 - "columnsTo": [ 730 - "id" 731 - ], 690 + "columnsFrom": ["card_id"], 691 + "columnsTo": ["id"], 732 692 "onDelete": "cascade", 733 693 "onUpdate": "no action" 734 694 }, ··· 736 696 "name": "library_memberships_published_record_id_published_records_id_fk", 737 697 "tableFrom": "library_memberships", 738 698 "tableTo": "published_records", 739 - "columnsFrom": [ 740 - "published_record_id" 741 - ], 742 - "columnsTo": [ 743 - "id" 744 - ], 699 + "columnsFrom": ["published_record_id"], 700 + "columnsTo": ["id"], 745 701 "onDelete": "no action", 746 702 "onUpdate": "no action" 747 703 } ··· 749 705 "compositePrimaryKeys": { 750 706 "library_memberships_card_id_user_id_pk": { 751 707 "name": "library_memberships_card_id_user_id_pk", 752 - "columns": [ 753 - "card_id", 754 - "user_id" 755 - ] 708 + "columns": ["card_id", "user_id"] 756 709 } 757 710 }, 758 711 "uniqueConstraints": {}, ··· 1227 1180 "name": "auth_refresh_tokens_user_did_users_id_fk", 1228 1181 "tableFrom": "auth_refresh_tokens", 1229 1182 "tableTo": "users", 1230 - "columnsFrom": [ 1231 - "user_did" 1232 - ], 1233 - "columnsTo": [ 1234 - "id" 1235 - ], 1183 + "columnsFrom": ["user_did"], 1184 + "columnsTo": ["id"], 1236 1185 "onDelete": "no action", 1237 1186 "onUpdate": "no action" 1238 1187 } ··· 1292 1241 "schemas": {}, 1293 1242 "tables": {} 1294 1243 } 1295 - } 1244 + }
+1 -1
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 87 87 "breakpoints": true 88 88 } 89 89 ] 90 - } 90 + }