From 5a3c22a4e3082ecea1f3dd475b3368c5f6e35356 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 23 Jul 2026 18:36:10 +0800 Subject: [PATCH] feat(db): add SQLite FCM registration repository Mirror the JSON repository API on SQLite without switching callers; clarify that SQLite is the storage backend and there is no JSON data migration. --- src/db/fcmTokensSqlite.ts | 213 ++++++++++++++++++++++++++++++++++++++ src/db/sqlite.ts | 2 +- 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/db/fcmTokensSqlite.ts diff --git a/src/db/fcmTokensSqlite.ts b/src/db/fcmTokensSqlite.ts new file mode 100644 index 0000000..c2f1272 --- /dev/null +++ b/src/db/fcmTokensSqlite.ts @@ -0,0 +1,213 @@ +import { randomUUID } from "node:crypto"; +import type { StoredRow } from "./fcmTokens.js"; +import { getDatabase } from "./sqlite.js"; + +/** + * SQLite-backed FCM registration repository. + * This is the service's storage backend; the JSON repository remains only until switch-over. + * There is no production-data migration from JSON. + */ +export type { StoredRow }; + +type DbRow = { + id: string; + user_id: string; + device_id: string; + fcm_token: string; + platform: string; + test_mode: number | null; + created_at: string; + updated_at: string; + last_notified_at: number | null; +}; + +function toStoredRow(row: DbRow): StoredRow { + return { + id: row.id, + userId: row.user_id, + deviceId: row.device_id, + fcmToken: row.fcm_token, + platform: row.platform, + testMode: row.test_mode === null ? undefined : row.test_mode !== 0, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastNotifiedAt: + row.last_notified_at === null ? undefined : row.last_notified_at, + }; +} + +function testModeToDb(testMode: boolean | undefined): number | null { + if (testMode === undefined) return null; + return testMode ? 1 : 0; +} + +export const db = { + async upsert(row: { + userId: string; + deviceId: string; + fcmToken: string; + platform: string; + testMode?: boolean; + updatedAt: Date; + }): Promise { + const connection = getDatabase(); + const now = row.updatedAt.toISOString(); + const prev = connection + .prepare( + `SELECT * FROM fcm_registrations WHERE user_id = ? AND device_id = ?` + ) + .get(row.userId, row.deviceId) as DbRow | undefined; + + const id = prev?.id ?? randomUUID(); + const createdAt = prev?.created_at ?? now; + const lastNotifiedAt = prev?.last_notified_at ?? null; + + const run = connection.transaction(() => { + connection + .prepare( + ` + INSERT INTO fcm_registrations ( + id, user_id, device_id, fcm_token, platform, test_mode, + created_at, updated_at, last_notified_at + ) VALUES ( + @id, @user_id, @device_id, @fcm_token, @platform, @test_mode, + @created_at, @updated_at, @last_notified_at + ) + ON CONFLICT(user_id, device_id) DO UPDATE SET + fcm_token = excluded.fcm_token, + platform = excluded.platform, + test_mode = excluded.test_mode, + updated_at = excluded.updated_at + ` + ) + .run({ + id, + user_id: row.userId, + device_id: row.deviceId, + fcm_token: row.fcmToken, + platform: row.platform, + test_mode: testModeToDb(row.testMode), + created_at: createdAt, + updated_at: now, + last_notified_at: lastNotifiedAt, + }); + + connection + .prepare( + ` + DELETE FROM fcm_registrations + WHERE user_id = ? AND fcm_token = ? AND device_id != ? + ` + ) + .run(row.userId, row.fcmToken, row.deviceId); + }); + + run(); + }, + + async getAll(): Promise { + const rows = getDatabase() + .prepare(`SELECT * FROM fcm_registrations`) + .all() as DbRow[]; + return rows.map(toStoredRow); + }, + + /** Scheduler iteration; excludes `__legacy__` rows. */ + async getAllForScheduler(): Promise { + const rows = getDatabase() + .prepare( + `SELECT * FROM fcm_registrations WHERE user_id != '__legacy__'` + ) + .all() as DbRow[]; + return rows.map(toStoredRow); + }, + + /** + * 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 rows = getDatabase() + .prepare(`SELECT * FROM fcm_registrations WHERE user_id = ?`) + .all(userId) as DbRow[]; + return rows.map(toStoredRow); + }, + + async getByDeviceId( + userId: string, + deviceId: string + ): Promise { + const row = getDatabase() + .prepare( + `SELECT * FROM fcm_registrations WHERE user_id = ? AND device_id = ?` + ) + .get(userId, deviceId) as DbRow | undefined; + return row === undefined ? undefined : toStoredRow(row); + }, + + async getByFcmToken(fcmToken: string): Promise { + // Prefer non-legacy rows; within that pool, newest updated_at wins. + const row = getDatabase() + .prepare( + ` + SELECT * FROM fcm_registrations + WHERE fcm_token = ? + ORDER BY (user_id = '__legacy__') ASC, updated_at DESC + LIMIT 1 + ` + ) + .get(fcmToken) as DbRow | undefined; + return row === undefined ? undefined : toStoredRow(row); + }, + + async getByFcmTokenForUser( + userId: string, + fcmToken: string + ): Promise { + const row = getDatabase() + .prepare( + ` + SELECT * FROM fcm_registrations + WHERE user_id = ? AND fcm_token = ? + LIMIT 1 + ` + ) + .get(userId, fcmToken) as DbRow | undefined; + return row === undefined ? undefined : toStoredRow(row); + }, + + async update(id: string, patch: { lastNotifiedAt: number }): Promise { + getDatabase() + .prepare( + `UPDATE fcm_registrations SET last_notified_at = ? WHERE id = ?` + ) + .run(patch.lastNotifiedAt, id); + }, +}; diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts index 9d349b8..29ea90b 100644 --- a/src/db/sqlite.ts +++ b/src/db/sqlite.ts @@ -40,7 +40,7 @@ function ensureSchema(connection: Database.Database): void { /** * Returns a singleton SQLite connection with schema initialized. - * Foundation for the SQLite-backed notification storage. + * SQLite is the service's storage backend. */ export function getDatabase(): Database.Database { if (database === null) {