chore(db): remove obsolete JSON FCM token repository
Delete the unused JSON storage implementation now that SQLite is the sole persistence backend.
This commit is contained in:
@@ -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<Record<string, StoredRow>> {
|
||||
try {
|
||||
const raw = await readFile(dataFile, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, ParsedRow>;
|
||||
let dirty = false;
|
||||
const markDirty = (): void => {
|
||||
dirty = true;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, StoredRow[]>();
|
||||
|
||||
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<string, StoredRow> = {};
|
||||
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<string, StoredRow>): Promise<void> {
|
||||
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<void> {
|
||||
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<StoredRow[]> {
|
||||
const all = await load();
|
||||
return Object.values(all);
|
||||
},
|
||||
|
||||
/** Scheduler iteration; excludes legacy rows pending migration cleanup. */
|
||||
async getAllForScheduler(): Promise<StoredRow[]> {
|
||||
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<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 all = await load();
|
||||
return Object.values(all).filter((r) => r.userId === userId);
|
||||
},
|
||||
|
||||
async getByDeviceId(
|
||||
userId: string,
|
||||
deviceId: string
|
||||
): Promise<StoredRow | undefined> {
|
||||
const all = await load();
|
||||
return all[storageKey(userId, deviceId)];
|
||||
},
|
||||
|
||||
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
|
||||
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<StoredRow | undefined> {
|
||||
const all = await load();
|
||||
return Object.values(all).find(
|
||||
(r) => r.userId === userId && r.fcmToken === fcmToken
|
||||
);
|
||||
},
|
||||
|
||||
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user