From fa8ab86cdbab3ab89a5ef816fe5e4c1c0f263734 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 23 Jul 2026 17:44:27 +0800 Subject: [PATCH] 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. --- Dockerfile | 4 +++ package.json | 6 +++-- pnpm-lock.yaml | 27 ++++++++++++++++++++ pnpm-workspace.yaml | 2 ++ src/db/sqlite.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/db/sqlite.ts diff --git a/Dockerfile b/Dockerfile index 142579c..fe2f8c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package.json b/package.json index 13cb526..de60b76 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d72a2e..e15d74e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c490cb9..c510811 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts new file mode 100644 index 0000000..9d349b8 --- /dev/null +++ b/src/db/sqlite.ts @@ -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; + } +}