feat(db): add SQLite foundation for FCM registration storage

Introduce better-sqlite3 and a schema-init module so we can migrate off JSON storage without changing runtime behavior yet.
This commit is contained in:
Jose Olarte III
2026-07-23 17:44:27 +08:00
parent 55d43a1a20
commit fa8ab86cdb
5 changed files with 98 additions and 2 deletions

View File

@@ -1,6 +1,8 @@
# ---- build stage: install everything, type-check + compile to JS ----
FROM node:22-alpine AS build
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app
@@ -15,6 +17,8 @@ RUN pnpm build
# ---- runtime stage: prod deps + compiled JS only, no tsx/esbuild ----
FROM node:22-alpine AS runtime
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app

View File

@@ -12,18 +12,20 @@
"dependencies": {
"@peculiar/asn1-ecc": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"better-sqlite3": "^13.0.1",
"cbor-x": "^1.6.4",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"dotenv": "^16.6.1",
"express": "^5.2.1",
"firebase-admin": "^13.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^22.19.19",
"@types/cors": "^2.8.19",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
}

27
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@peculiar/asn1-schema':
specifier: ^2.7.0
version: 2.7.0
better-sqlite3:
specifier: ^13.0.1
version: 13.0.1
cbor-x:
specifier: ^1.6.4
version: 1.6.4
@@ -36,6 +39,9 @@ importers:
specifier: ^13.10.0
version: 13.10.0
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
@@ -382,6 +388,9 @@ packages:
resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==}
engines: {node: '>= 10'}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -474,6 +483,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@13.0.1:
resolution: {integrity: sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==}
engines: {node: '>=22'}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
@@ -924,6 +937,10 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-addon-api@8.9.0:
resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==}
engines: {node: ^18 || ^20 || >= 21}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -1492,6 +1509,10 @@ snapshots:
'@tootallnate/once@2.0.1':
optional: true
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 22.19.19
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -1607,6 +1628,10 @@ snapshots:
base64-js@1.5.1: {}
better-sqlite3@13.0.1:
dependencies:
node-addon-api: 8.9.0
bignumber.js@9.3.1: {}
body-parser@2.2.2:
@@ -2210,6 +2235,8 @@ snapshots:
negotiator@1.0.0: {}
node-addon-api@8.9.0: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:

View File

@@ -1,11 +1,13 @@
strictDepBuilds: false
onlyBuiltDependencies:
- "@firebase/util"
- better-sqlite3
- cbor-extract
- esbuild
- protobufjs
allowBuilds:
'@firebase/util': set this to true or false
better-sqlite3: set this to true or false
cbor-extract: set this to true or false
esbuild: set this to true or false
protobufjs: set this to true or false

61
src/db/sqlite.ts Normal file
View File

@@ -0,0 +1,61 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
const dataDir =
process.env.FCM_TOKEN_DATA_DIR ?? path.join(process.cwd(), "data");
const dbFile = path.join(dataDir, "fcm-tokens.sqlite");
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS fcm_registrations (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
fcm_token TEXT NOT NULL,
platform TEXT NOT NULL,
test_mode INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_notified_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_fcm_registrations_user_device
ON fcm_registrations (user_id, device_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_id
ON fcm_registrations (user_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_device_id
ON fcm_registrations (device_id);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_fcm_token
ON fcm_registrations (fcm_token);
`;
let database: Database.Database | null = null;
function ensureSchema(connection: Database.Database): void {
connection.exec(SCHEMA_SQL);
}
/**
* Returns a singleton SQLite connection with schema initialized.
* Foundation for the SQLite-backed notification storage.
*/
export function getDatabase(): Database.Database {
if (database === null) {
mkdirSync(dataDir, { recursive: true });
database = new Database(dbFile);
database.pragma("journal_mode = WAL");
ensureSchema(database);
}
return database;
}
/** Closes the singleton connection. Intended for tests and graceful shutdown. */
export function closeDatabase(): void {
if (database !== null) {
database.close();
database = null;
}
}