Something went wrong. Try again.
A social knowledge tool for researchers built on ATProto
Something went wrong. Try again.
19 kB · 551 lines
TypeScript
at development
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552import { eq, inArray, and } from 'drizzle-orm';import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';import { ICollectionRepository } from '../../domain/ICollectionRepository';import { Collection } from '../../domain/Collection';import { CollectionId } from '../../domain/value-objects/CollectionId';import { CardId } from '../../domain/value-objects/CardId';import { CuratorId } from '../../domain/value-objects/CuratorId';import { collections, collectionCollaborators, collectionCards,} from './schema/collection.sql';import { publishedRecords } from './schema/publishedRecord.sql';import { CollectionDTO, CollectionMapper } from './mappers/CollectionMapper';import { Result, ok, err } from '../../../../shared/core/Result';import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
export class DrizzleCollectionRepository implements ICollectionRepository { constructor(private db: PostgresJsDatabase) {}
async findById(id: CollectionId): Promise<Result<Collection | null>> { try { const collectionId = id.getStringValue();
// Get the collection const collectionResult = await this.db .select({ collection: collections, publishedRecord: publishedRecords, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .where(eq(collections.id, collectionId)) .limit(1);
if (collectionResult.length === 0) { return ok(null); }
const result = collectionResult[0]; if (!result || !result.collection) { return ok(null); }
// Get collaborators const collaboratorResults = await this.db .select() .from(collectionCollaborators) .where(eq(collectionCollaborators.collectionId, collectionId));
const collaborators = collaboratorResults.map((c) => c.collaboratorId);
// Get card links const cardLinkResults = await this.db .select({ cardLink: collectionCards, publishedRecord: publishedRecords, }) .from(collectionCards) .leftJoin( publishedRecords, eq(collectionCards.publishedRecordId, publishedRecords.id), ) .where(eq(collectionCards.collectionId, collectionId));
const cardLinks = cardLinkResults.map((link) => ({ cardId: link.cardLink.cardId, addedBy: link.cardLink.addedBy, addedAt: link.cardLink.addedAt, publishedRecordId: link.publishedRecord?.id, publishedRecord: link.publishedRecord || undefined, }));
const collectionDTO: CollectionDTO = { id: result.collection.id, authorId: result.collection.authorId, name: result.collection.name, description: result.collection.description || undefined, accessType: result.collection.accessType, cardCount: result.collection.cardCount, createdAt: result.collection.createdAt, updatedAt: result.collection.updatedAt, publishedRecordId: result.publishedRecord?.id || null, publishedRecord: result.publishedRecord || undefined, collaborators, cardLinks, };
const domainResult = CollectionMapper.toDomain(collectionDTO); if (domainResult.isErr()) { return err(domainResult.error); }
return ok(domainResult.value); } catch (error) { return err(error as Error); } }
async findByCuratorId(curatorId: CuratorId): Promise<Result<Collection[]>> { try { const curatorIdString = curatorId.value;
// Find collections where user is author or collaborator const authorCollections = await this.db .select({ collection: collections, publishedRecord: publishedRecords, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .where(eq(collections.authorId, curatorIdString));
const collaboratorCollections = await this.db .select({ collection: collections, publishedRecord: publishedRecords, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .innerJoin( collectionCollaborators, eq(collections.id, collectionCollaborators.collectionId), ) .where(eq(collectionCollaborators.collaboratorId, curatorIdString));
// Combine and deduplicate const allCollectionResults = [ ...authorCollections, ...collaboratorCollections, ]; const uniqueCollections = allCollectionResults.filter( (collection, index, self) => index === self.findIndex((c) => c.collection.id === collection.collection.id), );
const domainCollections: Collection[] = []; for (const result of uniqueCollections) { const collectionId = result.collection.id;
// Get collaborators for this collection const collaboratorResults = await this.db .select() .from(collectionCollaborators) .where(eq(collectionCollaborators.collectionId, collectionId));
const collaborators = collaboratorResults.map((c) => c.collaboratorId);
// Get card links for this collection const cardLinkResults = await this.db .select({ cardLink: collectionCards, publishedRecord: publishedRecords, }) .from(collectionCards) .leftJoin( publishedRecords, eq(collectionCards.publishedRecordId, publishedRecords.id), ) .where(eq(collectionCards.collectionId, collectionId));
const cardLinks = cardLinkResults.map((link) => ({ cardId: link.cardLink.cardId, addedBy: link.cardLink.addedBy, addedAt: link.cardLink.addedAt, publishedRecordId: link.publishedRecord?.id, publishedRecord: link.publishedRecord || undefined, }));
const collectionDTO: CollectionDTO = { id: result.collection.id, authorId: result.collection.authorId, name: result.collection.name, description: result.collection.description || undefined, accessType: result.collection.accessType, cardCount: result.collection.cardCount, createdAt: result.collection.createdAt, updatedAt: result.collection.updatedAt, publishedRecordId: result.publishedRecord?.id || null, publishedRecord: result.publishedRecord || undefined, collaborators, cardLinks, };
const domainResult = CollectionMapper.toDomain(collectionDTO); if (domainResult.isErr()) { console.error( 'Error mapping collection to domain:', domainResult.error, ); continue; } domainCollections.push(domainResult.value); }
return ok(domainCollections); } catch (error) { return err(error as Error); } }
async findByCardId(cardId: CardId): Promise<Result<Collection[]>> { try { const cardIdString = cardId.getStringValue();
// Find collections that contain this card const collectionResults = await this.db .select({ collection: collections, publishedRecord: publishedRecords, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .innerJoin( collectionCards, eq(collections.id, collectionCards.collectionId), ) .where(eq(collectionCards.cardId, cardIdString));
const domainCollections: Collection[] = []; for (const result of collectionResults) { const collectionId = result.collection.id;
// Get collaborators for this collection const collaboratorResults = await this.db .select() .from(collectionCollaborators) .where(eq(collectionCollaborators.collectionId, collectionId));
const collaborators = collaboratorResults.map((c) => c.collaboratorId);
// Get card links for this collection const cardLinkResults = await this.db .select({ cardLink: collectionCards, publishedRecord: publishedRecords, }) .from(collectionCards) .leftJoin( publishedRecords, eq(collectionCards.publishedRecordId, publishedRecords.id), ) .where(eq(collectionCards.collectionId, collectionId));
const cardLinks = cardLinkResults.map((link) => ({ cardId: link.cardLink.cardId, addedBy: link.cardLink.addedBy, addedAt: link.cardLink.addedAt, publishedRecordId: link.publishedRecord?.id, publishedRecord: link.publishedRecord || undefined, }));
const collectionDTO: CollectionDTO = { id: result.collection.id, authorId: result.collection.authorId, name: result.collection.name, description: result.collection.description || undefined, accessType: result.collection.accessType, cardCount: result.collection.cardCount, createdAt: result.collection.createdAt, updatedAt: result.collection.updatedAt, publishedRecordId: result.publishedRecord?.id || null, publishedRecord: result.publishedRecord || undefined, collaborators, cardLinks, };
const domainResult = CollectionMapper.toDomain(collectionDTO); if (domainResult.isErr()) { console.error( 'Error mapping collection to domain:', domainResult.error, ); continue; } domainCollections.push(domainResult.value); }
return ok(domainCollections); } catch (error) { return err(error as Error); } }
async findByCuratorIdContainingCard( authorId: CuratorId, cardId: CardId, ): Promise<Result<Collection[]>> { try { const authorIdString = authorId.value; const cardIdString = cardId.getStringValue();
// Find collections authored by this curator that contain this card const collectionResults = await this.db .select({ collection: collections, publishedRecord: publishedRecords, }) .from(collections) .leftJoin( publishedRecords, eq(collections.publishedRecordId, publishedRecords.id), ) .innerJoin( collectionCards, eq(collections.id, collectionCards.collectionId), ) .where( and( eq(collections.authorId, authorIdString), eq(collectionCards.cardId, cardIdString), ), );
const domainCollections: Collection[] = []; for (const result of collectionResults) { const collectionId = result.collection.id;
// Get collaborators for this collection const collaboratorResults = await this.db .select() .from(collectionCollaborators) .where(eq(collectionCollaborators.collectionId, collectionId));
const collaborators = collaboratorResults.map((c) => c.collaboratorId);
// Get card links for this collection const cardLinkResults = await this.db .select({ cardLink: collectionCards, publishedRecord: publishedRecords, }) .from(collectionCards) .leftJoin( publishedRecords, eq(collectionCards.publishedRecordId, publishedRecords.id), ) .where(eq(collectionCards.collectionId, collectionId));
const cardLinks = cardLinkResults.map((link) => ({ cardId: link.cardLink.cardId, addedBy: link.cardLink.addedBy, addedAt: link.cardLink.addedAt, publishedRecordId: link.publishedRecord?.id, publishedRecord: link.publishedRecord || undefined, }));
const collectionDTO: CollectionDTO = { id: result.collection.id, authorId: result.collection.authorId, name: result.collection.name, description: result.collection.description || undefined, accessType: result.collection.accessType, cardCount: result.collection.cardCount, createdAt: result.collection.createdAt, updatedAt: result.collection.updatedAt, publishedRecordId: result.publishedRecord?.id || null, publishedRecord: result.publishedRecord || undefined, collaborators, cardLinks, };
const domainResult = CollectionMapper.toDomain(collectionDTO); if (domainResult.isErr()) { console.error( 'Error mapping collection to domain:', domainResult.error, ); continue; } domainCollections.push(domainResult.value); }
return ok(domainCollections); } catch (error) { return err(error as Error); } }
async save(collection: Collection): Promise<Result<void>> { try { const { collection: collectionData, collaborators, cardLinks, publishedRecord, linkPublishedRecords, } = CollectionMapper.toPersistence(collection);
await this.db.transaction(async (tx) => { // Handle collection published record if it exists let publishedRecordId: string | undefined = undefined;
if (publishedRecord) { const publishedRecordResult = await tx .insert(publishedRecords) .values({ id: publishedRecord.id, uri: publishedRecord.uri, cid: publishedRecord.cid, recordedAt: publishedRecord.recordedAt || new Date(), }) .onConflictDoNothing({ target: [publishedRecords.uri, publishedRecords.cid], }) .returning({ id: publishedRecords.id });
if (publishedRecordResult.length === 0) { const existingRecord = await tx .select() .from(publishedRecords) .where( and( eq(publishedRecords.uri, publishedRecord.uri), eq(publishedRecords.cid, publishedRecord.cid), ), ) .limit(1);
if (existingRecord.length > 0) { publishedRecordId = existingRecord[0]!.id; } } else { publishedRecordId = publishedRecordResult[0]!.id; } }
// Handle link published records const linkPublishedRecordMap = new Map<string, string>(); if (linkPublishedRecords) { for (const linkRecord of linkPublishedRecords) { const linkPublishedRecordResult = await tx .insert(publishedRecords) .values({ id: linkRecord.id, uri: linkRecord.uri, cid: linkRecord.cid, recordedAt: linkRecord.recordedAt || new Date(), }) .onConflictDoNothing({ target: [publishedRecords.uri, publishedRecords.cid], }) .returning({ id: publishedRecords.id });
let actualRecordId: string; if (linkPublishedRecordResult.length === 0) { const existingRecord = await tx .select() .from(publishedRecords) .where( and( eq(publishedRecords.uri, linkRecord.uri), eq(publishedRecords.cid, linkRecord.cid), ), ) .limit(1);
if (existingRecord.length > 0) { actualRecordId = existingRecord[0]!.id; } else { actualRecordId = linkRecord.id; } } else { actualRecordId = linkPublishedRecordResult[0]!.id; }
linkPublishedRecordMap.set(linkRecord.id, actualRecordId); } }
// Upsert the collection await tx .insert(collections) .values({ ...collectionData, publishedRecordId: publishedRecordId, }) .onConflictDoUpdate({ target: collections.id, set: { authorId: collectionData.authorId, name: collectionData.name, description: collectionData.description, accessType: collectionData.accessType, cardCount: collectionData.cardCount, updatedAt: collectionData.updatedAt, publishedRecordId: publishedRecordId, }, });
// Delete existing collaborators and card links await tx .delete(collectionCollaborators) .where(eq(collectionCollaborators.collectionId, collectionData.id));
await tx .delete(collectionCards) .where(eq(collectionCards.collectionId, collectionData.id));
// Insert new collaborators if (collaborators.length > 0) { await tx.insert(collectionCollaborators).values(collaborators); }
// Insert new card links with mapped published record IDs if (cardLinks.length > 0) { const cardLinksWithMappedRecords = cardLinks.map((link) => ({ ...link, publishedRecordId: link.publishedRecordId ? linkPublishedRecordMap.get(link.publishedRecordId) || link.publishedRecordId : undefined, }));
await tx.insert(collectionCards).values(cardLinksWithMappedRecords); } });
return ok(undefined); } catch (error) { return err(error as Error); } }
async delete(collectionId: CollectionId): Promise<Result<void>> { try { const id = collectionId.getStringValue();
// The foreign key constraints with ON DELETE CASCADE will automatically // delete related records in the collaborators and card links tables await this.db.delete(collections).where(eq(collections.id, id));
return ok(undefined); } catch (error) { return err(error as Error); } }}