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.
This commit is contained in:
Jose Olarte III
2026-07-23 18:36:10 +08:00
parent fa8ab86cdb
commit 5a3c22a4e3
2 changed files with 214 additions and 1 deletions

213
src/db/fcmTokensSqlite.ts Normal file
View File

@@ -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<void> {
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<StoredRow[]> {
const rows = getDatabase()
.prepare(`SELECT * FROM fcm_registrations`)
.all() as DbRow[];
return rows.map(toStoredRow);
},
/** Scheduler iteration; excludes `__legacy__` rows. */
async getAllForScheduler(): Promise<StoredRow[]> {
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<StoredRow | undefined> {
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<StoredRow[]> {
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<StoredRow | undefined> {
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<StoredRow | undefined> {
// 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<StoredRow | undefined> {
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<void> {
getDatabase()
.prepare(
`UPDATE fcm_registrations SET last_notified_at = ? WHERE id = ?`
)
.run(patch.lastNotifiedAt, id);
},
};

View File

@@ -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) {