refactor(desktop): remove old database and service (#3911)

This commit is contained in:
Stephen Zhou 2025-06-12 15:58:47 +08:00 committed by GitHub
parent be45c9b8f5
commit 52dd557d3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 26 additions and 1364 deletions

View File

@ -48,7 +48,6 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"dayjs": "1.11.13",
"dexie": "4.0.11",
"dnum": "2.15.0",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.0.2",

View File

@ -1 +0,0 @@
export const LOCAL_DB_NAME = "FOLLOW_DB"

View File

@ -1,36 +0,0 @@
import { afterEach } from "node:test"
import { describe, expect, it } from "vitest"
import { browserDB } from "./db"
describe("upgradeToV8", () => {
afterEach(async () => {
await browserDB.delete()
})
it("should set tipUsers to an empty array if tipUsers is not an array", async () => {
const insertFeeds = [
{ id: 1, tipUsers: {} },
{ id: 2, tipUsers: null },
{ id: 3, tipUsers: [{ name: "user1" }] },
]
// @ts-expect-error
await browserDB.feeds.bulkAdd(insertFeeds)
const feeds = await browserDB.feeds.toArray()
expect(feeds.length).toEqual(3)
expect(feeds[0]!.tipUsers).toEqual(insertFeeds[0]!.tipUsers)
expect(feeds[1]!.tipUsers).toEqual(insertFeeds[1]!.tipUsers)
expect(feeds[2]!.tipUsers).toEqual(insertFeeds[2]!.tipUsers)
await browserDB.transaction("rw", [browserDB.feeds], async (tx) => {
await browserDB.upgradeToV8(tx)
})
const feedsAfterMigrate = await browserDB.feeds.toArray()
expect(feedsAfterMigrate.length).toEqual(3)
expect(feedsAfterMigrate[0]!.tipUsers).toEqual([])
expect(feedsAfterMigrate[1]!.tipUsers).toEqual(null)
expect(feedsAfterMigrate[2]!.tipUsers).toEqual([{ name: "user1" }])
})
})

View File

@ -1,95 +0,0 @@
import type { Transaction } from "dexie"
import Dexie from "dexie"
import { LOCAL_DB_NAME } from "./constants"
import {
dbSchemaV1,
dbSchemaV2,
dbSchemaV3,
dbSchemaV4,
dbSchemaV5,
dbSchemaV6,
dbSchemaV7,
dbSchemaV8,
} from "./db_schema"
import type { DB_Cleaner } from "./schemas/cleaner"
import type { DB_Entry, DB_EntryRelated } from "./schemas/entry"
import type { DB_Feed, DB_FeedUnread } from "./schemas/feed"
import type { DB_Inbox } from "./schemas/inbox"
import type { DB_List } from "./schemas/list"
import type { DB_Subscription } from "./schemas/subscription"
export interface LocalDBSchemaMap {
entries: DB_Entry
feeds: DB_Feed
subscriptions: DB_Subscription
entryRelated: DB_EntryRelated
feedUnreads: DB_FeedUnread
cleaner: DB_Cleaner
lists: DB_List
inboxes: DB_Inbox
}
// Define a local DB
class BrowserDB extends Dexie {
public entries: BrowserDBTable<"entries">
public feeds: BrowserDBTable<"feeds">
public subscriptions: BrowserDBTable<"subscriptions">
public entryRelated: BrowserDBTable<"entryRelated">
public feedUnreads: BrowserDBTable<"feedUnreads">
public lists: BrowserDBTable<"lists">
public inboxes: BrowserDBTable<"inboxes">
public cleaner: BrowserDBTable<"cleaner">
constructor() {
super(LOCAL_DB_NAME)
this.version(1).stores(dbSchemaV1)
this.version(2).stores(dbSchemaV2).upgrade(this.upgradeToV2)
this.version(3).stores(dbSchemaV3)
this.version(4).stores(dbSchemaV4)
this.version(5).stores(dbSchemaV5)
this.version(6).stores(dbSchemaV6)
this.version(7).stores(dbSchemaV7)
this.version(8).stores(dbSchemaV8).upgrade(this.upgradeToV8)
this.entries = this.table("entries")
this.feeds = this.table("feeds")
this.subscriptions = this.table("subscriptions")
this.entryRelated = this.table("entryRelated")
this.feedUnreads = this.table("feedUnreads")
this.cleaner = this.table("cleaner")
this.lists = this.table("lists")
this.inboxes = this.table("inboxes")
}
async upgradeToV2(trans: Transaction) {
const session = trans.table("feedUnreads")
session.delete("feedId")
}
async upgradeToV8(trans: Transaction) {
// Fix https://github.com/RSSNext/Follow/issues/1308
const session = trans.table("feeds")
return session.toCollection().modify((feed) => {
if (!feed.tipUsers || Array.isArray(feed.tipUsers)) return
feed.tipUsers = []
})
}
}
export const browserDB = new BrowserDB()
// ================================================ //
// ================================================ //
// ================================================ //
// ================================================ //
// ================================================ //
// types helper
export type BrowserDBSchema = {
[t in keyof LocalDBSchemaMap]: {
model: LocalDBSchemaMap[t]
table: Dexie.Table<LocalDBSchemaMap[t], string>
}
}
type BrowserDBTable<T extends keyof LocalDBSchemaMap> = BrowserDBSchema[T]["table"]

View File

@ -1,42 +0,0 @@
export const dbSchemaV1 = {
entries: "&id",
feeds: "&id",
subscriptions: "&id",
entryRelated: "&id",
feedUnreads: "&id",
}
export const dbSchemaV2 = {
...dbSchemaV1,
subscriptions: "&id, userId, feedId",
}
export const dbSchemaV3 = {
...dbSchemaV2,
feedEntries: null,
subscriptions: "&id, userId, &feedId",
}
export const dbSchemaV4 = {
...dbSchemaV3,
entries: "&id, feedId",
subscriptions: "&id, userId, feedId",
}
export const dbSchemaV5 = {
...dbSchemaV4,
cleaner: "&refId, visitedAt",
}
export const dbSchemaV6 = {
...dbSchemaV5,
lists: "&id, title",
}
export const dbSchemaV7 = {
...dbSchemaV6,
inboxes: "&id",
}
export const dbSchemaV8 = dbSchemaV7

View File

@ -1,13 +0,0 @@
declare global {
// This flag controls write data in indexedDB, if it's false, pass data insert to db
// When app not ready, it's false, after hydrate data, it's true
// Or set is false when disable indexedDB in setting
export let __dbIsReady: boolean
interface Window {
__dbIsReady: boolean
}
}
export {}

View File

@ -1,22 +0,0 @@
import { browserDB } from "./db"
export * from "./db"
export * from "./schemas"
export const DB_NOT_READY_OR_DISABLED = "Database is not ready or disabled"
/**
* @description Check if database is ready
* If users disabled data persist, it's always false, that means you can't do operation with database.
*
*/
export const runTransactionInScope = <T>(
fn: (db: typeof browserDB) => T,
): T | typeof DB_NOT_READY_OR_DISABLED => {
if (!window.__dbIsReady) {
// Or, push to waiting queue
return DB_NOT_READY_OR_DISABLED
}
return fn(browserDB)
}

View File

@ -1,7 +0,0 @@
import { z } from "zod"
export const DB_BaseSchema = z.object({
id: z.string(),
})
export type DB_Base = z.infer<typeof DB_BaseSchema>

View File

@ -1,6 +0,0 @@
export type CleanerType = "feed" | "entry" | "list" | "inbox"
export type DB_Cleaner = {
refId: string
visitedAt: number
type: CleanerType
}

View File

@ -1,8 +0,0 @@
import type { EntryModel } from "@follow/models/types"
export type DB_Entry = EntryModel & { feedId: string }
export type DB_EntryRelated = {
id: string
data: any
}

View File

@ -1,8 +0,0 @@
import type { FeedModel } from "@follow/models/types"
export type DB_FeedUnread = {
id: string
count: number
}
export type DB_Feed = FeedModel & { id: string }

View File

@ -1,4 +0,0 @@
export type DB_Inbox = {
id: string
title: string
}

View File

@ -1,2 +0,0 @@
export * from "./base"
export * from "./feed"

View File

@ -1,12 +0,0 @@
export type DB_List = {
id: string
title: string
createdAt: number
updatedAt: number
description: string
fee: number
image: string
ownerUserId: string
timelineUpdatedAt: string
feedIds: string[]
}

View File

@ -1,3 +0,0 @@
export type DB_Subscription = any & {
id: string
}

View File

@ -1,44 +1,6 @@
import { sleep } from "@follow/utils/utils"
import { initializeDefaultGeneralSettings } from "~/atoms/settings/general"
import { initializeDefaultIntegrationSettings } from "~/atoms/settings/integration"
import { initializeDefaultUISettings } from "~/atoms/settings/ui"
import { appLog } from "~/lib/log"
import { EntryService, FeedService, SubscriptionService, UnreadService } from "~/services"
import { InboxService } from "~/services/inbox"
import type { Hydratable } from "~/services/interface"
import { ListService } from "~/services/list"
export const setHydrated = (v: boolean) => {
window.__dbIsReady = v
}
export const hydrateDatabaseToStore = async () => {
async function hydrate() {
const now = Date.now()
const hydrates: Hydratable[] = [
FeedService,
SubscriptionService,
UnreadService,
EntryService,
ListService,
InboxService,
]
await Promise.all(hydrates.map((h) => h.hydrate()))
window.__dbIsReady = true
const costTime = Date.now() - now
return costTime
}
return Promise.race([hydrate(), sleep(1000).then(() => 10e10)]).then((result) => {
if (result === 10e10) {
appLog("Hydrate data timeout")
}
return result
})
}
export const hydrateSettings = () => {
initializeDefaultUISettings()

View File

@ -6,32 +6,19 @@ import { tracker } from "@follow/tracker"
import { repository } from "@pkg"
import { enableMapSet } from "immer"
import { browserDB } from "~/database"
import { initI18n } from "~/i18n"
import { settingSyncQueue } from "~/modules/settings/helper/sync-queue"
import { ElectronCloseEvent, ElectronShowEvent } from "~/providers/invalidate-query-provider"
import { CleanerService } from "~/services/cleaner"
import { subscribeNetworkStatus } from "../atoms/network"
import { getGeneralSettings, subscribeShouldUseIndexedDB } from "../atoms/settings/general"
import { getGeneralSettings } from "../atoms/settings/general"
import { appLog } from "../lib/log"
import { initAnalytics } from "./analytics"
import { registerHistoryStack } from "./history"
import { hydrateSettings, setHydrated } from "./hydrate"
import { hydrateSettings } from "./hydrate"
import { doMigration } from "./migrates"
import { initSentry } from "./sentry"
const cleanup = subscribeShouldUseIndexedDB((value) => {
if (!value) {
browserDB.tables.forEach((table) => {
table.clear()
})
setHydrated(false)
return
}
setHydrated(true)
})
declare global {
interface Window {
version: string
@ -109,8 +96,6 @@ export const initializeApp = async () => {
migrateDatabase: true,
})
})
CleanerService.cleanOutdatedData()
}
await apm("initAnalytics", initAnalytics)
@ -128,8 +113,6 @@ export const initializeApp = async () => {
})
}
import.meta.hot?.dispose(cleanup)
const apm = async (label: string, fn: () => Promise<any> | any) => {
const start = Date.now()
const result = await fn()

View File

@ -4,7 +4,6 @@ import { useEffect } from "react"
import { setUserRole, setWhoami } from "~/atoms/user"
import { setIntegrationIdentify } from "~/initialize/helper"
import { useSession } from "~/queries/auth"
import { CleanerService } from "~/services/cleaner"
export const UserProvider = () => {
const { session } = useSession()
@ -15,9 +14,8 @@ export const UserProvider = () => {
if (session.role) {
setUserRole(session.role as UserRole)
}
// @ts-expect-error FIXME
setIntegrationIdentify(session.user)
CleanerService.cleanRemainingData(session.user.id)
}, [session?.role, session?.user])
return null

View File

@ -1,228 +0,0 @@
[
{
"read": true,
"view": 0,
"entries": {
"id": "46877798792098816",
"title": "DWMBlurGlass 为 Windows 10/11 启用窗口透明/玻璃/模糊效果",
"url": "https://www.appinn.com/dwmblurglass/",
"description": "DWMBlurGlass 是一款为 Win10、Win11 全局系统标题栏添加自定义效果的开源工具主要有模糊、混合颜色、文本颜色、Aero 效果等。@Appinn 来自发现频道,@suntrise 同学的推荐https://meta.appinn.net/t/topic/59864\n\nDWMBlurGlass为 Windows 10/11 启用窗口透明/玻璃/模糊效果\n\n今年又有一款能让窗口标题栏拥有透明效果的应用 DWMBlurGlass 出现,不仅支持 Win10 2004+ 和 Win11而且还是免费开源的。…",
"guid": "https://www.appinn.com/?p=48489",
"author": "青小蛙",
"authorUrl": null,
"authorAvatar": null,
"insertedAt": "2024-08-15T11:59:12.267Z",
"publishedAt": "2024-08-15T11:34:12.839Z",
"media": [
{
"url": "https://www.appinn.com/wp-content/uploads/2024/08/Appinn-feature-images-2024-08-15T185350.869.jpg",
"type": "photo",
"width": 1608,
"height": 700
},
{
"url": "https://www.appinn.com/wp-content/uploads/2024/08/001701-1.avif",
"type": "photo"
},
{
"url": "https://www.appinn.com/wp-content/uploads/2024/08/013521.avif",
"type": "photo"
},
{
"url": "https://www.appinn.com/wp-content/uploads/2024/08/e3c13f90a0c5d7ca478d7.avif",
"type": "photo"
}
],
"categories": ["Windows", "美化主题", "模糊", "玻璃", "透明"],
"attachments": null
},
"feeds": {
"id": "41370691926515712",
"url": "https://www.appinn.com/feed/",
"title": "小众软件",
"description": "分享免费、小巧、实用、有趣、绿色的软件",
"siteUrl": "https://www.appinn.com/",
"image": null,
"checkedAt": "2024-08-15T11:59:12.267Z",
"lastModifiedHeader": "Thu, 15 Aug 2024 11:34:18 GMT",
"etagHeader": "W/\"74dc42ccaecdc9834948c29b13261d87\"",
"ttl": 30,
"errorMessage": null,
"errorAt": null,
"ownerUserId": null
},
"collections": null,
"settings": {}
},
{
"read": true,
"view": 0,
"entries": {
"id": "46871301655897088",
"title": "开放麒麟 openKylin 新增 LTS 长期支持版,采用“创新版本 + LTS 版本”双轨并行策略",
"url": "https://www.ithome.com/0/788/779.htm",
"description": "IT 之家 8 月 15 日消息,开放原子开源基金会上周刚刚发布了最新的 openKylin 2.0 版本!该版本汇聚了超过 6500 + 开发者的智慧与汗水,并得到了 110+ SIG 和 520 + 企业的鼎力支持。经过社区投票决议openKylin 今天宣布将推出长期支持版本LTS未来社区将采用“创新版本 + LTS 版本”双轨并行策略:\n\n创新版本每年发布 1 个版本,提供 1 年被动更新支持\n\nLTS 版本:每 3 年发布 1 个版本,提供 1 年主动更新支持 + 1 年被动更新支持\n\nopenKylin 介绍称LTS…",
"guid": "https://www.ithome.com/0/788/779.htm",
"author": null,
"authorUrl": null,
"authorAvatar": null,
"insertedAt": "2024-08-15T11:33:04.823Z",
"publishedAt": "2024-08-15T11:11:47.914Z",
"media": [
{
"url": "https://img.ithome.com/newsuploadfiles/2024/8/1145ab09-45b1-4af4-a6e4-7bbec2548cfe.png?x-bce-process=image/format,f_auto",
"type": "photo",
"width": 1080,
"height": 448
}
],
"categories": null,
"attachments": null
},
"feeds": {
"id": "41397633064960000",
"url": "https://www.ithome.com/rss/",
"title": "IT 之家",
"description": "IT 之家 - 软媒旗下网站",
"siteUrl": "https://www.ithome.com/",
"image": null,
"checkedAt": "2024-08-15T11:33:04.823Z",
"lastModifiedHeader": null,
"etagHeader": null,
"ttl": 30,
"errorMessage": null,
"errorAt": null,
"ownerUserId": null
},
"collections": null,
"settings": {}
},
{
"read": true,
"view": 0,
"entries": {
"id": "46871301655897089",
"title": "微星海外推出 2024 款 Pro DP21 系列迷你主机:升级 14 代酷睿处理器、自带“古老”COM 串口",
"url": "https://www.ithome.com/0/788/778.htm",
"description": "IT 之家 8 月 15 日消息据微星官网微星今天在海外推出 2024 款 Pro DP21 系列迷你主机该迷你主机主要升级 14 代酷睿处理器适用于商务应用场景截至 IT 之家发稿,官网还未显示具体售价信息。外观方面,这款迷你主机尺寸为 204 x 208 x 54.8mm,重 1.52 千克,整体有点类似 CD 光驱正面采用格栅式设计侧面和顶部配备进气孔支持通过 VESA 支架壁挂。\n\n规格方面该机基于英特尔 Q670 主板可选 20 核心 28 线程酷睿 i7-14700 或 14 核心 20 线程酷睿 i5-14500 处理器至高可选…",
"guid": "https://www.ithome.com/0/788/778.htm",
"author": null,
"authorUrl": null,
"authorAvatar": null,
"insertedAt": "2024-08-15T11:33:04.823Z",
"publishedAt": "2024-08-15T11:06:25.894Z",
"media": [
{
"url": "https://img.ithome.com/newsuploadfiles/2024/8/dc1110c9-483a-4b6d-84cc-b229ec92a7b4.png",
"type": "photo",
"width": 1440,
"height": 668
},
{
"url": "https://img.ithome.com/newsuploadfiles/2024/8/df476b81-144a-4fbd-b0bb-6d81c9734b76.png",
"type": "photo",
"width": 1210,
"height": 862
},
{
"url": "https://img.ithome.com/newsuploadfiles/2024/8/568aaf73-5501-4562-9f11-ab77d654c733.png",
"type": "photo",
"width": 1440,
"height": 611
},
{
"url": "https://img.ithome.com/newsuploadfiles/2024/8/6120d871-2337-42d3-acb9-f8694362f63a.png",
"type": "photo",
"width": 1440,
"height": 575
},
{
"url": "https://img.ithome.com/newsuploadfiles/2024/8/e0dffc94-9746-4fc8-bbde-175a13af4115.png",
"type": "photo",
"width": 1440,
"height": 866
}
],
"categories": null,
"attachments": null
},
"feeds": {
"id": "41397633064960000",
"url": "https://www.ithome.com/rss/",
"title": "IT 之家",
"description": "IT 之家 - 软媒旗下网站",
"siteUrl": "https://www.ithome.com/",
"image": null,
"checkedAt": "2024-08-15T11:33:04.823Z",
"lastModifiedHeader": null,
"etagHeader": null,
"ttl": 30,
"errorMessage": null,
"errorAt": null,
"ownerUserId": null
},
"collections": null,
"settings": {}
},
{
"read": true,
"view": 0,
"entries": {
"id": "46871301655897090",
"title": "半年低至 88 元:百度网盘 SVIP 官方预售 0 点开始",
"url": "https://www.ithome.com/0/788/777.htm",
"description": "百度网盘超级会员年卡官方售价 298 元,今日半年卡预告将在 8 月 16 日 0 点开启 88 元预售狂促。约合 14.66 元 / 月、176 元 / 年好价:\n\n天猫百度网盘 超级会员 半年卡填登录手机号 16 日 0 点 88 元直达链接\n\n注现购买权益时长 + 已有权益时长,不能超过 5 年,否则会充值失败。\n\n百度网盘 SVIP 特权:\n\n天猫百度网盘 超级会员 半年卡填登录手机号 16 日 0 点 88 元直达链接",
"guid": "https://www.ithome.com/0/788/777.htm",
"author": null,
"authorUrl": null,
"authorAvatar": null,
"insertedAt": "2024-08-15T11:33:04.823Z",
"publishedAt": "2024-08-15T11:04:27.697Z",
"media": [
{
"url": "https://img.alicdn.com/bao/uploaded/i4/2201501708471/O1CN01JbgDBP2CRm3TqFHQs_!!0-item_pic.jpg",
"type": "photo",
"width": 800,
"height": 800
},
{
"url": "https://img.alicdn.com/imgextra/i4/2201501708471/O1CN01rpvXMz2CRltEbTc4P_!!2201501708471.jpg",
"type": "photo",
"width": 1500,
"height": 2322
},
{
"url": "https://img.alicdn.com/imgextra/i4/2201501708471/O1CN01W0kow62CRlt2y0KYf_!!2201501708471.jpg",
"type": "photo",
"width": 1500,
"height": 762
},
{
"url": "https://img.alicdn.com/bao/uploaded/i4/2201501708471/O1CN01JbgDBP2CRm3TqFHQs_!!0-item_pic.jpg",
"type": "photo",
"width": 800,
"height": 800
}
],
"categories": null,
"attachments": null
},
"feeds": {
"id": "41397633064960000",
"url": "https://www.ithome.com/rss/",
"title": "IT 之家",
"description": "IT 之家 - 软媒旗下网站",
"siteUrl": "https://www.ithome.com/",
"image": null,
"checkedAt": "2024-08-15T11:33:04.823Z",
"lastModifiedHeader": null,
"etagHeader": null,
"ttl": 30,
"errorMessage": null,
"errorAt": null,
"ownerUserId": null
},
"collections": null,
"settings": {}
}
]

View File

@ -1,85 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test db cleaner > data should be set up correctly 1`] = `
[
{
"id": "feed-id-1",
},
{
"id": "feed-id-2",
},
{
"id": "feed-id-3",
},
{
"id": "feed-id-4",
},
{
"id": "feed-id-5",
},
]
`;
exports[`test db cleaner > data should be set up correctly 2`] = `
[
{
"feedId": "feed-id-1",
"id": "entry-id-1",
},
{
"feedId": "feed-id-2",
"id": "entry-id-2",
},
{
"feedId": "feed-id-3",
"id": "entry-id-3",
},
{
"feedId": "feed-id-4",
"id": "entry-id-4",
},
{
"feedId": "feed-id-5",
"id": "entry-id-5",
},
{
"feedId": "feed-id-1",
"id": "entry-id-6",
},
]
`;
exports[`test db cleaner > data should be set up correctly 3`] = `
[
{
"feedId": "feed-id-1",
"id": "test-user-id-1/feed-id-1",
"userId": "test-user-id-1",
},
{
"feedId": "feed-id-3",
"id": "test-user-id-1/feed-id-3",
"userId": "test-user-id-1",
},
{
"feedId": "feed-id-4",
"id": "test-user-id-2/feed-id-4",
"userId": "test-user-id-2",
},
{
"feedId": "feed-id-5",
"id": "test-user-id-3/feed-id-5",
"userId": "test-user-id-3",
},
{
"feedId": "feed-id-1",
"id": "test-user-id/feed-id-1",
"userId": "test-user-id",
},
{
"feedId": "feed-id-2",
"id": "test-user-id/feed-id-2",
"userId": "test-user-id",
},
]
`;

View File

@ -1,27 +0,0 @@
import type Dexie from "dexie"
import type { UpdateSpec } from "dexie"
export abstract class BaseService<T extends { id: string }> {
constructor(public readonly table: Dexie.Table<T, string>) {}
async upsert(data: T): Promise<unknown> {
return this.table.put(data)
}
async upsertMany(data: T[]) {
return this.table.bulkPut(data)
}
async findAll() {
return this.table.toArray()
}
async patch(id: string, data: Partial<T>) {
const oldData = await this.table.get(id)
if (!oldData) return
await this.table.update(id, {
...oldData,
...data,
} as unknown as UpdateSpec<T>)
}
}

View File

@ -1,158 +0,0 @@
// @ts-nocheck
import type { EntryModel } from "@follow/models/types"
import type { TargetModel } from "@follow/shared/hono"
import { beforeAll, describe, expect, test } from "vitest"
import { browserDB } from "~/database"
import type { SubscriptionFlatModel } from "~/store/subscription"
import { CleanerService } from "./cleaner"
import { EntryService } from "./entry"
import { FeedService } from "./feed"
import { SubscriptionService } from "./subscription"
const currentUserID = "test-user-id"
const otherUserIDs = ["test-user-id-1", "test-user-id-2", "test-user-id-3"]
const subscriptions: SubscriptionFlatModel[] = [
{
feedId: "feed-id-1",
userId: currentUserID,
},
{
feedId: "feed-id-2",
userId: currentUserID,
},
{
feedId: "feed-id-3",
userId: otherUserIDs[0],
},
{
feedId: "feed-id-4",
userId: otherUserIDs[1],
},
{
feedId: "feed-id-5",
userId: otherUserIDs[2],
},
// ====
{
feedId: "feed-id-1",
userId: otherUserIDs[0],
},
]
const feeds: TargetModel[] = [
{
id: "feed-id-1",
},
{
id: "feed-id-2",
},
{
id: "feed-id-3",
},
{
id: "feed-id-4",
},
{
id: "feed-id-5",
},
]
const entries: EntryModel[] = [
{
id: "entry-id-1",
},
{
id: "entry-id-2",
},
{
id: "entry-id-3",
},
{
id: "entry-id-4",
},
{
id: "entry-id-5",
},
{
id: "entry-id-6",
},
]
const entryFeedIdMap = {
"entry-id-1": "feed-id-1",
"entry-id-2": "feed-id-2",
"entry-id-3": "feed-id-3",
"entry-id-4": "feed-id-4",
"entry-id-5": "feed-id-5",
"entry-id-6": "feed-id-1",
}
describe("test db cleaner", () => {
beforeAll(async () => {
await browserDB.delete()
})
beforeAll(async () => {
await browserDB.open()
await SubscriptionService.upsertMany(subscriptions)
await FeedService.upsertMany(feeds)
await EntryService.upsertMany(entries, entryFeedIdMap)
})
test("data should be set up correctly", async () => {
const feeds = await FeedService.findAll()
const entries = await EntryService.findAll()
const subscriptions = await SubscriptionService.findAll()
expect(feeds).toMatchSnapshot()
expect(entries).toMatchSnapshot()
expect(subscriptions).toMatchSnapshot()
})
test("should clean remaining data", async () => {
await CleanerService.cleanRemainingData(currentUserID)
const feeds = await FeedService.findAll()
expect(feeds).toMatchInlineSnapshot(`
[
{
"id": "feed-id-1",
},
{
"id": "feed-id-2",
},
]
`)
const subscripions = await SubscriptionService.findAll()
expect(subscripions).toMatchInlineSnapshot(`
[
{
"feedId": "feed-id-1",
"id": "test-user-id/feed-id-1",
"userId": "test-user-id",
},
{
"feedId": "feed-id-2",
"id": "test-user-id/feed-id-2",
"userId": "test-user-id",
},
]
`)
const entries = await EntryService.findAll()
expect(entries).toMatchInlineSnapshot(`
[
{
"feedId": "feed-id-1",
"id": "entry-id-1",
},
{
"feedId": "feed-id-2",
"id": "entry-id-2",
},
{
"feedId": "feed-id-1",
"id": "entry-id-6",
},
]
`)
})
})

View File

@ -1,104 +0,0 @@
import { browserDB } from "~/database"
import type { CleanerType } from "~/database/schemas/cleaner"
import { appLog } from "~/lib/log"
import { EntryService } from "./entry"
import { FeedService } from "./feed"
import { UnreadService } from "./feed-unread"
import { InboxService } from "./inbox"
import { ListService } from "./list"
import { SubscriptionService } from "./subscription"
const cleanerModel = browserDB.cleaner
class CleanerServiceStatic {
// Clean other user subscriptions, should call this after login
async cleanRemainingData(currentUserId: string) {
const dbUserIds = await SubscriptionService.getUserIds()
const otherUserIds = dbUserIds.filter((id) => id !== currentUserId)
const remainingSubscriptions = await SubscriptionService.getUserSubscriptions(otherUserIds)
const currentSubscription = await SubscriptionService.getUserSubscriptions([currentUserId])
const currentSubscriptionFeedsSet = new Set(currentSubscription.map((s) => s.feedId))
// Finds a subscripion that does not exist for the current user, but a feedId that exists in the db
const toRemoveSubscription = remainingSubscriptions.filter(
(s) => !currentSubscriptionFeedsSet.has(s.feedId),
)
const toRemoveFeedIds = toRemoveSubscription.map((s) => s.feedId)
await Promise.allSettled([
otherUserIds.map((id) => SubscriptionService.removeSubscription(id)),
FeedService.bulkDelete(toRemoveFeedIds),
UnreadService.bulkDelete(toRemoveFeedIds),
EntryService.deleteEntriesByFeedIds(toRemoveFeedIds),
])
}
/**
* Mark the which data recently used
*/
reset(list: { type: CleanerType; id: string }[]) {
const now = Date.now()
return cleanerModel.bulkPut(
list.map((item) => ({
refId: item.id,
visitedAt: now,
type: item.type,
})),
)
}
/**
* Remove the data that not used for a long time
*/
async cleanOutdatedData() {
const now = Date.now()
const expiredTime = now - 1000 * 60 * 60 * 24 * 30 // 30 days
const data = await cleanerModel.where("visitedAt").below(expiredTime).toArray()
if (data.length === 0) {
return
}
const deleteEntries = [] as string[]
const deleteFeeds = [] as string[]
const deleteLists = [] as string[]
const deleteInboxes = [] as string[]
for (const item of data) {
switch (item.type) {
case "feed": {
deleteFeeds.push(item.refId)
break
}
case "entry": {
deleteEntries.push(item.refId)
break
}
case "list": {
deleteLists.push(item.refId)
// TODO delete entries related to the list
break
}
case "inbox": {
deleteInboxes.push(item.refId)
break
}
}
}
appLog("Clean outdated data...", "feeds:", deleteFeeds.length, "entries:", deleteEntries.length)
await Promise.allSettled([
FeedService.bulkDelete(deleteFeeds),
EntryService.deleteEntries(deleteEntries),
EntryService.deleteEntriesByFeedIds(deleteFeeds),
cleanerModel.bulkDelete(data.map((d) => d.refId)),
ListService.bulkDelete(deleteLists),
InboxService.bulkDelete(deleteInboxes),
])
}
async cleanRefById(refIds: string[]) {
return cleanerModel.bulkDelete(refIds)
}
}
export const CleanerService = new CleanerServiceStatic()

View File

@ -1,72 +0,0 @@
import { browserDB } from "~/database"
export enum EntryRelatedKey {
READ = "READ",
/** @deprecated */
FEED_ID = "FEED_ID",
COLLECTION = "COLLECTION",
}
const taskQueue = new Map<EntryRelatedKey, Promise<any>>(
[EntryRelatedKey.READ, EntryRelatedKey.FEED_ID, EntryRelatedKey.COLLECTION].map((key) => [
key,
Promise.resolve(),
]),
)
type IdToIdRecord = Record<string, string>
type IdToBooleanRecord = Record<string, boolean>
type IdToAnyObjectRecord = Record<string, Record<string, any>>
const entryRelatedModel = browserDB.entryRelated
class ServiceStatic {
async findAll(type: EntryRelatedKey.FEED_ID): Promise<IdToIdRecord>
async findAll(type: EntryRelatedKey.READ): Promise<IdToBooleanRecord>
async findAll(type: EntryRelatedKey.COLLECTION): Promise<IdToAnyObjectRecord>
async findAll(type: EntryRelatedKey): Promise<Record<string, any>> {
const data = await entryRelatedModel.get(type)
return data ? data.data : {}
}
/**
*
* @param data key is entryId, value is read status
* @returns
*/
async upsert(type: EntryRelatedKey.READ, data: IdToBooleanRecord): Promise<void>
async upsert(type: EntryRelatedKey.FEED_ID, data: IdToIdRecord): Promise<void>
async upsert(type: EntryRelatedKey.COLLECTION, data: IdToAnyObjectRecord): Promise<void>
async upsert(type: any, data: Record<string, any>) {
const getPreviousTask = taskQueue.get(type) || Promise.resolve()
const task = getPreviousTask.finally(async () => {
const oldData = await this.findAll(type)
entryRelatedModel.put({
data: { ...oldData, ...data },
id: type,
})
})
taskQueue.set(type, task)
return task
}
async deleteItems(type: EntryRelatedKey, keys: string[]) {
const oldData = await this.findAll(type as any)
keys.forEach((key) => {
delete oldData[key]
})
return entryRelatedModel.put({
data: oldData,
id: type,
})
}
async clear() {
return entryRelatedModel.clear()
}
}
export const EntryRelatedService = new ServiceStatic()

View File

@ -1,95 +0,0 @@
import type { EntryModel } from "@follow/models/types"
import { browserDB } from "~/database"
import { BaseService } from "./base"
import { CleanerService } from "./cleaner"
import { EntryRelatedKey, EntryRelatedService } from "./entry-related"
import type { Hydratable } from "./interface"
type EntryCollection = {
createdAt: string
}
class EntryServiceStatic extends BaseService<EntryModel> implements Hydratable {
constructor() {
super(browserDB.entries)
}
// @ts-expect-error
override async upsertMany(data: EntryModel[], entryFeedMap: Record<string, string>) {
const renewList = [] as { type: "entry"; id: string }[]
const nextData = [] as (EntryModel & { feedId?: string; inboxId?: string })[]
for (const entry of data) {
const feedId = entryFeedMap[entry.id]
if (!feedId) {
console.error("EntryService.upsertMany: feedId not found", entry)
continue
}
renewList.push({ type: "entry", id: entry.id })
nextData.push(Object.assign({}, entry, feedId ? { feedId } : { inboxId: feedId }))
}
CleanerService.reset(renewList)
return super.upsertMany(nextData)
}
// @ts-ignore
override async upsert(feedId: string, data: EntryModel): Promise<unknown> {
CleanerService.reset([
{
type: "entry",
id: data.id,
},
])
return super.upsert({
...data,
// @ts-expect-error
feedId,
})
}
async bulkPatch(data: { key: string; changes: Partial<EntryModel> }[]) {
await this.table.bulkUpdate(data)
CleanerService.reset(data.map((d) => ({ type: "entry", id: d.key })))
}
override async findAll() {
return super.findAll() as Promise<(EntryModel & { feedId: string; inboxId: string })[]>
}
bulkStoreReadStatus(record: Record<string, boolean>) {
return EntryRelatedService.upsert(EntryRelatedKey.READ, record)
}
async bulkStoreCollection(record: Record<string, EntryCollection>) {
return EntryRelatedService.upsert(EntryRelatedKey.COLLECTION, record)
}
async deleteCollection(entryId: string) {
return EntryRelatedService.deleteItems(EntryRelatedKey.COLLECTION, [entryId])
}
async deleteEntries(entryIds: string[]) {
await Promise.all([
this.table.bulkDelete(entryIds),
EntryRelatedService.deleteItems(EntryRelatedKey.READ, entryIds),
EntryRelatedService.deleteItems(EntryRelatedKey.COLLECTION, entryIds),
CleanerService.cleanRefById(entryIds),
])
}
async deleteEntriesByFeedIds(feedIds: string[]) {
const deleteEntryIds = await this.table.where("feedId").anyOf(feedIds).primaryKeys()
await Promise.all([
this.table.where("feedId").anyOf(feedIds).delete(),
EntryRelatedService.deleteItems(EntryRelatedKey.READ, deleteEntryIds),
EntryRelatedService.deleteItems(EntryRelatedKey.COLLECTION, deleteEntryIds),
])
}
async hydrate() {}
}
export const EntryService = new EntryServiceStatic()

View File

@ -1,31 +0,0 @@
import { browserDB } from "~/database"
import type { Hydratable } from "./interface"
const unreadModel = browserDB.feedUnreads
class ServiceStatic implements Hydratable {
updateUnread(list: [string, number][]) {
return unreadModel.bulkPut(list.map(([id, count]) => ({ id, count })))
}
getAll() {
return unreadModel.toArray() as Promise<
{
id: string
count: number
}[]
>
}
clear() {
return unreadModel.clear()
}
async bulkDelete(ids: string[]) {
return unreadModel.bulkDelete(ids)
}
async hydrate() {}
}
export const UnreadService = new ServiceStatic()

View File

@ -1,36 +0,0 @@
import type { FeedModel, FeedOrListModel } from "@follow/models/types"
import { browserDB } from "~/database"
import { BaseService } from "./base"
import { CleanerService } from "./cleaner"
import type { Hydratable } from "./interface"
type FeedModelWithId = FeedModel & { id: string }
class ServiceStatic extends BaseService<FeedModelWithId> implements Hydratable {
constructor() {
super(browserDB.feeds)
}
override async upsertMany(data: FeedOrListModel[]) {
const filterData = data.filter((d) => d.id)
CleanerService.reset(filterData.map((d) => ({ type: "feed", id: d.id! })))
return this.table.bulkPut(filterData as FeedModelWithId[])
}
override async upsert(data: FeedOrListModel): Promise<string | null> {
if (!data.id) return null
CleanerService.reset([{ type: "feed", id: data.id }])
return this.table.put(data as FeedModelWithId)
}
async bulkDelete(ids: string[]) {
return this.table.bulkDelete(ids)
}
async hydrate() {}
}
export const FeedService = new ServiceStatic()

View File

@ -1,37 +0,0 @@
import type { InboxModel } from "@follow/models/types"
import { browserDB } from "~/database"
import { BaseService } from "./base"
import { CleanerService } from "./cleaner"
import type { Hydratable } from "./interface"
class ServiceStatic extends BaseService<{ id: string }> implements Hydratable {
constructor() {
super(browserDB.inboxes)
}
override async upsertMany(data: InboxModel[]) {
CleanerService.reset(data.map((d) => ({ type: "inbox", id: d.id! })))
return this.table.bulkPut(data)
}
override async findAll() {
return super.findAll() as unknown as InboxModel[]
}
override async upsert(data: InboxModel): Promise<string | null> {
if (!data.id) return null
CleanerService.reset([{ type: "inbox", id: data.id }])
return this.table.put(data)
}
async bulkDelete(ids: string[]) {
return this.table.bulkDelete(ids)
}
async hydrate() {}
}
export const InboxService = new ServiceStatic()

View File

@ -1,5 +0,0 @@
export * from "./entry"
export * from "./entry-related"
export * from "./feed"
export * from "./feed-unread"
export * from "./subscription"

View File

@ -1,3 +0,0 @@
export interface Hydratable {
hydrate: () => Promise<void>
}

View File

@ -1,45 +0,0 @@
import type { ListModel } from "@follow/models/types"
import { browserDB } from "~/database"
import { BaseService } from "./base"
import { CleanerService } from "./cleaner"
import type { Hydratable } from "./interface"
class ServiceStatic extends BaseService<{ id: string }> implements Hydratable {
constructor() {
super(browserDB.lists)
}
override async upsertMany(data: ListModel[]) {
CleanerService.reset(data.map((d) => ({ type: "list", id: d.id! })))
// FIXME The backend should not pass these computed attributes, and these need to be removed here.
// Subsequent refactoring of the backend data flow should not nest computed attributes
return this.table.bulkPut(data.map(({ owner, ...d }) => d) as ListModel[])
}
override async findAll() {
return super.findAll() as unknown as ListModel[]
}
override async upsert({ owner, ...data }: ListModel): Promise<string | null> {
if (!data.id) return null
CleanerService.reset([{ type: "list", id: data.id }])
return this.table.put(data as ListModel)
}
async bulkDelete(ids: string[]) {
return this.table.bulkDelete(ids)
}
async findAndUpdate(id: string, data: Partial<ListModel>) {
const list = await this.table.get(id)
if (!list) return
return this.table.put({ ...list, ...data })
}
async hydrate() {}
}
export const ListService = new ServiceStatic()

View File

@ -1,89 +0,0 @@
import { uniq } from "es-toolkit/compat"
import { browserDB } from "~/database"
import { BaseService } from "./base"
import type { Hydratable } from "./interface"
type SubscriptionModelWithId = any & { id: string }
class SubscriptionServiceStatic extends BaseService<SubscriptionModelWithId> implements Hydratable {
constructor() {
super(browserDB.subscriptions)
}
public getUserSubscriptions(userIds: string[]) {
return this.table.where("userId").anyOf(userIds).toArray()
}
public async getUserIds() {
return uniq(
(await this.table
.toCollection()
.uniqueKeys()
.then((keys) => keys.map((k) => k.toString().split("/")[0]))) as string[],
)
}
override async upsertMany(data: any[]) {
return this.table.bulkPut(
data.map(({ feeds, lists, inboxes, ...d }: any) => ({
...d,
id: this.uniqueId(d.userId, d.feedId),
})),
)
}
override upsert(data: any) {
return this.table.put({
...data,
id: this.uniqueId(data.userId, data.feedId),
})
}
private uniqueId(userId: string, feedId: string) {
return `${userId}/${feedId}`
}
async changeView(feedId: string, view: number) {
return this.table.where("feedId").equals(feedId).modify({ view })
}
async changeViews(feedIdList: string[], view: number) {
return this.table.where("feedId").anyOf(feedIdList).modify({ view })
}
async updateCategory(feedId: string, category?: string | null) {
return this.table.where("feedId").equals(feedId).modify({ category })
}
async updateCategories(feedIdList: string[], category?: string | null) {
return this.table.where("feedId").anyOf(feedIdList).modify({ category })
}
async removeSubscription(userId: string, feedId: string): Promise<void>
// @ts-expect-error
async removeSubscription(userId: string): Promise<void>
async removeSubscription(userId: string, feedId: string) {
if (feedId && userId) {
return this.table.delete(this.uniqueId(userId, feedId))
}
if (!feedId && userId) {
return this.table.where("userId").equals(userId).delete()
}
}
async removeSubscriptionMany(userId: string, feedIdList: string[]) {
return this.table.bulkDelete(feedIdList.map((feedId) => this.uniqueId(userId, feedId)))
}
async renameCategory(userId: string, feedIdList: string[], category: string) {
return this.table
.where("userId")
.equals(userId)
.and((item) => feedIdList.includes(item.feedId))
.modify({ category })
}
async hydrate() {}
}
export const SubscriptionService = new SubscriptionServiceStatic()

View File

@ -1,4 +1,7 @@
import type { EntryModel } from "@follow/models/types"
import { EntryService } from "@follow/database/services/entry"
import { FeedService } from "@follow/database/services/feed"
import { SubscriptionService } from "@follow/database/services/subscription"
import type { EntryModel } from "@follow/store/entry/types"
import type { SubscriptionModel } from "@follow/store/subscription/types"
import { getStorageNS } from "@follow/utils/ns"
import type { IFuseOptions } from "fuse.js"
@ -7,7 +10,6 @@ import { useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { jotaiStore } from "~/lib/jotai"
import { EntryService, FeedService, SubscriptionService } from "~/services"
import { createZustandStore } from "../utils/helper"
import { SearchType } from "./constants"
@ -45,9 +47,12 @@ class SearchActions {
async createLocalDbSearch() {
const [entries, feeds, subscriptions] = await Promise.all([
EntryService.findAll(),
FeedService.findAll(),
SubscriptionService.findAll(),
EntryService.getEntryAll(),
(await FeedService.getFeedAll()).map((feed) => ({
...feed,
type: "feed" as const,
})),
SubscriptionService.getSubscriptionAll(),
])
const feedsMap = new Map(feeds.map((feed) => [feed.id, feed]))
@ -72,7 +77,7 @@ class SearchActions {
const processedEntries = [] as SearchResult<EntryModel, { feedId: string }>[]
for (const entry of entries) {
const feedId = feedsMap.get(entry.item.feedId)?.id
const feedId = entry.item.feedId ? feedsMap.get(entry.item.feedId)?.id : undefined
if (feedId) {
processedEntries.push({ item: entry.item, feedId })
}

View File

@ -1,4 +1,5 @@
import type { EntryModel, FeedOrListRespModel } from "@follow/models/types"
import type { EntryModel } from "@follow/store/entry/types"
import type { FeedModel } from "@follow/store/feed/types"
import type { SubscriptionModel } from "@follow/store/subscription/types"
// @ts-expect-error
@ -7,7 +8,7 @@ export interface SearchResult<T extends object, A extends object = object> exten
}
export interface SearchState {
feeds: SearchResult<FeedOrListRespModel>[]
feeds: SearchResult<FeedModel>[]
entries: SearchResult<EntryModel, { feedId: string }>[]
subscriptions: SearchResult<SubscriptionModel, { feedId: string }>[]

View File

@ -7,7 +7,6 @@ import { unreadActions } from "@follow/store/unread/store"
import { getStorageNS } from "@follow/utils/ns"
import { clearUISettings } from "~/atoms/settings/ui"
import { browserDB } from "~/database"
import { clearImageDimensionsDb } from "../image/db"
@ -27,7 +26,6 @@ export const clearLocalPersistStoreData = async () => {
clearUISettings()
await clearImageDimensionsDb()
await Promise.all(browserDB.tables.map((table) => table.clear()))
}
const storedUserId = getStorageNS("user_id")

View File

@ -6,8 +6,6 @@ import { shallow } from "zustand/shallow"
import type { UseBoundStoreWithEqualityFn } from "zustand/traditional"
import { createWithEqualityFn } from "zustand/traditional"
import { runTransactionInScope } from "~/database"
declare const window: any
export const localStorage: PersistStorage<any> = {
getItem: (name: string) => {
@ -172,12 +170,10 @@ class Transaction<S, Ctx> {
}
if (this.onPersist) {
await runTransactionInScope(() =>
Promise.resolve(this.onPersist!(this._snapshot, this._ctx)).catch((err) => {
console.error(err)
throw err
}),
)
await Promise.resolve(this.onPersist!(this._snapshot, this._ctx)).catch((err) => {
console.error(err)
throw err
})
}
}
}

View File

@ -220,7 +220,7 @@ export default ({ mode }) => {
["react", "react-dom"],
["react-error-boundary", "react-dom/server", "react-router"],
// Data Statement
["zustand", "jotai", "use-context-selector", "immer", "dexie"],
["zustand", "jotai", "use-context-selector", "immer"],
// Remark
[
"remark-directive",

View File

@ -18,6 +18,7 @@ const UserProvider = () => {
useEffect(() => {
if (!session?.user) return
// @ts-expect-error FIXME
setWhoami(session.user)
setIntegrationIdentify(session.user)

View File

@ -8,6 +8,7 @@ export const UserProvider = () => {
useEffect(() => {
if (!session?.user) return
// @ts-expect-error FIXME
setWhoami(session.user)
setIntegrationIdentify(session.user)

View File

@ -195,8 +195,8 @@ class UnreadSyncService {
}
})
tx.request(() => {
return apiClient().reads.$post({
tx.request(async () => {
await apiClient().reads.$post({
json: { entryIds: [entryId] },
})
})

View File

@ -550,9 +550,6 @@ importers:
dayjs:
specifier: 1.11.13
version: 1.11.13
dexie:
specifier: 4.0.11
version: 4.0.11
dnum:
specifier: 2.15.0
version: 2.15.0
@ -8072,9 +8069,6 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dexie@4.0.11:
resolution: {integrity: sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -24208,8 +24202,6 @@ snapshots:
dependencies:
dequal: 2.0.3
dexie@4.0.11: {}
didyoumean@1.2.2: {}
diff@4.0.2: {}