inmemory to postgres

This commit is contained in:
2025-12-29 18:34:12 +01:00
parent 9e17d0752a
commit f5639a367f
176 changed files with 10175 additions and 468 deletions

View File

@@ -0,0 +1,45 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import type { FeedItemType } from '@core/social/domain/types/FeedItemType';
@Entity({ name: 'social_feed_items' })
export class FeedItemOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'timestamptz' })
timestamp!: Date;
@Column({ type: 'text' })
type!: FeedItemType;
@Column({ type: 'uuid', nullable: true })
actorFriendId!: string | null;
@Column({ type: 'uuid', nullable: true })
actorDriverId!: string | null;
@Column({ type: 'uuid', nullable: true })
leagueId!: string | null;
@Column({ type: 'uuid', nullable: true })
raceId!: string | null;
@Column({ type: 'uuid', nullable: true })
teamId!: string | null;
@Column({ type: 'int', nullable: true })
position!: number | null;
@Column({ type: 'text' })
headline!: string;
@Column({ type: 'text', nullable: true })
body!: string | null;
@Column({ type: 'text', nullable: true })
ctaLabel!: string | null;
@Column({ type: 'text', nullable: true })
ctaHref!: string | null;
}

View File

@@ -0,0 +1,13 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'social_friendships' })
export class FriendshipOrmEntity {
@PrimaryColumn({ type: 'uuid' })
driverId!: string;
@PrimaryColumn({ type: 'uuid' })
friendId!: string;
@Column({ type: 'timestamptz', nullable: true })
createdAt!: Date | null;
}

View File

@@ -0,0 +1,32 @@
export type TypeOrmSocialSchemaErrorReason =
| 'missing'
| 'not_string'
| 'empty_string'
| 'not_number'
| 'not_integer'
| 'not_boolean'
| 'not_date'
| 'invalid_date'
| 'invalid_enum_value'
| 'invalid_shape';
export class TypeOrmSocialSchemaError extends Error {
readonly entityName: string;
readonly fieldName: string;
readonly reason: TypeOrmSocialSchemaErrorReason | (string & {});
constructor(params: {
entityName: string;
fieldName: string;
reason: TypeOrmSocialSchemaError['reason'];
message?: string;
}) {
const { entityName, fieldName, reason, message } = params;
super(message);
this.name = 'TypeOrmSocialSchemaError';
this.entityName = entityName;
this.fieldName = fieldName;
this.reason = reason;
}
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity';
import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError';
import { FeedItemOrmMapper } from './FeedItemOrmMapper';
describe('FeedItemOrmMapper', () => {
it('maps orm entity to domain FeedItem', () => {
const mapper = new FeedItemOrmMapper();
const orm = new FeedItemOrmEntity();
orm.id = '00000000-0000-0000-0000-000000000001';
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.type = 'new-result-posted';
orm.actorFriendId = null;
orm.actorDriverId = '00000000-0000-0000-0000-0000000000aa';
orm.leagueId = null;
orm.raceId = '00000000-0000-0000-0000-0000000000bb';
orm.teamId = null;
orm.position = 2;
orm.headline = 'Hello';
orm.body = 'World';
orm.ctaLabel = null;
orm.ctaHref = null;
const domain = mapper.toDomain(orm);
expect(domain).toEqual({
id: orm.id,
timestamp: orm.timestamp,
type: orm.type,
actorDriverId: orm.actorDriverId,
raceId: orm.raceId,
position: orm.position,
headline: orm.headline,
body: orm.body,
});
});
it('throws TypeOrmSocialSchemaError for invalid shape', () => {
const mapper = new FeedItemOrmMapper();
const orm = new FeedItemOrmEntity();
orm.id = '';
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.type = 'new-result-posted';
orm.actorFriendId = null;
orm.actorDriverId = null;
orm.leagueId = null;
orm.raceId = null;
orm.teamId = null;
orm.position = null;
orm.headline = 'Hello';
orm.body = null;
orm.ctaLabel = null;
orm.ctaHref = null;
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmSocialSchemaError);
});
});

View File

@@ -0,0 +1,100 @@
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type { FeedItemType } from '@core/social/domain/types/FeedItemType';
import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError';
import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalIntegerOrNull,
assertOptionalStringOrNull,
} from '../schema/TypeOrmSocialSchemaGuards';
export class FeedItemOrmMapper {
toOrmEntity(domain: FeedItem): FeedItemOrmEntity {
const entity = new FeedItemOrmEntity();
entity.id = domain.id;
entity.timestamp = domain.timestamp;
entity.type = domain.type;
entity.actorFriendId = domain.actorFriendId ?? null;
entity.actorDriverId = domain.actorDriverId ?? null;
entity.leagueId = domain.leagueId ?? null;
entity.raceId = domain.raceId ?? null;
entity.teamId = domain.teamId ?? null;
entity.position = domain.position ?? null;
entity.headline = domain.headline;
entity.body = domain.body ?? null;
entity.ctaLabel = domain.ctaLabel ?? null;
entity.ctaHref = domain.ctaHref ?? null;
return entity;
}
toDomain(entity: FeedItemOrmEntity): FeedItem {
const entityName = 'SocialFeedItem';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertDate(entityName, 'timestamp', entity.timestamp);
assertEnumValue<FeedItemType>(
entityName,
'type',
entity.type,
[
'friend-joined-league',
'friend-joined-team',
'friend-finished-race',
'friend-new-personal-best',
'new-race-scheduled',
'new-result-posted',
'league-highlight',
] as const,
);
assertOptionalStringOrNull(entityName, 'actorFriendId', entity.actorFriendId);
assertOptionalStringOrNull(entityName, 'actorDriverId', entity.actorDriverId);
assertOptionalStringOrNull(entityName, 'leagueId', entity.leagueId);
assertOptionalStringOrNull(entityName, 'raceId', entity.raceId);
assertOptionalStringOrNull(entityName, 'teamId', entity.teamId);
assertOptionalIntegerOrNull(entityName, 'position', entity.position);
assertNonEmptyString(entityName, 'headline', entity.headline);
assertOptionalStringOrNull(entityName, 'body', entity.body);
assertOptionalStringOrNull(entityName, 'ctaLabel', entity.ctaLabel);
assertOptionalStringOrNull(entityName, 'ctaHref', entity.ctaHref);
return {
id: entity.id,
timestamp: entity.timestamp,
type: entity.type,
...(entity.actorFriendId !== null && entity.actorFriendId !== undefined
? { actorFriendId: entity.actorFriendId }
: {}),
...(entity.actorDriverId !== null && entity.actorDriverId !== undefined
? { actorDriverId: entity.actorDriverId }
: {}),
...(entity.leagueId !== null && entity.leagueId !== undefined ? { leagueId: entity.leagueId } : {}),
...(entity.raceId !== null && entity.raceId !== undefined ? { raceId: entity.raceId } : {}),
...(entity.teamId !== null && entity.teamId !== undefined ? { teamId: entity.teamId } : {}),
...(entity.position !== null && entity.position !== undefined ? { position: entity.position } : {}),
headline: entity.headline,
...(entity.body !== null && entity.body !== undefined ? { body: entity.body } : {}),
...(entity.ctaLabel !== null && entity.ctaLabel !== undefined ? { ctaLabel: entity.ctaLabel } : {}),
...(entity.ctaHref !== null && entity.ctaHref !== undefined ? { ctaHref: entity.ctaHref } : {}),
};
} catch (error) {
if (error instanceof TypeOrmSocialSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted SocialFeedItem';
throw new TypeOrmSocialSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import type { Repository } from 'typeorm';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity';
import { FriendshipOrmEntity } from '../entities/FriendshipOrmEntity';
import { FeedItemOrmMapper } from '../mappers/FeedItemOrmMapper';
import { TypeOrmFeedRepository } from './TypeOrmFeedRepository';
describe('TypeOrmFeedRepository', () => {
it('getFeedForDriver returns mapped items for friend actor ids', async () => {
const feedRepo = {
find: vi.fn(),
} as unknown as Repository<FeedItemOrmEntity>;
const friendshipRepo = {
find: vi.fn(),
} as unknown as Repository<FriendshipOrmEntity>;
const mapper = {
toDomain: vi.fn(),
} as unknown as FeedItemOrmMapper;
const repo = new TypeOrmFeedRepository(feedRepo, friendshipRepo, mapper);
const friendship = new FriendshipOrmEntity();
friendship.driverId = '00000000-0000-0000-0000-000000000001';
friendship.friendId = '00000000-0000-0000-0000-000000000002';
friendship.createdAt = null;
friendshipRepo.find = vi.fn().mockResolvedValue([friendship]);
const ormItem = new FeedItemOrmEntity();
ormItem.id = '00000000-0000-0000-0000-0000000000aa';
ormItem.timestamp = new Date('2025-01-01T00:00:00.000Z');
ormItem.type = 'new-result-posted';
ormItem.actorFriendId = null;
ormItem.actorDriverId = friendship.friendId;
ormItem.leagueId = null;
ormItem.raceId = null;
ormItem.teamId = null;
ormItem.position = null;
ormItem.headline = 'Headline';
ormItem.body = null;
ormItem.ctaLabel = null;
ormItem.ctaHref = null;
feedRepo.find = vi.fn().mockResolvedValue([ormItem]);
const mapped: FeedItem = {
id: ormItem.id,
timestamp: ormItem.timestamp,
type: ormItem.type,
actorDriverId: ormItem.actorDriverId ?? undefined,
headline: ormItem.headline,
};
mapper.toDomain = vi.fn().mockReturnValue(mapped);
const result = await repo.getFeedForDriver(friendship.driverId);
expect(friendshipRepo.find).toHaveBeenCalledWith({ where: { driverId: friendship.driverId } });
expect(feedRepo.find).toHaveBeenCalledTimes(1);
expect(mapper.toDomain).toHaveBeenCalledWith(ormItem);
expect(result).toEqual([mapped]);
});
it('getFeedForDriver returns [] when driver has no friends', async () => {
const feedRepo = {
find: vi.fn(),
} as unknown as Repository<FeedItemOrmEntity>;
const friendshipRepo = {
find: vi.fn().mockResolvedValue([]),
} as unknown as Repository<FriendshipOrmEntity>;
const mapper = {
toDomain: vi.fn(),
} as unknown as FeedItemOrmMapper;
const repo = new TypeOrmFeedRepository(feedRepo, friendshipRepo, mapper);
const result = await repo.getFeedForDriver('00000000-0000-0000-0000-000000000001');
expect(result).toEqual([]);
expect(feedRepo.find).not.toHaveBeenCalled();
expect(mapper.toDomain).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,42 @@
import { In, type Repository } from 'typeorm';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity';
import { FriendshipOrmEntity } from '../entities/FriendshipOrmEntity';
import { FeedItemOrmMapper } from '../mappers/FeedItemOrmMapper';
export class TypeOrmFeedRepository implements IFeedRepository {
constructor(
private readonly feedRepo: Repository<FeedItemOrmEntity>,
private readonly friendshipRepo: Repository<FriendshipOrmEntity>,
private readonly mapper: FeedItemOrmMapper,
) {}
async getFeedForDriver(driverId: string, limit?: number): Promise<FeedItem[]> {
const friendships = await this.friendshipRepo.find({ where: { driverId } });
const friendIds = friendships.map((f) => f.friendId);
if (friendIds.length === 0) return [];
const entities = await this.feedRepo.find({
where: {
actorDriverId: In(friendIds),
},
order: { timestamp: 'DESC' },
...(typeof limit === 'number' ? { take: limit } : {}),
});
return entities.map((e) => this.mapper.toDomain(e));
}
async getGlobalFeed(limit?: number): Promise<FeedItem[]> {
const entities = await this.feedRepo.find({
order: { timestamp: 'DESC' },
...(typeof limit === 'number' ? { take: limit } : {}),
});
return entities.map((e) => this.mapper.toDomain(e));
}
}

View File

@@ -0,0 +1,84 @@
import { In, type Repository } from 'typeorm';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { Driver } from '@core/racing/domain/entities/Driver';
import { DriverOrmEntity } from '@adapters/racing/persistence/typeorm/entities/DriverOrmEntity';
import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper';
import { FriendshipOrmEntity } from '../entities/FriendshipOrmEntity';
import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError';
import { assertNonEmptyString } from '../schema/TypeOrmSocialSchemaGuards';
export class TypeOrmSocialGraphRepository implements ISocialGraphRepository {
constructor(
private readonly friendshipRepo: Repository<FriendshipOrmEntity>,
private readonly driverRepo: Repository<DriverOrmEntity>,
private readonly driverMapper: DriverOrmMapper,
) {}
async getFriends(driverId: string): Promise<Driver[]> {
const friendIds = await this.getFriendIds(driverId);
if (friendIds.length === 0) return [];
const drivers = await this.driverRepo.find({ where: { id: In(friendIds) } });
const byId = new Map(drivers.map((d) => [d.id, d]));
return friendIds
.map((id) => byId.get(id))
.filter((d): d is DriverOrmEntity => Boolean(d))
.map((d) => this.driverMapper.toDomain(d));
}
async getFriendIds(driverId: string): Promise<string[]> {
const entityName = 'SocialFriendship';
const friendships = await this.friendshipRepo.find({ where: { driverId } });
return friendships.map((f) => {
try {
assertNonEmptyString(entityName, 'driverId', f.driverId);
assertNonEmptyString(entityName, 'friendId', f.friendId);
return f.friendId;
} catch (error) {
if (error instanceof TypeOrmSocialSchemaError) throw error;
const message = error instanceof Error ? error.message : 'Invalid persisted SocialFriendship';
throw new TypeOrmSocialSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
});
}
async getSuggestedFriends(driverId: string, limit?: number): Promise<Driver[]> {
const directFriendIds = new Set(await this.getFriendIds(driverId));
if (directFriendIds.size === 0) return [];
const friendships = await this.friendshipRepo.find({
where: { driverId: In(Array.from(directFriendIds)) },
});
const suggestions = new Map<string, number>();
for (const friendship of friendships) {
const friendOfFriendId = friendship.friendId;
if (friendOfFriendId === driverId) continue;
if (directFriendIds.has(friendOfFriendId)) continue;
suggestions.set(friendOfFriendId, (suggestions.get(friendOfFriendId) ?? 0) + 1);
}
const rankedIds = Array.from(suggestions.entries())
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
const topIds = typeof limit === 'number' ? rankedIds.slice(0, limit) : rankedIds;
if (topIds.length === 0) return [];
const drivers = await this.driverRepo.find({ where: { id: In(topIds) } });
const byId = new Map(drivers.map((d) => [d.id, d]));
return topIds
.map((id) => byId.get(id))
.filter((d): d is DriverOrmEntity => Boolean(d))
.map((d) => this.driverMapper.toDomain(d));
}
}

View File

@@ -0,0 +1,114 @@
import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (value === undefined || value === null) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'string') {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertOptionalStringOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'string') {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_string' });
}
}
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (value === undefined || value === null) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertOptionalNumberOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (value === undefined || value === null) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertOptionalIntegerOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
if (value === undefined || value === null) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'boolean') {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_boolean' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (value === undefined || value === null) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (!(value instanceof Date)) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'invalid_date' });
}
}
export function assertEnumValue<TAllowed extends string>(
entityName: string,
fieldName: string,
value: unknown,
allowed: readonly TAllowed[],
): asserts value is TAllowed {
if (value === undefined || value === null) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'string') {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!allowed.includes(value as TAllowed)) {
throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export const TypeOrmSocialSchemaGuards = {
assertNonEmptyString,
assertOptionalStringOrNull,
assertNumber,
assertOptionalNumberOrNull,
assertInteger,
assertOptionalIntegerOrNull,
assertBoolean,
assertDate,
assertEnumValue,
};