From 15c7d5eeac4657243ed892108e2055fc36d11cdc Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Thu, 13 Jun 2024 13:17:22 +0900 Subject: [PATCH 1/6] feat: define repository interfaces, update docker-compose --- pkg/timeline/model/repository.ts | 23 +++++++++++++++++++++++ resources/docker/compose.yaml | 9 +++++++++ 2 files changed, 32 insertions(+) diff --git a/pkg/timeline/model/repository.ts b/pkg/timeline/model/repository.ts index 9ad9c195..423c0964 100644 --- a/pkg/timeline/model/repository.ts +++ b/pkg/timeline/model/repository.ts @@ -24,5 +24,28 @@ export interface TimelineRepository { accountId: AccountID, filter: FetchAccountTimelineFilter, ): Promise>; + + /** + * @description Fetch home timeline + * @param noteIDs IDs of the notes to be fetched + * @param filter Filter for fetching notes + * */ + getHomeTimeline( + noteIDs: NoteID[], + filter: FetchAccountTimelineFilter, + ): Promise>; } export const timelineRepoSymbol = Ether.newEtherSymbol(); + +export interface TimelineNotesCacheRepository { + addNotesToHomeTimeline( + accountID: AccountID, + notes: Note[], + ): Promise>; + + getHomeTimeline( + accountID: AccountID, + ): Promise>; +} +export const timelineNotesCacheRepoSymbol = + Ether.newEtherSymbol(); diff --git a/resources/docker/compose.yaml b/resources/docker/compose.yaml index acb9f2e9..2679a563 100644 --- a/resources/docker/compose.yaml +++ b/resources/docker/compose.yaml @@ -9,6 +9,15 @@ services: environment: - POSTGRES_USER=pulsate - POSTGRES_PASSWORD=pulsate_db_pass + kv: + image: valkey/valkey + container_name: kv + ports: + - "6379:6379" + volumes: + - kv_data:/data + volumes: db_data: + kv_data: From 270acbeda534297c09560fccdc6ff5e06abbce86 Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Thu, 13 Jun 2024 23:05:02 +0900 Subject: [PATCH 2/6] feat: add TimelineRepository.getHomeTimeline, TimelineNotesCacheRepository --- package.json | 1 + pkg/timeline/adaptor/repository/dummy.ts | 40 +++++++++-- pkg/timeline/adaptor/repository/dummyCache.ts | 57 ++++++++++++++++ pkg/timeline/adaptor/repository/prisma.ts | 24 ++++++- .../adaptor/repository/valkeyCache.ts | 55 +++++++++++++++ pnpm-lock.yaml | 68 ++++++++++++++++++- 6 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 pkg/timeline/adaptor/repository/dummyCache.ts create mode 100644 pkg/timeline/adaptor/repository/valkeyCache.ts diff --git a/package.json b/package.json index a4a29ff4..1c18f283 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/timeline/adaptor/repository/dummy.ts b/pkg/timeline/adaptor/repository/dummy.ts index be6b7d7b..0403804e 100644 --- a/pkg/timeline/adaptor/repository/dummy.ts +++ b/pkg/timeline/adaptor/repository/dummy.ts @@ -1,17 +1,18 @@ 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; + private readonly data: Map; constructor(data: readonly Note[] = []) { - this.data = new Set(data); + this.data = new Map(data.map((v) => [v.getID(), v])); } async getAccountTimeline( @@ -19,16 +20,41 @@ export class InMemoryTimelineRepository implements TimelineRepository { filter: FetchAccountTimelineFilter, ): Promise> { 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> { + const notes: Note[] = []; + for (const noteID of noteIDs) { + const n = this.data.get(noteID); + if (!n) { + return Result.err(new Error('Not found')); + } + 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) + : notes.length; + + return Result.ok(notes.slice(0, beforeIndex)); } } diff --git a/pkg/timeline/adaptor/repository/dummyCache.ts b/pkg/timeline/adaptor/repository/dummyCache.ts new file mode 100644 index 00000000..a657f790 --- /dev/null +++ b/pkg/timeline/adaptor/repository/dummyCache.ts @@ -0,0 +1,57 @@ +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; + 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> { + const fetched = this.data.get(this.generateObjectKey(accountID)); + if (!fetched) { + this.data.set( + this.generateObjectKey(accountID), + notes.map((note) => note.getID()), + ); + return Result.ok(undefined); + } + // NOTE: replace by updated object + this.data.delete(this.generateObjectKey(accountID)); + + fetched.push(...notes.map((note) => note.getID())); + this.data.set(this.generateObjectKey(accountID), fetched); + + return Result.ok(undefined); + } + + async getHomeTimeline( + accountID: AccountID, + ): Promise> { + const fetched = this.data.get(this.generateObjectKey(accountID)); + if (!fetched) { + return Result.err(new Error('Not found')); + } + return Result.ok(fetched.sort()); + } +} diff --git a/pkg/timeline/adaptor/repository/prisma.ts b/pkg/timeline/adaptor/repository/prisma.ts index c2162137..0095b008 100644 --- a/pkg/timeline/adaptor/repository/prisma.ts +++ b/pkg/timeline/adaptor/repository/prisma.ts @@ -55,7 +55,6 @@ export class PrismaTimelineRepository implements TimelineRepository { accountId: AccountID, filter: FetchAccountTimelineFilter, ): Promise> { - console.log(filter); const accountNotes = await this.prisma.note.findMany({ where: { authorId: accountId, @@ -67,7 +66,28 @@ export class PrismaTimelineRepository implements TimelineRepository { id: filter.beforeId ?? '', }, }); - console.log(accountNotes); + return Result.ok(this.deserialize(accountNotes)); } + + async getHomeTimeline( + noteIDs: NoteID[], + filter: FetchAccountTimelineFilter, + ): Promise> { + const homeNotes = await this.prisma.note.findMany({ + where: { + id: { + in: noteIDs, + }, + }, + orderBy: { + createdAt: 'desc', + }, + cursor: { + id: filter.beforeId ?? '', + }, + take: 20, + }); + return Result.ok(this.deserialize(homeNotes)); + } } diff --git a/pkg/timeline/adaptor/repository/valkeyCache.ts b/pkg/timeline/adaptor/repository/valkeyCache.ts new file mode 100644 index 00000000..f67f12bf --- /dev/null +++ b/pkg/timeline/adaptor/repository/valkeyCache.ts @@ -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> { + 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> { + 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); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db2defd2..689c2a1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: hono: specifier: ^4.0.0 version: 4.4.4 + ioredis: + specifier: ^5.4.1 + version: 5.4.1 jose: specifier: ^5.2.1 version: 5.2.4 @@ -740,6 +743,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.2.0': + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -796,7 +802,6 @@ packages: '@ls-lint/ls-lint@2.2.3': resolution: {integrity: sha512-ekM12jNm/7O2I/hsRv9HvYkRdfrHpiV1epVuI2NP+eTIcEgdIdKkKCs9KgQydu/8R5YXTov9aHdOgplmCHLupw==} - cpu: [x64, arm64, s390x] os: [darwin, linux, win32] hasBin: true @@ -1592,6 +1597,10 @@ packages: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} @@ -1698,6 +1707,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2115,6 +2128,10 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + ioredis@5.4.1: + resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} + engines: {node: '>=12.22.0'} + is-absolute-url@4.0.1: resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2366,6 +2383,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2800,6 +2823,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -2930,6 +2961,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} @@ -3853,6 +3887,8 @@ snapshots: '@img/sharp-win32-x64@0.33.4': optional: true + '@ioredis/commands@1.2.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4965,6 +5001,8 @@ snapshots: clsx@2.0.0: {} + cluster-key-slot@1.1.2: {} + codemirror@6.0.1(@lezer/common@1.2.1): dependencies: '@codemirror/autocomplete': 6.16.2(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.27.0)(@lezer/common@1.2.1) @@ -5082,6 +5120,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} detect-libc@2.0.3: {} @@ -5611,6 +5651,20 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + ioredis@5.4.1: + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.5 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-absolute-url@4.0.1: {} is-arguments@1.1.1: @@ -5824,6 +5878,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -6405,6 +6463,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + regenerator-runtime@0.14.1: {} regexp.prototype.flags@1.5.2: @@ -6636,6 +6700,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.7.0: {} stop-iteration-iterator@1.0.0: From 4cbc8207a944a5b80468e036410a5bce340c8051 Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Thu, 13 Jun 2024 23:05:52 +0900 Subject: [PATCH 3/6] feat: impl home timeline services --- pkg/intermodule/timeline.ts | 31 ++++++ pkg/timeline/mod.ts | 90 ++++++++++++----- pkg/timeline/model/repository.ts | 2 + pkg/timeline/router.ts | 51 ++++++++++ pkg/timeline/service/home.test.ts | 46 +++++++++ pkg/timeline/service/home.ts | 38 +++++++ pkg/timeline/service/noteVisibility.test.ts | 106 ++++++++------------ pkg/timeline/service/noteVisibility.ts | 6 ++ pkg/timeline/service/push.test.ts | 31 ++++++ pkg/timeline/service/push.ts | 55 ++++++++++ pkg/timeline/testData/testData.ts | 68 +++++++++++++ 11 files changed, 433 insertions(+), 91 deletions(-) create mode 100644 pkg/intermodule/timeline.ts create mode 100644 pkg/timeline/service/home.test.ts create mode 100644 pkg/timeline/service/home.ts create mode 100644 pkg/timeline/service/push.test.ts create mode 100644 pkg/timeline/service/push.ts create mode 100644 pkg/timeline/testData/testData.ts diff --git a/pkg/intermodule/timeline.ts b/pkg/intermodule/timeline.ts new file mode 100644 index 00000000..3e1e9828 --- /dev/null +++ b/pkg/intermodule/timeline.ts @@ -0,0 +1,31 @@ +import { Option } 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( + 'http://localhost:3000', + ); + constructor() {} + + /* + * @description Push note to timeline + * @param note to be pushed + * */ + async pushNoteToTimeline(note: Note): Promise> { + // ToDo: TimelineServiceを呼び出す + const res = await this.client.timeline.index.$post({ + json: { + id: note.getID(), + authorId: note.getAuthorID(), + }, + }); + if (!res.ok) { + return Option.some(new Error('Failed to push note')); + } + + return Option.none(); + } +} diff --git a/pkg/timeline/mod.ts b/pkg/timeline/mod.ts index fba4306a..6e59c1c6 100644 --- a/pkg/timeline/mod.ts +++ b/pkg/timeline/mod.ts @@ -1,46 +1,86 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import { Result } from '@mikuroxina/mini-fn'; +import { Option, Result } from '@mikuroxina/mini-fn'; +import type { AccountID } from '../accounts/model/account.js'; import { AccountModule } from '../intermodule/account.js'; +import { Note, type NoteID } from '../notes/model/note.js'; 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'; import { AccountTimelineService } from './service/account.js'; import { NoteVisibilityService } from './service/noteVisibility.js'; +import { PushTimelineService } from './service/push.js'; const accountModule = new AccountModule(); const timelineRepository = new InMemoryTimelineRepository(); +const timelineNotesCacheRepository = new InMemoryTimelineCacheRepository(); +const noteVisibilityService = new NoteVisibilityService(accountModule); const controller = new TimelineController({ accountTimelineService: new AccountTimelineService({ - noteVisibilityService: new NoteVisibilityService(accountModule), + noteVisibilityService: noteVisibilityService, 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'); -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', + }), ); if (Result.isErr(res)) { - return c.json({ error: res[1].message, status: 400 }); + return c.json({ error: res[1].message, status: 500 }); } - return c.json(res[1]); - }); + return new Response(undefined, { status: 204 }); + }, +); + +// ToDo: impl DropNoteFromTimelineRoute diff --git a/pkg/timeline/model/repository.ts b/pkg/timeline/model/repository.ts index 423c0964..4cf31e3b 100644 --- a/pkg/timeline/model/repository.ts +++ b/pkg/timeline/model/repository.ts @@ -13,6 +13,7 @@ export interface FetchAccountTimelineFilter { * @description if undefined, Retrieved from latest notes */ beforeId?: NoteID; } +export type FetchHomeTimelineFilter = Omit; export interface TimelineRepository { /** @@ -37,6 +38,7 @@ export interface TimelineRepository { } export const timelineRepoSymbol = Ether.newEtherSymbol(); +export type CacheObjectKey = `timeline:home:${AccountID}`; export interface TimelineNotesCacheRepository { addNotesToHomeTimeline( accountID: AccountID, diff --git a/pkg/timeline/router.ts b/pkg/timeline/router.ts index 296bc480..ebd28652 100644 --- a/pkg/timeline/router.ts +++ b/pkg/timeline/router.ts @@ -56,3 +56,54 @@ export const GetAccountTimelineRoute = createRoute({ }, }, }); + +export const PushNoteToTimelineRoute = createRoute({ + method: 'post', + description: '', + tags: ['timeline'], + path: '/timeline/', + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + id: z.string(), + authorId: z.string(), + }), + }, + }, + }, + }, + responses: { + 204: { + description: 'OK', + }, + }, +}); + +export const DropNoteFromTimelineRoute = createRoute({ + method: 'delete', + description: '', + tags: ['timeline'], + path: '/timeline/:id', + request: { + body: { + content: { + 'application/json': { + schema: z.object({}), + }, + }, + }, + params: z.object({ + id: z.string().openapi('note authorID'), + }), + }, + responses: { + 204: { + description: 'OK', + }, + 500: { + description: 'Task failed', + }, + }, +}); diff --git a/pkg/timeline/service/home.test.ts b/pkg/timeline/service/home.test.ts new file mode 100644 index 00000000..5f2c8aa0 --- /dev/null +++ b/pkg/timeline/service/home.test.ts @@ -0,0 +1,46 @@ +import { Result } from '@mikuroxina/mini-fn'; +import { describe, expect, it } from 'vitest'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { NoteID } from '../../notes/model/note.js'; +import { InMemoryTimelineRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryTimelineCacheRepository } from '../adaptor/repository/dummyCache.js'; +import { + dummyDirectNote, + dummyFollowersNote, + dummyHomeNote, + dummyPublicNote, +} from '../testData/testData.js'; +import { HomeTimelineService } from './home.js'; + +describe('HomeTimelineService', () => { + const timelineCacheRepository = new InMemoryTimelineCacheRepository([ + ['101' as AccountID, ['1' as NoteID, '2' as NoteID, '3' as NoteID]], + ]); + const timelineRepository = new InMemoryTimelineRepository([ + dummyPublicNote, + dummyHomeNote, + dummyFollowersNote, + dummyDirectNote, + ]); + const homeTimelineService = new HomeTimelineService( + timelineCacheRepository, + timelineRepository, + ); + + it('Successfully get home timeline', async () => { + const res = await homeTimelineService.fetchHomeTimeline( + '101' as AccountID, + { + hasAttachment: false, + noNsfw: false, + }, + ); + + expect(Result.unwrap(res).map((v) => v.getID())).toStrictEqual([ + '3', + '2', + '1', + ]); + }); +}); diff --git a/pkg/timeline/service/home.ts b/pkg/timeline/service/home.ts new file mode 100644 index 00000000..70f9df8b --- /dev/null +++ b/pkg/timeline/service/home.ts @@ -0,0 +1,38 @@ +import { Result } from '@mikuroxina/mini-fn'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { Note } from '../../notes/model/note.js'; +import type { + FetchHomeTimelineFilter, + TimelineNotesCacheRepository, + TimelineRepository, +} from '../model/repository.js'; + +export class HomeTimelineService { + constructor( + private readonly timelineCacheRepository: TimelineNotesCacheRepository, + private readonly timelineRepository: TimelineRepository, + ) {} + + async fetchHomeTimeline( + accountID: AccountID, + filter: FetchHomeTimelineFilter, + ): Promise> { + // ToDo: get note IDs from cache repository + const noteIDs = + await this.timelineCacheRepository.getHomeTimeline(accountID); + if (Result.isErr(noteIDs)) { + return noteIDs; + } + + const res = await this.timelineRepository.getHomeTimeline(noteIDs[1], { + id: accountID, + ...filter, + }); + if (Result.isErr(res)) { + return res; + } + + return Result.ok(res[1]); + } +} diff --git a/pkg/timeline/service/noteVisibility.test.ts b/pkg/timeline/service/noteVisibility.test.ts index 2a72a6a3..37e1f874 100644 --- a/pkg/timeline/service/noteVisibility.test.ts +++ b/pkg/timeline/service/noteVisibility.test.ts @@ -1,78 +1,21 @@ -import { Option, Result } from '@mikuroxina/mini-fn'; +import { Result } from '@mikuroxina/mini-fn'; import { describe, expect, it, vi } from 'vitest'; -import { Account, type AccountID } from '../../accounts/model/account.js'; +import { type AccountID } from '../../accounts/model/account.js'; +import { AccountModule } from '../../intermodule/account.js'; import { - AccountModule, - type PartialAccount, -} from '../../intermodule/account.js'; -import { Note, type NoteID } from '../../notes/model/note.js'; + dummyDirectNote, + dummyFollowersNote, + dummyHomeNote, + dummyPublicNote, + partialAccount1, +} from '../testData/testData.js'; import { NoteVisibilityService } from './noteVisibility.js'; describe('NoteVisibilityService', () => { const accountModule = new AccountModule(); const visibilityService = new NoteVisibilityService(accountModule); - const dummyPublicNote = Note.new({ - id: '1' as NoteID, - authorID: '100' as AccountID, - content: 'Hello world', - contentsWarningComment: '', - createdAt: new Date(), - originalNoteID: Option.none(), - sendTo: Option.none(), - visibility: 'PUBLIC', - }); - const dummyHomeNote = Note.new({ - id: '2' as NoteID, - authorID: '100' as AccountID, - content: 'Hello world to Home', - contentsWarningComment: '', - createdAt: new Date(), - originalNoteID: Option.none(), - sendTo: Option.none(), - visibility: 'HOME', - }); - const dummyFollowersNote = Note.new({ - id: '3' as NoteID, - authorID: '100' as AccountID, - content: 'Hello world to followers', - contentsWarningComment: '', - createdAt: new Date(), - originalNoteID: Option.none(), - sendTo: Option.none(), - visibility: 'FOLLOWERS', - }); - const dummyDirectNote = Note.new({ - id: '4' as NoteID, - authorID: '100' as AccountID, - content: 'Hello world to direct', - contentsWarningComment: '', - createdAt: new Date(), - originalNoteID: Option.none(), - sendTo: Option.some('101' as AccountID), - visibility: 'DIRECT', - }); - const dummyAccount1 = Account.new({ - id: '101' as AccountID, - bio: 'this is test user', - mail: 'john@example.com', - name: '@john@example.com', - nickname: 'John Doe', - passphraseHash: '', - role: 'normal', - silenced: 'normal', - status: 'active', - frozen: 'normal', - createdAt: new Date(), - }); - const partialAccount1: PartialAccount = { - id: dummyAccount1.getID(), - name: dummyAccount1.getName(), - nickname: dummyAccount1.getNickname(), - bio: dummyAccount1.getBio(), - }; - it("when author's note: return true", async () => { vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { return Result.ok([partialAccount1]); @@ -176,4 +119,35 @@ describe('NoteVisibilityService', () => { }); expect(res).toBe(true); }); + + it("homeTimelineVisibilityCheck: return true if visibility is not 'DIRECT'", async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => + Result.ok([partialAccount1]), + ); + + expect( + await visibilityService.homeTimelineVisibilityCheck({ + accountID: '0' as AccountID, + note: dummyPublicNote, + }), + ).toBe(true); + expect( + await visibilityService.homeTimelineVisibilityCheck({ + accountID: '0' as AccountID, + note: dummyHomeNote, + }), + ).toBe(true); + expect( + await visibilityService.homeTimelineVisibilityCheck({ + accountID: '0' as AccountID, + note: dummyFollowersNote, + }), + ).toBe(true); + expect( + await visibilityService.homeTimelineVisibilityCheck({ + accountID: '0' as AccountID, + note: dummyDirectNote, + }), + ).toBe(false); + }); }); diff --git a/pkg/timeline/service/noteVisibility.ts b/pkg/timeline/service/noteVisibility.ts index 76afc6ba..66fe2053 100644 --- a/pkg/timeline/service/noteVisibility.ts +++ b/pkg/timeline/service/noteVisibility.ts @@ -42,6 +42,12 @@ export class NoteVisibilityService { return false; } + + public async homeTimelineVisibilityCheck( + args: NoteVisibilityCheckArgs, + ): Promise { + return args.note.getVisibility() !== 'DIRECT'; + } } export const noteVisibilitySymbol = Ether.newEtherSymbol(); diff --git a/pkg/timeline/service/push.test.ts b/pkg/timeline/service/push.test.ts new file mode 100644 index 00000000..a111182c --- /dev/null +++ b/pkg/timeline/service/push.test.ts @@ -0,0 +1,31 @@ +import { Result } from '@mikuroxina/mini-fn'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AccountModule } from '../../intermodule/account.js'; +import { InMemoryTimelineCacheRepository } from '../adaptor/repository/dummyCache.js'; +import { dummyPublicNote, partialAccount1 } from '../testData/testData.js'; +import { NoteVisibilityService } from './noteVisibility.js'; +import { PushTimelineService } from './push.js'; + +describe('PushTimelineService', () => { + const accountModule = new AccountModule(); + const noteVisibility = new NoteVisibilityService(accountModule); + const timelineCacheRepository = new InMemoryTimelineCacheRepository(); + const pushTimelineService = new PushTimelineService( + accountModule, + noteVisibility, + timelineCacheRepository, + ); + + beforeEach(() => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + }); + + it('push to home timeline', async () => { + const res = await pushTimelineService.handle(dummyPublicNote); + + expect(Result.unwrap(res)).toBe(undefined); + }); +}); diff --git a/pkg/timeline/service/push.ts b/pkg/timeline/service/push.ts new file mode 100644 index 00000000..6dd5a716 --- /dev/null +++ b/pkg/timeline/service/push.ts @@ -0,0 +1,55 @@ +import { Result } from '@mikuroxina/mini-fn'; + +import type { AccountModule } from '../../intermodule/account.js'; +import type { Note } from '../../notes/model/note.js'; +import type { TimelineNotesCacheRepository } from '../model/repository.js'; +import type { NoteVisibilityService } from './noteVisibility.js'; + +export class PushTimelineService { + constructor( + private readonly accountModule: AccountModule, + private readonly noteVisibility: NoteVisibilityService, + private readonly timelineNotesCacheRepository: TimelineNotesCacheRepository, + ) {} + + /** + * @description Push note to home timeline + * @param note to be pushed + * */ + async handle(note: Note): Promise> { + const followers = await this.accountModule.fetchFollowers( + note.getAuthorID(), + ); + if (Result.isErr(followers)) { + return followers; + } + const unwrappedFollowers = Result.unwrap(followers); + + /* + PUBLIC, HOME, FOLLOWER: OK + DIRECT: reject (direct note is not pushed to home timeline) + */ + const isNeedReject = + !(await this.noteVisibility.homeTimelineVisibilityCheck({ + accountID: note.getAuthorID(), + note, + })); + if (isNeedReject) { + return Result.err(new Error('Note is not visible')); + } + + // ToDo: bulk insert + const res = await Promise.all( + unwrappedFollowers.map((v) => { + return this.timelineNotesCacheRepository.addNotesToHomeTimeline(v.id, [ + note, + ]); + }), + ); + if (res.some(Result.isErr)) { + return res.find(Result.isErr)!; + } + + return Result.ok(undefined); + } +} diff --git a/pkg/timeline/testData/testData.ts b/pkg/timeline/testData/testData.ts new file mode 100644 index 00000000..234e598a --- /dev/null +++ b/pkg/timeline/testData/testData.ts @@ -0,0 +1,68 @@ +/* + * These are dummy data for test, don't use it in production environment + * */ +import { Option } from '@mikuroxina/mini-fn'; + +import { Account, type AccountID } from '../../accounts/model/account.js'; +import type { PartialAccount } from '../../intermodule/account.js'; +import { Note, type NoteID } from '../../notes/model/note.js'; + +export const dummyPublicNote = Note.new({ + id: '1' as NoteID, + authorID: '100' as AccountID, + content: 'Hello world', + contentsWarningComment: '', + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'PUBLIC', + createdAt: new Date('2023/09/10 00:00:00'), +}); +export const dummyHomeNote = Note.new({ + id: '2' as NoteID, + authorID: '100' as AccountID, + content: 'Hello world to Home', + contentsWarningComment: '', + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'HOME', + createdAt: new Date('2023/09/20 00:00:00'), +}); +export const dummyFollowersNote = Note.new({ + id: '3' as NoteID, + authorID: '100' as AccountID, + content: 'Hello world to followers', + contentsWarningComment: '', + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'FOLLOWERS', + createdAt: new Date('2023/09/30 00:00:00'), +}); +export const dummyDirectNote = Note.new({ + id: '4' as NoteID, + authorID: '100' as AccountID, + content: 'Hello world to direct', + contentsWarningComment: '', + originalNoteID: Option.none(), + sendTo: Option.some('101' as AccountID), + visibility: 'DIRECT', + createdAt: new Date('2023/10/10 00:00:00'), +}); +export const dummyAccount1 = Account.new({ + id: '101' as AccountID, + bio: 'this is test user', + mail: 'john@example.com', + name: '@john@example.com', + nickname: 'John Doe', + passphraseHash: '', + role: 'normal', + silenced: 'normal', + status: 'active', + frozen: 'normal', + createdAt: new Date(), +}); +export const partialAccount1: PartialAccount = { + id: dummyAccount1.getID(), + name: dummyAccount1.getName(), + nickname: dummyAccount1.getNickname(), + bio: dummyAccount1.getBio(), +}; From 32ba6f158a65a6d3d383a5ff938c2e4856bf4816 Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Thu, 13 Jun 2024 23:12:39 +0900 Subject: [PATCH 4/6] chore: remove unused comment --- pkg/intermodule/timeline.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/intermodule/timeline.ts b/pkg/intermodule/timeline.ts index 3e1e9828..1b567598 100644 --- a/pkg/intermodule/timeline.ts +++ b/pkg/intermodule/timeline.ts @@ -15,7 +15,6 @@ export class TimelineModule { * @param note to be pushed * */ async pushNoteToTimeline(note: Note): Promise> { - // ToDo: TimelineServiceを呼び出す const res = await this.client.timeline.index.$post({ json: { id: note.getID(), From ff999f898565e58155edcc180e32e03e557f46d7 Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Sat, 15 Jun 2024 14:57:00 +0900 Subject: [PATCH 5/6] fix: status codes, variable name --- pkg/timeline/adaptor/repository/dummyCache.ts | 4 ++-- pkg/timeline/adaptor/repository/prisma.ts | 3 ++- pkg/timeline/mod.ts | 2 +- pkg/timeline/model/repository.ts | 4 ++-- pkg/timeline/service/home.ts | 2 +- pkg/timeline/service/noteVisibility.test.ts | 8 ++++---- pkg/timeline/service/noteVisibility.ts | 2 +- pkg/timeline/service/push.ts | 18 +++++++----------- 8 files changed, 20 insertions(+), 23 deletions(-) diff --git a/pkg/timeline/adaptor/repository/dummyCache.ts b/pkg/timeline/adaptor/repository/dummyCache.ts index a657f790..b87da603 100644 --- a/pkg/timeline/adaptor/repository/dummyCache.ts +++ b/pkg/timeline/adaptor/repository/dummyCache.ts @@ -28,14 +28,14 @@ export class InMemoryTimelineCacheRepository accountID: AccountID, notes: Note[], ): Promise> { - const fetched = this.data.get(this.generateObjectKey(accountID)); - if (!fetched) { + if (!this.data.has(this.generateObjectKey(accountID))) { this.data.set( this.generateObjectKey(accountID), notes.map((note) => note.getID()), ); return Result.ok(undefined); } + const fetched = this.data.get(this.generateObjectKey(accountID))!; // NOTE: replace by updated object this.data.delete(this.generateObjectKey(accountID)); diff --git a/pkg/timeline/adaptor/repository/prisma.ts b/pkg/timeline/adaptor/repository/prisma.ts index 0095b008..f30a4587 100644 --- a/pkg/timeline/adaptor/repository/prisma.ts +++ b/pkg/timeline/adaptor/repository/prisma.ts @@ -13,6 +13,7 @@ import type { } from '../../model/repository.js'; export class PrismaTimelineRepository implements TimelineRepository { + private readonly TIMELINE_NOTE_LIMIT = 20; constructor(private readonly prisma: PrismaClient) {} private deserialize( @@ -86,7 +87,7 @@ export class PrismaTimelineRepository implements TimelineRepository { cursor: { id: filter.beforeId ?? '', }, - take: 20, + take: this.TIMELINE_NOTE_LIMIT, }); return Result.ok(this.deserialize(homeNotes)); } diff --git a/pkg/timeline/mod.ts b/pkg/timeline/mod.ts index 6e59c1c6..0ea27726 100644 --- a/pkg/timeline/mod.ts +++ b/pkg/timeline/mod.ts @@ -76,7 +76,7 @@ const pushNoteToTimeline = timeline.openapi( }), ); if (Result.isErr(res)) { - return c.json({ error: res[1].message, status: 500 }); + return c.json({ error: res[1].message, status: 400 }); } return new Response(undefined, { status: 204 }); diff --git a/pkg/timeline/model/repository.ts b/pkg/timeline/model/repository.ts index 4cf31e3b..083458da 100644 --- a/pkg/timeline/model/repository.ts +++ b/pkg/timeline/model/repository.ts @@ -32,7 +32,7 @@ export interface TimelineRepository { * @param filter Filter for fetching notes * */ getHomeTimeline( - noteIDs: NoteID[], + noteIDs: readonly NoteID[], filter: FetchAccountTimelineFilter, ): Promise>; } @@ -42,7 +42,7 @@ export type CacheObjectKey = `timeline:home:${AccountID}`; export interface TimelineNotesCacheRepository { addNotesToHomeTimeline( accountID: AccountID, - notes: Note[], + notes: readonly Note[], ): Promise>; getHomeTimeline( diff --git a/pkg/timeline/service/home.ts b/pkg/timeline/service/home.ts index 70f9df8b..2f759d42 100644 --- a/pkg/timeline/service/home.ts +++ b/pkg/timeline/service/home.ts @@ -33,6 +33,6 @@ export class HomeTimelineService { return res; } - return Result.ok(res[1]); + return res; } } diff --git a/pkg/timeline/service/noteVisibility.test.ts b/pkg/timeline/service/noteVisibility.test.ts index 37e1f874..57f03754 100644 --- a/pkg/timeline/service/noteVisibility.test.ts +++ b/pkg/timeline/service/noteVisibility.test.ts @@ -126,25 +126,25 @@ describe('NoteVisibilityService', () => { ); expect( - await visibilityService.homeTimelineVisibilityCheck({ + await visibilityService.isVisibleNoteInHomeTimeline({ accountID: '0' as AccountID, note: dummyPublicNote, }), ).toBe(true); expect( - await visibilityService.homeTimelineVisibilityCheck({ + await visibilityService.isVisibleNoteInHomeTimeline({ accountID: '0' as AccountID, note: dummyHomeNote, }), ).toBe(true); expect( - await visibilityService.homeTimelineVisibilityCheck({ + await visibilityService.isVisibleNoteInHomeTimeline({ accountID: '0' as AccountID, note: dummyFollowersNote, }), ).toBe(true); expect( - await visibilityService.homeTimelineVisibilityCheck({ + await visibilityService.isVisibleNoteInHomeTimeline({ accountID: '0' as AccountID, note: dummyDirectNote, }), diff --git a/pkg/timeline/service/noteVisibility.ts b/pkg/timeline/service/noteVisibility.ts index 66fe2053..4b24352f 100644 --- a/pkg/timeline/service/noteVisibility.ts +++ b/pkg/timeline/service/noteVisibility.ts @@ -43,7 +43,7 @@ export class NoteVisibilityService { return false; } - public async homeTimelineVisibilityCheck( + public async isVisibleNoteInHomeTimeline( args: NoteVisibilityCheckArgs, ): Promise { return args.note.getVisibility() !== 'DIRECT'; diff --git a/pkg/timeline/service/push.ts b/pkg/timeline/service/push.ts index 6dd5a716..27407bfb 100644 --- a/pkg/timeline/service/push.ts +++ b/pkg/timeline/service/push.ts @@ -29,13 +29,12 @@ export class PushTimelineService { PUBLIC, HOME, FOLLOWER: OK DIRECT: reject (direct note is not pushed to home timeline) */ - const isNeedReject = - !(await this.noteVisibility.homeTimelineVisibilityCheck({ - accountID: note.getAuthorID(), - note, - })); - if (isNeedReject) { - return Result.err(new Error('Note is not visible')); + const visible = await this.noteVisibility.isVisibleNoteInHomeTimeline({ + accountID: note.getAuthorID(), + note, + }); + if (!visible) { + return Result.err(new Error('Note invisible')); } // ToDo: bulk insert @@ -46,10 +45,7 @@ export class PushTimelineService { ]); }), ); - if (res.some(Result.isErr)) { - return res.find(Result.isErr)!; - } - return Result.ok(undefined); + return res.find(Result.isErr) ?? Result.ok(undefined); } } From cc5bb3c14bbd061e020b7385b2e9f9600e59fa50 Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Sat, 15 Jun 2024 21:56:26 +0900 Subject: [PATCH 6/6] fix: remove unnecessary if expression --- pkg/intermodule/timeline.ts | 8 ++++---- pkg/timeline/adaptor/repository/dummyCache.ts | 11 ++++++----- pkg/timeline/service/home.ts | 7 +------ 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/pkg/intermodule/timeline.ts b/pkg/intermodule/timeline.ts index 1b567598..25b1d71f 100644 --- a/pkg/intermodule/timeline.ts +++ b/pkg/intermodule/timeline.ts @@ -1,4 +1,4 @@ -import { Option } from '@mikuroxina/mini-fn'; +import { Result } from '@mikuroxina/mini-fn'; import { hc } from 'hono/client'; import type { Note } from '../notes/model/note.js'; @@ -14,7 +14,7 @@ export class TimelineModule { * @description Push note to timeline * @param note to be pushed * */ - async pushNoteToTimeline(note: Note): Promise> { + async pushNoteToTimeline(note: Note): Promise> { const res = await this.client.timeline.index.$post({ json: { id: note.getID(), @@ -22,9 +22,9 @@ export class TimelineModule { }, }); if (!res.ok) { - return Option.some(new Error('Failed to push note')); + return Result.err(new Error('Failed to push note')); } - return Option.none(); + return Result.ok(undefined); } } diff --git a/pkg/timeline/adaptor/repository/dummyCache.ts b/pkg/timeline/adaptor/repository/dummyCache.ts index b87da603..95b66dac 100644 --- a/pkg/timeline/adaptor/repository/dummyCache.ts +++ b/pkg/timeline/adaptor/repository/dummyCache.ts @@ -28,19 +28,20 @@ export class InMemoryTimelineCacheRepository accountID: AccountID, notes: Note[], ): Promise> { - if (!this.data.has(this.generateObjectKey(accountID))) { + const objectKey = this.generateObjectKey(accountID); + if (!this.data.has(objectKey)) { this.data.set( - this.generateObjectKey(accountID), + objectKey, notes.map((note) => note.getID()), ); return Result.ok(undefined); } - const fetched = this.data.get(this.generateObjectKey(accountID))!; + const fetched = this.data.get(objectKey)!; // NOTE: replace by updated object - this.data.delete(this.generateObjectKey(accountID)); + this.data.delete(objectKey); fetched.push(...notes.map((note) => note.getID())); - this.data.set(this.generateObjectKey(accountID), fetched); + this.data.set(objectKey, fetched); return Result.ok(undefined); } diff --git a/pkg/timeline/service/home.ts b/pkg/timeline/service/home.ts index 2f759d42..45f06f77 100644 --- a/pkg/timeline/service/home.ts +++ b/pkg/timeline/service/home.ts @@ -25,14 +25,9 @@ export class HomeTimelineService { return noteIDs; } - const res = await this.timelineRepository.getHomeTimeline(noteIDs[1], { + return await this.timelineRepository.getHomeTimeline(noteIDs[1], { id: accountID, ...filter, }); - if (Result.isErr(res)) { - return res; - } - - return res; } }