Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: impl home timeline #440

Merged
merged 7 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"blurhash": "^2.0.5",
"file-type": "^19.0.0",
"hono": "^4.0.0",
"ioredis": "^5.4.1",
"jose": "^5.2.1",
"prisma": "^5.9.1",
"sharp": "^0.33.4",
Expand Down
30 changes: 30 additions & 0 deletions pkg/intermodule/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Result } from '@mikuroxina/mini-fn';
import { hc } from 'hono/client';

import type { Note } from '../notes/model/note.js';
import type { TimelineModuleHandlerType } from '../timeline/mod.js';

export class TimelineModule {
private readonly client = hc<TimelineModuleHandlerType>(
'http://localhost:3000',
);
constructor() {}

/*
* @description Push note to timeline
* @param note to be pushed
* */
async pushNoteToTimeline(note: Note): Promise<Result.Result<Error, void>> {
const res = await this.client.timeline.index.$post({
json: {
id: note.getID(),
authorId: note.getAuthorID(),
},
});
if (!res.ok) {
return Result.err(new Error('Failed to push note'));
}

return Result.ok(undefined);
}
}
40 changes: 33 additions & 7 deletions pkg/timeline/adaptor/repository/dummy.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,60 @@
import { Result } from '@mikuroxina/mini-fn';

import type { AccountID } from '../../../accounts/model/account.js';
import type { Note } from '../../../notes/model/note.js';
import type { Note, NoteID } from '../../../notes/model/note.js';
import type {
FetchAccountTimelineFilter,
FetchHomeTimelineFilter,
TimelineRepository,
} from '../../model/repository.js';

export class InMemoryTimelineRepository implements TimelineRepository {
private readonly data: Set<Note>;
private readonly data: Map<NoteID, Note>;

constructor(data: readonly Note[] = []) {
this.data = new Set<Note>(data);
this.data = new Map(data.map((v) => [v.getID(), v]));
}

async getAccountTimeline(
accountId: AccountID,
filter: FetchAccountTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
const accountNotes = [...this.data].filter(
(note) => note.getAuthorID() === accountId,
(note) => note[1].getAuthorID() === accountId,
);

// ToDo: filter hasAttachment, noNSFW
accountNotes.sort(
(a, b) => b.getCreatedAt().getTime() - a.getCreatedAt().getTime(),
(a, b) => b[1].getCreatedAt().getTime() - a[1].getCreatedAt().getTime(),
);
const beforeIndex = filter.beforeId
? accountNotes.findIndex((note) => note.getID() === filter.beforeId)
? accountNotes.findIndex((note) => note[1].getID() === filter.beforeId)
: accountNotes.length - 1;
return Result.ok(accountNotes.slice(0, beforeIndex));

return Result.ok(accountNotes.slice(0, beforeIndex).map((note) => note[1]));
}

async getHomeTimeline(
noteIDs: NoteID[],
filter: FetchHomeTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
const notes: Note[] = [];
for (const noteID of noteIDs) {
const n = this.data.get(noteID);
if (!n) {
return Result.err(new Error('Not found'));
}

Check warning on line 46 in pkg/timeline/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/dummy.ts#L45-L46

Added lines #L45 - L46 were not covered by tests
notes.push(n);
}

// ToDo: filter hasAttachment, noNSFW
notes.sort(
(a, b) => b.getCreatedAt().getTime() - a.getCreatedAt().getTime(),
);
const beforeIndex = filter.beforeId
? notes.findIndex((note) => note.getID() === filter.beforeId)

Check warning on line 55 in pkg/timeline/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/dummy.ts#L55

Added line #L55 was not covered by tests
: notes.length;

return Result.ok(notes.slice(0, beforeIndex));
}
}
58 changes: 58 additions & 0 deletions pkg/timeline/adaptor/repository/dummyCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Result } from '@mikuroxina/mini-fn';

import type { AccountID } from '../../../accounts/model/account.js';
import { type Note, type NoteID } from '../../../notes/model/note.js';
import type {
CacheObjectKey,
TimelineNotesCacheRepository,
} from '../../model/repository.js';

export class InMemoryTimelineCacheRepository
implements TimelineNotesCacheRepository
{
private readonly data: Map<CacheObjectKey, NoteID[]>;
constructor(data: [AccountID, NoteID[]][] = []) {
this.data = new Map(
data.map(([accountID, noteIDs]) => [
`timeline:home:${accountID}`,
noteIDs,
]),
);
}

private generateObjectKey(accountID: AccountID): CacheObjectKey {
return `timeline:home:${accountID}`;
}

async addNotesToHomeTimeline(
accountID: AccountID,
notes: Note[],
): Promise<Result.Result<Error, void>> {
const objectKey = this.generateObjectKey(accountID);
if (!this.data.has(objectKey)) {
this.data.set(
objectKey,
notes.map((note) => note.getID()),
);
return Result.ok(undefined);
}
const fetched = this.data.get(objectKey)!;
// NOTE: replace by updated object
this.data.delete(objectKey);

fetched.push(...notes.map((note) => note.getID()));
this.data.set(objectKey, fetched);

return Result.ok(undefined);

Check warning on line 46 in pkg/timeline/adaptor/repository/dummyCache.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/dummyCache.ts#L39-L46

Added lines #L39 - L46 were not covered by tests
}

async getHomeTimeline(
accountID: AccountID,
): Promise<Result.Result<Error, NoteID[]>> {
const fetched = this.data.get(this.generateObjectKey(accountID));
if (!fetched) {
return Result.err(new Error('Not found'));
}

Check warning on line 55 in pkg/timeline/adaptor/repository/dummyCache.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/dummyCache.ts#L54-L55

Added lines #L54 - L55 were not covered by tests
return Result.ok(fetched.sort());
}
}
25 changes: 23 additions & 2 deletions pkg/timeline/adaptor/repository/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
} from '../../model/repository.js';

export class PrismaTimelineRepository implements TimelineRepository {
private readonly TIMELINE_NOTE_LIMIT = 20;

Check warning on line 16 in pkg/timeline/adaptor/repository/prisma.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/prisma.ts#L16

Added line #L16 was not covered by tests
constructor(private readonly prisma: PrismaClient) {}

private deserialize(
Expand Down Expand Up @@ -55,7 +56,6 @@
accountId: AccountID,
filter: FetchAccountTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
console.log(filter);
const accountNotes = await this.prisma.note.findMany({
where: {
authorId: accountId,
Expand All @@ -67,7 +67,28 @@
id: filter.beforeId ?? '',
},
});
console.log(accountNotes);

Check warning on line 70 in pkg/timeline/adaptor/repository/prisma.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/prisma.ts#L70

Added line #L70 was not covered by tests
return Result.ok(this.deserialize(accountNotes));
}

async getHomeTimeline(
noteIDs: NoteID[],
filter: FetchAccountTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
const homeNotes = await this.prisma.note.findMany({
where: {
id: {
in: noteIDs,
},
},
orderBy: {
createdAt: 'desc',
},
cursor: {
id: filter.beforeId ?? '',
},
take: this.TIMELINE_NOTE_LIMIT,
});
return Result.ok(this.deserialize(homeNotes));
}

Check warning on line 93 in pkg/timeline/adaptor/repository/prisma.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/prisma.ts#L73-L93

Added lines #L73 - L93 were not covered by tests
}
55 changes: 55 additions & 0 deletions pkg/timeline/adaptor/repository/valkeyCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Result } from '@mikuroxina/mini-fn';
import { type Redis } from 'ioredis';

import type { AccountID } from '../../../accounts/model/account.js';
import { type Note, type NoteID } from '../../../notes/model/note.js';
import type {
CacheObjectKey,
TimelineNotesCacheRepository,
} from '../../model/repository.js';

export class ValkeyTimelineCacheRepository
implements TimelineNotesCacheRepository
{
constructor(private readonly redisClient: Redis) {}

private generateObjectKey(accountID: AccountID): CacheObjectKey {
return `timeline:home:${accountID}`;
}

async addNotesToHomeTimeline(
accountID: AccountID,
notes: Note[],
): Promise<Result.Result<Error, void>> {
try {
// ToDo: replace with bulk insert
await Promise.all(
notes.map((v) =>
this.redisClient.zadd(
this.generateObjectKey(accountID),
v.getCreatedAt().getTime(),
v.getID(),
),
),
);
return Result.ok(undefined);
} catch (e) {
return Result.err(e as Error);
}
}

async getHomeTimeline(
accountID: AccountID,
): Promise<Result.Result<Error, NoteID[]>> {
try {
const fetched = await this.redisClient.zrange(
this.generateObjectKey(accountID),
0,
-1,
);
return Result.ok(fetched as NoteID[]);
} catch (e) {
return Result.err(e as Error);
}
}
}

Check warning on line 55 in pkg/timeline/adaptor/repository/valkeyCache.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/adaptor/repository/valkeyCache.ts#L1-L55

Added lines #L1 - L55 were not covered by tests
88 changes: 64 additions & 24 deletions pkg/timeline/mod.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,86 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import { Result } from '@mikuroxina/mini-fn';
import { Option, Result } from '@mikuroxina/mini-fn';

Check warning on line 2 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L2

Added line #L2 was not covered by tests

import type { AccountID } from '../accounts/model/account.js';

Check warning on line 4 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L4

Added line #L4 was not covered by tests
import { AccountModule } from '../intermodule/account.js';
import { Note, type NoteID } from '../notes/model/note.js';

Check warning on line 6 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L6

Added line #L6 was not covered by tests
import { TimelineController } from './adaptor/controller/timeline.js';
import { InMemoryTimelineRepository } from './adaptor/repository/dummy.js';
import { GetAccountTimelineRoute } from './router.js';
import { InMemoryTimelineCacheRepository } from './adaptor/repository/dummyCache.js';
import { GetAccountTimelineRoute, PushNoteToTimelineRoute } from './router.js';

Check warning on line 10 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L9-L10

Added lines #L9 - L10 were not covered by tests
import { AccountTimelineService } from './service/account.js';
import { NoteVisibilityService } from './service/noteVisibility.js';
import { PushTimelineService } from './service/push.js';

Check warning on line 13 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L13

Added line #L13 was not covered by tests

const accountModule = new AccountModule();
const timelineRepository = new InMemoryTimelineRepository();
const timelineNotesCacheRepository = new InMemoryTimelineCacheRepository();
const noteVisibilityService = new NoteVisibilityService(accountModule);

Check warning on line 18 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L17-L18

Added lines #L17 - L18 were not covered by tests
const controller = new TimelineController({
accountTimelineService: new AccountTimelineService({
noteVisibilityService: new NoteVisibilityService(accountModule),
noteVisibilityService: noteVisibilityService,

Check warning on line 21 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L21

Added line #L21 was not covered by tests
timelineRepository: timelineRepository,
}),
accountModule,
});
const pushTimelineService = new PushTimelineService(
accountModule,
noteVisibilityService,
timelineNotesCacheRepository,
);

export type TimelineModuleHandlerType = typeof pushNoteToTimeline;

export const timeline = new OpenAPIHono().doc('/timeline/doc.json', {
openapi: '3.0.0',
info: {
title: 'Timeline API',
version: '0.1.0',
},
});
timeline.openapi(GetAccountTimelineRoute, async (c) => {
// ToDo: get account id who is trying to see the timeline
const { id } = c.req.param();
const { has_attachment, no_nsfw, before_id } = c.req.valid('query');

Check warning on line 44 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L26-L44

Added lines #L26 - L44 were not covered by tests

export const timeline = new OpenAPIHono()
.doc('/timeline/doc.json', {
openapi: '3.0.0',
info: {
title: 'Timeline API',
version: '0.1.0',
},
})
.openapi(GetAccountTimelineRoute, async (c) => {
// ToDo: get account id who is trying to see the timeline
const { id } = c.req.param();
const { has_attachment, no_nsfw, before_id } = c.req.valid('query');

const res = await controller.getAccountTimeline(
id,
'',
has_attachment,
no_nsfw,
before_id,
const res = await controller.getAccountTimeline(
id,
'',
has_attachment,
no_nsfw,
before_id,
);
if (Result.isErr(res)) {
return c.json({ error: res[1].message, status: 400 });
}

return c.json(res[1]);
});

// ToDo: Require internal access token in this endpoint
// NOTE: This is internal endpoint
const pushNoteToTimeline = timeline.openapi(
PushNoteToTimelineRoute,
async (c) => {
const { id, authorId } = c.req.valid('json');
const res = await pushTimelineService.handle(
Note.new({
id: id as NoteID,
authorID: authorId as AccountID,
content: '',
contentsWarningComment: '',
createdAt: new Date(),
originalNoteID: Option.none(),
sendTo: Option.none(),
visibility: 'FOLLOWERS',
}),

Check warning on line 76 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L46-L76

Added lines #L46 - L76 were not covered by tests
);
if (Result.isErr(res)) {
return c.json({ error: res[1].message, status: 400 });
}

return c.json(res[1]);
});
return new Response(undefined, { status: 204 });
},
);

// ToDo: impl DropNoteFromTimelineRoute

Check warning on line 86 in pkg/timeline/mod.ts

View check run for this annotation

Codecov / codecov/patch

pkg/timeline/mod.ts#L82-L86

Added lines #L82 - L86 were not covered by tests
Loading