This repository has no description
0

Configure Feed

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

initial implementation of sync subdomain and basic usecase that handles domain event

+2048 -1
+15
.agent/logs/202601229_domain_event_handling_guide.md
··· 325 325 7. `src/shared/infrastructure/http/factories/UseCaseFactory.ts` — Pass eventPublisher 326 326 8. `src/modules/<module>/application/eventHandlers/...Handler.ts` — Create handler 327 327 9. `src/shared/infrastructure/processes/<Worker>Process.ts` — Register handler 328 + 329 + ## Build steps: 330 + 331 + make sure to update `tsup.config.ts`: 332 + which has the `entry` field: 333 + 334 + ```ts 335 + entry: { 336 + index: 'src/index.ts', 337 + 'workers/feed-worker': 'src/workers/feed-worker.ts', 338 + 'workers/search-worker': 'src/workers/search-worker.ts', 339 + 'workers/firehose-worker': 'src/workers/firehose-worker.ts', 340 + 'workers/notification-worker': 'src/workers/notification-worker.ts', 341 + }, 342 + ```
+7
fly.development.toml
··· 9 9 search-worker = "npm run worker:search" 10 10 firehose-worker = "npm run worker:firehose" 11 11 notification-worker = "npm run worker:notifications" 12 + sync-worker = "npm run worker:sync" 12 13 13 14 [http_service] 14 15 internal_port = 3000 ··· 46 47 [[vm]] 47 48 processes = ['notification-worker'] 48 49 memory = '256mb' 50 + cpu_kind = 'shared' 51 + cpus = 1 52 + 53 + [[vm]] 54 + processes = ['sync-worker'] 55 + memory = '512mb' 49 56 cpu_kind = 'shared' 50 57 cpus = 1 51 58
+7
fly.production.toml
··· 9 9 search-worker = "npm run worker:search" 10 10 firehose-worker = "npm run worker:firehose" 11 11 notification-worker = "npm run worker:notifications" 12 + sync-worker = "npm run worker:sync" 12 13 13 14 [http_service] 14 15 internal_port = 3000 ··· 47 48 [[vm]] 48 49 processes = ['notification-worker'] 49 50 memory = '512mb' 51 + cpu_kind = 'shared' 52 + cpus = 1 53 + 54 + [[vm]] 55 + processes = ['sync-worker'] 56 + memory = '1gb' 50 57 cpu_kind = 'shared' 51 58 cpus = 1 52 59
+1
package.json
··· 21 21 "worker:search": "node dist/workers/search-worker.js", 22 22 "worker:firehose": "node dist/workers/firehose-worker.js", 23 23 "worker:notifications": "node dist/workers/notification-worker.js", 24 + "worker:sync": "node dist/workers/sync-worker.js", 24 25 "test": "jest", 25 26 "test:unit": "jest --testPathIgnorePatterns='.integration.test.' --testPathIgnorePatterns='.e2e.test.'", 26 27 "test:integration": "jest --testPathPattern='.integration.test.'",
+13
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 96 96 created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 97 97 updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 98 98 )`, 99 + 100 + // Sync statuses table (no dependencies) 101 + sql`CREATE TABLE IF NOT EXISTS sync_statuses ( 102 + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 103 + curator_id TEXT NOT NULL UNIQUE, 104 + sync_state TEXT NOT NULL, 105 + last_synced_at TIMESTAMP WITH TIME ZONE, 106 + last_sync_attempt_at TIMESTAMP WITH TIME ZONE, 107 + sync_error_message TEXT, 108 + records_processed INTEGER, 109 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 110 + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 111 + )`, 99 112 ]; 100 113 101 114 // Execute table creation queries in order
+22
src/modules/sync/application/eventHandlers/CardAddedToLibraryEventHandler.ts
··· 1 + import { CardAddedToLibraryEvent } from '../../../cards/domain/events/CardAddedToLibraryEvent'; 2 + import { IEventHandler } from '../../../../shared/application/events/IEventSubscriber'; 3 + import { Result } from '../../../../shared/core/Result'; 4 + import { SyncAccountDataUseCase } from '../useCases/SyncAccountDataUseCase'; 5 + 6 + export class CardAddedToLibraryEventHandler 7 + implements IEventHandler<CardAddedToLibraryEvent> 8 + { 9 + constructor(private syncAccountDataUseCase: SyncAccountDataUseCase) {} 10 + 11 + async handle(event: CardAddedToLibraryEvent): Promise<Result<void>> { 12 + console.log( 13 + `[SYNC] Handling CardAddedToLibraryEvent for curator: ${event.curatorId.value}`, 14 + ); 15 + 16 + // Pass the curator ID from the event to the sync use case 17 + return this.syncAccountDataUseCase.execute({ 18 + curatorId: event.curatorId.value, 19 + cardId: event.cardId.getStringValue(), 20 + }); 21 + } 22 + }
+141
src/modules/sync/application/useCases/SyncAccountDataUseCase.ts
··· 1 + import { Result, ok, err } from '../../../../shared/core/Result'; 2 + import { UseCase } from '../../../../shared/core/UseCase'; 3 + import { UseCaseError } from '../../../../shared/core/UseCaseError'; 4 + import { AppError } from '../../../../shared/core/AppError'; 5 + import { ISyncStatusRepository } from '../../domain/repositories/ISyncStatusRepository'; 6 + import { DID } from '../../../user/domain/value-objects/DID'; 7 + import { SyncStatus } from '../../domain/SyncStatus'; 8 + 9 + export interface SyncAccountDataDTO { 10 + curatorId: string; 11 + cardId: string; 12 + } 13 + 14 + export class ValidationError extends UseCaseError { 15 + constructor(message: string) { 16 + super(message); 17 + } 18 + } 19 + 20 + export class SyncAccountDataUseCase 21 + implements 22 + UseCase< 23 + SyncAccountDataDTO, 24 + Result<void, ValidationError | AppError.UnexpectedError> 25 + > 26 + { 27 + constructor(private syncStatusRepo: ISyncStatusRepository) {} 28 + 29 + async execute( 30 + request: SyncAccountDataDTO, 31 + ): Promise<Result<void, ValidationError | AppError.UnexpectedError>> { 32 + try { 33 + console.log( 34 + `[SYNC] SyncAccountDataUseCase triggered for curator: ${request.curatorId}, card: ${request.cardId}`, 35 + ); 36 + 37 + // Create DID value object 38 + const didResult = DID.create(request.curatorId); 39 + if (didResult.isErr()) { 40 + return err(new ValidationError(didResult.error.message)); 41 + } 42 + const curatorId = didResult.value; 43 + 44 + // Check if user has already been synced 45 + const syncStatusResult = 46 + await this.syncStatusRepo.findByCuratorId(curatorId); 47 + if (syncStatusResult.isErr()) { 48 + console.error( 49 + '[SYNC] Error fetching sync status:', 50 + syncStatusResult.error, 51 + ); 52 + return err(AppError.UnexpectedError.create(syncStatusResult.error)); 53 + } 54 + 55 + const existingSyncStatus = syncStatusResult.value; 56 + 57 + if (existingSyncStatus && existingSyncStatus.syncState.isCompleted()) { 58 + console.log( 59 + `[SYNC] User ${request.curatorId} has already been synced, skipping`, 60 + ); 61 + return ok(undefined); 62 + } 63 + 64 + // Create or update sync status to mark in progress 65 + let syncStatus: SyncStatus; 66 + if (existingSyncStatus) { 67 + syncStatus = existingSyncStatus; 68 + syncStatus.markSyncInProgress(); 69 + } else { 70 + const newSyncStatusResult = SyncStatus.createNew(curatorId); 71 + if (newSyncStatusResult.isErr()) { 72 + return err( 73 + AppError.UnexpectedError.create(newSyncStatusResult.error), 74 + ); 75 + } 76 + syncStatus = newSyncStatusResult.value; 77 + syncStatus.markSyncInProgress(); 78 + } 79 + 80 + // Save sync status 81 + const saveResult = await this.syncStatusRepo.save(syncStatus); 82 + if (saveResult.isErr()) { 83 + console.error('[SYNC] Error saving sync status:', saveResult.error); 84 + return err(AppError.UnexpectedError.create(saveResult.error)); 85 + } 86 + 87 + console.log( 88 + `[SYNC] Starting sync process for user ${request.curatorId}...`, 89 + ); 90 + 91 + // TODO: Trigger the actual sync process 92 + // This would involve: 93 + // 1. Getting an authenticated agent for the user 94 + // 2. Fetching records from their ATProto repository 95 + // 3. Transforming records into cards/collections 96 + // 4. Saving them to the database 97 + const recordsProcessed = await this.performSync(request.curatorId); 98 + 99 + // Mark sync as completed 100 + syncStatus.markSyncCompleted(recordsProcessed); 101 + const finalSaveResult = await this.syncStatusRepo.save(syncStatus); 102 + if (finalSaveResult.isErr()) { 103 + console.error( 104 + '[SYNC] Error saving final sync status:', 105 + finalSaveResult.error, 106 + ); 107 + // Don't fail the whole operation if we can't save the status 108 + } 109 + 110 + console.log( 111 + `[SYNC] Sync process completed for user ${request.curatorId}`, 112 + ); 113 + 114 + return ok(undefined); 115 + } catch (error) { 116 + console.error('[SYNC] Error in SyncAccountDataUseCase:', error); 117 + return err(AppError.UnexpectedError.create(error)); 118 + } 119 + } 120 + 121 + /** 122 + * Perform the actual sync process for a user 123 + * TODO: Implement actual sync logic 124 + */ 125 + private async performSync(curatorId: string): Promise<number> { 126 + // Stub implementation 127 + console.log(`[SYNC] Performing sync for user ${curatorId} (stub)`); 128 + 129 + // Future implementation would: 130 + // 1. Get authenticated agent 131 + // 2. Call agent.com.atproto.repo.listRecords for each collection type 132 + // 3. Transform and save records 133 + // 4. Return count of records processed 134 + 135 + // Simulate async operation 136 + await new Promise((resolve) => setTimeout(resolve, 100)); 137 + 138 + // Return stub count 139 + return 0; 140 + } 141 + }
+112
src/modules/sync/domain/SyncStatus.ts
··· 1 + import { AggregateRoot } from 'src/shared/domain/AggregateRoot'; 2 + import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; 3 + import { Guard, IGuardArgument } from 'src/shared/core/Guard'; 4 + import { err, ok, Result } from 'src/shared/core/Result'; 5 + import { DID } from 'src/modules/user/domain/value-objects/DID'; 6 + import { SyncState } from './value-objects/SyncState'; 7 + 8 + export interface SyncStatusProps { 9 + curatorId: DID; 10 + syncState: SyncState; 11 + lastSyncedAt?: Date; 12 + lastSyncAttemptAt?: Date; 13 + syncErrorMessage?: string; 14 + recordsProcessed?: number; 15 + createdAt: Date; 16 + updatedAt: Date; 17 + } 18 + 19 + export class SyncStatus extends AggregateRoot<SyncStatusProps> { 20 + get syncStatusId(): UniqueEntityID { 21 + return this._id; 22 + } 23 + 24 + get curatorId(): DID { 25 + return this.props.curatorId; 26 + } 27 + 28 + get syncState(): SyncState { 29 + return this.props.syncState; 30 + } 31 + 32 + get lastSyncedAt(): Date | undefined { 33 + return this.props.lastSyncedAt; 34 + } 35 + 36 + get lastSyncAttemptAt(): Date | undefined { 37 + return this.props.lastSyncAttemptAt; 38 + } 39 + 40 + get syncErrorMessage(): string | undefined { 41 + return this.props.syncErrorMessage; 42 + } 43 + 44 + get recordsProcessed(): number | undefined { 45 + return this.props.recordsProcessed; 46 + } 47 + 48 + get createdAt(): Date { 49 + return this.props.createdAt; 50 + } 51 + 52 + get updatedAt(): Date { 53 + return this.props.updatedAt; 54 + } 55 + 56 + public markSyncInProgress(): void { 57 + this.props.syncState = SyncState.inProgress(); 58 + this.props.lastSyncAttemptAt = new Date(); 59 + this.props.updatedAt = new Date(); 60 + } 61 + 62 + public markSyncCompleted(recordsProcessed: number): void { 63 + this.props.syncState = SyncState.completed(); 64 + this.props.lastSyncedAt = new Date(); 65 + this.props.recordsProcessed = recordsProcessed; 66 + this.props.syncErrorMessage = undefined; 67 + this.props.updatedAt = new Date(); 68 + } 69 + 70 + public markSyncFailed(errorMessage: string): void { 71 + this.props.syncState = SyncState.failed(); 72 + this.props.syncErrorMessage = errorMessage; 73 + this.props.updatedAt = new Date(); 74 + } 75 + 76 + private constructor(props: SyncStatusProps, id?: UniqueEntityID) { 77 + super(props, id); 78 + } 79 + 80 + public static create( 81 + props: SyncStatusProps, 82 + id?: UniqueEntityID, 83 + ): Result<SyncStatus> { 84 + const guardArgs: IGuardArgument[] = [ 85 + { argument: props.curatorId, argumentName: 'curatorId' }, 86 + { argument: props.syncState, argumentName: 'syncState' }, 87 + { argument: props.createdAt, argumentName: 'createdAt' }, 88 + { argument: props.updatedAt, argumentName: 'updatedAt' }, 89 + ]; 90 + 91 + const guardResult = Guard.againstNullOrUndefinedBulk(guardArgs); 92 + 93 + if (guardResult.isErr()) { 94 + return err(new Error(guardResult.error)); 95 + } 96 + 97 + const syncStatus = new SyncStatus(props, id); 98 + 99 + return ok(syncStatus); 100 + } 101 + 102 + public static createNew(curatorId: DID): Result<SyncStatus> { 103 + const now = new Date(); 104 + 105 + return SyncStatus.create({ 106 + curatorId, 107 + syncState: SyncState.notSynced(), 108 + createdAt: now, 109 + updatedAt: now, 110 + }); 111 + } 112 + }
+8
src/modules/sync/domain/repositories/ISyncStatusRepository.ts
··· 1 + import { Result } from 'src/shared/core/Result'; 2 + import { SyncStatus } from '../SyncStatus'; 3 + import { DID } from 'src/modules/user/domain/value-objects/DID'; 4 + 5 + export interface ISyncStatusRepository { 6 + findByCuratorId(curatorId: DID): Promise<Result<SyncStatus | null>>; 7 + save(syncStatus: SyncStatus): Promise<Result<void>>; 8 + }
+64
src/modules/sync/domain/value-objects/SyncState.ts
··· 1 + import { Result, ok, err } from 'src/shared/core/Result'; 2 + 3 + export enum SyncStateEnum { 4 + NOT_SYNCED = 'NOT_SYNCED', 5 + IN_PROGRESS = 'IN_PROGRESS', 6 + COMPLETED = 'COMPLETED', 7 + FAILED = 'FAILED', 8 + } 9 + 10 + export class SyncState { 11 + private constructor(private readonly _value: SyncStateEnum) {} 12 + 13 + get value(): SyncStateEnum { 14 + return this._value; 15 + } 16 + 17 + public static create(state: string): Result<SyncState> { 18 + const upperState = state.toUpperCase(); 19 + if (!Object.values(SyncStateEnum).includes(upperState as SyncStateEnum)) { 20 + return err( 21 + new Error( 22 + `Invalid sync state: ${state}. Must be one of: ${Object.values(SyncStateEnum).join(', ')}`, 23 + ), 24 + ); 25 + } 26 + return ok(new SyncState(upperState as SyncStateEnum)); 27 + } 28 + 29 + public static notSynced(): SyncState { 30 + return new SyncState(SyncStateEnum.NOT_SYNCED); 31 + } 32 + 33 + public static inProgress(): SyncState { 34 + return new SyncState(SyncStateEnum.IN_PROGRESS); 35 + } 36 + 37 + public static completed(): SyncState { 38 + return new SyncState(SyncStateEnum.COMPLETED); 39 + } 40 + 41 + public static failed(): SyncState { 42 + return new SyncState(SyncStateEnum.FAILED); 43 + } 44 + 45 + public isNotSynced(): boolean { 46 + return this._value === SyncStateEnum.NOT_SYNCED; 47 + } 48 + 49 + public isInProgress(): boolean { 50 + return this._value === SyncStateEnum.IN_PROGRESS; 51 + } 52 + 53 + public isCompleted(): boolean { 54 + return this._value === SyncStateEnum.COMPLETED; 55 + } 56 + 57 + public isFailed(): boolean { 58 + return this._value === SyncStateEnum.FAILED; 59 + } 60 + 61 + public equals(other: SyncState): boolean { 62 + return this._value === other._value; 63 + } 64 + }
+95
src/modules/sync/infrastructure/repositories/DrizzleSyncStatusRepository.ts
··· 1 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 2 + import { eq } from 'drizzle-orm'; 3 + import { ISyncStatusRepository } from '../../domain/repositories/ISyncStatusRepository'; 4 + import { SyncStatus } from '../../domain/SyncStatus'; 5 + import { DID } from 'src/modules/user/domain/value-objects/DID'; 6 + import { SyncState } from '../../domain/value-objects/SyncState'; 7 + import { syncStatuses } from './schema/syncStatus.sql'; 8 + import { UniqueEntityID } from 'src/shared/domain/UniqueEntityID'; 9 + import { err, ok, Result } from 'src/shared/core/Result'; 10 + 11 + export class DrizzleSyncStatusRepository implements ISyncStatusRepository { 12 + constructor(private db: PostgresJsDatabase) {} 13 + 14 + async findByCuratorId(curatorId: DID): Promise<Result<SyncStatus | null>> { 15 + try { 16 + const result = await this.db 17 + .select() 18 + .from(syncStatuses) 19 + .where(eq(syncStatuses.curatorId, curatorId.value)) 20 + .limit(1); 21 + 22 + if (result.length === 0) { 23 + return ok(null); 24 + } 25 + 26 + const data = result[0]; 27 + 28 + if (!data) { 29 + return ok(null); 30 + } 31 + 32 + // Create sync state value object 33 + const syncStateResult = SyncState.create(data.syncState); 34 + if (syncStateResult.isErr()) { 35 + return err(syncStateResult.error); 36 + } 37 + 38 + // Create sync status entity 39 + const syncStatusResult = SyncStatus.create( 40 + { 41 + curatorId, 42 + syncState: syncStateResult.value, 43 + lastSyncedAt: data.lastSyncedAt ?? undefined, 44 + lastSyncAttemptAt: data.lastSyncAttemptAt ?? undefined, 45 + syncErrorMessage: data.syncErrorMessage ?? undefined, 46 + recordsProcessed: data.recordsProcessed ?? undefined, 47 + createdAt: data.createdAt, 48 + updatedAt: data.updatedAt, 49 + }, 50 + new UniqueEntityID(data.id), 51 + ); 52 + 53 + if (syncStatusResult.isErr()) { 54 + return err(syncStatusResult.error); 55 + } 56 + 57 + return ok(syncStatusResult.value); 58 + } catch (error: any) { 59 + return err(error); 60 + } 61 + } 62 + 63 + async save(syncStatus: SyncStatus): Promise<Result<void>> { 64 + try { 65 + await this.db 66 + .insert(syncStatuses) 67 + .values({ 68 + id: syncStatus.syncStatusId.toString(), 69 + curatorId: syncStatus.curatorId.value, 70 + syncState: syncStatus.syncState.value, 71 + lastSyncedAt: syncStatus.lastSyncedAt ?? null, 72 + lastSyncAttemptAt: syncStatus.lastSyncAttemptAt ?? null, 73 + syncErrorMessage: syncStatus.syncErrorMessage ?? null, 74 + recordsProcessed: syncStatus.recordsProcessed ?? null, 75 + createdAt: syncStatus.createdAt, 76 + updatedAt: syncStatus.updatedAt, 77 + }) 78 + .onConflictDoUpdate({ 79 + target: syncStatuses.curatorId, 80 + set: { 81 + syncState: syncStatus.syncState.value, 82 + lastSyncedAt: syncStatus.lastSyncedAt ?? null, 83 + lastSyncAttemptAt: syncStatus.lastSyncAttemptAt ?? null, 84 + syncErrorMessage: syncStatus.syncErrorMessage ?? null, 85 + recordsProcessed: syncStatus.recordsProcessed ?? null, 86 + updatedAt: syncStatus.updatedAt, 87 + }, 88 + }); 89 + 90 + return ok(undefined); 91 + } catch (error: any) { 92 + return err(error); 93 + } 94 + } 95 + }
+13
src/modules/sync/infrastructure/repositories/schema/syncStatus.sql.ts
··· 1 + import { pgTable, text, timestamp, integer, uuid } from 'drizzle-orm/pg-core'; 2 + 3 + export const syncStatuses = pgTable('sync_statuses', { 4 + id: uuid('id').primaryKey().defaultRandom(), 5 + curatorId: text('curator_id').notNull().unique(), 6 + syncState: text('sync_state').notNull(), 7 + lastSyncedAt: timestamp('last_synced_at'), 8 + lastSyncAttemptAt: timestamp('last_sync_attempt_at'), 9 + syncErrorMessage: text('sync_error_message'), 10 + recordsProcessed: integer('records_processed'), 11 + createdAt: timestamp('created_at').notNull().defaultNow(), 12 + updatedAt: timestamp('updated_at').notNull().defaultNow(), 13 + });
+42
src/modules/sync/tests/infrastructure/InMemorySyncStatusRepository.ts
··· 1 + import { Result, ok, err } from 'src/shared/core/Result'; 2 + import { SyncStatus } from '../../domain/SyncStatus'; 3 + import { ISyncStatusRepository } from '../../domain/repositories/ISyncStatusRepository'; 4 + import { DID } from 'src/modules/user/domain/value-objects/DID'; 5 + 6 + export class InMemorySyncStatusRepository implements ISyncStatusRepository { 7 + private static instance: InMemorySyncStatusRepository; 8 + private syncStatuses: Map<string, SyncStatus> = new Map(); 9 + 10 + private constructor() {} 11 + 12 + public static getInstance(): InMemorySyncStatusRepository { 13 + if (!InMemorySyncStatusRepository.instance) { 14 + InMemorySyncStatusRepository.instance = 15 + new InMemorySyncStatusRepository(); 16 + } 17 + return InMemorySyncStatusRepository.instance; 18 + } 19 + 20 + async findByCuratorId(curatorId: DID): Promise<Result<SyncStatus | null>> { 21 + try { 22 + const syncStatus = this.syncStatuses.get(curatorId.value); 23 + return ok(syncStatus || null); 24 + } catch (error: any) { 25 + return err(error); 26 + } 27 + } 28 + 29 + async save(syncStatus: SyncStatus): Promise<Result<void>> { 30 + try { 31 + this.syncStatuses.set(syncStatus.curatorId.value, syncStatus); 32 + return ok(undefined); 33 + } catch (error: any) { 34 + return err(error); 35 + } 36 + } 37 + 38 + // Helper method for testing 39 + clear(): void { 40 + this.syncStatuses.clear(); 41 + } 42 + }
+12
src/shared/infrastructure/database/migrations/0014_cuddly_nighthawk.sql
··· 1 + CREATE TABLE "sync_statuses" ( 2 + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 3 + "curator_id" text NOT NULL, 4 + "sync_state" text NOT NULL, 5 + "last_synced_at" timestamp, 6 + "last_sync_attempt_at" timestamp, 7 + "sync_error_message" text, 8 + "records_processed" integer, 9 + "created_at" timestamp DEFAULT now() NOT NULL, 10 + "updated_at" timestamp DEFAULT now() NOT NULL, 11 + CONSTRAINT "sync_statuses_curator_id_unique" UNIQUE("curator_id") 12 + );
+1389
src/shared/infrastructure/database/migrations/meta/0014_snapshot.json
··· 1 + { 2 + "id": "1400606c-d063-46eb-aa12-e7d3ea29b75b", 3 + "prevId": "cf1e68f2-16bf-4dcf-81a9-3aa4e2004501", 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 + }, 400 + "foreignKeys": { 401 + "collection_cards_collection_id_collections_id_fk": { 402 + "name": "collection_cards_collection_id_collections_id_fk", 403 + "tableFrom": "collection_cards", 404 + "tableTo": "collections", 405 + "columnsFrom": ["collection_id"], 406 + "columnsTo": ["id"], 407 + "onDelete": "cascade", 408 + "onUpdate": "no action" 409 + }, 410 + "collection_cards_card_id_cards_id_fk": { 411 + "name": "collection_cards_card_id_cards_id_fk", 412 + "tableFrom": "collection_cards", 413 + "tableTo": "cards", 414 + "columnsFrom": ["card_id"], 415 + "columnsTo": ["id"], 416 + "onDelete": "cascade", 417 + "onUpdate": "no action" 418 + }, 419 + "collection_cards_via_card_id_cards_id_fk": { 420 + "name": "collection_cards_via_card_id_cards_id_fk", 421 + "tableFrom": "collection_cards", 422 + "tableTo": "cards", 423 + "columnsFrom": ["via_card_id"], 424 + "columnsTo": ["id"], 425 + "onDelete": "no action", 426 + "onUpdate": "no action" 427 + }, 428 + "collection_cards_published_record_id_published_records_id_fk": { 429 + "name": "collection_cards_published_record_id_published_records_id_fk", 430 + "tableFrom": "collection_cards", 431 + "tableTo": "published_records", 432 + "columnsFrom": ["published_record_id"], 433 + "columnsTo": ["id"], 434 + "onDelete": "no action", 435 + "onUpdate": "no action" 436 + } 437 + }, 438 + "compositePrimaryKeys": {}, 439 + "uniqueConstraints": {}, 440 + "policies": {}, 441 + "checkConstraints": {}, 442 + "isRLSEnabled": false 443 + }, 444 + "public.collection_collaborators": { 445 + "name": "collection_collaborators", 446 + "schema": "", 447 + "columns": { 448 + "id": { 449 + "name": "id", 450 + "type": "uuid", 451 + "primaryKey": true, 452 + "notNull": true 453 + }, 454 + "collection_id": { 455 + "name": "collection_id", 456 + "type": "uuid", 457 + "primaryKey": false, 458 + "notNull": true 459 + }, 460 + "collaborator_id": { 461 + "name": "collaborator_id", 462 + "type": "text", 463 + "primaryKey": false, 464 + "notNull": true 465 + } 466 + }, 467 + "indexes": {}, 468 + "foreignKeys": { 469 + "collection_collaborators_collection_id_collections_id_fk": { 470 + "name": "collection_collaborators_collection_id_collections_id_fk", 471 + "tableFrom": "collection_collaborators", 472 + "tableTo": "collections", 473 + "columnsFrom": ["collection_id"], 474 + "columnsTo": ["id"], 475 + "onDelete": "cascade", 476 + "onUpdate": "no action" 477 + } 478 + }, 479 + "compositePrimaryKeys": {}, 480 + "uniqueConstraints": {}, 481 + "policies": {}, 482 + "checkConstraints": {}, 483 + "isRLSEnabled": false 484 + }, 485 + "public.collections": { 486 + "name": "collections", 487 + "schema": "", 488 + "columns": { 489 + "id": { 490 + "name": "id", 491 + "type": "uuid", 492 + "primaryKey": true, 493 + "notNull": true 494 + }, 495 + "author_id": { 496 + "name": "author_id", 497 + "type": "text", 498 + "primaryKey": false, 499 + "notNull": true 500 + }, 501 + "name": { 502 + "name": "name", 503 + "type": "text", 504 + "primaryKey": false, 505 + "notNull": true 506 + }, 507 + "description": { 508 + "name": "description", 509 + "type": "text", 510 + "primaryKey": false, 511 + "notNull": false 512 + }, 513 + "access_type": { 514 + "name": "access_type", 515 + "type": "text", 516 + "primaryKey": false, 517 + "notNull": true 518 + }, 519 + "card_count": { 520 + "name": "card_count", 521 + "type": "integer", 522 + "primaryKey": false, 523 + "notNull": true, 524 + "default": 0 525 + }, 526 + "created_at": { 527 + "name": "created_at", 528 + "type": "timestamp", 529 + "primaryKey": false, 530 + "notNull": true, 531 + "default": "now()" 532 + }, 533 + "updated_at": { 534 + "name": "updated_at", 535 + "type": "timestamp", 536 + "primaryKey": false, 537 + "notNull": true, 538 + "default": "now()" 539 + }, 540 + "published_record_id": { 541 + "name": "published_record_id", 542 + "type": "uuid", 543 + "primaryKey": false, 544 + "notNull": false 545 + } 546 + }, 547 + "indexes": { 548 + "collections_author_id_idx": { 549 + "name": "collections_author_id_idx", 550 + "columns": [ 551 + { 552 + "expression": "author_id", 553 + "isExpression": false, 554 + "asc": true, 555 + "nulls": "last" 556 + } 557 + ], 558 + "isUnique": false, 559 + "concurrently": false, 560 + "method": "btree", 561 + "with": {} 562 + }, 563 + "collections_author_updated_at_idx": { 564 + "name": "collections_author_updated_at_idx", 565 + "columns": [ 566 + { 567 + "expression": "author_id", 568 + "isExpression": false, 569 + "asc": true, 570 + "nulls": "last" 571 + }, 572 + { 573 + "expression": "updated_at", 574 + "isExpression": false, 575 + "asc": true, 576 + "nulls": "last" 577 + } 578 + ], 579 + "isUnique": false, 580 + "concurrently": false, 581 + "method": "btree", 582 + "with": {} 583 + } 584 + }, 585 + "foreignKeys": { 586 + "collections_published_record_id_published_records_id_fk": { 587 + "name": "collections_published_record_id_published_records_id_fk", 588 + "tableFrom": "collections", 589 + "tableTo": "published_records", 590 + "columnsFrom": ["published_record_id"], 591 + "columnsTo": ["id"], 592 + "onDelete": "no action", 593 + "onUpdate": "no action" 594 + } 595 + }, 596 + "compositePrimaryKeys": {}, 597 + "uniqueConstraints": {}, 598 + "policies": {}, 599 + "checkConstraints": {}, 600 + "isRLSEnabled": false 601 + }, 602 + "public.library_memberships": { 603 + "name": "library_memberships", 604 + "schema": "", 605 + "columns": { 606 + "card_id": { 607 + "name": "card_id", 608 + "type": "uuid", 609 + "primaryKey": false, 610 + "notNull": true 611 + }, 612 + "user_id": { 613 + "name": "user_id", 614 + "type": "text", 615 + "primaryKey": false, 616 + "notNull": true 617 + }, 618 + "added_at": { 619 + "name": "added_at", 620 + "type": "timestamp", 621 + "primaryKey": false, 622 + "notNull": true, 623 + "default": "now()" 624 + }, 625 + "published_record_id": { 626 + "name": "published_record_id", 627 + "type": "uuid", 628 + "primaryKey": false, 629 + "notNull": false 630 + } 631 + }, 632 + "indexes": { 633 + "idx_user_cards": { 634 + "name": "idx_user_cards", 635 + "columns": [ 636 + { 637 + "expression": "user_id", 638 + "isExpression": false, 639 + "asc": true, 640 + "nulls": "last" 641 + } 642 + ], 643 + "isUnique": false, 644 + "concurrently": false, 645 + "method": "btree", 646 + "with": {} 647 + }, 648 + "idx_card_users": { 649 + "name": "idx_card_users", 650 + "columns": [ 651 + { 652 + "expression": "card_id", 653 + "isExpression": false, 654 + "asc": true, 655 + "nulls": "last" 656 + } 657 + ], 658 + "isUnique": false, 659 + "concurrently": false, 660 + "method": "btree", 661 + "with": {} 662 + }, 663 + "idx_library_memberships_user_type_covering": { 664 + "name": "idx_library_memberships_user_type_covering", 665 + "columns": [ 666 + { 667 + "expression": "user_id", 668 + "isExpression": false, 669 + "asc": true, 670 + "nulls": "last" 671 + }, 672 + { 673 + "expression": "added_at", 674 + "isExpression": false, 675 + "asc": false, 676 + "nulls": "last" 677 + } 678 + ], 679 + "isUnique": false, 680 + "concurrently": false, 681 + "method": "btree", 682 + "with": {} 683 + } 684 + }, 685 + "foreignKeys": { 686 + "library_memberships_card_id_cards_id_fk": { 687 + "name": "library_memberships_card_id_cards_id_fk", 688 + "tableFrom": "library_memberships", 689 + "tableTo": "cards", 690 + "columnsFrom": ["card_id"], 691 + "columnsTo": ["id"], 692 + "onDelete": "cascade", 693 + "onUpdate": "no action" 694 + }, 695 + "library_memberships_published_record_id_published_records_id_fk": { 696 + "name": "library_memberships_published_record_id_published_records_id_fk", 697 + "tableFrom": "library_memberships", 698 + "tableTo": "published_records", 699 + "columnsFrom": ["published_record_id"], 700 + "columnsTo": ["id"], 701 + "onDelete": "no action", 702 + "onUpdate": "no action" 703 + } 704 + }, 705 + "compositePrimaryKeys": { 706 + "library_memberships_card_id_user_id_pk": { 707 + "name": "library_memberships_card_id_user_id_pk", 708 + "columns": ["card_id", "user_id"] 709 + } 710 + }, 711 + "uniqueConstraints": {}, 712 + "policies": {}, 713 + "checkConstraints": {}, 714 + "isRLSEnabled": false 715 + }, 716 + "public.published_records": { 717 + "name": "published_records", 718 + "schema": "", 719 + "columns": { 720 + "id": { 721 + "name": "id", 722 + "type": "uuid", 723 + "primaryKey": true, 724 + "notNull": true 725 + }, 726 + "uri": { 727 + "name": "uri", 728 + "type": "text", 729 + "primaryKey": false, 730 + "notNull": true 731 + }, 732 + "cid": { 733 + "name": "cid", 734 + "type": "text", 735 + "primaryKey": false, 736 + "notNull": true 737 + }, 738 + "recorded_at": { 739 + "name": "recorded_at", 740 + "type": "timestamp", 741 + "primaryKey": false, 742 + "notNull": true, 743 + "default": "now()" 744 + } 745 + }, 746 + "indexes": { 747 + "uri_cid_unique_idx": { 748 + "name": "uri_cid_unique_idx", 749 + "columns": [ 750 + { 751 + "expression": "uri", 752 + "isExpression": false, 753 + "asc": true, 754 + "nulls": "last" 755 + }, 756 + { 757 + "expression": "cid", 758 + "isExpression": false, 759 + "asc": true, 760 + "nulls": "last" 761 + } 762 + ], 763 + "isUnique": true, 764 + "concurrently": false, 765 + "method": "btree", 766 + "with": {} 767 + }, 768 + "published_records_uri_idx": { 769 + "name": "published_records_uri_idx", 770 + "columns": [ 771 + { 772 + "expression": "uri", 773 + "isExpression": false, 774 + "asc": true, 775 + "nulls": "last" 776 + } 777 + ], 778 + "isUnique": false, 779 + "concurrently": false, 780 + "method": "btree", 781 + "with": {} 782 + } 783 + }, 784 + "foreignKeys": {}, 785 + "compositePrimaryKeys": {}, 786 + "uniqueConstraints": {}, 787 + "policies": {}, 788 + "checkConstraints": {}, 789 + "isRLSEnabled": false 790 + }, 791 + "public.feed_activities": { 792 + "name": "feed_activities", 793 + "schema": "", 794 + "columns": { 795 + "id": { 796 + "name": "id", 797 + "type": "uuid", 798 + "primaryKey": true, 799 + "notNull": true 800 + }, 801 + "actor_id": { 802 + "name": "actor_id", 803 + "type": "text", 804 + "primaryKey": false, 805 + "notNull": true 806 + }, 807 + "card_id": { 808 + "name": "card_id", 809 + "type": "text", 810 + "primaryKey": false, 811 + "notNull": false 812 + }, 813 + "type": { 814 + "name": "type", 815 + "type": "text", 816 + "primaryKey": false, 817 + "notNull": true 818 + }, 819 + "metadata": { 820 + "name": "metadata", 821 + "type": "jsonb", 822 + "primaryKey": false, 823 + "notNull": true 824 + }, 825 + "url_type": { 826 + "name": "url_type", 827 + "type": "text", 828 + "primaryKey": false, 829 + "notNull": false 830 + }, 831 + "source": { 832 + "name": "source", 833 + "type": "text", 834 + "primaryKey": false, 835 + "notNull": false 836 + }, 837 + "created_at": { 838 + "name": "created_at", 839 + "type": "timestamp", 840 + "primaryKey": false, 841 + "notNull": true, 842 + "default": "now()" 843 + } 844 + }, 845 + "indexes": { 846 + "feed_activities_type_idx": { 847 + "name": "feed_activities_type_idx", 848 + "columns": [ 849 + { 850 + "expression": "type", 851 + "isExpression": false, 852 + "asc": true, 853 + "nulls": "last" 854 + } 855 + ], 856 + "isUnique": false, 857 + "concurrently": false, 858 + "method": "btree", 859 + "with": {} 860 + }, 861 + "feed_activities_url_type_idx": { 862 + "name": "feed_activities_url_type_idx", 863 + "columns": [ 864 + { 865 + "expression": "url_type", 866 + "isExpression": false, 867 + "asc": true, 868 + "nulls": "last" 869 + } 870 + ], 871 + "isUnique": false, 872 + "concurrently": false, 873 + "method": "btree", 874 + "with": {} 875 + }, 876 + "feed_activities_created_at_idx": { 877 + "name": "feed_activities_created_at_idx", 878 + "columns": [ 879 + { 880 + "expression": "created_at", 881 + "isExpression": false, 882 + "asc": false, 883 + "nulls": "last" 884 + } 885 + ], 886 + "isUnique": false, 887 + "concurrently": false, 888 + "method": "btree", 889 + "with": {} 890 + }, 891 + "feed_activities_type_created_at_idx": { 892 + "name": "feed_activities_type_created_at_idx", 893 + "columns": [ 894 + { 895 + "expression": "type", 896 + "isExpression": false, 897 + "asc": true, 898 + "nulls": "last" 899 + }, 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_url_type_created_at_idx": { 913 + "name": "feed_activities_url_type_created_at_idx", 914 + "columns": [ 915 + { 916 + "expression": "url_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_type_url_type_created_at_idx": { 934 + "name": "feed_activities_type_url_type_created_at_idx", 935 + "columns": [ 936 + { 937 + "expression": "type", 938 + "isExpression": false, 939 + "asc": true, 940 + "nulls": "last" 941 + }, 942 + { 943 + "expression": "url_type", 944 + "isExpression": false, 945 + "asc": true, 946 + "nulls": "last" 947 + }, 948 + { 949 + "expression": "created_at", 950 + "isExpression": false, 951 + "asc": false, 952 + "nulls": "last" 953 + } 954 + ], 955 + "isUnique": false, 956 + "concurrently": false, 957 + "method": "btree", 958 + "with": {} 959 + }, 960 + "feed_activities_dedup_idx": { 961 + "name": "feed_activities_dedup_idx", 962 + "columns": [ 963 + { 964 + "expression": "actor_id", 965 + "isExpression": false, 966 + "asc": true, 967 + "nulls": "last" 968 + }, 969 + { 970 + "expression": "card_id", 971 + "isExpression": false, 972 + "asc": true, 973 + "nulls": "last" 974 + }, 975 + { 976 + "expression": "created_at", 977 + "isExpression": false, 978 + "asc": false, 979 + "nulls": "last" 980 + } 981 + ], 982 + "isUnique": false, 983 + "concurrently": false, 984 + "method": "btree", 985 + "with": {} 986 + }, 987 + "feed_activities_card_id_idx": { 988 + "name": "feed_activities_card_id_idx", 989 + "columns": [ 990 + { 991 + "expression": "card_id", 992 + "isExpression": false, 993 + "asc": true, 994 + "nulls": "last" 995 + } 996 + ], 997 + "isUnique": false, 998 + "concurrently": false, 999 + "method": "btree", 1000 + "with": {} 1001 + }, 1002 + "feed_activities_source_idx": { 1003 + "name": "feed_activities_source_idx", 1004 + "columns": [ 1005 + { 1006 + "expression": "source", 1007 + "isExpression": false, 1008 + "asc": true, 1009 + "nulls": "last" 1010 + } 1011 + ], 1012 + "isUnique": false, 1013 + "concurrently": false, 1014 + "method": "btree", 1015 + "with": {} 1016 + } 1017 + }, 1018 + "foreignKeys": {}, 1019 + "compositePrimaryKeys": {}, 1020 + "uniqueConstraints": {}, 1021 + "policies": {}, 1022 + "checkConstraints": {}, 1023 + "isRLSEnabled": false 1024 + }, 1025 + "public.notifications": { 1026 + "name": "notifications", 1027 + "schema": "", 1028 + "columns": { 1029 + "id": { 1030 + "name": "id", 1031 + "type": "uuid", 1032 + "primaryKey": true, 1033 + "notNull": true 1034 + }, 1035 + "recipient_user_id": { 1036 + "name": "recipient_user_id", 1037 + "type": "text", 1038 + "primaryKey": false, 1039 + "notNull": true 1040 + }, 1041 + "actor_user_id": { 1042 + "name": "actor_user_id", 1043 + "type": "text", 1044 + "primaryKey": false, 1045 + "notNull": true 1046 + }, 1047 + "type": { 1048 + "name": "type", 1049 + "type": "text", 1050 + "primaryKey": false, 1051 + "notNull": true 1052 + }, 1053 + "metadata": { 1054 + "name": "metadata", 1055 + "type": "jsonb", 1056 + "primaryKey": false, 1057 + "notNull": true 1058 + }, 1059 + "read": { 1060 + "name": "read", 1061 + "type": "boolean", 1062 + "primaryKey": false, 1063 + "notNull": true, 1064 + "default": false 1065 + }, 1066 + "created_at": { 1067 + "name": "created_at", 1068 + "type": "timestamp", 1069 + "primaryKey": false, 1070 + "notNull": true, 1071 + "default": "now()" 1072 + }, 1073 + "updated_at": { 1074 + "name": "updated_at", 1075 + "type": "timestamp", 1076 + "primaryKey": false, 1077 + "notNull": true, 1078 + "default": "now()" 1079 + } 1080 + }, 1081 + "indexes": { 1082 + "notifications_recipient_idx": { 1083 + "name": "notifications_recipient_idx", 1084 + "columns": [ 1085 + { 1086 + "expression": "recipient_user_id", 1087 + "isExpression": false, 1088 + "asc": true, 1089 + "nulls": "last" 1090 + } 1091 + ], 1092 + "isUnique": false, 1093 + "concurrently": false, 1094 + "method": "btree", 1095 + "with": {} 1096 + }, 1097 + "notifications_recipient_created_at_idx": { 1098 + "name": "notifications_recipient_created_at_idx", 1099 + "columns": [ 1100 + { 1101 + "expression": "recipient_user_id", 1102 + "isExpression": false, 1103 + "asc": true, 1104 + "nulls": "last" 1105 + }, 1106 + { 1107 + "expression": "created_at", 1108 + "isExpression": false, 1109 + "asc": false, 1110 + "nulls": "last" 1111 + } 1112 + ], 1113 + "isUnique": false, 1114 + "concurrently": false, 1115 + "method": "btree", 1116 + "with": {} 1117 + }, 1118 + "notifications_recipient_read_idx": { 1119 + "name": "notifications_recipient_read_idx", 1120 + "columns": [ 1121 + { 1122 + "expression": "recipient_user_id", 1123 + "isExpression": false, 1124 + "asc": true, 1125 + "nulls": "last" 1126 + }, 1127 + { 1128 + "expression": "read", 1129 + "isExpression": false, 1130 + "asc": true, 1131 + "nulls": "last" 1132 + } 1133 + ], 1134 + "isUnique": false, 1135 + "concurrently": false, 1136 + "method": "btree", 1137 + "with": {} 1138 + } 1139 + }, 1140 + "foreignKeys": {}, 1141 + "compositePrimaryKeys": {}, 1142 + "uniqueConstraints": {}, 1143 + "policies": {}, 1144 + "checkConstraints": {}, 1145 + "isRLSEnabled": false 1146 + }, 1147 + "public.sync_statuses": { 1148 + "name": "sync_statuses", 1149 + "schema": "", 1150 + "columns": { 1151 + "id": { 1152 + "name": "id", 1153 + "type": "uuid", 1154 + "primaryKey": true, 1155 + "notNull": true, 1156 + "default": "gen_random_uuid()" 1157 + }, 1158 + "curator_id": { 1159 + "name": "curator_id", 1160 + "type": "text", 1161 + "primaryKey": false, 1162 + "notNull": true 1163 + }, 1164 + "sync_state": { 1165 + "name": "sync_state", 1166 + "type": "text", 1167 + "primaryKey": false, 1168 + "notNull": true 1169 + }, 1170 + "last_synced_at": { 1171 + "name": "last_synced_at", 1172 + "type": "timestamp", 1173 + "primaryKey": false, 1174 + "notNull": false 1175 + }, 1176 + "last_sync_attempt_at": { 1177 + "name": "last_sync_attempt_at", 1178 + "type": "timestamp", 1179 + "primaryKey": false, 1180 + "notNull": false 1181 + }, 1182 + "sync_error_message": { 1183 + "name": "sync_error_message", 1184 + "type": "text", 1185 + "primaryKey": false, 1186 + "notNull": false 1187 + }, 1188 + "records_processed": { 1189 + "name": "records_processed", 1190 + "type": "integer", 1191 + "primaryKey": false, 1192 + "notNull": false 1193 + }, 1194 + "created_at": { 1195 + "name": "created_at", 1196 + "type": "timestamp", 1197 + "primaryKey": false, 1198 + "notNull": true, 1199 + "default": "now()" 1200 + }, 1201 + "updated_at": { 1202 + "name": "updated_at", 1203 + "type": "timestamp", 1204 + "primaryKey": false, 1205 + "notNull": true, 1206 + "default": "now()" 1207 + } 1208 + }, 1209 + "indexes": {}, 1210 + "foreignKeys": {}, 1211 + "compositePrimaryKeys": {}, 1212 + "uniqueConstraints": { 1213 + "sync_statuses_curator_id_unique": { 1214 + "name": "sync_statuses_curator_id_unique", 1215 + "nullsNotDistinct": false, 1216 + "columns": ["curator_id"] 1217 + } 1218 + }, 1219 + "policies": {}, 1220 + "checkConstraints": {}, 1221 + "isRLSEnabled": false 1222 + }, 1223 + "public.auth_session": { 1224 + "name": "auth_session", 1225 + "schema": "", 1226 + "columns": { 1227 + "key": { 1228 + "name": "key", 1229 + "type": "text", 1230 + "primaryKey": true, 1231 + "notNull": true 1232 + }, 1233 + "session": { 1234 + "name": "session", 1235 + "type": "text", 1236 + "primaryKey": false, 1237 + "notNull": true 1238 + } 1239 + }, 1240 + "indexes": {}, 1241 + "foreignKeys": {}, 1242 + "compositePrimaryKeys": {}, 1243 + "uniqueConstraints": {}, 1244 + "policies": {}, 1245 + "checkConstraints": {}, 1246 + "isRLSEnabled": false 1247 + }, 1248 + "public.auth_state": { 1249 + "name": "auth_state", 1250 + "schema": "", 1251 + "columns": { 1252 + "key": { 1253 + "name": "key", 1254 + "type": "text", 1255 + "primaryKey": true, 1256 + "notNull": true 1257 + }, 1258 + "state": { 1259 + "name": "state", 1260 + "type": "text", 1261 + "primaryKey": false, 1262 + "notNull": true 1263 + }, 1264 + "created_at": { 1265 + "name": "created_at", 1266 + "type": "timestamp", 1267 + "primaryKey": false, 1268 + "notNull": false, 1269 + "default": "now()" 1270 + } 1271 + }, 1272 + "indexes": {}, 1273 + "foreignKeys": {}, 1274 + "compositePrimaryKeys": {}, 1275 + "uniqueConstraints": {}, 1276 + "policies": {}, 1277 + "checkConstraints": {}, 1278 + "isRLSEnabled": false 1279 + }, 1280 + "public.auth_refresh_tokens": { 1281 + "name": "auth_refresh_tokens", 1282 + "schema": "", 1283 + "columns": { 1284 + "token_id": { 1285 + "name": "token_id", 1286 + "type": "text", 1287 + "primaryKey": true, 1288 + "notNull": true 1289 + }, 1290 + "user_did": { 1291 + "name": "user_did", 1292 + "type": "text", 1293 + "primaryKey": false, 1294 + "notNull": true 1295 + }, 1296 + "refresh_token": { 1297 + "name": "refresh_token", 1298 + "type": "text", 1299 + "primaryKey": false, 1300 + "notNull": true 1301 + }, 1302 + "issued_at": { 1303 + "name": "issued_at", 1304 + "type": "timestamp", 1305 + "primaryKey": false, 1306 + "notNull": true 1307 + }, 1308 + "expires_at": { 1309 + "name": "expires_at", 1310 + "type": "timestamp", 1311 + "primaryKey": false, 1312 + "notNull": true 1313 + }, 1314 + "revoked": { 1315 + "name": "revoked", 1316 + "type": "boolean", 1317 + "primaryKey": false, 1318 + "notNull": false, 1319 + "default": false 1320 + } 1321 + }, 1322 + "indexes": {}, 1323 + "foreignKeys": { 1324 + "auth_refresh_tokens_user_did_users_id_fk": { 1325 + "name": "auth_refresh_tokens_user_did_users_id_fk", 1326 + "tableFrom": "auth_refresh_tokens", 1327 + "tableTo": "users", 1328 + "columnsFrom": ["user_did"], 1329 + "columnsTo": ["id"], 1330 + "onDelete": "no action", 1331 + "onUpdate": "no action" 1332 + } 1333 + }, 1334 + "compositePrimaryKeys": {}, 1335 + "uniqueConstraints": {}, 1336 + "policies": {}, 1337 + "checkConstraints": {}, 1338 + "isRLSEnabled": false 1339 + }, 1340 + "public.users": { 1341 + "name": "users", 1342 + "schema": "", 1343 + "columns": { 1344 + "id": { 1345 + "name": "id", 1346 + "type": "text", 1347 + "primaryKey": true, 1348 + "notNull": true 1349 + }, 1350 + "handle": { 1351 + "name": "handle", 1352 + "type": "text", 1353 + "primaryKey": false, 1354 + "notNull": false 1355 + }, 1356 + "linked_at": { 1357 + "name": "linked_at", 1358 + "type": "timestamp", 1359 + "primaryKey": false, 1360 + "notNull": true 1361 + }, 1362 + "last_login_at": { 1363 + "name": "last_login_at", 1364 + "type": "timestamp", 1365 + "primaryKey": false, 1366 + "notNull": true 1367 + } 1368 + }, 1369 + "indexes": {}, 1370 + "foreignKeys": {}, 1371 + "compositePrimaryKeys": {}, 1372 + "uniqueConstraints": {}, 1373 + "policies": {}, 1374 + "checkConstraints": {}, 1375 + "isRLSEnabled": false 1376 + } 1377 + }, 1378 + "enums": {}, 1379 + "schemas": {}, 1380 + "sequences": {}, 1381 + "roles": {}, 1382 + "policies": {}, 1383 + "views": {}, 1384 + "_meta": { 1385 + "columns": {}, 1386 + "schemas": {}, 1387 + "tables": {} 1388 + } 1389 + }
+7
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 99 99 "when": 1769654438339, 100 100 "tag": "0013_huge_hellion", 101 101 "breakpoints": true 102 + }, 103 + { 104 + "idx": 14, 105 + "version": "7", 106 + "when": 1769804929810, 107 + "tag": "0014_cuddly_nighthawk", 108 + "breakpoints": true 102 109 } 103 110 ] 104 111 }
+6 -1
src/shared/infrastructure/events/BullMQEventPublisher.ts
··· 53 53 private getTargetQueues(eventName: EventName): QueueName[] { 54 54 switch (eventName) { 55 55 case EventNames.CARD_ADDED_TO_LIBRARY: 56 - return [QueueNames.FEEDS, QueueNames.SEARCH, QueueNames.NOTIFICATIONS]; 56 + return [ 57 + QueueNames.FEEDS, 58 + QueueNames.SEARCH, 59 + QueueNames.NOTIFICATIONS, 60 + QueueNames.SYNC, 61 + ]; 57 62 case EventNames.CARD_ADDED_TO_COLLECTION: 58 63 return [QueueNames.FEEDS, QueueNames.NOTIFICATIONS]; 59 64 case EventNames.CARD_REMOVED_FROM_LIBRARY:
+8
src/shared/infrastructure/events/QueueConfig.ts
··· 3 3 SEARCH: 'search', 4 4 ANALYTICS: 'analytics', 5 5 NOTIFICATIONS: 'notifications', 6 + SYNC: 'sync', 6 7 } as const; 7 8 8 9 export type QueueName = (typeof QueueNames)[keyof typeof QueueNames]; ··· 35 36 removeOnComplete: 50, 36 37 removeOnFail: 25, 37 38 concurrency: 10, 39 + }, 40 + [QueueNames.SYNC]: { 41 + attempts: 3, 42 + backoff: { type: 'exponential' as const, delay: 3000 }, 43 + removeOnComplete: 50, 44 + removeOnFail: 25, 45 + concurrency: 5, // Lower concurrency for sync operations to avoid rate limits 38 46 }, 39 47 } as const;
+7
src/shared/infrastructure/http/factories/RepositoryFactory.ts
··· 39 39 import { INotificationRepository } from '../../../../modules/notifications/domain/INotificationRepository'; 40 40 import { DrizzleNotificationRepository } from '../../../../modules/notifications/infrastructure/repositories/DrizzleNotificationRepository'; 41 41 import { InMemoryNotificationRepository } from '../../../../modules/notifications/tests/infrastructure/InMemoryNotificationRepository'; 42 + import { ISyncStatusRepository } from '../../../../modules/sync/domain/repositories/ISyncStatusRepository'; 43 + import { DrizzleSyncStatusRepository } from '../../../../modules/sync/infrastructure/repositories/DrizzleSyncStatusRepository'; 44 + import { InMemorySyncStatusRepository } from '../../../../modules/sync/tests/infrastructure/InMemorySyncStatusRepository'; 42 45 43 46 export interface Repositories { 44 47 userRepository: IUserRepository; ··· 50 53 appPasswordSessionRepository: IAppPasswordSessionRepository; 51 54 feedRepository: IFeedRepository; 52 55 notificationRepository: INotificationRepository; 56 + syncStatusRepository: ISyncStatusRepository; 53 57 atUriResolutionService: IAtUriResolutionService; 54 58 oauthStateStore: NodeSavedStateStore; 55 59 oauthSessionStore: NodeSavedSessionStore; ··· 84 88 InMemoryNotificationRepository.getInstance(); 85 89 // Inject dependencies into notification repository 86 90 notificationRepository.setDependencies(cardQueryRepository); 91 + const syncStatusRepository = InMemorySyncStatusRepository.getInstance(); 87 92 const oauthStateStore = InMemoryStateStore.getInstance(); 88 93 const oauthSessionStore = InMemorySessionStore.getInstance(); 89 94 ··· 97 102 appPasswordSessionRepository, 98 103 feedRepository, 99 104 notificationRepository, 105 + syncStatusRepository, 100 106 atUriResolutionService, 101 107 oauthStateStore, 102 108 oauthSessionStore, ··· 120 126 appPasswordSessionRepository: new DrizzleAppPasswordSessionRepository(db), 121 127 feedRepository: new DrizzleFeedRepository(db), 122 128 notificationRepository: new DrizzleNotificationRepository(db), 129 + syncStatusRepository: new DrizzleSyncStatusRepository(db), 123 130 atUriResolutionService: new DrizzleAtUriResolutionService(db), 124 131 oauthStateStore, 125 132 oauthSessionStore,
+6
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 49 49 import { MarkNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase'; 50 50 import { MarkAllNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase'; 51 51 import { CreateNotificationUseCase } from '../../../../modules/notifications/application/useCases/commands/CreateNotificationUseCase'; 52 + import { SyncAccountDataUseCase } from '../../../../modules/sync/application/useCases/SyncAccountDataUseCase'; 52 53 53 54 export interface WorkerUseCases { 54 55 addActivityToFeedUseCase: AddActivityToFeedUseCase; 55 56 indexUrlForSearchUseCase: IndexUrlForSearchUseCase; 56 57 createNotificationUseCase: CreateNotificationUseCase; 58 + syncAccountDataUseCase: SyncAccountDataUseCase; 57 59 // Firehose-specific use cases 58 60 addUrlToLibraryUseCase: AddUrlToLibraryUseCase; 59 61 updateUrlCardAssociationsUseCase: UpdateUrlCardAssociationsUseCase; ··· 341 343 // Notification use cases (only ones needed by workers) 342 344 createNotificationUseCase: new CreateNotificationUseCase( 343 345 services.notificationService, 346 + ), 347 + // Sync use cases (only ones needed by workers) 348 + syncAccountDataUseCase: new SyncAccountDataUseCase( 349 + repositories.syncStatusRepository, 344 350 ), 345 351 // Firehose-specific use cases 346 352 addUrlToLibraryUseCase: new AddUrlToLibraryUseCase(
+50
src/shared/infrastructure/processes/SyncWorkerProcess.ts
··· 1 + import { EnvironmentConfigService } from '../config/EnvironmentConfigService'; 2 + import { 3 + ServiceFactory, 4 + WorkerServices, 5 + } from '../http/factories/ServiceFactory'; 6 + import { UseCaseFactory } from '../http/factories/UseCaseFactory'; 7 + import { CardAddedToLibraryEventHandler } from '../../../modules/sync/application/eventHandlers/CardAddedToLibraryEventHandler'; 8 + import { QueueNames } from '../events/QueueConfig'; 9 + import { EventNames } from '../events/EventConfig'; 10 + import { BaseWorkerProcess } from './BaseWorkerProcess'; 11 + import { IEventSubscriber } from '../../application/events/IEventSubscriber'; 12 + import { Repositories } from '../http/factories/RepositoryFactory'; 13 + 14 + export class SyncWorkerProcess extends BaseWorkerProcess { 15 + constructor(configService: EnvironmentConfigService) { 16 + super(configService, QueueNames.SYNC); 17 + } 18 + 19 + protected createServices(repositories: Repositories): WorkerServices { 20 + return ServiceFactory.createForWorker(this.configService, repositories); 21 + } 22 + 23 + protected async validateDependencies( 24 + services: WorkerServices, 25 + ): Promise<void> { 26 + if (!services.redisConnection) { 27 + throw new Error('Redis connection required for sync worker'); 28 + } 29 + await services.redisConnection.ping(); 30 + } 31 + 32 + protected async registerHandlers( 33 + subscriber: IEventSubscriber, 34 + services: WorkerServices, 35 + repositories: Repositories, 36 + ): Promise<void> { 37 + const useCases = UseCaseFactory.createForWorker(repositories, services); 38 + 39 + // Create event handler for CardAddedToLibraryEvent 40 + const cardAddedToLibraryHandler = new CardAddedToLibraryEventHandler( 41 + useCases.syncAccountDataUseCase, 42 + ); 43 + 44 + // Subscribe to the event 45 + await subscriber.subscribe( 46 + EventNames.CARD_ADDED_TO_LIBRARY, 47 + cardAddedToLibraryHandler, 48 + ); 49 + } 50 + }
+22
src/workers/sync-worker.ts
··· 1 + import { EnvironmentConfigService } from '../shared/infrastructure/config/EnvironmentConfigService'; 2 + import { SyncWorkerProcess } from '../shared/infrastructure/processes/SyncWorkerProcess'; 3 + 4 + async function main() { 5 + const configService = new EnvironmentConfigService(); 6 + const useInMemoryEvents = configService.shouldUseInMemoryEvents(); 7 + 8 + if (useInMemoryEvents) { 9 + console.log('[SYNC] Skipping sync worker - using in-memory events'); 10 + return; 11 + } 12 + 13 + console.log('[SYNC] Starting sync worker...'); 14 + 15 + const syncWorkerProcess = new SyncWorkerProcess(configService); 16 + await syncWorkerProcess.start(); 17 + } 18 + 19 + main().catch((error) => { 20 + console.error('[SYNC] Failed to start sync worker:', error); 21 + process.exit(1); 22 + });
+1
tsup.config.ts
··· 7 7 'workers/search-worker': 'src/workers/search-worker.ts', 8 8 'workers/firehose-worker': 'src/workers/firehose-worker.ts', 9 9 'workers/notification-worker': 'src/workers/notification-worker.ts', 10 + 'workers/sync-worker': 'src/workers/sync-worker.ts', 10 11 }, 11 12 outDir: 'dist', 12 13 target: 'node18',