Merge pull request 'Convert JSON storage to SQLite' (#3) from sqlite-storage into master

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-29 11:40:59 +00:00
14 changed files with 525 additions and 439 deletions

View File

@@ -8,7 +8,8 @@ PORT=3003
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
# Local persistence directory for registered FCM tokens (default: ./data)
# Directory for the SQLite FCM registration database (default: ./data).
# Creates notify.sqlite (plus -wal/-shm while the process is running).
# FCM_TOKEN_DATA_DIR=./data
# Set to "test-local" to bypass ethr JWT expiry verification in local dev only.

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

@@ -1,5 +1,7 @@
A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) push notifications to wake up registered devices.
Device registrations are stored in a local **SQLite** database (not JSON).
## Dev
```bash
@@ -11,6 +13,8 @@ Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c
Optionally set `ENDORSER_URL` if you are not using the default production Endorser API (`https://api.endorser.ch`).
Optionally set `FCM_TOKEN_DATA_DIR` if you want the SQLite database somewhere other than `./data`.
```bash
pnpm install
pnpm run dev
@@ -18,6 +22,8 @@ pnpm run dev
The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes.
On first use, the service creates `FCM_TOKEN_DATA_DIR` (default `./data`) and the SQLite file `notify.sqlite` with the required schema.
### Authentication
`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT.
@@ -26,6 +32,42 @@ The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reload
Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification during local development (this is separate from the `testMode` bypass above).
## Storage
### Database location
| Path | Description |
|---|---|
| `{FCM_TOKEN_DATA_DIR}/notify.sqlite` | Primary SQLite database (default dir: `./data`) |
| `{FCM_TOKEN_DATA_DIR}/notify.sqlite-wal` | WAL journal (present while the process is running) |
| `{FCM_TOKEN_DATA_DIR}/notify.sqlite-shm` | Shared-memory file used with WAL mode |
`FCM_TOKEN_DATA_DIR` defaults to `./data` (relative to the process working directory). The `data/` directory is gitignored.
### Schema (high level)
Table `fcm_registrations` holds one row per registered device:
- Identity: `id`, `user_id`, `device_id`, `fcm_token`, `platform`
- Flags: `test_mode`
- Timestamps: `created_at`, `updated_at`, `last_notified_at`
Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`, `fcm_token`, and `(user_id, fcm_token)`.
The schema is created automatically on startup if the database or tables do not already exist.
### JSON → SQLite
There is **no automatic migration** from the old JSON file (`fcm-tokens.json`). That format is no longer used. If you still have a local `fcm-tokens.json` from earlier development, it is ignored; re-register devices or import data manually if you need it.
### Backup
Persist or back up the SQLite files under `FCM_TOKEN_DATA_DIR`:
1. Prefer stopping the service, then copy `notify.sqlite` (and any `-wal` / `-shm` sidecars if present).
2. Or, while the service is running, copy **all three** files (`notify.sqlite`, `-wal`, `-shm`) together so the backup stays consistent under WAL mode.
3. For Docker, mount a volume at the data directory (or set `FCM_TOKEN_DATA_DIR` to a mounted path) so registrations survive container recreation.
## Production
Runs TypeScript directly via `tsx` (no compile step).
@@ -35,13 +77,19 @@ pnpm install --prod
pnpm start
```
Ensure `FCM_TOKEN_DATA_DIR` points at a durable location (or accept the default `./data` next to the process cwd).
Or with Docker:
```bash
docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
docker run --env-file notify-wakeup-api.env -p 3003:3003 notify-wakeup-api
docker run --env-file notify-wakeup-api.env -p 3003:3003 \
-v notify-wakeup-data:/app/data \
notify-wakeup-api
```
Mount a volume over `/app/data` (or whatever path you set with `FCM_TOKEN_DATA_DIR`) so the SQLite database is not lost when the container is replaced.
Required environment variables:
| Variable | Description |
@@ -49,4 +97,4 @@ Required environment variables:
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
| `PORT` | HTTP port (default: `3003`). |
| `ENDORSER_URL` | Endorser API base URL used for auth checks on register/refresh (default: `https://api.endorser.ch`). |
| `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |
| `FCM_TOKEN_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). |

289
package-lock.json generated
View File

@@ -1,29 +1,30 @@
{
"name": "notification-wakeup-service",
"version": "0.1.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "notification-wakeup-service",
"version": "0.1.0",
"version": "0.1.2",
"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",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"dotenv": "^16.6.1",
"express": "^5.2.1",
"firebase-admin": "^13.10.0",
"tsx": "^4.22.3"
"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",
"tsx": "^4.19.2",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
}
},
@@ -106,9 +107,9 @@
]
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -123,9 +124,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -140,9 +141,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -157,9 +158,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -174,9 +175,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -191,9 +192,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -208,9 +209,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -225,9 +226,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -242,9 +243,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -259,9 +260,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -276,9 +277,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -293,9 +294,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -310,9 +311,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -327,9 +328,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -344,9 +345,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -361,9 +362,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -378,9 +379,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -395,9 +396,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -412,9 +413,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -429,9 +430,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -446,9 +447,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -463,9 +464,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -480,9 +481,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -497,9 +498,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -514,9 +515,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -531,9 +532,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -1054,6 +1055,16 @@
"node": ">= 10"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -1333,6 +1344,19 @@
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz",
"integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -1760,9 +1784,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1773,32 +1797,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
@@ -2245,19 +2269,6 @@
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
@@ -2877,6 +2888,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -3169,16 +3189,6 @@
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -3547,14 +3557,13 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"

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

View File

@@ -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);
},
};

219
src/db/fcmTokensSqlite.ts Normal file
View File

@@ -0,0 +1,219 @@
import { randomUUID } from "node:crypto";
import { getDatabase } from "./sqlite.js";
/**
* SQLite-backed FCM registration repository.
* This is the service's storage backend.
*/
export type StoredRow = {
id: string;
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
testMode?: boolean;
createdAt: string;
updatedAt: string;
lastNotifiedAt?: number;
};
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;
}
/** Columns required to build a StoredRow. */
const ROW_COLUMNS =
"id, user_id, device_id, fcm_token, platform, test_mode, created_at, updated_at, last_notified_at";
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();
// No pre-read: ON CONFLICT preserves id, created_at, and last_notified_at.
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: randomUUID(),
user_id: row.userId,
device_id: row.deviceId,
fcm_token: row.fcmToken,
platform: row.platform,
test_mode: testModeToDb(row.testMode),
created_at: now,
updated_at: now,
last_notified_at: null,
});
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 ${ROW_COLUMNS} FROM fcm_registrations`)
.all() as DbRow[];
return rows.map(toStoredRow);
},
/** Scheduler iteration; excludes `__legacy__` rows. */
async getAllForScheduler(): Promise<StoredRow[]> {
const rows = getDatabase()
.prepare(
`SELECT ${ROW_COLUMNS} 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 ${ROW_COLUMNS} 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 ${ROW_COLUMNS} 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 ${ROW_COLUMNS} 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 ${ROW_COLUMNS} 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);
},
};

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

@@ -0,0 +1,64 @@
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, "notify.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);
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token
ON fcm_registrations (user_id, 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.
* SQLite is the service's storage backend.
*/
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;
}
}

View File

@@ -1,5 +1,5 @@
import express, { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { db } from "../db/fcmTokensSqlite.js";
import {
requireAuth,
requireAuthOrNotificationLocalTest,

View File

@@ -1,5 +1,5 @@
import express, { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { db } from "../db/fcmTokensSqlite.js";
import {
requireAuthOrNotificationLocalTest,
requireEndorserAuth,

View File

@@ -1,4 +1,4 @@
import { db } from "./db/fcmTokens.js";
import { db } from "./db/fcmTokensSqlite.js";
import { sendPushToDevice } from "./services/pushService.js";
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";

View File

@@ -1,4 +1,4 @@
import { db, type StoredRow } from "../db/fcmTokens.js";
import { db, type StoredRow } from "../db/fcmTokensSqlite.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
import { messaging } from "./firebase.js";