feat(db): track last push time by device id with numeric timestamps

Assign stable ids to stored tokens, migrate legacy ISO lastNotifiedAt
to epoch ms, replace setLastNotifiedAt with db.update, and persist
lastNotifiedAt only after a successful FCM send. Extend Device with
optional lastNotifiedAt (ms).
This commit is contained in:
Jose Olarte III
2026-05-11 21:23:10 +08:00
parent 096f393df9
commit 86d589d0e8
3 changed files with 59 additions and 15 deletions

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -5,19 +6,50 @@ const dataDir =
process.env.FCM_TOKEN_DATA_DIR ?? path.join(process.cwd(), "data");
const dataFile = path.join(dataDir, "fcm-tokens.json");
type StoredRow = {
export type StoredRow = {
id: string;
fcmToken: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: string;
lastNotifiedAt?: number;
};
type ParsedRow = Omit<StoredRow, "id" | "lastNotifiedAt"> & {
id?: string;
lastNotifiedAt?: number | string;
};
async function load(): Promise<Record<string, StoredRow>> {
try {
const raw = await readFile(dataFile, "utf8");
return JSON.parse(raw) as Record<string, StoredRow>;
const records = JSON.parse(raw) as Record<string, ParsedRow>;
let dirty = false;
for (const key of Object.keys(records)) {
let r: ParsedRow = records[key];
if (r.id === undefined || r.id === "") {
r = { ...r, id: randomUUID() };
records[key] = r;
dirty = true;
}
if (typeof r.lastNotifiedAt === "string") {
const ms = Date.parse(r.lastNotifiedAt);
r = {
...r,
lastNotifiedAt: Number.isNaN(ms) ? undefined : ms,
};
records[key] = r;
dirty = true;
}
}
const normalized = records as Record<string, StoredRow>;
if (dirty) await save(normalized);
return normalized;
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") return {};
@@ -45,6 +77,7 @@ export const db = {
const prev = all[key];
const now = row.updatedAt.toISOString();
all[key] = {
id: prev?.id ?? randomUUID(),
fcmToken: row.fcmToken,
platform: row.platform,
testMode: row.testMode,
@@ -65,11 +98,12 @@ export const db = {
return all[fcmToken];
},
async setLastNotifiedAt(fcmToken: string, at: Date): Promise<void> {
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
const all = await load();
const row = all[fcmToken];
if (row === undefined) return;
all[fcmToken] = { ...row, lastNotifiedAt: at.toISOString() };
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);
},
};

View File

@@ -4,4 +4,6 @@ export interface Device {
platform: "ios" | "android" | "web";
createdAt: Date;
updatedAt: Date;
/** Epoch ms; set only after a successful push send. */
lastNotifiedAt?: number;
}

View File

@@ -1,5 +1,5 @@
import { db, type StoredRow } from "../db/fcmTokens.js";
import { messaging } from "./firebase.js";
import { db } from "../db/fcmTokens.js";
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
const MS_TEST = 10 * 60 * 1000;
@@ -8,6 +8,13 @@ function notifyThresholdMs(testMode?: boolean): number {
return testMode === true ? MS_TEST : MS_PRODUCTION;
}
function lastNotifiedMs(row: StoredRow | undefined): number | undefined {
const v = row?.lastNotifiedAt;
if (v === undefined) return undefined;
if (typeof v === "number") return Number.isNaN(v) ? undefined : v;
return undefined;
}
function stringifyData(
payload: Record<string, unknown>
): Record<string, string> {
@@ -28,14 +35,11 @@ export async function sendPushToDevice(
): Promise<"sent" | "skipped" | "failed"> {
const row = await db.getByFcmToken(fcmToken);
const now = Date.now();
const lastNotifiedAt = row?.lastNotifiedAt
? Date.parse(row.lastNotifiedAt)
: undefined;
const last = lastNotifiedMs(row);
if (
lastNotifiedAt !== undefined &&
!Number.isNaN(lastNotifiedAt) &&
now - lastNotifiedAt < notifyThresholdMs(row?.testMode)
last !== undefined &&
now - last < notifyThresholdMs(row?.testMode)
) {
return "skipped";
}
@@ -62,7 +66,11 @@ export async function sendPushToDevice(
},
data,
});
await db.setLastNotifiedAt(fcmToken, new Date());
const persisted = await db.getByFcmToken(fcmToken);
if (persisted !== undefined) {
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
}
return "sent";
} catch (err) {
console.error("FCM send failed", err);