diff --git a/src/db/fcmTokens.ts b/src/db/fcmTokens.ts deleted file mode 100644 index 0be5936..0000000 --- a/src/db/fcmTokens.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import path from "node:path"; - -const dataDir = - process.env.FCM_TOKEN_DATA_DIR ?? path.join(process.cwd(), "data"); -const dataFile = path.join(dataDir, "fcm-tokens.json"); - -export type StoredRow = { - id: string; - userId: string; - deviceId: string; - fcmToken: string; - platform: string; - testMode?: boolean; - createdAt: string; - updatedAt: string; - lastNotifiedAt?: number; -}; - -type ParsedRow = { - id?: string; - userId?: string; - deviceId?: string; - fcmToken: string; - platform: string; - testMode?: boolean; - createdAt: string; - updatedAt: string; - lastNotifiedAt?: number | string; -}; - -export function storageKey(userId: string, deviceId: string): string { - return `${userId}::${deviceId}`; -} - -function mergeDeviceRows( - key: string, - a: StoredRow, - b: StoredRow -): StoredRow { - const primary = - new Date(a.updatedAt) >= new Date(b.updatedAt) ? a : b; - const lastMs = Math.max(a.lastNotifiedAt ?? 0, b.lastNotifiedAt ?? 0); - const created = - new Date(a.createdAt) <= new Date(b.createdAt) - ? a.createdAt - : b.createdAt; - return { - ...primary, - id: primary.id, - userId: primary.userId, - deviceId: primary.deviceId, - fcmToken: primary.fcmToken, - lastNotifiedAt: lastMs > 0 ? lastMs : undefined, - createdAt: created, - }; -} - -function normalizeParsedRow( - mapKey: string, - r: ParsedRow, - onMutate: () => void -): StoredRow { - let id = r.id; - if (id === undefined || id === "") { - id = randomUUID(); - onMutate(); - } - - let lastNotifiedAt: number | undefined; - if (typeof r.lastNotifiedAt === "string") { - const ms = Date.parse(r.lastNotifiedAt); - lastNotifiedAt = Number.isNaN(ms) ? undefined : ms; - onMutate(); - } else if (typeof r.lastNotifiedAt === "number") { - lastNotifiedAt = Number.isNaN(r.lastNotifiedAt) - ? undefined - : r.lastNotifiedAt; - } - - const deviceId = (r.deviceId ?? r.fcmToken ?? mapKey).trim(); - if (r.deviceId === undefined || r.deviceId === "") { - onMutate(); - } - - let userId = r.userId?.trim(); - if (userId === undefined || userId === "") { - const fromKey = mapKey.includes("::") - ? mapKey.slice(0, mapKey.indexOf("::")) - : ""; - userId = fromKey || "__legacy__"; - onMutate(); - } - - return { - id, - userId, - deviceId, - fcmToken: r.fcmToken, - platform: r.platform, - testMode: r.testMode, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - lastNotifiedAt, - }; -} - -function rowKey(row: StoredRow): string { - if (row.userId === "__legacy__") { - return row.deviceId; - } - return storageKey(row.userId, row.deviceId); -} - -async function load(): Promise> { - try { - const raw = await readFile(dataFile, "utf8"); - const parsed = JSON.parse(raw) as Record; - let dirty = false; - const markDirty = (): void => { - dirty = true; - }; - - const buckets = new Map(); - - for (const [mapKey, rawRow] of Object.entries(parsed)) { - const row = normalizeParsedRow(mapKey, rawRow, markDirty); - const key = rowKey(row); - if (mapKey !== key) markDirty(); - const list = buckets.get(key) ?? []; - list.push(row); - buckets.set(key, list); - } - - const out: Record = {}; - for (const [key, rows] of buckets) { - if (rows.length === 1) { - out[key] = rows[0]; - } else { - out[key] = rows - .slice(1) - .reduce((acc, cur) => mergeDeviceRows(key, acc, cur), rows[0]); - markDirty(); - } - } - - if (dirty) await save(out); - return out; - } catch (e: unknown) { - const code = (e as NodeJS.ErrnoException).code; - if (code === "ENOENT") return {}; - throw e; - } -} - -async function save(records: Record): Promise { - await mkdir(dataDir, { recursive: true }); - const tmp = path.join(dataDir, `.fcm-tokens.${process.pid}.tmp`); - const payload = JSON.stringify(records, null, 2); - await writeFile(tmp, payload, "utf8"); - await rename(tmp, dataFile); -} - -export const db = { - async upsert(row: { - userId: string; - deviceId: string; - fcmToken: string; - platform: string; - testMode?: boolean; - updatedAt: Date; - }): Promise { - const all = await load(); - const key = storageKey(row.userId, row.deviceId); - const prev = all[key]; - const now = row.updatedAt.toISOString(); - all[key] = { - id: prev?.id ?? randomUUID(), - userId: row.userId, - deviceId: row.deviceId, - fcmToken: row.fcmToken, - platform: row.platform, - testMode: row.testMode, - updatedAt: now, - createdAt: prev?.createdAt ?? now, - lastNotifiedAt: prev?.lastNotifiedAt, - }; - - for (const k of [...Object.keys(all)]) { - const other = all[k]; - if ( - k !== key && - other.userId === row.userId && - other.fcmToken === row.fcmToken - ) { - delete all[k]; - } - } - - await save(all); - }, - - async getAll(): Promise { - const all = await load(); - return Object.values(all); - }, - - /** Scheduler iteration; excludes legacy rows pending migration cleanup. */ - async getAllForScheduler(): Promise { - const all = await load(); - // TODO: migrate or remove __legacy__ rows after auth rollout - return Object.values(all).filter((r) => r.userId !== "__legacy__"); - }, - - /** - * Resolve a device owned by userId via deviceId and/or fcmToken. - * When both are given, they must refer to the same row. - */ - async resolveOwnedDevice( - userId: string, - query: { deviceId?: string; fcmToken?: string } - ): Promise { - const deviceId = query.deviceId?.trim(); - const fcmToken = query.fcmToken; - - if (deviceId !== undefined && deviceId.length > 0) { - const byDevice = await this.getByDeviceId(userId, deviceId); - if (byDevice === undefined) return undefined; - if ( - fcmToken !== undefined && - fcmToken.length > 0 && - byDevice.fcmToken !== fcmToken - ) { - return undefined; - } - return byDevice; - } - - if (fcmToken !== undefined && fcmToken.length > 0) { - return this.getByFcmTokenForUser(userId, fcmToken); - } - - return undefined; - }, - - async getByUserId(userId: string): Promise { - const all = await load(); - return Object.values(all).filter((r) => r.userId === userId); - }, - - async getByDeviceId( - userId: string, - deviceId: string - ): Promise { - const all = await load(); - return all[storageKey(userId, deviceId)]; - }, - - async getByFcmToken(fcmToken: string): Promise { - const all = await load(); - const matches = Object.values(all).filter((r) => r.fcmToken === fcmToken); - if (matches.length === 0) return undefined; - const owned = matches.filter((r) => r.userId !== "__legacy__"); - const pool = owned.length > 0 ? owned : matches; - return pool.sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() - )[0]; - }, - - async getByFcmTokenForUser( - userId: string, - fcmToken: string - ): Promise { - const all = await load(); - return Object.values(all).find( - (r) => r.userId === userId && r.fcmToken === fcmToken - ); - }, - - async update(id: string, patch: { lastNotifiedAt: number }): Promise { - const all = await load(); - const found = Object.entries(all).find(([, r]) => r.id === id); - if (found === undefined) return; - const [key, row] = found; - all[key] = { ...row, ...patch }; - await save(all); - }, -}; diff --git a/src/db/fcmTokensSqlite.ts b/src/db/fcmTokensSqlite.ts index cff76e9..389837c 100644 --- a/src/db/fcmTokensSqlite.ts +++ b/src/db/fcmTokensSqlite.ts @@ -4,8 +4,6 @@ import { getDatabase } from "./sqlite.js"; /** * SQLite-backed FCM registration repository. * This is the service's storage backend. - * The JSON repository is unused at runtime and will be removed in a later cleanup. - * There is no production-data migration from JSON. */ export type StoredRow = { id: string;