This repository has no description
0

Configure Feed

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

implement fan-out feed activity to user and collection followers

+1999 -1
+13
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 118 118 created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 119 119 PRIMARY KEY (follower_id, target_id, target_type) 120 120 )`, 121 + 122 + // Following feed items table (references feed_activities) 123 + sql`CREATE TABLE IF NOT EXISTS following_feed_items ( 124 + user_id TEXT NOT NULL, 125 + activity_id UUID NOT NULL REFERENCES feed_activities(id) ON DELETE CASCADE, 126 + created_at TIMESTAMP NOT NULL, 127 + PRIMARY KEY (user_id, activity_id) 128 + )`, 121 129 ]; 122 130 123 131 // Execute table creation queries in order ··· 255 263 `); 256 264 await db.execute(sql` 257 265 CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_id, target_type); 266 + `); 267 + 268 + // Following feed items indexes 269 + await db.execute(sql` 270 + CREATE INDEX IF NOT EXISTS idx_following_feed_user_time ON following_feed_items(user_id, created_at DESC); 258 271 `); 259 272 }
+84 -1
src/modules/feeds/application/useCases/commands/AddActivityToFeedUseCase.ts
··· 10 10 import { ICardRepository } from '../../../../cards/domain/ICardRepository'; 11 11 import { SourceTypeEnum } from '../../../domain/value-objects/SourceType'; 12 12 import { ATPROTO_NSID } from '../../../../../shared/constants/atproto'; 13 + import { IFollowsRepository } from '../../../../user/domain/repositories/IFollowsRepository'; 14 + import { IFeedRepository } from '../../../domain/IFeedRepository'; 15 + import { 16 + FollowTargetType, 17 + FollowTargetTypeEnum, 18 + } from '../../../../user/domain/value-objects/FollowTargetType'; 13 19 14 20 export interface AddCardCollectedActivityDTO { 15 21 type: ActivityTypeEnum.CARD_COLLECTED; ··· 44 50 constructor( 45 51 private feedService: FeedService, 46 52 private cardRepository: ICardRepository, 53 + private followsRepository: IFollowsRepository, 54 + private feedRepository: IFeedRepository, 47 55 ) {} 48 56 49 57 async execute( ··· 153 161 return err(new ValidationError(activityResult.error.message)); 154 162 } 155 163 164 + const activity = activityResult.value; 165 + 166 + // ======================================== 167 + // PHASE 4: GET FOLLOWERS 168 + // ======================================== 169 + 170 + // 4a. Get followers of the actor (user who created activity) 171 + const targetTypeResult = FollowTargetType.create( 172 + FollowTargetTypeEnum.USER, 173 + ); 174 + if (targetTypeResult.isErr()) { 175 + console.error( 176 + 'Failed to create FollowTargetType:', 177 + targetTypeResult.error, 178 + ); 179 + return ok({ 180 + activityId: activity.activityId.getStringValue(), 181 + }); 182 + } 183 + 184 + const userFollowersResult = await this.followsRepository.getFollowers( 185 + actorId.value, 186 + targetTypeResult.value, 187 + ); 188 + 189 + const userFollowers = userFollowersResult.isOk() 190 + ? userFollowersResult.value.map((f) => f.followerId.value) 191 + : []; 192 + 193 + // 4b. Get followers of collections (if any) 194 + let collectionFollowers: string[] = []; 195 + if (collectionIds && collectionIds.length > 0) { 196 + const collectionIdStrings = collectionIds.map((id) => 197 + id.getStringValue(), 198 + ); 199 + const collectionFollowersResult = 200 + await this.followsRepository.getFollowersOfCollections( 201 + collectionIdStrings, 202 + ); 203 + 204 + collectionFollowers = collectionFollowersResult.isOk() 205 + ? collectionFollowersResult.value.map((f) => f.followerId.value) 206 + : []; 207 + } 208 + 209 + // 4c. Combine and deduplicate follower IDs 210 + const allFollowerIds = new Set([ 211 + ...userFollowers, 212 + ...collectionFollowers, 213 + ]); 214 + 215 + // ======================================== 216 + // PHASE 5: FAN-OUT 217 + // ======================================== 218 + 219 + if (allFollowerIds.size > 0) { 220 + const fanOutResult = 221 + await this.feedRepository.fanOutActivityToFollowers( 222 + activity.activityId, 223 + Array.from(allFollowerIds), 224 + activity.createdAt, 225 + ); 226 + 227 + // Error handling: Log but don't fail the use case 228 + // Activity already exists in global feed 229 + // Event retries will eventually distribute it 230 + if (fanOutResult.isErr()) { 231 + console.error( 232 + 'Fan-out failed (will retry on event retry):', 233 + fanOutResult.error, 234 + ); 235 + // Note: We do NOT return err here - activity was created successfully 236 + } 237 + } 238 + 156 239 return ok({ 157 - activityId: activityResult.value.activityId.getStringValue(), 240 + activityId: activity.activityId.getStringValue(), 158 241 }); 159 242 } catch (error) { 160 243 return err(AppError.UnexpectedError.create(error));
+41
src/modules/feeds/domain/IFeedRepository.ts
··· 36 36 withinMinutes: number, 37 37 ): Promise<Result<FeedActivity | null>>; 38 38 updateActivity(activity: FeedActivity): Promise<Result<void>>; 39 + 40 + /** 41 + * Fan-out an activity to multiple followers' following feeds. 42 + * 43 + * @param activityId - Activity to distribute 44 + * @param followerIds - User DIDs to receive this activity (deduplicated by caller) 45 + * @param createdAt - Activity timestamp (denormalized for sorting) 46 + * @returns Success or error 47 + * 48 + * Idempotency guarantee: 49 + * - Uses ON CONFLICT DO NOTHING on primary key (user_id, activity_id) 50 + * - Safe to call multiple times with same inputs 51 + * - Retries are silent (no error on duplicate) 52 + * 53 + * Performance: 54 + * - Bulk insert operation (single query) 55 + * - Returns immediately if followerIds is empty (no-op) 56 + */ 57 + fanOutActivityToFollowers( 58 + activityId: ActivityId, 59 + followerIds: string[], 60 + createdAt: Date, 61 + ): Promise<Result<void>>; 62 + 63 + /** 64 + * Get a user's following feed (paginated). 65 + * 66 + * @param userId - User DID whose feed to fetch 67 + * @param options - Pagination, filters (urlType, source, beforeActivityId) 68 + * @returns Paginated feed activities 69 + * 70 + * Query pattern: 71 + * - Filters by user_id on following_feed_items 72 + * - JOINs to feed_activities for full activity data 73 + * - Supports same filters as global feed (urlType, source) 74 + * - Cursor-based pagination via beforeActivityId 75 + */ 76 + getFollowingFeed( 77 + userId: string, 78 + options: FeedQueryOptions, 79 + ): Promise<Result<PaginatedFeedResult>>; 39 80 }
+150
src/modules/feeds/infrastructure/repositories/DrizzleFeedRepository.ts
··· 8 8 import { FeedActivity } from '../../domain/FeedActivity'; 9 9 import { ActivityId } from '../../domain/value-objects/ActivityId'; 10 10 import { feedActivities } from './schema/feedActivity.sql'; 11 + import { followingFeedItems } from './schema/followingFeedItem.sql'; 11 12 import { 12 13 FeedActivityMapper, 13 14 FeedActivityDTO, ··· 420 421 .where(eq(feedActivities.id, dto.id)); 421 422 422 423 return ok(undefined); 424 + } catch (error) { 425 + return err(error as Error); 426 + } 427 + } 428 + 429 + async fanOutActivityToFollowers( 430 + activityId: ActivityId, 431 + followerIds: string[], 432 + createdAt: Date, 433 + ): Promise<Result<void>> { 434 + try { 435 + if (followerIds.length === 0) { 436 + return ok(undefined); 437 + } 438 + 439 + const values = followerIds.map((userId) => ({ 440 + userId: userId, 441 + activityId: activityId.getStringValue(), 442 + createdAt: createdAt, 443 + })); 444 + 445 + await this.db 446 + .insert(followingFeedItems) 447 + .values(values) 448 + .onConflictDoNothing(); 449 + 450 + return ok(undefined); 451 + } catch (error) { 452 + return err(error as Error); 453 + } 454 + } 455 + 456 + async getFollowingFeed( 457 + userId: string, 458 + options: FeedQueryOptions, 459 + ): Promise<Result<PaginatedFeedResult>> { 460 + try { 461 + const { page, limit, beforeActivityId } = options; 462 + const offset = (page - 1) * limit; 463 + 464 + // Build where conditions 465 + const whereConditions = [eq(followingFeedItems.userId, userId)]; 466 + 467 + if (options.urlType) { 468 + whereConditions.push(eq(feedActivities.urlType, options.urlType)); 469 + } 470 + 471 + if (options.source) { 472 + if (options.source === ActivitySource.SEMBLE) { 473 + whereConditions.push(sql`${feedActivities.source} IS NULL`); 474 + } else { 475 + whereConditions.push(eq(feedActivities.source, options.source)); 476 + } 477 + } 478 + 479 + // Cursor-based pagination 480 + if (beforeActivityId) { 481 + const beforeActivity = await this.db 482 + .select({ createdAt: followingFeedItems.createdAt }) 483 + .from(followingFeedItems) 484 + .where( 485 + and( 486 + eq(followingFeedItems.userId, userId), 487 + eq( 488 + followingFeedItems.activityId, 489 + beforeActivityId.getStringValue(), 490 + ), 491 + ), 492 + ) 493 + .limit(1); 494 + 495 + if (beforeActivity.length > 0) { 496 + whereConditions.push( 497 + lt(followingFeedItems.createdAt, beforeActivity[0]!.createdAt), 498 + ); 499 + } 500 + } 501 + 502 + // Main query with JOIN 503 + const activitiesResult = await this.db 504 + .select({ 505 + id: feedActivities.id, 506 + actorId: feedActivities.actorId, 507 + cardId: feedActivities.cardId, 508 + type: feedActivities.type, 509 + metadata: feedActivities.metadata, 510 + urlType: feedActivities.urlType, 511 + source: feedActivities.source, 512 + createdAt: followingFeedItems.createdAt, // Use denormalized timestamp 513 + }) 514 + .from(followingFeedItems) 515 + .innerJoin( 516 + feedActivities, 517 + eq(feedActivities.id, followingFeedItems.activityId), 518 + ) 519 + .where(and(...whereConditions)) 520 + .orderBy( 521 + desc(followingFeedItems.createdAt), 522 + desc(followingFeedItems.activityId), 523 + ) 524 + .limit(limit) 525 + .offset(offset); 526 + 527 + // Count total (with same filters) 528 + const totalCountResult = await this.db 529 + .select({ count: count() }) 530 + .from(followingFeedItems) 531 + .innerJoin( 532 + feedActivities, 533 + eq(feedActivities.id, followingFeedItems.activityId), 534 + ) 535 + .where(and(...whereConditions)); 536 + 537 + const totalCount = totalCountResult[0]?.count || 0; 538 + 539 + // Map to domain objects 540 + const activities: FeedActivity[] = []; 541 + for (const activityData of activitiesResult) { 542 + const dto: FeedActivityDTO = { 543 + id: activityData.id, 544 + actorId: activityData.actorId, 545 + cardId: activityData.cardId || undefined, 546 + type: activityData.type, 547 + metadata: activityData.metadata as any, 548 + urlType: activityData.urlType || undefined, 549 + source: activityData.source || undefined, 550 + createdAt: activityData.createdAt, 551 + }; 552 + 553 + const domainResult = FeedActivityMapper.toDomain(dto); 554 + if (domainResult.isErr()) { 555 + return err(domainResult.error); 556 + } 557 + 558 + activities.push(domainResult.value); 559 + } 560 + 561 + const hasMore = offset + activities.length < totalCount; 562 + const nextCursor = 563 + hasMore && activities.length > 0 564 + ? activities[activities.length - 1]!.activityId 565 + : undefined; 566 + 567 + return ok({ 568 + activities, 569 + totalCount, 570 + hasMore, 571 + nextCursor, 572 + }); 423 573 } catch (error) { 424 574 return err(error as Error); 425 575 }
+29
src/modules/feeds/infrastructure/repositories/schema/followingFeedItem.sql.ts
··· 1 + import { 2 + pgTable, 3 + text, 4 + timestamp, 5 + uuid, 6 + index, 7 + primaryKey, 8 + } from 'drizzle-orm/pg-core'; 9 + import { feedActivities } from './feedActivity.sql'; 10 + 11 + export const followingFeedItems = pgTable( 12 + 'following_feed_items', 13 + { 14 + userId: text('user_id').notNull(), // DID of feed owner 15 + activityId: uuid('activity_id') 16 + .notNull() 17 + .references(() => feedActivities.id, { onDelete: 'cascade' }), 18 + createdAt: timestamp('created_at').notNull(), // Denormalized from activity for sorting 19 + }, 20 + (table) => ({ 21 + // Composite primary key 22 + pk: primaryKey({ columns: [table.userId, table.activityId] }), 23 + // Index for efficient user feed queries sorted by time 24 + userTimeIdx: index('idx_following_feed_user_time').on( 25 + table.userId, 26 + table.createdAt.desc(), 27 + ), 28 + }), 29 + );
+87
src/modules/feeds/tests/infrastructure/InMemoryFeedRepository.ts
··· 11 11 export class InMemoryFeedRepository implements IFeedRepository { 12 12 private static instance: InMemoryFeedRepository | null = null; 13 13 private activities: FeedActivity[] = []; 14 + // Store following feed items as Map<userId, Set<activityId>> 15 + private followingFeedItems: Map<string, Set<string>> = new Map(); 14 16 15 17 private constructor() {} 16 18 ··· 212 214 } 213 215 } 214 216 217 + async fanOutActivityToFollowers( 218 + activityId: ActivityId, 219 + followerIds: string[], 220 + createdAt: Date, 221 + ): Promise<Result<void>> { 222 + try { 223 + if (followerIds.length === 0) { 224 + return ok(undefined); 225 + } 226 + 227 + const activityIdString = activityId.getStringValue(); 228 + 229 + for (const userId of followerIds) { 230 + if (!this.followingFeedItems.has(userId)) { 231 + this.followingFeedItems.set(userId, new Set()); 232 + } 233 + this.followingFeedItems.get(userId)!.add(activityIdString); 234 + } 235 + 236 + return ok(undefined); 237 + } catch (error) { 238 + return err(error as Error); 239 + } 240 + } 241 + 242 + async getFollowingFeed( 243 + userId: string, 244 + options: FeedQueryOptions, 245 + ): Promise<Result<PaginatedFeedResult>> { 246 + try { 247 + const { page, limit, beforeActivityId, urlType } = options; 248 + 249 + // Get activity IDs for this user's following feed 250 + const userActivityIds = this.followingFeedItems.get(userId) || new Set(); 251 + 252 + // Filter activities that are in this user's following feed 253 + let filteredActivities = this.activities.filter((activity) => 254 + userActivityIds.has(activity.activityId.getStringValue()), 255 + ); 256 + 257 + // Filter by URL type if provided 258 + if (urlType) { 259 + filteredActivities = filteredActivities.filter( 260 + (activity) => activity.urlType === urlType, 261 + ); 262 + } 263 + 264 + // Filter by cursor if provided 265 + if (beforeActivityId) { 266 + const beforeIndex = filteredActivities.findIndex((activity) => 267 + activity.activityId.equals(beforeActivityId), 268 + ); 269 + if (beforeIndex >= 0) { 270 + filteredActivities = filteredActivities.slice(beforeIndex + 1); 271 + } 272 + } 273 + 274 + // Paginate 275 + const offset = (page - 1) * limit; 276 + const paginatedActivities = filteredActivities.slice( 277 + offset, 278 + offset + limit, 279 + ); 280 + 281 + const totalCount = filteredActivities.length; 282 + const hasMore = offset + paginatedActivities.length < totalCount; 283 + 284 + let nextCursor: ActivityId | undefined; 285 + if (hasMore && paginatedActivities.length > 0) { 286 + nextCursor = 287 + paginatedActivities[paginatedActivities.length - 1]!.activityId; 288 + } 289 + 290 + return ok({ 291 + activities: paginatedActivities, 292 + totalCount, 293 + hasMore, 294 + nextCursor, 295 + }); 296 + } catch (error) { 297 + return err(error as Error); 298 + } 299 + } 300 + 215 301 // Test helper methods 216 302 clear(): void { 217 303 this.activities = []; 304 + this.followingFeedItems.clear(); 218 305 } 219 306 220 307 getAll(): FeedActivity[] {
+19
src/shared/infrastructure/database/migrations/0016_ancient_cerise.sql
··· 1 + CREATE TABLE "following_feed_items" ( 2 + "user_id" text NOT NULL, 3 + "activity_id" uuid NOT NULL, 4 + "created_at" timestamp NOT NULL, 5 + CONSTRAINT "following_feed_items_user_id_activity_id_pk" PRIMARY KEY("user_id","activity_id") 6 + ); 7 + --> statement-breakpoint 8 + CREATE TABLE "follows" ( 9 + "follower_id" text NOT NULL, 10 + "target_id" text NOT NULL, 11 + "target_type" text NOT NULL, 12 + "created_at" timestamp DEFAULT now() NOT NULL, 13 + CONSTRAINT "follows_follower_id_target_id_target_type_pk" PRIMARY KEY("follower_id","target_id","target_type") 14 + ); 15 + --> statement-breakpoint 16 + ALTER TABLE "following_feed_items" ADD CONSTRAINT "following_feed_items_activity_id_feed_activities_id_fk" FOREIGN KEY ("activity_id") REFERENCES "public"."feed_activities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 17 + CREATE INDEX "idx_following_feed_user_time" ON "following_feed_items" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint 18 + CREATE INDEX "idx_follows_follower" ON "follows" USING btree ("follower_id");--> statement-breakpoint 19 + CREATE INDEX "idx_follows_target" ON "follows" USING btree ("target_id","target_type");
+1558
src/shared/infrastructure/database/migrations/meta/0016_snapshot.json
··· 1 + { 2 + "id": "1186ff74-4783-4cc7-94c4-ae3e8f93ac59", 3 + "prevId": "a6374d24-e08b-4e73-9589-82060d95ba81", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.app_password_sessions": { 8 + "name": "app_password_sessions", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session_data": { 18 + "name": "session_data", 19 + "type": "jsonb", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "app_password": { 24 + "name": "app_password", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "created_at": { 30 + "name": "created_at", 31 + "type": "timestamp", 32 + "primaryKey": false, 33 + "notNull": false, 34 + "default": "now()" 35 + }, 36 + "updated_at": { 37 + "name": "updated_at", 38 + "type": "timestamp", 39 + "primaryKey": false, 40 + "notNull": false, 41 + "default": "now()" 42 + } 43 + }, 44 + "indexes": {}, 45 + "foreignKeys": {}, 46 + "compositePrimaryKeys": {}, 47 + "uniqueConstraints": {}, 48 + "policies": {}, 49 + "checkConstraints": {}, 50 + "isRLSEnabled": false 51 + }, 52 + "public.cards": { 53 + "name": "cards", 54 + "schema": "", 55 + "columns": { 56 + "id": { 57 + "name": "id", 58 + "type": "uuid", 59 + "primaryKey": true, 60 + "notNull": true 61 + }, 62 + "author_id": { 63 + "name": "author_id", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "type": { 69 + "name": "type", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "content_data": { 75 + "name": "content_data", 76 + "type": "jsonb", 77 + "primaryKey": false, 78 + "notNull": true 79 + }, 80 + "url": { 81 + "name": "url", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "url_type": { 87 + "name": "url_type", 88 + "type": "text", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "parent_card_id": { 93 + "name": "parent_card_id", 94 + "type": "uuid", 95 + "primaryKey": false, 96 + "notNull": false 97 + }, 98 + "via_card_id": { 99 + "name": "via_card_id", 100 + "type": "uuid", 101 + "primaryKey": false, 102 + "notNull": false 103 + }, 104 + "published_record_id": { 105 + "name": "published_record_id", 106 + "type": "uuid", 107 + "primaryKey": false, 108 + "notNull": false 109 + }, 110 + "library_count": { 111 + "name": "library_count", 112 + "type": "integer", 113 + "primaryKey": false, 114 + "notNull": true, 115 + "default": 0 116 + }, 117 + "created_at": { 118 + "name": "created_at", 119 + "type": "timestamp", 120 + "primaryKey": false, 121 + "notNull": true, 122 + "default": "now()" 123 + }, 124 + "updated_at": { 125 + "name": "updated_at", 126 + "type": "timestamp", 127 + "primaryKey": false, 128 + "notNull": true, 129 + "default": "now()" 130 + } 131 + }, 132 + "indexes": { 133 + "cards_author_url_idx": { 134 + "name": "cards_author_url_idx", 135 + "columns": [ 136 + { 137 + "expression": "author_id", 138 + "isExpression": false, 139 + "asc": true, 140 + "nulls": "last" 141 + }, 142 + { 143 + "expression": "url", 144 + "isExpression": false, 145 + "asc": true, 146 + "nulls": "last" 147 + } 148 + ], 149 + "isUnique": false, 150 + "concurrently": false, 151 + "method": "btree", 152 + "with": {} 153 + }, 154 + "cards_author_id_idx": { 155 + "name": "cards_author_id_idx", 156 + "columns": [ 157 + { 158 + "expression": "author_id", 159 + "isExpression": false, 160 + "asc": true, 161 + "nulls": "last" 162 + } 163 + ], 164 + "isUnique": false, 165 + "concurrently": false, 166 + "method": "btree", 167 + "with": {} 168 + }, 169 + "idx_cards_type_updated_at": { 170 + "name": "idx_cards_type_updated_at", 171 + "columns": [ 172 + { 173 + "expression": "type", 174 + "isExpression": false, 175 + "asc": true, 176 + "nulls": "last" 177 + }, 178 + { 179 + "expression": "updated_at", 180 + "isExpression": false, 181 + "asc": false, 182 + "nulls": "last" 183 + } 184 + ], 185 + "isUnique": false, 186 + "concurrently": false, 187 + "method": "btree", 188 + "with": {} 189 + }, 190 + "idx_cards_url_type": { 191 + "name": "idx_cards_url_type", 192 + "columns": [ 193 + { 194 + "expression": "url", 195 + "isExpression": false, 196 + "asc": true, 197 + "nulls": "last" 198 + }, 199 + { 200 + "expression": "type", 201 + "isExpression": false, 202 + "asc": true, 203 + "nulls": "last" 204 + } 205 + ], 206 + "isUnique": false, 207 + "concurrently": false, 208 + "method": "btree", 209 + "with": {} 210 + }, 211 + "idx_cards_url_type_filter": { 212 + "name": "idx_cards_url_type_filter", 213 + "columns": [ 214 + { 215 + "expression": "url_type", 216 + "isExpression": false, 217 + "asc": true, 218 + "nulls": "last" 219 + } 220 + ], 221 + "isUnique": false, 222 + "concurrently": false, 223 + "method": "btree", 224 + "with": {} 225 + }, 226 + "idx_cards_parent_type": { 227 + "name": "idx_cards_parent_type", 228 + "columns": [ 229 + { 230 + "expression": "parent_card_id", 231 + "isExpression": false, 232 + "asc": true, 233 + "nulls": "last" 234 + }, 235 + { 236 + "expression": "type", 237 + "isExpression": false, 238 + "asc": true, 239 + "nulls": "last" 240 + } 241 + ], 242 + "isUnique": false, 243 + "where": "type = 'NOTE'", 244 + "concurrently": false, 245 + "method": "btree", 246 + "with": {} 247 + } 248 + }, 249 + "foreignKeys": { 250 + "cards_parent_card_id_cards_id_fk": { 251 + "name": "cards_parent_card_id_cards_id_fk", 252 + "tableFrom": "cards", 253 + "tableTo": "cards", 254 + "columnsFrom": ["parent_card_id"], 255 + "columnsTo": ["id"], 256 + "onDelete": "no action", 257 + "onUpdate": "no action" 258 + }, 259 + "cards_via_card_id_cards_id_fk": { 260 + "name": "cards_via_card_id_cards_id_fk", 261 + "tableFrom": "cards", 262 + "tableTo": "cards", 263 + "columnsFrom": ["via_card_id"], 264 + "columnsTo": ["id"], 265 + "onDelete": "no action", 266 + "onUpdate": "no action" 267 + }, 268 + "cards_published_record_id_published_records_id_fk": { 269 + "name": "cards_published_record_id_published_records_id_fk", 270 + "tableFrom": "cards", 271 + "tableTo": "published_records", 272 + "columnsFrom": ["published_record_id"], 273 + "columnsTo": ["id"], 274 + "onDelete": "no action", 275 + "onUpdate": "no action" 276 + } 277 + }, 278 + "compositePrimaryKeys": {}, 279 + "uniqueConstraints": {}, 280 + "policies": {}, 281 + "checkConstraints": {}, 282 + "isRLSEnabled": false 283 + }, 284 + "public.collection_cards": { 285 + "name": "collection_cards", 286 + "schema": "", 287 + "columns": { 288 + "id": { 289 + "name": "id", 290 + "type": "uuid", 291 + "primaryKey": true, 292 + "notNull": true 293 + }, 294 + "collection_id": { 295 + "name": "collection_id", 296 + "type": "uuid", 297 + "primaryKey": false, 298 + "notNull": true 299 + }, 300 + "card_id": { 301 + "name": "card_id", 302 + "type": "uuid", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "added_by": { 307 + "name": "added_by", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "added_at": { 313 + "name": "added_at", 314 + "type": "timestamp", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "default": "now()" 318 + }, 319 + "via_card_id": { 320 + "name": "via_card_id", 321 + "type": "uuid", 322 + "primaryKey": false, 323 + "notNull": false 324 + }, 325 + "published_record_id": { 326 + "name": "published_record_id", 327 + "type": "uuid", 328 + "primaryKey": false, 329 + "notNull": false 330 + } 331 + }, 332 + "indexes": { 333 + "collection_cards_card_id_idx": { 334 + "name": "collection_cards_card_id_idx", 335 + "columns": [ 336 + { 337 + "expression": "card_id", 338 + "isExpression": false, 339 + "asc": true, 340 + "nulls": "last" 341 + } 342 + ], 343 + "isUnique": false, 344 + "concurrently": false, 345 + "method": "btree", 346 + "with": {} 347 + }, 348 + "collection_cards_collection_id_idx": { 349 + "name": "collection_cards_collection_id_idx", 350 + "columns": [ 351 + { 352 + "expression": "collection_id", 353 + "isExpression": false, 354 + "asc": true, 355 + "nulls": "last" 356 + } 357 + ], 358 + "isUnique": false, 359 + "concurrently": false, 360 + "method": "btree", 361 + "with": {} 362 + }, 363 + "idx_collection_cards_collection_added": { 364 + "name": "idx_collection_cards_collection_added", 365 + "columns": [ 366 + { 367 + "expression": "collection_id", 368 + "isExpression": false, 369 + "asc": true, 370 + "nulls": "last" 371 + }, 372 + { 373 + "expression": "added_at", 374 + "isExpression": false, 375 + "asc": false, 376 + "nulls": "last" 377 + } 378 + ], 379 + "isUnique": false, 380 + "concurrently": false, 381 + "method": "btree", 382 + "with": {} 383 + }, 384 + "idx_collection_cards_card_collection": { 385 + "name": "idx_collection_cards_card_collection", 386 + "columns": [ 387 + { 388 + "expression": "card_id", 389 + "isExpression": false, 390 + "asc": true, 391 + "nulls": "last" 392 + } 393 + ], 394 + "isUnique": false, 395 + "concurrently": false, 396 + "method": "btree", 397 + "with": {} 398 + }, 399 + "idx_collection_cards_added_by_added_at": { 400 + "name": "idx_collection_cards_added_by_added_at", 401 + "columns": [ 402 + { 403 + "expression": "added_by", 404 + "isExpression": false, 405 + "asc": true, 406 + "nulls": "last" 407 + }, 408 + { 409 + "expression": "added_at", 410 + "isExpression": false, 411 + "asc": false, 412 + "nulls": "last" 413 + } 414 + ], 415 + "isUnique": false, 416 + "concurrently": false, 417 + "method": "btree", 418 + "with": {} 419 + } 420 + }, 421 + "foreignKeys": { 422 + "collection_cards_collection_id_collections_id_fk": { 423 + "name": "collection_cards_collection_id_collections_id_fk", 424 + "tableFrom": "collection_cards", 425 + "tableTo": "collections", 426 + "columnsFrom": ["collection_id"], 427 + "columnsTo": ["id"], 428 + "onDelete": "cascade", 429 + "onUpdate": "no action" 430 + }, 431 + "collection_cards_card_id_cards_id_fk": { 432 + "name": "collection_cards_card_id_cards_id_fk", 433 + "tableFrom": "collection_cards", 434 + "tableTo": "cards", 435 + "columnsFrom": ["card_id"], 436 + "columnsTo": ["id"], 437 + "onDelete": "cascade", 438 + "onUpdate": "no action" 439 + }, 440 + "collection_cards_via_card_id_cards_id_fk": { 441 + "name": "collection_cards_via_card_id_cards_id_fk", 442 + "tableFrom": "collection_cards", 443 + "tableTo": "cards", 444 + "columnsFrom": ["via_card_id"], 445 + "columnsTo": ["id"], 446 + "onDelete": "no action", 447 + "onUpdate": "no action" 448 + }, 449 + "collection_cards_published_record_id_published_records_id_fk": { 450 + "name": "collection_cards_published_record_id_published_records_id_fk", 451 + "tableFrom": "collection_cards", 452 + "tableTo": "published_records", 453 + "columnsFrom": ["published_record_id"], 454 + "columnsTo": ["id"], 455 + "onDelete": "no action", 456 + "onUpdate": "no action" 457 + } 458 + }, 459 + "compositePrimaryKeys": {}, 460 + "uniqueConstraints": {}, 461 + "policies": {}, 462 + "checkConstraints": {}, 463 + "isRLSEnabled": false 464 + }, 465 + "public.collection_collaborators": { 466 + "name": "collection_collaborators", 467 + "schema": "", 468 + "columns": { 469 + "id": { 470 + "name": "id", 471 + "type": "uuid", 472 + "primaryKey": true, 473 + "notNull": true 474 + }, 475 + "collection_id": { 476 + "name": "collection_id", 477 + "type": "uuid", 478 + "primaryKey": false, 479 + "notNull": true 480 + }, 481 + "collaborator_id": { 482 + "name": "collaborator_id", 483 + "type": "text", 484 + "primaryKey": false, 485 + "notNull": true 486 + } 487 + }, 488 + "indexes": {}, 489 + "foreignKeys": { 490 + "collection_collaborators_collection_id_collections_id_fk": { 491 + "name": "collection_collaborators_collection_id_collections_id_fk", 492 + "tableFrom": "collection_collaborators", 493 + "tableTo": "collections", 494 + "columnsFrom": ["collection_id"], 495 + "columnsTo": ["id"], 496 + "onDelete": "cascade", 497 + "onUpdate": "no action" 498 + } 499 + }, 500 + "compositePrimaryKeys": {}, 501 + "uniqueConstraints": {}, 502 + "policies": {}, 503 + "checkConstraints": {}, 504 + "isRLSEnabled": false 505 + }, 506 + "public.collections": { 507 + "name": "collections", 508 + "schema": "", 509 + "columns": { 510 + "id": { 511 + "name": "id", 512 + "type": "uuid", 513 + "primaryKey": true, 514 + "notNull": true 515 + }, 516 + "author_id": { 517 + "name": "author_id", 518 + "type": "text", 519 + "primaryKey": false, 520 + "notNull": true 521 + }, 522 + "name": { 523 + "name": "name", 524 + "type": "text", 525 + "primaryKey": false, 526 + "notNull": true 527 + }, 528 + "description": { 529 + "name": "description", 530 + "type": "text", 531 + "primaryKey": false, 532 + "notNull": false 533 + }, 534 + "access_type": { 535 + "name": "access_type", 536 + "type": "text", 537 + "primaryKey": false, 538 + "notNull": true 539 + }, 540 + "card_count": { 541 + "name": "card_count", 542 + "type": "integer", 543 + "primaryKey": false, 544 + "notNull": true, 545 + "default": 0 546 + }, 547 + "created_at": { 548 + "name": "created_at", 549 + "type": "timestamp", 550 + "primaryKey": false, 551 + "notNull": true, 552 + "default": "now()" 553 + }, 554 + "updated_at": { 555 + "name": "updated_at", 556 + "type": "timestamp", 557 + "primaryKey": false, 558 + "notNull": true, 559 + "default": "now()" 560 + }, 561 + "published_record_id": { 562 + "name": "published_record_id", 563 + "type": "uuid", 564 + "primaryKey": false, 565 + "notNull": false 566 + } 567 + }, 568 + "indexes": { 569 + "collections_author_id_idx": { 570 + "name": "collections_author_id_idx", 571 + "columns": [ 572 + { 573 + "expression": "author_id", 574 + "isExpression": false, 575 + "asc": true, 576 + "nulls": "last" 577 + } 578 + ], 579 + "isUnique": false, 580 + "concurrently": false, 581 + "method": "btree", 582 + "with": {} 583 + }, 584 + "collections_author_updated_at_idx": { 585 + "name": "collections_author_updated_at_idx", 586 + "columns": [ 587 + { 588 + "expression": "author_id", 589 + "isExpression": false, 590 + "asc": true, 591 + "nulls": "last" 592 + }, 593 + { 594 + "expression": "updated_at", 595 + "isExpression": false, 596 + "asc": true, 597 + "nulls": "last" 598 + } 599 + ], 600 + "isUnique": false, 601 + "concurrently": false, 602 + "method": "btree", 603 + "with": {} 604 + } 605 + }, 606 + "foreignKeys": { 607 + "collections_published_record_id_published_records_id_fk": { 608 + "name": "collections_published_record_id_published_records_id_fk", 609 + "tableFrom": "collections", 610 + "tableTo": "published_records", 611 + "columnsFrom": ["published_record_id"], 612 + "columnsTo": ["id"], 613 + "onDelete": "no action", 614 + "onUpdate": "no action" 615 + } 616 + }, 617 + "compositePrimaryKeys": {}, 618 + "uniqueConstraints": {}, 619 + "policies": {}, 620 + "checkConstraints": {}, 621 + "isRLSEnabled": false 622 + }, 623 + "public.library_memberships": { 624 + "name": "library_memberships", 625 + "schema": "", 626 + "columns": { 627 + "card_id": { 628 + "name": "card_id", 629 + "type": "uuid", 630 + "primaryKey": false, 631 + "notNull": true 632 + }, 633 + "user_id": { 634 + "name": "user_id", 635 + "type": "text", 636 + "primaryKey": false, 637 + "notNull": true 638 + }, 639 + "added_at": { 640 + "name": "added_at", 641 + "type": "timestamp", 642 + "primaryKey": false, 643 + "notNull": true, 644 + "default": "now()" 645 + }, 646 + "published_record_id": { 647 + "name": "published_record_id", 648 + "type": "uuid", 649 + "primaryKey": false, 650 + "notNull": false 651 + } 652 + }, 653 + "indexes": { 654 + "idx_user_cards": { 655 + "name": "idx_user_cards", 656 + "columns": [ 657 + { 658 + "expression": "user_id", 659 + "isExpression": false, 660 + "asc": true, 661 + "nulls": "last" 662 + } 663 + ], 664 + "isUnique": false, 665 + "concurrently": false, 666 + "method": "btree", 667 + "with": {} 668 + }, 669 + "idx_card_users": { 670 + "name": "idx_card_users", 671 + "columns": [ 672 + { 673 + "expression": "card_id", 674 + "isExpression": false, 675 + "asc": true, 676 + "nulls": "last" 677 + } 678 + ], 679 + "isUnique": false, 680 + "concurrently": false, 681 + "method": "btree", 682 + "with": {} 683 + }, 684 + "idx_library_memberships_user_type_covering": { 685 + "name": "idx_library_memberships_user_type_covering", 686 + "columns": [ 687 + { 688 + "expression": "user_id", 689 + "isExpression": false, 690 + "asc": true, 691 + "nulls": "last" 692 + }, 693 + { 694 + "expression": "added_at", 695 + "isExpression": false, 696 + "asc": false, 697 + "nulls": "last" 698 + } 699 + ], 700 + "isUnique": false, 701 + "concurrently": false, 702 + "method": "btree", 703 + "with": {} 704 + } 705 + }, 706 + "foreignKeys": { 707 + "library_memberships_card_id_cards_id_fk": { 708 + "name": "library_memberships_card_id_cards_id_fk", 709 + "tableFrom": "library_memberships", 710 + "tableTo": "cards", 711 + "columnsFrom": ["card_id"], 712 + "columnsTo": ["id"], 713 + "onDelete": "cascade", 714 + "onUpdate": "no action" 715 + }, 716 + "library_memberships_published_record_id_published_records_id_fk": { 717 + "name": "library_memberships_published_record_id_published_records_id_fk", 718 + "tableFrom": "library_memberships", 719 + "tableTo": "published_records", 720 + "columnsFrom": ["published_record_id"], 721 + "columnsTo": ["id"], 722 + "onDelete": "no action", 723 + "onUpdate": "no action" 724 + } 725 + }, 726 + "compositePrimaryKeys": { 727 + "library_memberships_card_id_user_id_pk": { 728 + "name": "library_memberships_card_id_user_id_pk", 729 + "columns": ["card_id", "user_id"] 730 + } 731 + }, 732 + "uniqueConstraints": {}, 733 + "policies": {}, 734 + "checkConstraints": {}, 735 + "isRLSEnabled": false 736 + }, 737 + "public.published_records": { 738 + "name": "published_records", 739 + "schema": "", 740 + "columns": { 741 + "id": { 742 + "name": "id", 743 + "type": "uuid", 744 + "primaryKey": true, 745 + "notNull": true 746 + }, 747 + "uri": { 748 + "name": "uri", 749 + "type": "text", 750 + "primaryKey": false, 751 + "notNull": true 752 + }, 753 + "cid": { 754 + "name": "cid", 755 + "type": "text", 756 + "primaryKey": false, 757 + "notNull": true 758 + }, 759 + "recorded_at": { 760 + "name": "recorded_at", 761 + "type": "timestamp", 762 + "primaryKey": false, 763 + "notNull": true, 764 + "default": "now()" 765 + } 766 + }, 767 + "indexes": { 768 + "uri_cid_unique_idx": { 769 + "name": "uri_cid_unique_idx", 770 + "columns": [ 771 + { 772 + "expression": "uri", 773 + "isExpression": false, 774 + "asc": true, 775 + "nulls": "last" 776 + }, 777 + { 778 + "expression": "cid", 779 + "isExpression": false, 780 + "asc": true, 781 + "nulls": "last" 782 + } 783 + ], 784 + "isUnique": true, 785 + "concurrently": false, 786 + "method": "btree", 787 + "with": {} 788 + }, 789 + "published_records_uri_idx": { 790 + "name": "published_records_uri_idx", 791 + "columns": [ 792 + { 793 + "expression": "uri", 794 + "isExpression": false, 795 + "asc": true, 796 + "nulls": "last" 797 + } 798 + ], 799 + "isUnique": false, 800 + "concurrently": false, 801 + "method": "btree", 802 + "with": {} 803 + } 804 + }, 805 + "foreignKeys": {}, 806 + "compositePrimaryKeys": {}, 807 + "uniqueConstraints": {}, 808 + "policies": {}, 809 + "checkConstraints": {}, 810 + "isRLSEnabled": false 811 + }, 812 + "public.feed_activities": { 813 + "name": "feed_activities", 814 + "schema": "", 815 + "columns": { 816 + "id": { 817 + "name": "id", 818 + "type": "uuid", 819 + "primaryKey": true, 820 + "notNull": true 821 + }, 822 + "actor_id": { 823 + "name": "actor_id", 824 + "type": "text", 825 + "primaryKey": false, 826 + "notNull": true 827 + }, 828 + "card_id": { 829 + "name": "card_id", 830 + "type": "text", 831 + "primaryKey": false, 832 + "notNull": false 833 + }, 834 + "type": { 835 + "name": "type", 836 + "type": "text", 837 + "primaryKey": false, 838 + "notNull": true 839 + }, 840 + "metadata": { 841 + "name": "metadata", 842 + "type": "jsonb", 843 + "primaryKey": false, 844 + "notNull": true 845 + }, 846 + "url_type": { 847 + "name": "url_type", 848 + "type": "text", 849 + "primaryKey": false, 850 + "notNull": false 851 + }, 852 + "source": { 853 + "name": "source", 854 + "type": "text", 855 + "primaryKey": false, 856 + "notNull": false 857 + }, 858 + "created_at": { 859 + "name": "created_at", 860 + "type": "timestamp", 861 + "primaryKey": false, 862 + "notNull": true, 863 + "default": "now()" 864 + } 865 + }, 866 + "indexes": { 867 + "feed_activities_type_idx": { 868 + "name": "feed_activities_type_idx", 869 + "columns": [ 870 + { 871 + "expression": "type", 872 + "isExpression": false, 873 + "asc": true, 874 + "nulls": "last" 875 + } 876 + ], 877 + "isUnique": false, 878 + "concurrently": false, 879 + "method": "btree", 880 + "with": {} 881 + }, 882 + "feed_activities_url_type_idx": { 883 + "name": "feed_activities_url_type_idx", 884 + "columns": [ 885 + { 886 + "expression": "url_type", 887 + "isExpression": false, 888 + "asc": true, 889 + "nulls": "last" 890 + } 891 + ], 892 + "isUnique": false, 893 + "concurrently": false, 894 + "method": "btree", 895 + "with": {} 896 + }, 897 + "feed_activities_created_at_idx": { 898 + "name": "feed_activities_created_at_idx", 899 + "columns": [ 900 + { 901 + "expression": "created_at", 902 + "isExpression": false, 903 + "asc": false, 904 + "nulls": "last" 905 + } 906 + ], 907 + "isUnique": false, 908 + "concurrently": false, 909 + "method": "btree", 910 + "with": {} 911 + }, 912 + "feed_activities_type_created_at_idx": { 913 + "name": "feed_activities_type_created_at_idx", 914 + "columns": [ 915 + { 916 + "expression": "type", 917 + "isExpression": false, 918 + "asc": true, 919 + "nulls": "last" 920 + }, 921 + { 922 + "expression": "created_at", 923 + "isExpression": false, 924 + "asc": false, 925 + "nulls": "last" 926 + } 927 + ], 928 + "isUnique": false, 929 + "concurrently": false, 930 + "method": "btree", 931 + "with": {} 932 + }, 933 + "feed_activities_url_type_created_at_idx": { 934 + "name": "feed_activities_url_type_created_at_idx", 935 + "columns": [ 936 + { 937 + "expression": "url_type", 938 + "isExpression": false, 939 + "asc": true, 940 + "nulls": "last" 941 + }, 942 + { 943 + "expression": "created_at", 944 + "isExpression": false, 945 + "asc": false, 946 + "nulls": "last" 947 + } 948 + ], 949 + "isUnique": false, 950 + "concurrently": false, 951 + "method": "btree", 952 + "with": {} 953 + }, 954 + "feed_activities_type_url_type_created_at_idx": { 955 + "name": "feed_activities_type_url_type_created_at_idx", 956 + "columns": [ 957 + { 958 + "expression": "type", 959 + "isExpression": false, 960 + "asc": true, 961 + "nulls": "last" 962 + }, 963 + { 964 + "expression": "url_type", 965 + "isExpression": false, 966 + "asc": true, 967 + "nulls": "last" 968 + }, 969 + { 970 + "expression": "created_at", 971 + "isExpression": false, 972 + "asc": false, 973 + "nulls": "last" 974 + } 975 + ], 976 + "isUnique": false, 977 + "concurrently": false, 978 + "method": "btree", 979 + "with": {} 980 + }, 981 + "feed_activities_dedup_idx": { 982 + "name": "feed_activities_dedup_idx", 983 + "columns": [ 984 + { 985 + "expression": "actor_id", 986 + "isExpression": false, 987 + "asc": true, 988 + "nulls": "last" 989 + }, 990 + { 991 + "expression": "card_id", 992 + "isExpression": false, 993 + "asc": true, 994 + "nulls": "last" 995 + }, 996 + { 997 + "expression": "created_at", 998 + "isExpression": false, 999 + "asc": false, 1000 + "nulls": "last" 1001 + } 1002 + ], 1003 + "isUnique": false, 1004 + "concurrently": false, 1005 + "method": "btree", 1006 + "with": {} 1007 + }, 1008 + "feed_activities_card_id_idx": { 1009 + "name": "feed_activities_card_id_idx", 1010 + "columns": [ 1011 + { 1012 + "expression": "card_id", 1013 + "isExpression": false, 1014 + "asc": true, 1015 + "nulls": "last" 1016 + } 1017 + ], 1018 + "isUnique": false, 1019 + "concurrently": false, 1020 + "method": "btree", 1021 + "with": {} 1022 + }, 1023 + "feed_activities_source_idx": { 1024 + "name": "feed_activities_source_idx", 1025 + "columns": [ 1026 + { 1027 + "expression": "source", 1028 + "isExpression": false, 1029 + "asc": true, 1030 + "nulls": "last" 1031 + } 1032 + ], 1033 + "isUnique": false, 1034 + "concurrently": false, 1035 + "method": "btree", 1036 + "with": {} 1037 + } 1038 + }, 1039 + "foreignKeys": {}, 1040 + "compositePrimaryKeys": {}, 1041 + "uniqueConstraints": {}, 1042 + "policies": {}, 1043 + "checkConstraints": {}, 1044 + "isRLSEnabled": false 1045 + }, 1046 + "public.following_feed_items": { 1047 + "name": "following_feed_items", 1048 + "schema": "", 1049 + "columns": { 1050 + "user_id": { 1051 + "name": "user_id", 1052 + "type": "text", 1053 + "primaryKey": false, 1054 + "notNull": true 1055 + }, 1056 + "activity_id": { 1057 + "name": "activity_id", 1058 + "type": "uuid", 1059 + "primaryKey": false, 1060 + "notNull": true 1061 + }, 1062 + "created_at": { 1063 + "name": "created_at", 1064 + "type": "timestamp", 1065 + "primaryKey": false, 1066 + "notNull": true 1067 + } 1068 + }, 1069 + "indexes": { 1070 + "idx_following_feed_user_time": { 1071 + "name": "idx_following_feed_user_time", 1072 + "columns": [ 1073 + { 1074 + "expression": "user_id", 1075 + "isExpression": false, 1076 + "asc": true, 1077 + "nulls": "last" 1078 + }, 1079 + { 1080 + "expression": "created_at", 1081 + "isExpression": false, 1082 + "asc": false, 1083 + "nulls": "last" 1084 + } 1085 + ], 1086 + "isUnique": false, 1087 + "concurrently": false, 1088 + "method": "btree", 1089 + "with": {} 1090 + } 1091 + }, 1092 + "foreignKeys": { 1093 + "following_feed_items_activity_id_feed_activities_id_fk": { 1094 + "name": "following_feed_items_activity_id_feed_activities_id_fk", 1095 + "tableFrom": "following_feed_items", 1096 + "tableTo": "feed_activities", 1097 + "columnsFrom": ["activity_id"], 1098 + "columnsTo": ["id"], 1099 + "onDelete": "cascade", 1100 + "onUpdate": "no action" 1101 + } 1102 + }, 1103 + "compositePrimaryKeys": { 1104 + "following_feed_items_user_id_activity_id_pk": { 1105 + "name": "following_feed_items_user_id_activity_id_pk", 1106 + "columns": ["user_id", "activity_id"] 1107 + } 1108 + }, 1109 + "uniqueConstraints": {}, 1110 + "policies": {}, 1111 + "checkConstraints": {}, 1112 + "isRLSEnabled": false 1113 + }, 1114 + "public.notifications": { 1115 + "name": "notifications", 1116 + "schema": "", 1117 + "columns": { 1118 + "id": { 1119 + "name": "id", 1120 + "type": "uuid", 1121 + "primaryKey": true, 1122 + "notNull": true 1123 + }, 1124 + "recipient_user_id": { 1125 + "name": "recipient_user_id", 1126 + "type": "text", 1127 + "primaryKey": false, 1128 + "notNull": true 1129 + }, 1130 + "actor_user_id": { 1131 + "name": "actor_user_id", 1132 + "type": "text", 1133 + "primaryKey": false, 1134 + "notNull": true 1135 + }, 1136 + "type": { 1137 + "name": "type", 1138 + "type": "text", 1139 + "primaryKey": false, 1140 + "notNull": true 1141 + }, 1142 + "metadata": { 1143 + "name": "metadata", 1144 + "type": "jsonb", 1145 + "primaryKey": false, 1146 + "notNull": true 1147 + }, 1148 + "read": { 1149 + "name": "read", 1150 + "type": "boolean", 1151 + "primaryKey": false, 1152 + "notNull": true, 1153 + "default": false 1154 + }, 1155 + "created_at": { 1156 + "name": "created_at", 1157 + "type": "timestamp", 1158 + "primaryKey": false, 1159 + "notNull": true, 1160 + "default": "now()" 1161 + }, 1162 + "updated_at": { 1163 + "name": "updated_at", 1164 + "type": "timestamp", 1165 + "primaryKey": false, 1166 + "notNull": true, 1167 + "default": "now()" 1168 + } 1169 + }, 1170 + "indexes": { 1171 + "notifications_recipient_idx": { 1172 + "name": "notifications_recipient_idx", 1173 + "columns": [ 1174 + { 1175 + "expression": "recipient_user_id", 1176 + "isExpression": false, 1177 + "asc": true, 1178 + "nulls": "last" 1179 + } 1180 + ], 1181 + "isUnique": false, 1182 + "concurrently": false, 1183 + "method": "btree", 1184 + "with": {} 1185 + }, 1186 + "notifications_recipient_created_at_idx": { 1187 + "name": "notifications_recipient_created_at_idx", 1188 + "columns": [ 1189 + { 1190 + "expression": "recipient_user_id", 1191 + "isExpression": false, 1192 + "asc": true, 1193 + "nulls": "last" 1194 + }, 1195 + { 1196 + "expression": "created_at", 1197 + "isExpression": false, 1198 + "asc": false, 1199 + "nulls": "last" 1200 + } 1201 + ], 1202 + "isUnique": false, 1203 + "concurrently": false, 1204 + "method": "btree", 1205 + "with": {} 1206 + }, 1207 + "notifications_recipient_read_idx": { 1208 + "name": "notifications_recipient_read_idx", 1209 + "columns": [ 1210 + { 1211 + "expression": "recipient_user_id", 1212 + "isExpression": false, 1213 + "asc": true, 1214 + "nulls": "last" 1215 + }, 1216 + { 1217 + "expression": "read", 1218 + "isExpression": false, 1219 + "asc": true, 1220 + "nulls": "last" 1221 + } 1222 + ], 1223 + "isUnique": false, 1224 + "concurrently": false, 1225 + "method": "btree", 1226 + "with": {} 1227 + } 1228 + }, 1229 + "foreignKeys": {}, 1230 + "compositePrimaryKeys": {}, 1231 + "uniqueConstraints": {}, 1232 + "policies": {}, 1233 + "checkConstraints": {}, 1234 + "isRLSEnabled": false 1235 + }, 1236 + "public.sync_statuses": { 1237 + "name": "sync_statuses", 1238 + "schema": "", 1239 + "columns": { 1240 + "id": { 1241 + "name": "id", 1242 + "type": "uuid", 1243 + "primaryKey": true, 1244 + "notNull": true, 1245 + "default": "gen_random_uuid()" 1246 + }, 1247 + "curator_id": { 1248 + "name": "curator_id", 1249 + "type": "text", 1250 + "primaryKey": false, 1251 + "notNull": true 1252 + }, 1253 + "sync_state": { 1254 + "name": "sync_state", 1255 + "type": "text", 1256 + "primaryKey": false, 1257 + "notNull": true 1258 + }, 1259 + "last_synced_at": { 1260 + "name": "last_synced_at", 1261 + "type": "timestamp", 1262 + "primaryKey": false, 1263 + "notNull": false 1264 + }, 1265 + "last_sync_attempt_at": { 1266 + "name": "last_sync_attempt_at", 1267 + "type": "timestamp", 1268 + "primaryKey": false, 1269 + "notNull": false 1270 + }, 1271 + "sync_error_message": { 1272 + "name": "sync_error_message", 1273 + "type": "text", 1274 + "primaryKey": false, 1275 + "notNull": false 1276 + }, 1277 + "records_processed": { 1278 + "name": "records_processed", 1279 + "type": "integer", 1280 + "primaryKey": false, 1281 + "notNull": false 1282 + }, 1283 + "created_at": { 1284 + "name": "created_at", 1285 + "type": "timestamp", 1286 + "primaryKey": false, 1287 + "notNull": true, 1288 + "default": "now()" 1289 + }, 1290 + "updated_at": { 1291 + "name": "updated_at", 1292 + "type": "timestamp", 1293 + "primaryKey": false, 1294 + "notNull": true, 1295 + "default": "now()" 1296 + } 1297 + }, 1298 + "indexes": {}, 1299 + "foreignKeys": {}, 1300 + "compositePrimaryKeys": {}, 1301 + "uniqueConstraints": { 1302 + "sync_statuses_curator_id_unique": { 1303 + "name": "sync_statuses_curator_id_unique", 1304 + "nullsNotDistinct": false, 1305 + "columns": ["curator_id"] 1306 + } 1307 + }, 1308 + "policies": {}, 1309 + "checkConstraints": {}, 1310 + "isRLSEnabled": false 1311 + }, 1312 + "public.auth_session": { 1313 + "name": "auth_session", 1314 + "schema": "", 1315 + "columns": { 1316 + "key": { 1317 + "name": "key", 1318 + "type": "text", 1319 + "primaryKey": true, 1320 + "notNull": true 1321 + }, 1322 + "session": { 1323 + "name": "session", 1324 + "type": "text", 1325 + "primaryKey": false, 1326 + "notNull": true 1327 + } 1328 + }, 1329 + "indexes": {}, 1330 + "foreignKeys": {}, 1331 + "compositePrimaryKeys": {}, 1332 + "uniqueConstraints": {}, 1333 + "policies": {}, 1334 + "checkConstraints": {}, 1335 + "isRLSEnabled": false 1336 + }, 1337 + "public.auth_state": { 1338 + "name": "auth_state", 1339 + "schema": "", 1340 + "columns": { 1341 + "key": { 1342 + "name": "key", 1343 + "type": "text", 1344 + "primaryKey": true, 1345 + "notNull": true 1346 + }, 1347 + "state": { 1348 + "name": "state", 1349 + "type": "text", 1350 + "primaryKey": false, 1351 + "notNull": true 1352 + }, 1353 + "created_at": { 1354 + "name": "created_at", 1355 + "type": "timestamp", 1356 + "primaryKey": false, 1357 + "notNull": false, 1358 + "default": "now()" 1359 + } 1360 + }, 1361 + "indexes": {}, 1362 + "foreignKeys": {}, 1363 + "compositePrimaryKeys": {}, 1364 + "uniqueConstraints": {}, 1365 + "policies": {}, 1366 + "checkConstraints": {}, 1367 + "isRLSEnabled": false 1368 + }, 1369 + "public.auth_refresh_tokens": { 1370 + "name": "auth_refresh_tokens", 1371 + "schema": "", 1372 + "columns": { 1373 + "token_id": { 1374 + "name": "token_id", 1375 + "type": "text", 1376 + "primaryKey": true, 1377 + "notNull": true 1378 + }, 1379 + "user_did": { 1380 + "name": "user_did", 1381 + "type": "text", 1382 + "primaryKey": false, 1383 + "notNull": true 1384 + }, 1385 + "refresh_token": { 1386 + "name": "refresh_token", 1387 + "type": "text", 1388 + "primaryKey": false, 1389 + "notNull": true 1390 + }, 1391 + "issued_at": { 1392 + "name": "issued_at", 1393 + "type": "timestamp", 1394 + "primaryKey": false, 1395 + "notNull": true 1396 + }, 1397 + "expires_at": { 1398 + "name": "expires_at", 1399 + "type": "timestamp", 1400 + "primaryKey": false, 1401 + "notNull": true 1402 + }, 1403 + "revoked": { 1404 + "name": "revoked", 1405 + "type": "boolean", 1406 + "primaryKey": false, 1407 + "notNull": false, 1408 + "default": false 1409 + } 1410 + }, 1411 + "indexes": {}, 1412 + "foreignKeys": { 1413 + "auth_refresh_tokens_user_did_users_id_fk": { 1414 + "name": "auth_refresh_tokens_user_did_users_id_fk", 1415 + "tableFrom": "auth_refresh_tokens", 1416 + "tableTo": "users", 1417 + "columnsFrom": ["user_did"], 1418 + "columnsTo": ["id"], 1419 + "onDelete": "no action", 1420 + "onUpdate": "no action" 1421 + } 1422 + }, 1423 + "compositePrimaryKeys": {}, 1424 + "uniqueConstraints": {}, 1425 + "policies": {}, 1426 + "checkConstraints": {}, 1427 + "isRLSEnabled": false 1428 + }, 1429 + "public.follows": { 1430 + "name": "follows", 1431 + "schema": "", 1432 + "columns": { 1433 + "follower_id": { 1434 + "name": "follower_id", 1435 + "type": "text", 1436 + "primaryKey": false, 1437 + "notNull": true 1438 + }, 1439 + "target_id": { 1440 + "name": "target_id", 1441 + "type": "text", 1442 + "primaryKey": false, 1443 + "notNull": true 1444 + }, 1445 + "target_type": { 1446 + "name": "target_type", 1447 + "type": "text", 1448 + "primaryKey": false, 1449 + "notNull": true 1450 + }, 1451 + "created_at": { 1452 + "name": "created_at", 1453 + "type": "timestamp", 1454 + "primaryKey": false, 1455 + "notNull": true, 1456 + "default": "now()" 1457 + } 1458 + }, 1459 + "indexes": { 1460 + "idx_follows_follower": { 1461 + "name": "idx_follows_follower", 1462 + "columns": [ 1463 + { 1464 + "expression": "follower_id", 1465 + "isExpression": false, 1466 + "asc": true, 1467 + "nulls": "last" 1468 + } 1469 + ], 1470 + "isUnique": false, 1471 + "concurrently": false, 1472 + "method": "btree", 1473 + "with": {} 1474 + }, 1475 + "idx_follows_target": { 1476 + "name": "idx_follows_target", 1477 + "columns": [ 1478 + { 1479 + "expression": "target_id", 1480 + "isExpression": false, 1481 + "asc": true, 1482 + "nulls": "last" 1483 + }, 1484 + { 1485 + "expression": "target_type", 1486 + "isExpression": false, 1487 + "asc": true, 1488 + "nulls": "last" 1489 + } 1490 + ], 1491 + "isUnique": false, 1492 + "concurrently": false, 1493 + "method": "btree", 1494 + "with": {} 1495 + } 1496 + }, 1497 + "foreignKeys": {}, 1498 + "compositePrimaryKeys": { 1499 + "follows_follower_id_target_id_target_type_pk": { 1500 + "name": "follows_follower_id_target_id_target_type_pk", 1501 + "columns": ["follower_id", "target_id", "target_type"] 1502 + } 1503 + }, 1504 + "uniqueConstraints": {}, 1505 + "policies": {}, 1506 + "checkConstraints": {}, 1507 + "isRLSEnabled": false 1508 + }, 1509 + "public.users": { 1510 + "name": "users", 1511 + "schema": "", 1512 + "columns": { 1513 + "id": { 1514 + "name": "id", 1515 + "type": "text", 1516 + "primaryKey": true, 1517 + "notNull": true 1518 + }, 1519 + "handle": { 1520 + "name": "handle", 1521 + "type": "text", 1522 + "primaryKey": false, 1523 + "notNull": false 1524 + }, 1525 + "linked_at": { 1526 + "name": "linked_at", 1527 + "type": "timestamp", 1528 + "primaryKey": false, 1529 + "notNull": true 1530 + }, 1531 + "last_login_at": { 1532 + "name": "last_login_at", 1533 + "type": "timestamp", 1534 + "primaryKey": false, 1535 + "notNull": true 1536 + } 1537 + }, 1538 + "indexes": {}, 1539 + "foreignKeys": {}, 1540 + "compositePrimaryKeys": {}, 1541 + "uniqueConstraints": {}, 1542 + "policies": {}, 1543 + "checkConstraints": {}, 1544 + "isRLSEnabled": false 1545 + } 1546 + }, 1547 + "enums": {}, 1548 + "schemas": {}, 1549 + "sequences": {}, 1550 + "roles": {}, 1551 + "policies": {}, 1552 + "views": {}, 1553 + "_meta": { 1554 + "columns": {}, 1555 + "schemas": {}, 1556 + "tables": {} 1557 + } 1558 + }
+7
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 113 113 "when": 1770074100791, 114 114 "tag": "0015_small_loki", 115 115 "breakpoints": true 116 + }, 117 + { 118 + "idx": 16, 119 + "version": "7", 120 + "when": 1770427721556, 121 + "tag": "0016_ancient_cerise", 122 + "breakpoints": true 116 123 } 117 124 ] 118 125 }
+7
src/shared/infrastructure/http/factories/RepositoryFactory.ts
··· 42 42 import { ISyncStatusRepository } from '../../../../modules/sync/domain/repositories/ISyncStatusRepository'; 43 43 import { DrizzleSyncStatusRepository } from '../../../../modules/sync/infrastructure/repositories/DrizzleSyncStatusRepository'; 44 44 import { InMemorySyncStatusRepository } from '../../../../modules/sync/tests/infrastructure/InMemorySyncStatusRepository'; 45 + import { IFollowsRepository } from '../../../../modules/user/domain/repositories/IFollowsRepository'; 46 + import { DrizzleFollowsRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleFollowsRepository'; 47 + import { InMemoryFollowsRepository } from '../../../../modules/user/tests/infrastructure/InMemoryFollowsRepository'; 45 48 46 49 export interface Repositories { 47 50 userRepository: IUserRepository; ··· 52 55 collectionQueryRepository: ICollectionQueryRepository; 53 56 appPasswordSessionRepository: IAppPasswordSessionRepository; 54 57 feedRepository: IFeedRepository; 58 + followsRepository: IFollowsRepository; 55 59 notificationRepository: INotificationRepository; 56 60 syncStatusRepository: ISyncStatusRepository; 57 61 atUriResolutionService: IAtUriResolutionService; ··· 80 84 const appPasswordSessionRepository = 81 85 InMemoryAppPasswordSessionRepository.getInstance(); 82 86 const feedRepository = InMemoryFeedRepository.getInstance(); 87 + const followsRepository = InMemoryFollowsRepository.getInstance(); 83 88 const atUriResolutionService = new InMemoryAtUriResolutionService( 84 89 collectionRepository, 85 90 cardRepository, ··· 101 106 collectionQueryRepository, 102 107 appPasswordSessionRepository, 103 108 feedRepository, 109 + followsRepository, 104 110 notificationRepository, 105 111 syncStatusRepository, 106 112 atUriResolutionService, ··· 125 131 collectionQueryRepository: new DrizzleCollectionQueryRepository(db), 126 132 appPasswordSessionRepository: new DrizzleAppPasswordSessionRepository(db), 127 133 feedRepository: new DrizzleFeedRepository(db), 134 + followsRepository: new DrizzleFollowsRepository(db), 128 135 notificationRepository: new DrizzleNotificationRepository(db), 129 136 syncStatusRepository: new DrizzleSyncStatusRepository(db), 130 137 atUriResolutionService: new DrizzleAtUriResolutionService(db),
+4
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 299 299 addActivityToFeedUseCase: new AddActivityToFeedUseCase( 300 300 services.feedService, 301 301 repositories.cardRepository, 302 + repositories.followsRepository, 303 + repositories.feedRepository, 302 304 ), 303 305 // Search use cases 304 306 getSimilarUrlsForUrlUseCase: new GetSimilarUrlsForUrlUseCase( ··· 349 351 const addActivityToFeedUseCase = new AddActivityToFeedUseCase( 350 352 services.feedService, 351 353 repositories.cardRepository, 354 + repositories.followsRepository, 355 + repositories.feedRepository, 352 356 ); 353 357 354 358 // Search use cases