A social knowledge tool for researchers built on ATProto

Merge pull request #50 from cosmik-network/feature/record-key-in-api-response

Feature/record key in api response

authored by

Pouria Delfan and committed by
GitHub
cc51ba44 f8dde567

+1499 -146
+107 -11
src/modules/atproto/domain/ATUri.ts
··· 1 + import { ValueObject } from '../../../shared/domain/ValueObject'; 2 + import { Result, ok, err } from '../../../shared/core/Result'; 1 3 import { DID } from './DID'; 2 4 3 - export class ATUri { 4 - // at://did:plc:lehcqqkwzcwvjvw66uthu5oq/app.bsky.feed.post/3lnxh4zet5c2a 5 - // given the uri structure above, which is composed of did, CollectionNSID, and rkey, make a value object 5 + export class InvalidATUriError extends Error { 6 + constructor(message: string) { 7 + super(message); 8 + this.name = 'InvalidATUriError'; 9 + } 10 + } 6 11 12 + interface ATUriProps { 7 13 value: string; 8 14 did: DID; 9 15 collection: string; 10 16 rkey: string; 17 + } 18 + 19 + export class ATUri extends ValueObject<ATUriProps> { 20 + get value(): string { 21 + return this.props.value; 22 + } 23 + 24 + get did(): DID { 25 + return this.props.did; 26 + } 27 + 28 + get collection(): string { 29 + return this.props.collection; 30 + } 31 + 32 + get rkey(): string { 33 + return this.props.rkey; 34 + } 11 35 12 - constructor(uri: string) { 13 - this.value = uri; 14 - const parts = uri.split('/'); 36 + private constructor(props: ATUriProps) { 37 + super(props); 38 + } 39 + 40 + public static create(uri: string): Result<ATUri, InvalidATUriError> { 41 + if (!uri || uri.trim().length === 0) { 42 + return err(new InvalidATUriError('AT URI cannot be empty')); 43 + } 44 + 45 + const trimmedUri = uri.trim(); 46 + 47 + if (!trimmedUri.startsWith('at://')) { 48 + return err(new InvalidATUriError('AT URI must start with "at://"')); 49 + } 50 + 51 + const parts = trimmedUri.split('/'); 15 52 if (parts.length !== 5) { 16 - throw new Error('Invalid AT URI'); 53 + return err( 54 + new InvalidATUriError( 55 + 'AT URI must have exactly 5 parts separated by "/"', 56 + ), 57 + ); 17 58 } 59 + 18 60 const didResult = DID.create(parts[2]!); 19 61 if (didResult.isErr()) { 20 - throw new Error(`Invalid DID in AT URI: ${didResult.error.message}`); 62 + return err( 63 + new InvalidATUriError( 64 + `Invalid DID in AT URI: ${didResult.error.message}`, 65 + ), 66 + ); 21 67 } 22 - this.did = didResult.value; 23 - this.collection = parts[3]!; 24 - this.rkey = parts[4]!; 68 + 69 + const collection = parts[3]!; 70 + const rkey = parts[4]!; 71 + 72 + if (!collection || collection.length === 0) { 73 + return err(new InvalidATUriError('Collection cannot be empty')); 74 + } 75 + 76 + if (!rkey || rkey.length === 0) { 77 + return err(new InvalidATUriError('Record key cannot be empty')); 78 + } 79 + 80 + return ok( 81 + new ATUri({ 82 + value: trimmedUri, 83 + did: didResult.value, 84 + collection, 85 + rkey, 86 + }), 87 + ); 88 + } 89 + 90 + public static fromParts( 91 + did: DID, 92 + collection: string, 93 + rkey: string, 94 + ): Result<ATUri, InvalidATUriError> { 95 + if (!collection || collection.length === 0) { 96 + return err(new InvalidATUriError('Collection cannot be empty')); 97 + } 98 + 99 + if (!rkey || rkey.length === 0) { 100 + return err(new InvalidATUriError('Record key cannot be empty')); 101 + } 102 + 103 + const uri = `at://${did.value}/${collection}/${rkey}`; 104 + 105 + return ok( 106 + new ATUri({ 107 + value: uri, 108 + did, 109 + collection, 110 + rkey, 111 + }), 112 + ); 113 + } 114 + 115 + public toString(): string { 116 + return this.props.value; 117 + } 118 + 119 + public equals(other: ATUri): boolean { 120 + return this.props.value === other.props.value; 25 121 } 26 122 }
+6 -1
src/modules/atproto/domain/StrongRef.ts
··· 24 24 25 25 constructor(props: StrongRefProps) { 26 26 super(props); 27 - this._atUri = new ATUri(props.uri); 27 + const atUriResult = ATUri.create(props.uri); 28 + if (atUriResult.isErr()) { 29 + throw new Error(`Invalid AT URI: ${atUriResult.error.message}`); 30 + } 31 + const atUri = atUriResult.value; 32 + this._atUri = atUri; 28 33 } 29 34 30 35 equals(other: StrongRef): boolean {
+5
src/modules/atproto/infrastructure/services/RepositoryCollectionIds.ts
··· 1 + export const REPOSITORY_COLLECTION_IDS = { 2 + COLLECTIONS: 'network.cosmik.collection', 3 + CARDS: 'network.cosmik.card', 4 + COLLECTION_LINKS: 'network.cosmik.collectionLink', 5 + };
+98
src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase.ts
··· 1 + import { err, Result } from 'src/shared/core/Result'; 2 + import { UseCase } from 'src/shared/core/UseCase'; 3 + import { IAtUriResolutionService } from '../../../domain/services/IAtUriResolutionService'; 4 + import { IIdentityResolutionService } from '../../../../atproto/domain/services/IIdentityResolutionService'; 5 + import { DIDOrHandle } from '../../../../atproto/domain/DIDOrHandle'; 6 + import { ATUri } from '../../../../atproto/domain/ATUri'; 7 + import { 8 + GetCollectionPageUseCase, 9 + GetCollectionPageResult, 10 + CollectionNotFoundError, 11 + } from './GetCollectionPageUseCase'; 12 + import { 13 + CardSortField, 14 + SortOrder, 15 + } from 'src/modules/cards/domain/ICardQueryRepository'; 16 + import { REPOSITORY_COLLECTION_IDS } from 'src/modules/atproto/infrastructure/services/RepositoryCollectionIds'; 17 + 18 + export interface GetCollectionPageByAtUriQuery { 19 + handle: string; 20 + recordKey: string; 21 + callerDid?: string; 22 + page?: number; 23 + limit?: number; 24 + sortBy?: CardSortField; 25 + sortOrder?: SortOrder; 26 + } 27 + 28 + export class GetCollectionPageByAtUriUseCase 29 + implements 30 + UseCase<GetCollectionPageByAtUriQuery, Result<GetCollectionPageResult>> 31 + { 32 + constructor( 33 + private identityResolutionService: IIdentityResolutionService, 34 + private atUriResolutionService: IAtUriResolutionService, 35 + private getCollectionPageUseCase: GetCollectionPageUseCase, 36 + ) {} 37 + 38 + async execute( 39 + query: GetCollectionPageByAtUriQuery, 40 + ): Promise<Result<GetCollectionPageResult>> { 41 + // First resolve the handle to a DID 42 + const identifierResult = DIDOrHandle.create(query.handle); 43 + if (identifierResult.isErr()) { 44 + return err( 45 + new Error(`Invalid handle: ${identifierResult.error.message}`), 46 + ); 47 + } 48 + 49 + const didResult = await this.identityResolutionService.resolveToDID( 50 + identifierResult.value, 51 + ); 52 + if (didResult.isErr()) { 53 + return err( 54 + new Error( 55 + `Failed to resolve handle to DID: ${didResult.error.message}`, 56 + ), 57 + ); 58 + } 59 + 60 + // Construct the AT URI using the resolved DID 61 + const atUriResult = ATUri.fromParts( 62 + didResult.value, 63 + REPOSITORY_COLLECTION_IDS.COLLECTIONS, 64 + query.recordKey, 65 + ); 66 + if (atUriResult.isErr()) { 67 + return err( 68 + new Error(`Failed to construct AT URI: ${atUriResult.error.message}`), 69 + ); 70 + } 71 + 72 + // Resolve the AT URI to a collection ID 73 + const collectionIdResult = 74 + await this.atUriResolutionService.resolveCollectionId( 75 + atUriResult.value.value, 76 + ); 77 + 78 + if (collectionIdResult.isErr()) { 79 + return err(collectionIdResult.error); 80 + } 81 + 82 + if (!collectionIdResult.value) { 83 + return err( 84 + new CollectionNotFoundError('Collection not found for AT URI'), 85 + ); 86 + } 87 + 88 + // Delegate to the existing use case 89 + return this.getCollectionPageUseCase.execute({ 90 + collectionId: collectionIdResult.value.getStringValue(), 91 + callerDid: query.callerDid, 92 + page: query.page, 93 + limit: query.limit, 94 + sortBy: query.sortBy, 95 + sortOrder: query.sortOrder, 96 + }); 97 + } 98 + }
+6
src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts
··· 22 22 export type CollectionPageUrlCardDTO = UrlCardView; 23 23 export interface GetCollectionPageResult { 24 24 id: string; 25 + uri?: string; 25 26 name: string; 26 27 description?: string; 27 28 author: { ··· 103 104 return err(new CollectionNotFoundError('Collection not found')); 104 105 } 105 106 107 + const collectionPublishedRecordId = collection.publishedRecordId; 108 + 109 + const collectionUri = collectionPublishedRecordId?.uri; 110 + 106 111 // Get author profile 107 112 const profileResult = await this.profileService.getProfile( 108 113 collection.authorId.value, ··· 135 140 136 141 return ok({ 137 142 id: collection.collectionId.getStringValue(), 143 + uri: collectionUri, 138 144 name: collection.name.value, 139 145 description: collection.description?.value, 140 146 author: {
+2
src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts
··· 21 21 // Enriched data for the final use case result 22 22 export interface CollectionListItemDTO { 23 23 id: string; 24 + uri?: string; 24 25 name: string; 25 26 description?: string; 26 27 updatedAt: Date; ··· 120 121 (item) => { 121 122 return { 122 123 id: item.id, 124 + uri: item.uri, 123 125 name: item.name, 124 126 description: item.description, 125 127 updatedAt: item.updatedAt,
+1
src/modules/cards/domain/ICollectionQueryRepository.ts
··· 27 27 // Raw data from repository - minimal, just what's stored 28 28 export interface CollectionQueryResultDTO { 29 29 id: string; 30 + uri?: string; 30 31 name: string; 31 32 description?: string; 32 33 updatedAt: Date;
+18
src/modules/cards/domain/services/IAtUriResolutionService.ts
··· 1 + import { Result } from 'src/shared/core/Result'; 2 + import { CollectionId } from '../value-objects/CollectionId'; 3 + 4 + export enum AtUriResourceType { 5 + COLLECTION = 'collection', 6 + } 7 + 8 + export interface AtUriResolutionResult { 9 + type: AtUriResourceType; 10 + id: CollectionId; 11 + } 12 + 13 + export interface IAtUriResolutionService { 14 + resolveAtUri(atUri: string): Promise<Result<AtUriResolutionResult | null>>; 15 + 16 + // Convenience methods for specific types 17 + resolveCollectionId(atUri: string): Promise<Result<CollectionId | null>>; 18 + }
+46
src/modules/cards/infrastructure/http/controllers/GetCollectionPageByAtUriController.ts
··· 1 + import { Request, Response } from 'express'; 2 + import { Controller } from 'src/shared/infrastructure/http/Controller'; 3 + import { GetCollectionPageByAtUriUseCase } from '../../../application/useCases/queries/GetCollectionPageByAtUriUseCase'; 4 + import { AuthenticatedRequest } from 'src/shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { CardSortField, SortOrder } from '../../../domain/ICardQueryRepository'; 6 + 7 + export class GetCollectionPageByAtUriController extends Controller { 8 + constructor( 9 + private getCollectionPageByAtUriUseCase: GetCollectionPageByAtUriUseCase, 10 + ) { 11 + super(); 12 + } 13 + 14 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 15 + try { 16 + const { handle, recordKey } = req.params; 17 + if (!handle || !recordKey) { 18 + return this.badRequest(res, 'Handle and recordKey are required'); 19 + } 20 + const { page, limit, sortBy, sortOrder } = req.query; 21 + const callerDid = req.did; 22 + 23 + const result = await this.getCollectionPageByAtUriUseCase.execute({ 24 + handle, 25 + recordKey, 26 + callerDid, 27 + page: page ? parseInt(page as string) : undefined, 28 + limit: limit ? parseInt(limit as string) : undefined, 29 + sortBy: sortBy as CardSortField, 30 + sortOrder: sortOrder as SortOrder, 31 + }); 32 + 33 + if (result.isErr()) { 34 + const error = result.error; 35 + if (error.name === 'CollectionNotFoundError') { 36 + return this.notFound(res, error.message); 37 + } 38 + return this.fail(res, error); 39 + } 40 + 41 + return this.ok(res, result.value); 42 + } catch (error) { 43 + return this.fail(res, error as Error); 44 + } 45 + } 46 + }
+9
src/modules/cards/infrastructure/http/routes/collectionRoutes.ts
··· 5 5 import { GetCollectionPageController } from '../controllers/GetCollectionPageController'; 6 6 import { GetMyCollectionsController } from '../controllers/GetMyCollectionsController'; 7 7 import { GetUserCollectionsController } from '../controllers/GetUserCollectionsController'; 8 + import { GetCollectionPageByAtUriController } from '../controllers/GetCollectionPageByAtUriController'; 8 9 import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 9 10 10 11 export function createCollectionRoutes( ··· 15 16 getCollectionPageController: GetCollectionPageController, 16 17 getMyCollectionsController: GetMyCollectionsController, 17 18 getUserCollectionsController: GetUserCollectionsController, 19 + getCollectionPageByAtUriController: GetCollectionPageByAtUriController, 18 20 ): Router { 19 21 const router = Router(); 20 22 ··· 27 29 // GET /api/collections/user/:identifier - Get user's collections by identifier 28 30 router.get('/user/:identifier', authMiddleware.optionalAuth(), (req, res) => 29 31 getUserCollectionsController.execute(req, res), 32 + ); 33 + 34 + // GET /api/collections/at/:handle/:recordKey - Get collection by AT URI 35 + router.get( 36 + '/at/:handle/:recordKey', 37 + authMiddleware.optionalAuth(), 38 + (req, res) => getCollectionPageByAtUriController.execute(req, res), 30 39 ); 31 40 32 41 // GET /api/collections/:collectionId - Get collection page
+3
src/modules/cards/infrastructure/http/routes/index.ts
··· 19 19 import { AuthMiddleware } from 'src/shared/infrastructure/http/middleware'; 20 20 import { GetMyCollectionsController } from '../controllers/GetMyCollectionsController'; 21 21 import { GetUserCollectionsController } from '../controllers/GetUserCollectionsController'; 22 + import { GetCollectionPageByAtUriController } from '../controllers/GetCollectionPageByAtUriController'; 22 23 23 24 export function createCardsModuleRoutes( 24 25 authMiddleware: AuthMiddleware, ··· 39 40 updateCollectionController: UpdateCollectionController, 40 41 deleteCollectionController: DeleteCollectionController, 41 42 getCollectionPageController: GetCollectionPageController, 43 + getCollectionPageByAtUriController: GetCollectionPageByAtUriController, 42 44 getMyCollectionsController: GetMyCollectionsController, 43 45 getCollectionsController: GetUserCollectionsController, 44 46 ): Router { ··· 74 76 getCollectionPageController, 75 77 getMyCollectionsController, 76 78 getCollectionsController, 79 + getCollectionPageByAtUriController, 77 80 ), 78 81 ); 79 82
+9 -2
src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts
··· 8 8 CollectionSortField, 9 9 SortOrder, 10 10 } from '../../domain/ICollectionQueryRepository'; 11 - import { collections, collectionCards } from './schema/collection.sql'; 11 + import { collections } from './schema/collection.sql'; 12 + import { publishedRecords } from './schema/publishedRecord.sql'; 12 13 import { CollectionMapper } from './mappers/CollectionMapper'; 13 14 14 15 export class DrizzleCollectionQueryRepository ··· 41 42 ); 42 43 } 43 44 44 - // Simple query: get collections with their stored card counts 45 + // Simple query: get collections with their stored card counts and URIs 45 46 const collectionsQuery = this.db 46 47 .select({ 47 48 id: collections.id, ··· 51 52 updatedAt: collections.updatedAt, 52 53 authorId: collections.authorId, 53 54 cardCount: collections.cardCount, 55 + uri: publishedRecords.uri, 54 56 }) 55 57 .from(collections) 58 + .leftJoin( 59 + publishedRecords, 60 + eq(collections.publishedRecordId, publishedRecords.id), 61 + ) 56 62 .where( 57 63 sql`${whereConditions.reduce((acc, condition, index) => 58 64 index === 0 ? condition : sql`${acc} AND ${condition}`, ··· 81 87 const items = collectionsResult.map((raw) => 82 88 CollectionMapper.toQueryResult({ 83 89 id: raw.id, 90 + uri: raw.uri, 84 91 name: raw.name, 85 92 description: raw.description, 86 93 createdAt: raw.createdAt,
+2
src/modules/cards/infrastructure/repositories/mappers/CollectionMapper.ts
··· 31 31 export class CollectionMapper { 32 32 public static toQueryResult(raw: { 33 33 id: string; 34 + uri: string | null; 34 35 name: string; 35 36 description?: string | null; 36 37 createdAt: Date; ··· 40 41 }): CollectionQueryResultDTO { 41 42 return { 42 43 id: raw.id, 44 + uri: raw.uri || undefined, 43 45 name: raw.name, 44 46 description: raw.description || undefined, 45 47 createdAt: raw.createdAt,
+3
src/modules/cards/infrastructure/repositories/schema/publishedRecord.sql.ts
··· 4 4 uuid, 5 5 timestamp, 6 6 uniqueIndex, 7 + index, 7 8 } from 'drizzle-orm/pg-core'; 8 9 9 10 // Define the published records table schema ··· 19 20 return { 20 21 // Create a composite unique constraint to prevent exact duplicates 21 22 uriCidUnique: uniqueIndex('uri_cid_unique_idx').on(table.uri, table.cid), 23 + // Index for efficient AT URI lookups 24 + uriIdx: index('published_records_uri_idx').on(table.uri), 22 25 }; 23 26 }, 24 27 );
+67
src/modules/cards/infrastructure/services/DrizzleAtUriResolutionService.ts
··· 1 + import { eq } from 'drizzle-orm'; 2 + import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 + import { 4 + IAtUriResolutionService, 5 + AtUriResourceType, 6 + AtUriResolutionResult, 7 + } from '../../domain/services/IAtUriResolutionService'; 8 + import { CollectionId } from '../../domain/value-objects/CollectionId'; 9 + import { collections } from '../repositories/schema/collection.sql'; 10 + import { publishedRecords } from '../repositories/schema/publishedRecord.sql'; 11 + import { Result, ok, err } from 'src/shared/core/Result'; 12 + 13 + export class DrizzleAtUriResolutionService implements IAtUriResolutionService { 14 + constructor(private db: PostgresJsDatabase) {} 15 + 16 + async resolveAtUri( 17 + atUri: string, 18 + ): Promise<Result<AtUriResolutionResult | null>> { 19 + try { 20 + // Try collections first 21 + const collectionResult = await this.db 22 + .select({ 23 + id: collections.id, 24 + }) 25 + .from(collections) 26 + .innerJoin( 27 + publishedRecords, 28 + eq(collections.publishedRecordId, publishedRecords.id), 29 + ) 30 + .where(eq(publishedRecords.uri, atUri)) 31 + .limit(1); 32 + 33 + if (collectionResult.length > 0) { 34 + const collectionIdResult = CollectionId.createFromString( 35 + collectionResult[0]!.id, 36 + ); 37 + if (collectionIdResult.isErr()) { 38 + return err(collectionIdResult.error); 39 + } 40 + 41 + return ok({ 42 + type: AtUriResourceType.COLLECTION, 43 + id: collectionIdResult.value, 44 + }); 45 + } 46 + return ok(null); 47 + } catch (error) { 48 + return err(error as Error); 49 + } 50 + } 51 + 52 + async resolveCollectionId( 53 + atUri: string, 54 + ): Promise<Result<CollectionId | null>> { 55 + const result = await this.resolveAtUri(atUri); 56 + 57 + if (result.isErr()) { 58 + return err(result.error); 59 + } 60 + 61 + if (!result.value || result.value.type !== AtUriResourceType.COLLECTION) { 62 + return ok(null); 63 + } 64 + 65 + return ok(result.value.id as CollectionId); 66 + } 67 + }
+128 -116
src/modules/cards/tests/application/GetMyCollectionsUseCase.test.ts
··· 2 2 import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 3 3 import { FakeProfileService } from '../utils/FakeProfileService'; 4 4 import { FakeIdentityResolutionService } from '../utils/FakeIdentityResolutionService'; 5 + import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 5 6 import { CuratorId } from '../../domain/value-objects/CuratorId'; 6 7 import { Collection, CollectionAccessType } from '../../domain/Collection'; 7 8 import { ··· 67 68 }); 68 69 69 70 it("should return curator's collections with profile data", async () => { 70 - // Create test collections 71 - const collection1Result = Collection.create({ 72 - name: 'First Collection', 73 - description: 'First collection description', 74 - authorId: curatorId, 75 - accessType: CollectionAccessType.OPEN, 76 - collaboratorIds: [], 77 - createdAt: new Date(), 78 - updatedAt: new Date(), 79 - }); 80 - 81 - const collection2Result = Collection.create({ 82 - name: 'Second Collection', 83 - description: 'Second collection description', 84 - authorId: curatorId, 85 - accessType: CollectionAccessType.OPEN, 86 - collaboratorIds: [], 87 - createdAt: new Date(), 88 - updatedAt: new Date(), 89 - }); 71 + // Create test collections using CollectionBuilder 72 + const collection1 = new CollectionBuilder() 73 + .withName('First Collection') 74 + .withDescription('First collection description') 75 + .withAuthorId(curatorId.value) 76 + .withAccessType(CollectionAccessType.OPEN) 77 + .withPublished(true) 78 + .buildOrThrow(); 90 79 91 - if (collection1Result.isErr() || collection2Result.isErr()) { 92 - throw new Error('Failed to create test collections'); 93 - } 80 + const collection2 = new CollectionBuilder() 81 + .withName('Second Collection') 82 + .withDescription('Second collection description') 83 + .withAuthorId(curatorId.value) 84 + .withAccessType(CollectionAccessType.OPEN) 85 + .withPublished(true) 86 + .buildOrThrow(); 94 87 95 - await collectionRepo.save(collection1Result.value); 96 - await collectionRepo.save(collection2Result.value); 88 + await collectionRepo.save(collection1); 89 + await collectionRepo.save(collection2); 97 90 98 91 const query = { 99 92 curatorId: curatorId.value, ··· 112 105 expect(firstCollection.createdBy.name).toBe(userProfile.name); 113 106 expect(firstCollection.createdBy.handle).toBe(userProfile.handle); 114 107 expect(firstCollection.createdBy.avatarUrl).toBe(userProfile.avatarUrl); 108 + 109 + // Verify URI is included 110 + expect(firstCollection.uri).toBeDefined(); 111 + expect(typeof firstCollection.uri).toBe('string'); 112 + expect(firstCollection.uri?.length).toBeGreaterThan(0); 115 113 }); 116 114 117 115 it('should only return collections for the specified curator', async () => { 118 116 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 119 117 120 - // Create collections for different curators 121 - const myCollectionResult = Collection.create({ 122 - name: 'My Collection', 123 - authorId: curatorId, 124 - accessType: CollectionAccessType.OPEN, 125 - collaboratorIds: [], 126 - createdAt: new Date(), 127 - updatedAt: new Date(), 128 - }); 129 - 130 - const otherCollectionResult = Collection.create({ 131 - name: 'Other Collection', 132 - authorId: otherCuratorId, 133 - accessType: CollectionAccessType.OPEN, 134 - collaboratorIds: [], 135 - createdAt: new Date(), 136 - updatedAt: new Date(), 137 - }); 118 + // Create collections for different curators using CollectionBuilder 119 + const myCollection = new CollectionBuilder() 120 + .withName('My Collection') 121 + .withAuthorId(curatorId.value) 122 + .withAccessType(CollectionAccessType.OPEN) 123 + .withPublished(true) 124 + .buildOrThrow(); 138 125 139 - if (myCollectionResult.isErr() || otherCollectionResult.isErr()) { 140 - throw new Error('Failed to create test collections'); 141 - } 126 + const otherCollection = new CollectionBuilder() 127 + .withName('Other Collection') 128 + .withAuthorId(otherCuratorId.value) 129 + .withAccessType(CollectionAccessType.OPEN) 130 + .withPublished(true) 131 + .buildOrThrow(); 142 132 143 - await collectionRepo.save(myCollectionResult.value); 144 - await collectionRepo.save(otherCollectionResult.value); 133 + await collectionRepo.save(myCollection); 134 + await collectionRepo.save(otherCollection); 145 135 146 136 const query = { 147 137 curatorId: curatorId.value, ··· 153 143 const response = result.unwrap(); 154 144 expect(response.collections).toHaveLength(1); 155 145 expect(response.collections[0]!.name).toBe('My Collection'); 146 + 147 + // Verify URI is present for the collection 148 + expect(response.collections[0]!.uri).toBeDefined(); 149 + expect(typeof response.collections[0]!.uri).toBe('string'); 156 150 }); 157 151 }); 158 152 159 153 describe('Pagination', () => { 160 154 beforeEach(async () => { 161 - // Create multiple collections for pagination testing 155 + // Create multiple collections for pagination testing using CollectionBuilder 162 156 for (let i = 1; i <= 5; i++) { 163 - const collectionResult = Collection.create({ 164 - name: `Collection ${i}`, 165 - authorId: curatorId, 166 - accessType: CollectionAccessType.OPEN, 167 - collaboratorIds: [], 168 - createdAt: new Date(), 169 - updatedAt: new Date(), 170 - }); 157 + const collection = new CollectionBuilder() 158 + .withName(`Collection ${i}`) 159 + .withAuthorId(curatorId.value) 160 + .withAccessType(CollectionAccessType.OPEN) 161 + .withPublished(true) 162 + .buildOrThrow(); 171 163 172 - if (collectionResult.isErr()) { 173 - throw new Error(`Failed to create collection ${i}`); 174 - } 175 - 176 - await collectionRepo.save(collectionResult.value); 164 + await collectionRepo.save(collection); 177 165 } 178 166 }); 179 167 ··· 244 232 245 233 describe('Sorting', () => { 246 234 beforeEach(async () => { 247 - // Create collections with different properties for sorting 235 + // Create collections with different properties for sorting using CollectionBuilder 248 236 const now = new Date(); 249 237 250 - const collection1Result = Collection.create({ 251 - name: 'Alpha Collection', 252 - authorId: curatorId, 253 - accessType: CollectionAccessType.OPEN, 254 - collaboratorIds: [], 255 - createdAt: new Date(now.getTime() - 2000), 256 - updatedAt: new Date(now.getTime() - 1000), 257 - }); 238 + const collection1 = new CollectionBuilder() 239 + .withName('Alpha Collection') 240 + .withAuthorId(curatorId.value) 241 + .withAccessType(CollectionAccessType.OPEN) 242 + .withCreatedAt(new Date(now.getTime() - 2000)) 243 + .withUpdatedAt(new Date(now.getTime() - 1000)) 244 + .withPublished(true) 245 + .buildOrThrow(); 258 246 259 - const collection2Result = Collection.create({ 260 - name: 'Beta Collection', 261 - authorId: curatorId, 262 - accessType: CollectionAccessType.OPEN, 263 - collaboratorIds: [], 264 - createdAt: new Date(now.getTime() - 1000), 265 - updatedAt: new Date(now.getTime() - 2000), 266 - }); 247 + const collection2 = new CollectionBuilder() 248 + .withName('Beta Collection') 249 + .withAuthorId(curatorId.value) 250 + .withAccessType(CollectionAccessType.OPEN) 251 + .withCreatedAt(new Date(now.getTime() - 1000)) 252 + .withUpdatedAt(new Date(now.getTime() - 2000)) 253 + .withPublished(true) 254 + .buildOrThrow(); 267 255 268 - if (collection1Result.isErr() || collection2Result.isErr()) { 269 - throw new Error('Failed to create test collections'); 270 - } 271 - 272 - await collectionRepo.save(collection1Result.value); 273 - await collectionRepo.save(collection2Result.value); 256 + await collectionRepo.save(collection1); 257 + await collectionRepo.save(collection2); 274 258 }); 275 259 276 260 it('should sort by name ascending', async () => { ··· 321 305 322 306 describe('Text search', () => { 323 307 beforeEach(async () => { 324 - // Create collections with different names and descriptions for search testing 308 + // Create collections with different names and descriptions for search testing using CollectionBuilder 325 309 const collections = [ 326 310 { 327 311 name: 'Machine Learning Papers', ··· 346 330 ]; 347 331 348 332 for (const collectionData of collections) { 349 - const collectionResult = Collection.create({ 350 - name: collectionData.name, 351 - description: collectionData.description, 352 - authorId: curatorId, 353 - accessType: CollectionAccessType.OPEN, 354 - collaboratorIds: [], 355 - createdAt: new Date(), 356 - updatedAt: new Date(), 357 - }); 358 - 359 - if (collectionResult.isErr()) { 360 - throw new Error( 361 - `Failed to create collection: ${collectionData.name}`, 362 - ); 363 - } 333 + const collection = new CollectionBuilder() 334 + .withName(collectionData.name) 335 + .withDescription(collectionData.description) 336 + .withAuthorId(curatorId.value) 337 + .withAccessType(CollectionAccessType.OPEN) 338 + .withPublished(true) 339 + .buildOrThrow(); 364 340 365 - await collectionRepo.save(collectionResult.value); 341 + await collectionRepo.save(collection); 366 342 } 367 343 }); 368 344 ··· 511 487 expect(result.isOk()).toBe(true); 512 488 const response = result.unwrap(); 513 489 expect(response.collections).toHaveLength(3); 514 - }); 515 490 516 - it('should search collections with no description', async () => { 517 - // Create a collection without description 518 - const collectionResult = Collection.create({ 519 - name: 'No Description Collection', 520 - authorId: curatorId, 521 - accessType: CollectionAccessType.OPEN, 522 - collaboratorIds: [], 523 - createdAt: new Date(), 524 - updatedAt: new Date(), 491 + // Verify all collections have URIs 492 + response.collections.forEach((collection) => { 493 + expect(collection.uri).toBeDefined(); 494 + expect(typeof collection.uri).toBe('string'); 495 + expect(collection.uri?.length).toBeGreaterThan(0); 525 496 }); 497 + }); 526 498 527 - if (collectionResult.isErr()) { 528 - throw new Error('Failed to create collection without description'); 529 - } 499 + it('should search collections with no description', async () => { 500 + // Create a collection without description using CollectionBuilder 501 + const collection = new CollectionBuilder() 502 + .withName('No Description Collection') 503 + .withAuthorId(curatorId.value) 504 + .withAccessType(CollectionAccessType.OPEN) 505 + .withPublished(true) 506 + .buildOrThrow(); 530 507 531 - await collectionRepo.save(collectionResult.value); 508 + await collectionRepo.save(collection); 532 509 533 510 const query = { 534 511 curatorId: curatorId.value, ··· 541 518 const response = result.unwrap(); 542 519 expect(response.collections).toHaveLength(1); 543 520 expect(response.collections[0]!.name).toBe('No Description Collection'); 521 + }); 522 + }); 523 + 524 + describe('URI validation', () => { 525 + it('should return consistent URIs across multiple calls', async () => { 526 + // Create a test collection using CollectionBuilder 527 + const collection = new CollectionBuilder() 528 + .withName('Consistent URI Test') 529 + .withDescription('Testing URI consistency') 530 + .withAuthorId(curatorId.value) 531 + .withAccessType(CollectionAccessType.OPEN) 532 + .withPublished(true) 533 + .buildOrThrow(); 534 + 535 + await collectionRepo.save(collection); 536 + 537 + const query = { 538 + curatorId: curatorId.value, 539 + }; 540 + 541 + // Execute the same query twice 542 + const result1 = await useCase.execute(query); 543 + const result2 = await useCase.execute(query); 544 + 545 + expect(result1.isOk()).toBe(true); 546 + expect(result2.isOk()).toBe(true); 547 + 548 + const response1 = result1.unwrap(); 549 + const response2 = result2.unwrap(); 550 + 551 + expect(response1.collections).toHaveLength(1); 552 + expect(response2.collections).toHaveLength(1); 553 + 554 + // URIs should be consistent across calls 555 + expect(response1.collections[0]!.uri).toBe(response2.collections[0]!.uri); 544 556 }); 545 557 }); 546 558
+123
src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.integration.test.ts
··· 25 25 SortOrder, 26 26 } from '../../domain/ICollectionQueryRepository'; 27 27 import { createTestSchema } from '../test-utils/createTestSchema'; 28 + import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 29 + import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 28 30 29 31 describe('DrizzleCollectionQueryRepository', () => { 30 32 let container: StartedPostgreSqlContainer; ··· 32 34 let queryRepository: DrizzleCollectionQueryRepository; 33 35 let collectionRepository: DrizzleCollectionRepository; 34 36 let cardRepository: DrizzleCardRepository; 37 + let fakePublisher: FakeCollectionPublisher; 35 38 36 39 // Test data 37 40 let curatorId: CuratorId; ··· 52 55 queryRepository = new DrizzleCollectionQueryRepository(db); 53 56 collectionRepository = new DrizzleCollectionRepository(db); 54 57 cardRepository = new DrizzleCardRepository(db); 58 + fakePublisher = new FakeCollectionPublisher(); 55 59 56 60 // Create schema using helper function 57 61 await createTestSchema(db); ··· 75 79 await db.delete(libraryMemberships); 76 80 await db.delete(cards); 77 81 await db.delete(publishedRecords); 82 + // Clear fake publisher state between tests 83 + fakePublisher.clear(); 78 84 }); 79 85 80 86 describe('findByCreator', () => { ··· 934 940 }); 935 941 936 942 expect(result.items).toHaveLength(2); 943 + }); 944 + }); 945 + 946 + describe('published record URIs', () => { 947 + it('should return empty string for collections without published records', async () => { 948 + const collection = Collection.create( 949 + { 950 + authorId: curatorId, 951 + name: 'Unpublished Collection', 952 + accessType: CollectionAccessType.OPEN, 953 + collaboratorIds: [], 954 + createdAt: new Date(), 955 + updatedAt: new Date(), 956 + }, 957 + new UniqueEntityID(), 958 + ).unwrap(); 959 + 960 + await collectionRepository.save(collection); 961 + 962 + const result = await queryRepository.findByCreator(curatorId.value, { 963 + page: 1, 964 + limit: 10, 965 + sortBy: CollectionSortField.UPDATED_AT, 966 + sortOrder: SortOrder.DESC, 967 + }); 968 + 969 + expect(result.items).toHaveLength(1); 970 + expect(result.items[0]?.uri).toBeUndefined(); 971 + }); 972 + 973 + it('should return URI for collections with published records', async () => { 974 + const testUri = 975 + 'at://did:plc:testcurator/network.cosmik.collection/test123'; 976 + const testCid = 'bafytest123'; 977 + 978 + // Create collection using builder 979 + const collection = new CollectionBuilder() 980 + .withAuthorId(curatorId.value) 981 + .withName('Published Collection') 982 + .withAccessType(CollectionAccessType.OPEN) 983 + .buildOrThrow(); 984 + 985 + // Publish the collection using the fake publisher 986 + const publishResult = await fakePublisher.publish(collection); 987 + expect(publishResult.isOk()).toBe(true); 988 + 989 + const publishedRecordId = publishResult.unwrap(); 990 + 991 + // Mark the collection as published in the domain model 992 + collection.markAsPublished(publishedRecordId); 993 + 994 + // Save the collection 995 + await collectionRepository.save(collection); 996 + 997 + const result = await queryRepository.findByCreator(curatorId.value, { 998 + page: 1, 999 + limit: 10, 1000 + sortBy: CollectionSortField.UPDATED_AT, 1001 + sortOrder: SortOrder.DESC, 1002 + }); 1003 + 1004 + expect(result.items).toHaveLength(1); 1005 + expect(result.items[0]?.uri).toBe(publishedRecordId.uri); 1006 + expect(result.items[0]?.name).toBe('Published Collection'); 1007 + }); 1008 + 1009 + it('should handle mix of published and unpublished collections', async () => { 1010 + // Create published collection using builder 1011 + const publishedCollection = new CollectionBuilder() 1012 + .withAuthorId(curatorId.value) 1013 + .withName('Published Collection') 1014 + .withAccessType(CollectionAccessType.OPEN) 1015 + .withCreatedAt(new Date('2023-01-01')) 1016 + .withUpdatedAt(new Date('2023-01-01')) 1017 + .buildOrThrow(); 1018 + 1019 + // Create unpublished collection using builder 1020 + const unpublishedCollection = new CollectionBuilder() 1021 + .withAuthorId(curatorId.value) 1022 + .withName('Unpublished Collection') 1023 + .withAccessType(CollectionAccessType.OPEN) 1024 + .withCreatedAt(new Date('2023-01-02')) 1025 + .withUpdatedAt(new Date('2023-01-02')) 1026 + .buildOrThrow(); 1027 + 1028 + // Publish the first collection using the fake publisher 1029 + const publishResult = await fakePublisher.publish(publishedCollection); 1030 + expect(publishResult.isOk()).toBe(true); 1031 + 1032 + const publishedRecordId = publishResult.unwrap(); 1033 + 1034 + // Mark the collection as published in the domain model 1035 + publishedCollection.markAsPublished(publishedRecordId); 1036 + 1037 + // Save both collections 1038 + await collectionRepository.save(publishedCollection); 1039 + await collectionRepository.save(unpublishedCollection); 1040 + 1041 + const result = await queryRepository.findByCreator(curatorId.value, { 1042 + page: 1, 1043 + limit: 10, 1044 + sortBy: CollectionSortField.UPDATED_AT, 1045 + sortOrder: SortOrder.DESC, 1046 + }); 1047 + 1048 + expect(result.items).toHaveLength(2); 1049 + 1050 + // Find collections by name and check URIs 1051 + const publishedItem = result.items.find( 1052 + (item) => item.name === 'Published Collection', 1053 + ); 1054 + const unpublishedItem = result.items.find( 1055 + (item) => item.name === 'Unpublished Collection', 1056 + ); 1057 + 1058 + expect(publishedItem?.uri).toBe(publishedRecordId.uri); 1059 + expect(unpublishedItem?.uri).toBeUndefined(); 937 1060 }); 938 1061 }); 939 1062
+5
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 98 98 await db.execute(sql` 99 99 CREATE INDEX IF NOT EXISTS idx_feed_activities_actor_id ON feed_activities(actor_id); 100 100 `); 101 + 102 + // Index for efficient AT URI lookups 103 + await db.execute(sql` 104 + CREATE INDEX IF NOT EXISTS published_records_uri_idx ON published_records(uri); 105 + `); 101 106 }
+50
src/modules/cards/tests/utils/InMemoryAtUriResolutionService.ts
··· 1 + import { Result, ok, err } from '../../../../shared/core/Result'; 2 + import { 3 + IAtUriResolutionService, 4 + AtUriResourceType, 5 + AtUriResolutionResult, 6 + } from '../../domain/services/IAtUriResolutionService'; 7 + import { CollectionId } from '../../domain/value-objects/CollectionId'; 8 + import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; 9 + 10 + export class InMemoryAtUriResolutionService implements IAtUriResolutionService { 11 + constructor(private collectionRepository: InMemoryCollectionRepository) {} 12 + 13 + async resolveAtUri( 14 + atUri: string, 15 + ): Promise<Result<AtUriResolutionResult | null>> { 16 + try { 17 + // Get all collections and check if any have a published record with this URI 18 + const allCollections = this.collectionRepository.getAllCollections(); 19 + 20 + for (const collection of allCollections) { 21 + if (collection.publishedRecordId?.uri === atUri) { 22 + return ok({ 23 + type: AtUriResourceType.COLLECTION, 24 + id: collection.collectionId, 25 + }); 26 + } 27 + } 28 + 29 + return ok(null); 30 + } catch (error) { 31 + return err(error as Error); 32 + } 33 + } 34 + 35 + async resolveCollectionId( 36 + atUri: string, 37 + ): Promise<Result<CollectionId | null>> { 38 + const result = await this.resolveAtUri(atUri); 39 + 40 + if (result.isErr()) { 41 + return err(result.error); 42 + } 43 + 44 + if (!result.value || result.value.type !== AtUriResourceType.COLLECTION) { 45 + return ok(null); 46 + } 47 + 48 + return ok(result.value.id as CollectionId); 49 + } 50 + }
+14 -10
src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts
··· 57 57 58 58 // Transform to DTOs 59 59 const items: CollectionQueryResultDTO[] = paginatedCollections.map( 60 - (collection) => ({ 61 - id: collection.collectionId.getStringValue(), 62 - authorId: collection.authorId.value, 63 - name: collection.name.value, 64 - description: collection.description?.value, 65 - accessType: collection.accessType, 66 - cardCount: collection.cardCount, 67 - createdAt: collection.createdAt, 68 - updatedAt: collection.updatedAt, 69 - }), 60 + (collection) => { 61 + const collectionPublishedRecordId = collection.publishedRecordId; 62 + return { 63 + id: collection.collectionId.getStringValue(), 64 + uri: collectionPublishedRecordId?.uri, 65 + authorId: collection.authorId.value, 66 + name: collection.name.value, 67 + description: collection.description?.value, 68 + accessType: collection.accessType, 69 + cardCount: collection.cardCount, 70 + createdAt: collection.createdAt, 71 + updatedAt: collection.updatedAt, 72 + }; 73 + }, 70 74 ); 71 75 72 76 return {
+1
src/shared/infrastructure/database/migrations/0003_harsh_lady_mastermind.sql
··· 1 + CREATE INDEX "published_records_uri_idx" ON "published_records" USING btree ("uri");
+718
src/shared/infrastructure/database/migrations/meta/0003_snapshot.json
··· 1 + { 2 + "id": "69abc7ad-c872-43e9-8e83-4d9362cf62c7", 3 + "prevId": "91872a6a-78f5-4bf2-a7ec-691199cdf62b", 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 + "type": { 63 + "name": "type", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true 67 + }, 68 + "content_data": { 69 + "name": "content_data", 70 + "type": "jsonb", 71 + "primaryKey": false, 72 + "notNull": true 73 + }, 74 + "url": { 75 + "name": "url", 76 + "type": "text", 77 + "primaryKey": false, 78 + "notNull": false 79 + }, 80 + "parent_card_id": { 81 + "name": "parent_card_id", 82 + "type": "uuid", 83 + "primaryKey": false, 84 + "notNull": false 85 + }, 86 + "original_published_record_id": { 87 + "name": "original_published_record_id", 88 + "type": "uuid", 89 + "primaryKey": false, 90 + "notNull": false 91 + }, 92 + "library_count": { 93 + "name": "library_count", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "default": 0 98 + }, 99 + "created_at": { 100 + "name": "created_at", 101 + "type": "timestamp", 102 + "primaryKey": false, 103 + "notNull": true, 104 + "default": "now()" 105 + }, 106 + "updated_at": { 107 + "name": "updated_at", 108 + "type": "timestamp", 109 + "primaryKey": false, 110 + "notNull": true, 111 + "default": "now()" 112 + } 113 + }, 114 + "indexes": {}, 115 + "foreignKeys": { 116 + "cards_parent_card_id_cards_id_fk": { 117 + "name": "cards_parent_card_id_cards_id_fk", 118 + "tableFrom": "cards", 119 + "tableTo": "cards", 120 + "columnsFrom": ["parent_card_id"], 121 + "columnsTo": ["id"], 122 + "onDelete": "no action", 123 + "onUpdate": "no action" 124 + }, 125 + "cards_original_published_record_id_published_records_id_fk": { 126 + "name": "cards_original_published_record_id_published_records_id_fk", 127 + "tableFrom": "cards", 128 + "tableTo": "published_records", 129 + "columnsFrom": ["original_published_record_id"], 130 + "columnsTo": ["id"], 131 + "onDelete": "no action", 132 + "onUpdate": "no action" 133 + } 134 + }, 135 + "compositePrimaryKeys": {}, 136 + "uniqueConstraints": {}, 137 + "policies": {}, 138 + "checkConstraints": {}, 139 + "isRLSEnabled": false 140 + }, 141 + "public.collection_cards": { 142 + "name": "collection_cards", 143 + "schema": "", 144 + "columns": { 145 + "id": { 146 + "name": "id", 147 + "type": "uuid", 148 + "primaryKey": true, 149 + "notNull": true 150 + }, 151 + "collection_id": { 152 + "name": "collection_id", 153 + "type": "uuid", 154 + "primaryKey": false, 155 + "notNull": true 156 + }, 157 + "card_id": { 158 + "name": "card_id", 159 + "type": "uuid", 160 + "primaryKey": false, 161 + "notNull": true 162 + }, 163 + "added_by": { 164 + "name": "added_by", 165 + "type": "text", 166 + "primaryKey": false, 167 + "notNull": true 168 + }, 169 + "added_at": { 170 + "name": "added_at", 171 + "type": "timestamp", 172 + "primaryKey": false, 173 + "notNull": true, 174 + "default": "now()" 175 + }, 176 + "published_record_id": { 177 + "name": "published_record_id", 178 + "type": "uuid", 179 + "primaryKey": false, 180 + "notNull": false 181 + } 182 + }, 183 + "indexes": {}, 184 + "foreignKeys": { 185 + "collection_cards_collection_id_collections_id_fk": { 186 + "name": "collection_cards_collection_id_collections_id_fk", 187 + "tableFrom": "collection_cards", 188 + "tableTo": "collections", 189 + "columnsFrom": ["collection_id"], 190 + "columnsTo": ["id"], 191 + "onDelete": "cascade", 192 + "onUpdate": "no action" 193 + }, 194 + "collection_cards_card_id_cards_id_fk": { 195 + "name": "collection_cards_card_id_cards_id_fk", 196 + "tableFrom": "collection_cards", 197 + "tableTo": "cards", 198 + "columnsFrom": ["card_id"], 199 + "columnsTo": ["id"], 200 + "onDelete": "cascade", 201 + "onUpdate": "no action" 202 + }, 203 + "collection_cards_published_record_id_published_records_id_fk": { 204 + "name": "collection_cards_published_record_id_published_records_id_fk", 205 + "tableFrom": "collection_cards", 206 + "tableTo": "published_records", 207 + "columnsFrom": ["published_record_id"], 208 + "columnsTo": ["id"], 209 + "onDelete": "no action", 210 + "onUpdate": "no action" 211 + } 212 + }, 213 + "compositePrimaryKeys": {}, 214 + "uniqueConstraints": {}, 215 + "policies": {}, 216 + "checkConstraints": {}, 217 + "isRLSEnabled": false 218 + }, 219 + "public.collection_collaborators": { 220 + "name": "collection_collaborators", 221 + "schema": "", 222 + "columns": { 223 + "id": { 224 + "name": "id", 225 + "type": "uuid", 226 + "primaryKey": true, 227 + "notNull": true 228 + }, 229 + "collection_id": { 230 + "name": "collection_id", 231 + "type": "uuid", 232 + "primaryKey": false, 233 + "notNull": true 234 + }, 235 + "collaborator_id": { 236 + "name": "collaborator_id", 237 + "type": "text", 238 + "primaryKey": false, 239 + "notNull": true 240 + } 241 + }, 242 + "indexes": {}, 243 + "foreignKeys": { 244 + "collection_collaborators_collection_id_collections_id_fk": { 245 + "name": "collection_collaborators_collection_id_collections_id_fk", 246 + "tableFrom": "collection_collaborators", 247 + "tableTo": "collections", 248 + "columnsFrom": ["collection_id"], 249 + "columnsTo": ["id"], 250 + "onDelete": "cascade", 251 + "onUpdate": "no action" 252 + } 253 + }, 254 + "compositePrimaryKeys": {}, 255 + "uniqueConstraints": {}, 256 + "policies": {}, 257 + "checkConstraints": {}, 258 + "isRLSEnabled": false 259 + }, 260 + "public.collections": { 261 + "name": "collections", 262 + "schema": "", 263 + "columns": { 264 + "id": { 265 + "name": "id", 266 + "type": "uuid", 267 + "primaryKey": true, 268 + "notNull": true 269 + }, 270 + "author_id": { 271 + "name": "author_id", 272 + "type": "text", 273 + "primaryKey": false, 274 + "notNull": true 275 + }, 276 + "name": { 277 + "name": "name", 278 + "type": "text", 279 + "primaryKey": false, 280 + "notNull": true 281 + }, 282 + "description": { 283 + "name": "description", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": false 287 + }, 288 + "access_type": { 289 + "name": "access_type", 290 + "type": "text", 291 + "primaryKey": false, 292 + "notNull": true 293 + }, 294 + "card_count": { 295 + "name": "card_count", 296 + "type": "integer", 297 + "primaryKey": false, 298 + "notNull": true, 299 + "default": 0 300 + }, 301 + "created_at": { 302 + "name": "created_at", 303 + "type": "timestamp", 304 + "primaryKey": false, 305 + "notNull": true, 306 + "default": "now()" 307 + }, 308 + "updated_at": { 309 + "name": "updated_at", 310 + "type": "timestamp", 311 + "primaryKey": false, 312 + "notNull": true, 313 + "default": "now()" 314 + }, 315 + "published_record_id": { 316 + "name": "published_record_id", 317 + "type": "uuid", 318 + "primaryKey": false, 319 + "notNull": false 320 + } 321 + }, 322 + "indexes": {}, 323 + "foreignKeys": { 324 + "collections_published_record_id_published_records_id_fk": { 325 + "name": "collections_published_record_id_published_records_id_fk", 326 + "tableFrom": "collections", 327 + "tableTo": "published_records", 328 + "columnsFrom": ["published_record_id"], 329 + "columnsTo": ["id"], 330 + "onDelete": "no action", 331 + "onUpdate": "no action" 332 + } 333 + }, 334 + "compositePrimaryKeys": {}, 335 + "uniqueConstraints": {}, 336 + "policies": {}, 337 + "checkConstraints": {}, 338 + "isRLSEnabled": false 339 + }, 340 + "public.library_memberships": { 341 + "name": "library_memberships", 342 + "schema": "", 343 + "columns": { 344 + "card_id": { 345 + "name": "card_id", 346 + "type": "uuid", 347 + "primaryKey": false, 348 + "notNull": true 349 + }, 350 + "user_id": { 351 + "name": "user_id", 352 + "type": "text", 353 + "primaryKey": false, 354 + "notNull": true 355 + }, 356 + "added_at": { 357 + "name": "added_at", 358 + "type": "timestamp", 359 + "primaryKey": false, 360 + "notNull": true, 361 + "default": "now()" 362 + }, 363 + "published_record_id": { 364 + "name": "published_record_id", 365 + "type": "uuid", 366 + "primaryKey": false, 367 + "notNull": false 368 + } 369 + }, 370 + "indexes": { 371 + "idx_user_cards": { 372 + "name": "idx_user_cards", 373 + "columns": [ 374 + { 375 + "expression": "user_id", 376 + "isExpression": false, 377 + "asc": true, 378 + "nulls": "last" 379 + } 380 + ], 381 + "isUnique": false, 382 + "concurrently": false, 383 + "method": "btree", 384 + "with": {} 385 + }, 386 + "idx_card_users": { 387 + "name": "idx_card_users", 388 + "columns": [ 389 + { 390 + "expression": "card_id", 391 + "isExpression": false, 392 + "asc": true, 393 + "nulls": "last" 394 + } 395 + ], 396 + "isUnique": false, 397 + "concurrently": false, 398 + "method": "btree", 399 + "with": {} 400 + } 401 + }, 402 + "foreignKeys": { 403 + "library_memberships_card_id_cards_id_fk": { 404 + "name": "library_memberships_card_id_cards_id_fk", 405 + "tableFrom": "library_memberships", 406 + "tableTo": "cards", 407 + "columnsFrom": ["card_id"], 408 + "columnsTo": ["id"], 409 + "onDelete": "cascade", 410 + "onUpdate": "no action" 411 + }, 412 + "library_memberships_published_record_id_published_records_id_fk": { 413 + "name": "library_memberships_published_record_id_published_records_id_fk", 414 + "tableFrom": "library_memberships", 415 + "tableTo": "published_records", 416 + "columnsFrom": ["published_record_id"], 417 + "columnsTo": ["id"], 418 + "onDelete": "no action", 419 + "onUpdate": "no action" 420 + } 421 + }, 422 + "compositePrimaryKeys": { 423 + "library_memberships_card_id_user_id_pk": { 424 + "name": "library_memberships_card_id_user_id_pk", 425 + "columns": ["card_id", "user_id"] 426 + } 427 + }, 428 + "uniqueConstraints": {}, 429 + "policies": {}, 430 + "checkConstraints": {}, 431 + "isRLSEnabled": false 432 + }, 433 + "public.published_records": { 434 + "name": "published_records", 435 + "schema": "", 436 + "columns": { 437 + "id": { 438 + "name": "id", 439 + "type": "uuid", 440 + "primaryKey": true, 441 + "notNull": true 442 + }, 443 + "uri": { 444 + "name": "uri", 445 + "type": "text", 446 + "primaryKey": false, 447 + "notNull": true 448 + }, 449 + "cid": { 450 + "name": "cid", 451 + "type": "text", 452 + "primaryKey": false, 453 + "notNull": true 454 + }, 455 + "recorded_at": { 456 + "name": "recorded_at", 457 + "type": "timestamp", 458 + "primaryKey": false, 459 + "notNull": true, 460 + "default": "now()" 461 + } 462 + }, 463 + "indexes": { 464 + "uri_cid_unique_idx": { 465 + "name": "uri_cid_unique_idx", 466 + "columns": [ 467 + { 468 + "expression": "uri", 469 + "isExpression": false, 470 + "asc": true, 471 + "nulls": "last" 472 + }, 473 + { 474 + "expression": "cid", 475 + "isExpression": false, 476 + "asc": true, 477 + "nulls": "last" 478 + } 479 + ], 480 + "isUnique": true, 481 + "concurrently": false, 482 + "method": "btree", 483 + "with": {} 484 + }, 485 + "published_records_uri_idx": { 486 + "name": "published_records_uri_idx", 487 + "columns": [ 488 + { 489 + "expression": "uri", 490 + "isExpression": false, 491 + "asc": true, 492 + "nulls": "last" 493 + } 494 + ], 495 + "isUnique": false, 496 + "concurrently": false, 497 + "method": "btree", 498 + "with": {} 499 + } 500 + }, 501 + "foreignKeys": {}, 502 + "compositePrimaryKeys": {}, 503 + "uniqueConstraints": {}, 504 + "policies": {}, 505 + "checkConstraints": {}, 506 + "isRLSEnabled": false 507 + }, 508 + "public.feed_activities": { 509 + "name": "feed_activities", 510 + "schema": "", 511 + "columns": { 512 + "id": { 513 + "name": "id", 514 + "type": "uuid", 515 + "primaryKey": true, 516 + "notNull": true 517 + }, 518 + "actor_id": { 519 + "name": "actor_id", 520 + "type": "text", 521 + "primaryKey": false, 522 + "notNull": true 523 + }, 524 + "type": { 525 + "name": "type", 526 + "type": "text", 527 + "primaryKey": false, 528 + "notNull": true 529 + }, 530 + "metadata": { 531 + "name": "metadata", 532 + "type": "jsonb", 533 + "primaryKey": false, 534 + "notNull": true 535 + }, 536 + "created_at": { 537 + "name": "created_at", 538 + "type": "timestamp", 539 + "primaryKey": false, 540 + "notNull": true, 541 + "default": "now()" 542 + } 543 + }, 544 + "indexes": {}, 545 + "foreignKeys": {}, 546 + "compositePrimaryKeys": {}, 547 + "uniqueConstraints": {}, 548 + "policies": {}, 549 + "checkConstraints": {}, 550 + "isRLSEnabled": false 551 + }, 552 + "public.auth_session": { 553 + "name": "auth_session", 554 + "schema": "", 555 + "columns": { 556 + "key": { 557 + "name": "key", 558 + "type": "text", 559 + "primaryKey": true, 560 + "notNull": true 561 + }, 562 + "session": { 563 + "name": "session", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": true 567 + } 568 + }, 569 + "indexes": {}, 570 + "foreignKeys": {}, 571 + "compositePrimaryKeys": {}, 572 + "uniqueConstraints": {}, 573 + "policies": {}, 574 + "checkConstraints": {}, 575 + "isRLSEnabled": false 576 + }, 577 + "public.auth_state": { 578 + "name": "auth_state", 579 + "schema": "", 580 + "columns": { 581 + "key": { 582 + "name": "key", 583 + "type": "text", 584 + "primaryKey": true, 585 + "notNull": true 586 + }, 587 + "state": { 588 + "name": "state", 589 + "type": "text", 590 + "primaryKey": false, 591 + "notNull": true 592 + }, 593 + "created_at": { 594 + "name": "created_at", 595 + "type": "timestamp", 596 + "primaryKey": false, 597 + "notNull": false, 598 + "default": "now()" 599 + } 600 + }, 601 + "indexes": {}, 602 + "foreignKeys": {}, 603 + "compositePrimaryKeys": {}, 604 + "uniqueConstraints": {}, 605 + "policies": {}, 606 + "checkConstraints": {}, 607 + "isRLSEnabled": false 608 + }, 609 + "public.auth_refresh_tokens": { 610 + "name": "auth_refresh_tokens", 611 + "schema": "", 612 + "columns": { 613 + "token_id": { 614 + "name": "token_id", 615 + "type": "text", 616 + "primaryKey": true, 617 + "notNull": true 618 + }, 619 + "user_did": { 620 + "name": "user_did", 621 + "type": "text", 622 + "primaryKey": false, 623 + "notNull": true 624 + }, 625 + "refresh_token": { 626 + "name": "refresh_token", 627 + "type": "text", 628 + "primaryKey": false, 629 + "notNull": true 630 + }, 631 + "issued_at": { 632 + "name": "issued_at", 633 + "type": "timestamp", 634 + "primaryKey": false, 635 + "notNull": true 636 + }, 637 + "expires_at": { 638 + "name": "expires_at", 639 + "type": "timestamp", 640 + "primaryKey": false, 641 + "notNull": true 642 + }, 643 + "revoked": { 644 + "name": "revoked", 645 + "type": "boolean", 646 + "primaryKey": false, 647 + "notNull": false, 648 + "default": false 649 + } 650 + }, 651 + "indexes": {}, 652 + "foreignKeys": { 653 + "auth_refresh_tokens_user_did_users_id_fk": { 654 + "name": "auth_refresh_tokens_user_did_users_id_fk", 655 + "tableFrom": "auth_refresh_tokens", 656 + "tableTo": "users", 657 + "columnsFrom": ["user_did"], 658 + "columnsTo": ["id"], 659 + "onDelete": "no action", 660 + "onUpdate": "no action" 661 + } 662 + }, 663 + "compositePrimaryKeys": {}, 664 + "uniqueConstraints": {}, 665 + "policies": {}, 666 + "checkConstraints": {}, 667 + "isRLSEnabled": false 668 + }, 669 + "public.users": { 670 + "name": "users", 671 + "schema": "", 672 + "columns": { 673 + "id": { 674 + "name": "id", 675 + "type": "text", 676 + "primaryKey": true, 677 + "notNull": true 678 + }, 679 + "handle": { 680 + "name": "handle", 681 + "type": "text", 682 + "primaryKey": false, 683 + "notNull": false 684 + }, 685 + "linked_at": { 686 + "name": "linked_at", 687 + "type": "timestamp", 688 + "primaryKey": false, 689 + "notNull": true 690 + }, 691 + "last_login_at": { 692 + "name": "last_login_at", 693 + "type": "timestamp", 694 + "primaryKey": false, 695 + "notNull": true 696 + } 697 + }, 698 + "indexes": {}, 699 + "foreignKeys": {}, 700 + "compositePrimaryKeys": {}, 701 + "uniqueConstraints": {}, 702 + "policies": {}, 703 + "checkConstraints": {}, 704 + "isRLSEnabled": false 705 + } 706 + }, 707 + "enums": {}, 708 + "schemas": {}, 709 + "sequences": {}, 710 + "roles": {}, 711 + "policies": {}, 712 + "views": {}, 713 + "_meta": { 714 + "columns": {}, 715 + "schemas": {}, 716 + "tables": {} 717 + } 718 + }
+7
src/shared/infrastructure/database/migrations/meta/_journal.json
··· 22 22 "when": 1754082459527, 23 23 "tag": "0002_hesitant_caretaker", 24 24 "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "7", 29 + "when": 1758934838107, 30 + "tag": "0003_harsh_lady_mastermind", 31 + "breakpoints": true 25 32 } 26 33 ] 27 34 }
+1
src/shared/infrastructure/http/app.ts
··· 72 72 controllers.updateCollectionController, 73 73 controllers.deleteCollectionController, 74 74 controllers.getCollectionPageController, 75 + controllers.getCollectionPageByAtUriController, 75 76 controllers.getMyCollectionsController, 76 77 controllers.getCollectionsController, 77 78 );
+6
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 25 25 import { LogoutController } from 'src/modules/user/infrastructure/http/controllers/LogoutController'; 26 26 import { GenerateExtensionTokensController } from 'src/modules/user/infrastructure/http/controllers/GenerateExtensionTokensController'; 27 27 import { GetUserCollectionsController } from 'src/modules/cards/infrastructure/http/controllers/GetUserCollectionsController'; 28 + import { GetCollectionPageByAtUriController } from 'src/modules/cards/infrastructure/http/controllers/GetCollectionPageByAtUriController'; 28 29 29 30 export interface Controllers { 30 31 // User controllers ··· 52 53 updateCollectionController: UpdateCollectionController; 53 54 deleteCollectionController: DeleteCollectionController; 54 55 getCollectionPageController: GetCollectionPageController; 56 + getCollectionPageByAtUriController: GetCollectionPageByAtUriController; 55 57 getMyCollectionsController: GetMyCollectionsController; 56 58 getCollectionsController: GetUserCollectionsController; 57 59 // Feed controllers ··· 132 134 getCollectionPageController: new GetCollectionPageController( 133 135 useCases.getCollectionPageUseCase, 134 136 ), 137 + getCollectionPageByAtUriController: 138 + new GetCollectionPageByAtUriController( 139 + useCases.getCollectionPageByAtUriUseCase, 140 + ), 135 141 getMyCollectionsController: new GetMyCollectionsController( 136 142 useCases.getCollectionsUseCase, 137 143 ),
+9
src/shared/infrastructure/http/factories/RepositoryFactory.ts
··· 32 32 import { DrizzleFeedRepository } from '../../../../modules/feeds/infrastructure/repositories/DrizzleFeedRepository'; 33 33 import { InMemoryFeedRepository } from '../../../../modules/feeds/tests/infrastructure/InMemoryFeedRepository'; 34 34 import { IFeedRepository } from '../../../../modules/feeds/domain/IFeedRepository'; 35 + import { IAtUriResolutionService } from '../../../../modules/cards/domain/services/IAtUriResolutionService'; 36 + import { DrizzleAtUriResolutionService } from '../../../../modules/cards/infrastructure/services/DrizzleAtUriResolutionService'; 37 + import { InMemoryAtUriResolutionService } from '../../../../modules/cards/tests/utils/InMemoryAtUriResolutionService'; 35 38 36 39 export interface Repositories { 37 40 userRepository: IUserRepository; ··· 42 45 collectionQueryRepository: ICollectionQueryRepository; 43 46 appPasswordSessionRepository: IAppPasswordSessionRepository; 44 47 feedRepository: IFeedRepository; 48 + atUriResolutionService: IAtUriResolutionService; 45 49 oauthStateStore: NodeSavedStateStore; 46 50 oauthSessionStore: NodeSavedSessionStore; 47 51 } ··· 66 70 const appPasswordSessionRepository = 67 71 new InMemoryAppPasswordSessionRepository(); 68 72 const feedRepository = InMemoryFeedRepository.getInstance(); 73 + const atUriResolutionService = new InMemoryAtUriResolutionService( 74 + collectionRepository, 75 + ); 69 76 const oauthStateStore = new InMemoryStateStore(); 70 77 const oauthSessionStore = new InMemorySessionStore(); 71 78 ··· 78 85 collectionQueryRepository, 79 86 appPasswordSessionRepository, 80 87 feedRepository, 88 + atUriResolutionService, 81 89 oauthStateStore, 82 90 oauthSessionStore, 83 91 }; ··· 99 107 collectionQueryRepository: new DrizzleCollectionQueryRepository(db), 100 108 appPasswordSessionRepository: new DrizzleAppPasswordSessionRepository(db), 101 109 feedRepository: new DrizzleFeedRepository(db), 110 + atUriResolutionService: new DrizzleAtUriResolutionService(db), 102 111 oauthStateStore, 103 112 oauthSessionStore, 104 113 };
+13 -4
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 24 24 import { GetGlobalFeedUseCase } from '../../../../modules/feeds/application/useCases/queries/GetGlobalFeedUseCase'; 25 25 import { AddActivityToFeedUseCase } from '../../../../modules/feeds/application/useCases/commands/AddActivityToFeedUseCase'; 26 26 import { GetCollectionsUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionsUseCase'; 27 + import { GetCollectionPageByAtUriUseCase } from 'src/modules/cards/application/useCases/queries/GetCollectionPageByAtUriUseCase'; 27 28 28 29 export interface UseCases { 29 30 // User use cases ··· 49 50 updateCollectionUseCase: UpdateCollectionUseCase; 50 51 deleteCollectionUseCase: DeleteCollectionUseCase; 51 52 getCollectionPageUseCase: GetCollectionPageUseCase; 53 + getCollectionPageByAtUriUseCase: GetCollectionPageByAtUriUseCase; 52 54 getCollectionsUseCase: GetCollectionsUseCase; 53 55 // Feed use cases 54 56 getGlobalFeedUseCase: GetGlobalFeedUseCase; ··· 64 66 repositories: Repositories, 65 67 services: Services, 66 68 ): UseCases { 69 + const getCollectionPageUseCase = new GetCollectionPageUseCase( 70 + repositories.collectionRepository, 71 + repositories.cardQueryRepository, 72 + services.profileService, 73 + ); 74 + 67 75 return { 68 76 // User use cases 69 77 loginWithAppPasswordUseCase: new LoginWithAppPasswordUseCase( ··· 151 159 repositories.collectionRepository, 152 160 services.collectionPublisher, 153 161 ), 154 - getCollectionPageUseCase: new GetCollectionPageUseCase( 155 - repositories.collectionRepository, 156 - repositories.cardQueryRepository, 157 - services.profileService, 162 + getCollectionPageUseCase, 163 + getCollectionPageByAtUriUseCase: new GetCollectionPageByAtUriUseCase( 164 + services.identityResolutionService, 165 + repositories.atUriResolutionService, 166 + getCollectionPageUseCase, 158 167 ), 159 168 getCollectionsUseCase: new GetCollectionsUseCase( 160 169 repositories.collectionQueryRepository,
+7
src/webapp/api-client/ApiClient.ts
··· 28 28 GenerateExtensionTokensRequest, 29 29 GetMyUrlCardsParams, 30 30 GetCollectionPageParams, 31 + GetCollectionPageByAtUriParams, 31 32 GetMyCollectionsParams, 32 33 GetGlobalFeedParams, 33 34 // Response types ··· 130 131 params?: GetCollectionPageParams, 131 132 ): Promise<GetCollectionPageResponse> { 132 133 return this.queryClient.getCollectionPage(collectionId, params); 134 + } 135 + 136 + async getCollectionPageByAtUri( 137 + params: GetCollectionPageByAtUriParams, 138 + ): Promise<GetCollectionPageResponse> { 139 + return this.queryClient.getCollectionPageByAtUri(params); 133 140 } 134 141 135 142 async getMyCollections(
+22
src/webapp/api-client/clients/QueryClient.ts
··· 12 12 GetCollectionPageParams, 13 13 GetMyCollectionsParams, 14 14 GetCollectionsParams, 15 + GetCollectionPageByAtUriParams, 15 16 GetProfileParams, 16 17 } from '../types'; 17 18 ··· 96 97 const endpoint = queryString 97 98 ? `/api/collections/${collectionId}?${queryString}` 98 99 : `/api/collections/${collectionId}`; 100 + 101 + return this.request<GetCollectionPageResponse>('GET', endpoint); 102 + } 103 + 104 + async getCollectionPageByAtUri( 105 + params: GetCollectionPageByAtUriParams, 106 + ): Promise<GetCollectionPageResponse> { 107 + const { handle, recordKey, ...queryParams } = params; 108 + const searchParams = new URLSearchParams(); 109 + 110 + if (queryParams.page) searchParams.set('page', queryParams.page.toString()); 111 + if (queryParams.limit) 112 + searchParams.set('limit', queryParams.limit.toString()); 113 + if (queryParams.sortBy) searchParams.set('sortBy', queryParams.sortBy); 114 + if (queryParams.sortOrder) 115 + searchParams.set('sortOrder', queryParams.sortOrder); 116 + 117 + const queryString = searchParams.toString(); 118 + const endpoint = queryString 119 + ? `/api/collections/at/${handle}/${recordKey}?${queryString}` 120 + : `/api/collections/at/${handle}/${recordKey}`; 99 121 100 122 return this.request<GetCollectionPageResponse>('GET', endpoint); 101 123 }
+9
src/webapp/api-client/types/requests.ts
··· 84 84 searchText?: string; 85 85 } 86 86 87 + export interface GetCollectionPageByAtUriParams { 88 + handle: string; 89 + recordKey: string; 90 + page?: number; 91 + limit?: number; 92 + sortBy?: string; 93 + sortOrder?: 'asc' | 'desc'; 94 + } 95 + 87 96 // Feed request types 88 97 export interface GetGlobalFeedParams { 89 98 page?: number;
+4 -2
src/webapp/api-client/types/responses.ts
··· 174 174 175 175 export interface GetCollectionPageResponse { 176 176 id: string; 177 + uri?: string; 177 178 name: string; 178 179 description?: string; 179 180 author: { ··· 188 189 } 189 190 190 191 export interface GetCollectionsResponse { 191 - collections: Array<{ 192 + collections: { 192 193 id: string; 194 + uri?: string; 193 195 name: string; 194 196 description?: string; 195 197 cardCount: number; ··· 201 203 handle: string; 202 204 avatarUrl?: string; 203 205 }; 204 - }>; 206 + }[]; 205 207 pagination: Pagination; 206 208 sorting: { 207 209 sortBy: 'name' | 'createdAt' | 'updatedAt' | 'cardCount';