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:
213
src/db/fcmTokensSqlite.ts
Normal file
213
src/db/fcmTokensSqlite.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user