From 2cc65af775322d8eaf7ccd7640afee974d25ed1f Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 12:15:16 -0400 Subject: [PATCH 01/14] Design: Postgres migration and SuperTokens foundation Spec for moving persistence from SQLite to Postgres (v1.7) and adopting SuperTokens without breaking existing Discord/GitHub logins (v1.8). Decisions settled during brainstorming: dual backend with Postgres as the default, tests against real Postgres in Docker, auto-migrate on boot behind guards, and the identities auth split done up front so v1.8 stays small. Two findings drove the design. SuperTokens supports external user ID mapping, so users.id can stay 'provider:providerId' and no foreign key or SUPER_ADMIN_IDS handling has to change. And SuperTokens' /auth/callback/github is not a subdirectory of the currently registered /auth/github/callback, which would fail GitHub's redirect_uri rule - the registered callback gets widened to /auth so both paths work at once and passport keeps running throughout. Co-Authored-By: Claude Opus 5 --- .../2026-08-01-postgres-supertokens-design.md | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md new file mode 100644 index 0000000..43ba6f3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -0,0 +1,347 @@ +# Postgres Migration & SuperTokens Foundation — Design + +**Date:** 2026-08-01 +**Status:** Approved +**Releases:** v1.7 (Postgres) → v1.8 (SuperTokens) + +## 1. Goal + +Move RackStack's persistence from SQLite to PostgreSQL without losing a single +row of production data, and lay the auth groundwork so SuperTokens can be +adopted afterwards without breaking existing Discord/GitHub logins. + +Two motivations, in the owner's words: longevity, and a path to SuperTokens for +better future automation. + +## 2. Decisions + +These were settled explicitly during brainstorming and are not open questions: + +| Question | Decision | +|---|---| +| Backend | **Dual backend, Postgres default.** SQLite stays supported behind the same interface. | +| Tests | **Real Postgres via Docker** (Testcontainers locally, service container in CI). | +| Migration trigger | **Auto on boot, guarded**, and the same script runnable by hand. | +| Auth split | **Do it now** — extract an `identities` table in v1.7. | +| Sequencing | **Two releases, one design.** v1.7 ships and is confirmed in production before v1.8 starts. | +| Verification data | Owner supplies a current Unraid export. | + +### 2.1 A consequence worth stating plainly + +Dual backend plus Postgres-only tests would mean shipping a SQLite driver that +nothing exercises — dialect drift is precisely the bug class that loses data. +Therefore the vitest suite runs **twice, once per backend**: + +- `npm test` → Postgres (the default, what CI gates on) +- `npm run test:sqlite` → SQLite +- `npm run test:all` → both; CI runs this + +A backend that is shipped is a backend that is tested. No exceptions. + +## 3. Current state (measured, not assumed) + +- **7 tables**, all DDL and SQL confined to `server/db.js` (~600 lines). +- **~35 exported functions** form the de facto repository interface. +- **111 call sites** across 8 server files (`routes/api.js` 35, `eventService.js` + 18, `configService.js` 8, `stateService.js` 6, `auth.js` 3, + `leaderboardService.js` 3, `index.js` 1). +- **181 call sites** across 19 test files; 2 test files (`db.test.js`, + `db.events.test.js`) reach past the interface into raw `db.prepare`. +- **No transactions anywhere** in the codebase. +- v1.5 social state (contracts, achievements, streak) lives inside the + `saves.data` JSON blob, not in tables — it needs no schema work. +- `users.id` is the literal string `` `${provider}:${providerId}` ``, + constructed in exactly one place (`server/db.js`), referenced by 3 foreign + keys and by the `SUPER_ADMIN_IDS` env var. + +## 4. Release v1.7 — Postgres + +No user-visible behaviour changes. No auth changes. This release is a port. + +### 4.1 Architecture + +`server/db.js` becomes a facade over a driver selected at boot by the presence +of `DATABASE_URL` (absent → SQLite). + +``` +server/db/index.js facade: driver selection, re-exports the interface +server/db/schema.pg.js Postgres DDL +server/db/schema.sqlite.js SQLite DDL +server/db/driver.pg.js pg.Pool implementation +server/db/driver.sqlite.js better-sqlite3 implementation +server/db/migrate.js one-shot SQLite → Postgres copier +``` + +Two hand-written drivers were chosen over a query builder (Kysely) or a +single-SQL-plus-dialect-shim. Rationale: the schema is small and stable, the +codebase's existing style is hand-written heavily-commented SQL, and a leaky +shim is worse than honest duplication. The accepted cost is that every future +query is written twice — the backend test matrix is what keeps that honest. + +Each driver module exports the same ~35 functions. No caller may reach past the +facade; the two tests that currently use raw `db.prepare` are rewritten against +the interface or moved behind a driver-specific escape hatch used only for +schema assertions. + +### 4.2 The interface becomes async + +`better-sqlite3` is synchronous; `pg` is not. An async interface wraps a sync +implementation trivially (`async getSave(id) { return stmt.get(id); }`), so the +interface is async for both drivers and the refactor happens once. + +This is the bulk of the diff: 111 server call sites and 181 test call sites gain +`await`, and their enclosing functions become `async`. Mechanical, but it +touches `routes/api.js` heavily — every route handler becomes `async` and needs +error propagation checked (an unhandled rejection in an Express 4 handler does +not reach the error middleware; each handler must keep its try/catch or gain +one). + +### 4.3 Schema translation + +Epoch-millisecond timestamps become `BIGINT`, never `INTEGER` — `int4` overflows +in 2038. Boolean-ish flags stay `SMALLINT` 0/1 rather than becoming `BOOLEAN`, +so the existing `? 1 : 0` write logic and truthiness reads are untouched. + +JSON columns (`saves.data`, `config.data`, `live_events.modifiers`, etc.) stay +**`TEXT`, not `jsonb`**. `jsonb` reorders object keys, strips insignificant +whitespace, and outright rejects `\u0000` inside strings — any of which would +mean a save does not round-trip byte-for-byte. The application already +`JSON.parse`s these itself, so `jsonb` buys nothing here. + +| SQLite construct | Postgres equivalent | Consequence if missed | +|---|---|---| +| `ORDER BY rowid` in `getConfigHistory` | `id BIGSERIAL PRIMARY KEY`, order by it | Postgres has no `rowid`; config history order is **silently lost** | +| `UNIQUE INDEX ... (username COLLATE NOCASE)` | `CREATE UNIQUE INDEX ... ON users (lower(username))` | case-insensitive username uniqueness breaks | +| `WHERE username = ? COLLATE NOCASE` (3 sites) | `WHERE lower(username) = lower($1)` | duplicate usernames slip through | +| `INSERT OR IGNORE` | `ON CONFLICT DO NOTHING` | seasonal event seeding throws on reboot | +| `@named` parameters | `$1` positional | — (per-driver) | +| `SQLITE_CONSTRAINT_UNIQUE` / `SQLITE_CONSTRAINT` | SQLSTATE `23505` | the `upsertUser` collision retry stops working and **locks users out on login** | +| `INTEGER` epoch ms | `BIGINT` | overflow in 2038 | +| `INTEGER` 0/1 flags | `SMALLINT` | — (deliberate parity choice) | + +`ON CONFLICT (...) DO UPDATE SET ... excluded.x` is compatible across both and +needs no change. + +### 4.4 Auth split — the `identities` table + +`users.id` **does not change.** It remains `provider:providerId`, keeping all 3 +foreign keys and `SUPER_ADMIN_IDS` working untouched. Login methods move out: + +```sql +users -- id (PK, UNCHANGED), username, avatar_url, created_at, + -- roles, custom_username, leaderboard_opt_out, tours_completed + -- provider and provider_id are REMOVED + +identities -- PRIMARY KEY (provider, provider_id) + -- user_id → users(id) ON DELETE CASCADE + -- supertokens_user_id TEXT UNIQUE NULL (filled in v1.8) + -- created_at BIGINT NOT NULL, last_login_at BIGINT NULL +``` + +The migration inserts exactly one `identities` row per existing user, carrying +`users.created_at` across. + +Affected functions: + +- `upsertUser({provider, providerId, ...})` — look up `identities` by + `(provider, provider_id)`; found → load that `users` row; not found → create + both rows. The existing username-collision retry logic is preserved verbatim, + including both the INSERT and UPDATE paths. +- `getAllUsersWithSaves()` — currently selects `u.provider`. It gains a join and + returns the **primary identity's** provider (lowest `created_at`, ties broken + by provider name). Since no user has more than one identity until v1.8 ships a + linking flow, its output today is byte-identical to current behaviour. The + admin roles UI needs no change. + +This is what makes v1.8 cheap: `supertokens_user_id` is the linkage column, and +multi-identity accounts ("log in with Discord *or* GitHub, same save") become +possible without further schema work. + +### 4.5 Migration script + +`server/db/migrate.js`, exposed as `npm run migrate:pg` and invoked +automatically at boot under the guards in §4.6. + +**The WAL trap.** SQLite holds recent commits in `rackstack.db-wal`. Copying +only `rackstack.db` loses the newest data. The stale copy already sitting in +`~/Downloads` has a `-wal` file beside it, so this is a live risk, not a +hypothetical. The runbook requires stopping the container first and copying all +three files. The migrator additionally refuses to run against a database whose +`-wal` file exists but cannot be read, rather than silently migrating stale data. + +Safety properties, all mandatory: + +1. Stop-the-world: the migrator runs before the HTTP server binds a port. +2. WAL checkpointed (`PRAGMA wal_checkpoint(TRUNCATE)`) before any read. +3. The entire copy runs in **one Postgres transaction**. Any failure rolls back + the whole thing — never a partial import. +4. Verification **before** `COMMIT`: + - per-table row counts match exactly; + - every `saves.data` compared as an exact string, plus `last_save`; + - a SHA-256 manifest over each table's rows in primary-key order, computed on + both sides and compared. + Any mismatch → `ROLLBACK`, log the offending table and key, exit non-zero. +5. The SQLite file is **opened for reading only in the copy path** and is never + modified beyond the checkpoint, never deleted. It is the rollback artifact. +6. Idempotent: re-running against a populated Postgres is a no-op that exits 0 + with a clear message. + +**Old-schema tolerance.** The migrator reads whatever tables exist and defaults +the rest. The `~/Downloads` v1.1-era copy (only `users` + `saves`, 4 rows each, +no config/events/tours) becomes a committed test fixture for this path. + +### 4.6 Boot behaviour + +On startup, when `DATABASE_URL` is set: + +| Postgres state | `rackstack.db` present | Action | +|---|---|---| +| schema empty | yes | migrate, verify, then serve | +| schema empty | no | create schema, serve (fresh install) | +| already populated | either | skip migration entirely, serve | +| migration fails | — | **refuse to start**, non-zero exit | + +Refusing to start on failure is deliberate: serving an empty game to real +players is worse than being down, and an operator who sees a stopped container +investigates, whereas one who sees an empty leaderboard may not. + +Every step logs loudly with a `[migrate]` prefix, including row counts moved. + +### 4.7 Unraid runbook + +1. Add an official `postgres:16` container; appdata path of its own; create a + `rackstack` database. +2. **Stop** the rackstack container. +3. Back up `/mnt/user/appdata/rackstack-server/data/` — all three + `rackstack.db*` files. +4. Set `DATABASE_URL=postgresql://user:pass@host:5432/rackstack` on the + rackstack container. **Leave the `/app/data` mapping in place.** +5. Start it. Watch the log for the `[migrate]` summary and the row counts. + +Rollback at any point: unset `DATABASE_URL` and restart. SQLite data is +untouched. + +The Unraid template gains `DATABASE_URL` and its ``/data-path +descriptions are updated to stop describing SQLite as the whole story. + +### 4.8 Testing + +- Testcontainers spins up `postgres:16` locally; CI uses a `services: postgres` + container. Each test file gets a fresh schema (a per-worker database or + schema namespace) replacing today's `DB_PATH=':memory:'`. +- The full suite runs against both backends (§2.1). +- A dedicated migration test: seed a SQLite fixture → migrate → assert every + table's rows and every save's bytes are identical, and that the verification + step actually fails when a row is deliberately corrupted (test the guard, not + just the happy path). +- Existing e2e smoke suites (`smoke-v12` … `smoke-v16`) must pass against + Postgres unchanged. + +### 4.9 Dockerfile + +`pg` is pure JS. `better-sqlite3` remains a dependency (dual backend) so the +python3/make/g++ build stage stays. `DB_PATH` keeps its default so a +`DATABASE_URL`-less container behaves exactly as today. + +## 5. Release v1.8 — SuperTokens + +Starts only after v1.7 is confirmed running in production. + +### 5.1 Containers + +- `registry.supertokens.io/supertokens/supertokens-postgresql`, port 3567. +- `POSTGRESQL_CONNECTION_URI` pointing at its **own database** on the same + Postgres instance stood up in v1.7. +- Two documented footguns: the scheme must be `postgresql://` (`postgres://` + fails at startup), and the host may not be `localhost`/`127.0.0.1` from inside + a container. + +### 5.2 Strangler rollout via `AUTH_MODE` + +| `AUTH_MODE` | Behaviour | +|---|---| +| `passport` (default) | Exactly today. SuperTokens is not initialised. | +| `dual` | Both login paths live; sessions from either accepted. | +| `supertokens` | Passport routes disabled. | + +Auth middleware becomes a chain: try SuperTokens session → fall back to legacy +JWT → 401. Both populate `req.user = { sub, username, avatarUrl }`. + +**`req.user.sub` is the only thing the 35+ route handlers depend on**, so this +seam means zero route handler changes. + +### 5.3 Identity mapping — the critical mechanism + +SuperTokens' ThirdParty recipe supplies `thirdPartyId` (`'github'`/`'discord'`) +and `thirdPartyUserId` — the same pair passport supplies as `provider` and +`profile.id`. In the `signInUp` override: + +1. Look up `identities` by `(provider, provider_id)`. +2. Resolve the existing `users.id` (or create user + identity for a new player). +3. Call `createUserIdMapping({ supertokensUserId, externalUserId: users.id })`. + +Afterwards `session.getUserId()` returns e.g. `github:37058311`, so saves, +roles, event participation and `SUPER_ADMIN_IDS` all continue to resolve. + +**The mapping must be created before the session is issued**, or the session +carries SuperTokens' internal id instead of the external one. This is the single +highest-risk line in the release. It belongs in the recipe-function override +rather than the API override; the exact ordering is verified by test, not by +reading docs. + +### 5.4 OAuth callback URLs — what would otherwise break logins + +SuperTokens uses `/auth/callback/`; RackStack currently uses +`/auth//callback`. GitHub's rule is that *the redirect URL's path must +reference a subdirectory of the registered callback URL* — and +`/auth/callback/github` is **not** a subdirectory of `/auth/github/callback`. +Left alone, every SuperTokens GitHub login fails with a redirect_uri mismatch. + +Fix, one-time and reversible: **widen** the GitHub OAuth app's registered +callback URL to `https://your-domain/auth`. Both paths then qualify as +subdirectories and work simultaneously. Discord permits multiple redirect URIs, +so the new one is simply added. Nothing is removed, so passport keeps working +throughout the rollout. + +### 5.5 Shadow-mode verification gate + +Before any cutover, a shadow mode performs a SuperTokens login, computes +`provider:thirdPartyUserId`, compares it against the existing `identities` rows, +logs the result, and **does not** alter the caller's session. + +This exists because the assumption that SuperTokens' `thirdPartyUserId` equals +passport's `profile.id` is load-bearing and unverified. GitHub's numeric id and +Discord's snowflake are both expected to match, but "expected" is not good +enough when the failure mode is a player silently landing on a brand-new empty +save. The owner's production export is used to confirm every existing row maps. + +Cutover to `AUTH_MODE=dual` is gated on shadow mode reporting a 100% match. + +### 5.6 Rollback + +Set `AUTH_MODE=passport` and restart. Legacy JWT cookies remain valid for their +full 90-day expiry, so in-flight sessions survive the round trip. + +## 6. Risks + +| Risk | Mitigation | +|---|---| +| Save data lost or altered in migration | Single transaction, SHA-256 manifest verification pre-commit, byte-exact save comparison, SQLite file never deleted | +| Stale data migrated from an unclean copy | WAL checkpoint; refuse to run on unreadable `-wal`; runbook mandates stopping the container and copying all three files | +| Config history order lost | `BIGSERIAL` replaces `rowid`; covered by test | +| Users locked out by the constraint-code change | `23505` handling covered by the existing collision tests, which run on both backends | +| SQLite driver rots untested | Full suite runs on both backends in CI | +| SuperTokens id shape differs from stored `provider_id` | Shadow mode against production export; cutover gated on 100% match | +| SuperTokens session carries the wrong user id | Mapping created before session issuance; asserted by test | +| GitHub redirect_uri mismatch | Registered callback widened to `/auth` before enabling `dual` | +| Async refactor swallows route errors | Every handler audited for try/catch during the refactor | + +## 7. Out of scope + +- Installing SuperTokens in v1.7 (v1.8 only). +- Any account-linking UI. The schema permits it; no user-facing flow ships. +- Transactions around game actions. There are none today; noting the gap, not + closing it here. +- Migrating away from the JSON save blob into relational tables. +- Multi-tenancy, MFA, email/password, or any SuperTokens recipe beyond + ThirdParty and Session. From c883474c06348c3224a20e3a5d95bbc899a4006a Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 12:51:05 -0400 Subject: [PATCH 02/14] Plan: v1.7 Postgres migration Eight tasks, each ending in an independently testable deliverable: 1. Make the db interface async (SQLite unchanged underneath) - 292 call sites 2. Split db.js into facade + SQLite driver + schema module 3. Postgres test harness: Testcontainers, per-file databases, CI matrix 4. Postgres schema and driver, gated by a cross-dialect parity test 5. The identities auth split, with in-place upgrades for both backends 6. The SQLite to Postgres migrator, verified before COMMIT 7. Auto-migration on boot behind guards 8. Deployment config, Unraid runbook, smoke suites against Postgres Task 1 is deliberately alone: mixing the async refactor with the driver split would make the diff unreviewable. Two landmines found while writing this that the spec had not caught, both silent-corruption class rather than crash class: - pg returns BIGINT as a string by default. Every epoch-ms column here is BIGINT and every consumer does arithmetic on it, so the int8 type parser has to be registered before the first query runs. - Postgres folds unquoted identifiers to lowercase, so listLeaderboard's 'AS userId' would return 'userid' and every rungsClaimed would read undefined on a leaderboard that still rendered. Both are pinned by tests/db.parity.test.js. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-01-v1.7-postgres.md | 1970 +++++++++++++++++ 1 file changed, 1970 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-v1.7-postgres.md diff --git a/docs/superpowers/plans/2026-08-01-v1.7-postgres.md b/docs/superpowers/plans/2026-08-01-v1.7-postgres.md new file mode 100644 index 0000000..8de39c6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-v1.7-postgres.md @@ -0,0 +1,1970 @@ +# v1.7 Postgres Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move RackStack's persistence to PostgreSQL — dual-backend with Postgres as the default — and migrate existing SQLite installs without losing a row. + +**Architecture:** `server/db.js` becomes a facade over one of two drivers, selected at boot by the presence of `DATABASE_URL`. Both drivers implement the same async repository interface. A one-shot migrator copies SQLite → Postgres inside a single transaction with SHA-256 verification before commit. Async stops at the `server/` boundary — `shared/` stays synchronous and the client is untouched. + +**Tech Stack:** Node 20 (ESM), Express 4, `pg` 8, `better-sqlite3` 11, Postgres 16, vitest 3, Testcontainers. + +**Spec:** `docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md` + +## Global Constraints + +- **Node 20, ESM only.** `package.json` has `"type": "module"`. Top-level `await` is available and used for driver selection. +- **`shared/` must never import from `server/`** and must stay synchronous. The client bundles `shared/`. If a change would make a `shared/` function async, it is the wrong change. +- **Postgres 16.** Both Testcontainers and the documented Unraid container. +- **Epoch milliseconds are `BIGINT`**, never `INTEGER`. `int4` overflows in 2038. +- **`pg` must be configured to parse `int8` as a JS number** before any query runs (see Task 4, Step 1). Default `pg` returns `BIGINT` as a **string**, which silently corrupts every timestamp comparison in the codebase. +- **Every camelCase SQL alias must be double-quoted.** Postgres folds unquoted identifiers to lowercase. `AS userId` returns a column named `userid`. +- **Missing rows return `undefined`**, not `null`, in both drivers — matching `better-sqlite3`'s `.get()`. Existing tests assert `toBeUndefined()`. +- **JSON columns are `TEXT`, never `jsonb`.** `jsonb` reorders keys, strips whitespace, and rejects `\u0000`. +- **Boolean-ish flags are `SMALLINT` 0/1**, not `BOOLEAN`. The codebase writes `? 1 : 0` and reads truthiness. +- **The SQLite file is never modified by the migrator** beyond a WAL checkpoint, and never deleted. +- **Both backends are tested.** A change that passes on one backend and is not run against the other is not done. + +## File Structure + +**Created:** +- `server/db/index.js` — facade: driver selection, re-exports the interface +- `server/db/interface.md` — the ~37 function contract both drivers satisfy +- `server/db/schema.sqlite.js` — SQLite DDL + in-place schema migrations +- `server/db/schema.pg.js` — Postgres DDL + in-place schema migrations +- `server/db/driver.sqlite.js` — `better-sqlite3` implementation +- `server/db/driver.pg.js` — `pg.Pool` implementation +- `server/db/migrate.js` — one-shot SQLite → Postgres copier + verifier +- `tests/helpers/backend.js` — per-test-file database provisioning +- `tests/setup/pg-global.js` — vitest globalSetup: boots the Testcontainers Postgres +- `tests/migrate.test.js` — migration correctness + verification-guard tests +- `tests/fixtures/v11-sqlite.db` — the v1.1-era two-table fixture + +**Modified:** +- `server/db.js` — becomes a 2-line re-export of `server/db/index.js` (keeps every existing import path working) +- `server/configService.js`, `server/stateService.js`, `server/eventService.js`, `server/leaderboardService.js`, `server/auth.js`, `server/routes/api.js`, `server/index.js`, `server/app.js` — async propagation +- All 19 test files that touch the db — `await` + async provisioning +- `package.json`, `Dockerfile`, `docker-compose.yml`, `unraid-template.xml`, `.env.example`, `README.md`, `CHANGELOG.md`, `.github/workflows/` + +**Deleted:** nothing. + +--- + +### Task 1: Make the repository interface async (SQLite only) + +The largest task, and entirely mechanical. `server/db.js` keeps its current SQLite implementation; every exported function becomes `async`, and all 292 call sites gain `await`. No schema, no Postgres, no behaviour change. The existing suite must pass unchanged at the end. + +Do this first and alone. Mixing it with the driver split makes the diff unreviewable. + +**Files:** +- Modify: `server/db.js` (all ~37 exports) +- Modify: `server/configService.js`, `server/stateService.js`, `server/eventService.js`, `server/leaderboardService.js`, `server/auth.js`, `server/routes/api.js`, `server/index.js` +- Modify: all 19 test files listed in Step 6 + +**Interfaces:** +- Consumes: nothing (first task) +- Produces: every `server/db.js` export returns a `Promise`. Names, parameters and resolved shapes are **unchanged** from today — `getSave(userId)` now resolves to the same row object it used to return. Downstream tasks depend on these exact names: + `upsertUser, getUserById, getAllUsersWithSaves, getSave, putSave, deleteSave, getRoles, setRoles, getToursCompleted, setToursCompleted, setUsername, dedupeUsernames, createMinigameSession, getMinigameSession, getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow, getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, setEventStatus, deleteEvent, upsertParticipation, getParticipation, updateParticipationProgress, listParticipation, setLeaderboardOptOut, listLeaderboard, getLatestEventId, seedSeasonalEvents` +- Also produced (now async): `configService.ensureConfig/getConfig/getEffectiveConfig/updateConfig/rollbackConfig/getHistory`, `stateService.loadEvaluateAndSchedule/loadAndEvaluate/applyActions`, `auth.getEffectiveRoles`. + +- [ ] **Step 1: Make every `server/db.js` export async** + +Add `async` to each exported function. The bodies are unchanged — `better-sqlite3` stays synchronous inside. + +```js +export async function getSave(userId) { + return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); +} + +export async function putSave(userId, data, lastSave) { + db.prepare(` + INSERT INTO saves (user_id, data, last_save) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, last_save = excluded.last_save + `).run(userId, JSON.stringify(data), lastSave); +} +``` + +Two functions call their own siblings and need internal `await`: + +```js +export async function putEvent(event) { + const row = { /* ...unchanged... */ }; + putEventStmt.run(row); + return await getEvent(row.id); +} + +export async function upsertParticipation(row) { + const params = { /* ...unchanged... */ }; + upsertParticipationStmt.run(params); + return await getParticipation(params.user_id, params.event_id); +} +``` + +`dedupeUsernames()` is called at module top level (line ~149). Leave that call synchronous for now by keeping an internal sync `dedupeUsernamesSync()` that the module calls, and export an `async dedupeUsernames()` wrapper around it. Task 2 moves this into schema init properly. + +- [ ] **Step 2: Run the suite to see the expected breakage** + +Run: `npm test` +Expected: FAIL, widely. Assertions receive `Promise { ... }` instead of rows. This confirms you have found the real call sites rather than guessing. + +- [ ] **Step 3: Propagate async through `server/configService.js`** + +`ensureConfig`, `getConfig`, `getEffectiveConfig`, `updateConfig`, `rollbackConfig`, `getHistory` all become `async`. The two module-level caches (`cache`, `effectiveCache`) keep their exact current semantics — they are in-process and stay synchronous state. + +```js +export async function ensureConfig() { + const row = await getConfigRow(); + if (!row) { + await putConfigRow(1, DEFAULT_CONFIG, null); + cache = { version: 1, data: structuredClone(DEFAULT_CONFIG) }; + return cache; + } + // ...unchanged parsing/upgrade/validation... + if (changed) { + const nextVersion = row.version + 1; + await putConfigRow(nextVersion, data, row.updated_by || null); + cache = { version: nextVersion, data }; + } else { + cache = { version: row.version, data }; + } + return cache; +} + +export async function getConfig() { + if (!cache) return await ensureConfig(); + return cache; +} + +export async function getEffectiveConfig() { + const { version, data } = await getConfig(); + const activeEvent = await getActiveEvent(); + // ...rest unchanged... +} +``` + +- [ ] **Step 4: Propagate async through the remaining services** + +`server/stateService.js` — `loadEvaluateAndSchedule`, `loadAndEvaluate`, `applyActions` become `async`; `await` on `getEffectiveConfig`, `getSave`, `putSave`, `updateParticipationProgress`, and the `eventService` calls. The comment block at lines 37-55 about the shared cached config object stays accurate and must not be deleted. + +`server/eventService.js` — all 18 db call sites; `runScheduler`, `joinEventIfEligible`, `resolvePlayerEvents` and their siblings become `async`. + +`server/leaderboardService.js` — 3 db call sites plus its own cache; the cache stays synchronous in-process state. + +`server/auth.js`: + +```js +export async function getEffectiveRoles(id) { + if (isOwner(id)) return ['admin', 'event_coordinator']; + const stored = await getRoles(id); + const effective = new Set(stored); + if (effective.has('admin')) effective.add('event_coordinator'); + return [...effective]; +} + +// Express 4 does not route an async middleware's rejection to the error +// handler, so this must catch its own. +export function requireRole(role) { + return async (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'not authenticated' }); + try { + const roles = await getEffectiveRoles(req.user.sub); + if (roles.includes(role)) return next(); + return res.status(403).json({ error: 'forbidden' }); + } catch (e) { + return next(e); + } + }; +} +``` + +The two passport verify callbacks call `upsertUser`. Make each callback `async` and keep the `done(e)` error path: + +```js +}, async (accessToken, refreshToken, profile, done) => { + try { + const user = await upsertUser({ /* ...unchanged... */ }); + done(null, user); + } catch (e) { done(e); } +})); +``` + +`requireAuth` performs no database access and stays synchronous. + +- [ ] **Step 5: Propagate async through `server/routes/api.js` and `server/index.js`** + +Every handler that touches the db becomes `async`. **Each one must keep or gain a try/catch** — an unhandled rejection in an Express 4 handler never reaches error middleware, so the request would hang until timeout rather than 500. + +```js +router.get('/api/me', requireAuth, async (req, res, next) => { + try { + const dbUser = await getUserById(req.user.sub); + res.json({ + id: req.user.sub, + username: req.user.username, + avatarUrl: req.user.avatarUrl, + roles: await getEffectiveRoles(req.user.sub), + isOwner: isOwner(req.user.sub), + toursCompleted: await getToursCompleted(req.user.sub), + // ...rest unchanged... + }); + } catch (e) { next(e); } +}); +``` + +`server/index.js` uses top-level `await`: + +```js +await ensureConfig(); +await seedSeasonalEvents(); +await runScheduler(Date.now()); +setInterval(() => { runScheduler(Date.now()).catch((e) => console.error('[scheduler]', e)); }, 3600_000).unref(); +``` + +The `.catch` on the interval is required — an async `runScheduler` rejecting inside `setInterval` is an unhandled rejection that crashes Node 20 by default. + +- [ ] **Step 6: Update all 19 test files** + +Add `await` at every db/service call and make the enclosing `it`/`beforeEach` callbacks async. Files, by db-call density: + +`tests/api.test.js`, `tests/api.events.test.js`, `tests/api.events.hotfix.test.js`, `tests/api.finalfix.test.js`, `tests/api.social.test.js`, `tests/api.tutorial.test.js`, `tests/db.test.js`, `tests/db.events.test.js`, `tests/eventService.test.js`, `tests/eventService.finalfix.test.js`, `tests/stateService.events.test.js`, `tests/stateService.social.test.js`, and the 6 `tests/e2e/smoke-v1*.mjs` suites. + +`tests/db.test.js` and `tests/db.events.test.js` also reach past the interface into raw `db.prepare` for schema assertions. Leave those raw calls synchronous for now — Task 2 replaces them with a driver-agnostic helper. + +- [ ] **Step 7: Verify no un-awaited db calls remain** + +Run this and confirm it produces no output: + +```bash +grep -rnE "(^|[^a-zA-Z.])(getSave|putSave|getUserById|upsertUser|getRoles|setRoles|getToursCompleted|setToursCompleted|setUsername|getConfigRow|putConfigRow|getConfigHistory|getEvent|getActiveEvent|putEvent|listEvents|setEventStatus|deleteEvent|getParticipation|upsertParticipation|listParticipation|listLeaderboard|getLatestEventId|seedSeasonalEvents|getAllUsersWithSaves|deleteSave|createMinigameSession|getMinigameSession|getOpenMinigameSession|finishMinigameSession|setLeaderboardOptOut|updateParticipationProgress)\(" \ + server/ tests/ --include=*.js --include=*.mjs \ + | grep -v "await " | grep -v "export async function" | grep -v "^\s*\*" | grep -v "//" +``` + +Any hit is a bug that silently resolves to a `Promise` object. Investigate each; a legitimate exception (e.g. a `.catch()` chain) should be rewritten to `await` for consistency. + +- [ ] **Step 8: Run the full suite** + +Run: `npm test` +Expected: PASS — the same 451 tests that passed before this task. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "Make the db interface async ahead of the Postgres port + +Every server/db.js export returns a Promise; all 292 call sites across +server/ and tests/ now await. better-sqlite3 remains the implementation, so +behaviour is unchanged - this is purely the shape change that lets a pg +driver slot in behind the same interface. + +shared/ is deliberately untouched and stays synchronous: the client bundles +it, and an async reducer would be a far larger change than this migration +needs." +``` + +--- + +### Task 2: Split `db.js` into facade + SQLite driver + schema + +A pure refactor. No behaviour change, no new SQL. This carves out the seam the Postgres driver plugs into. + +**Files:** +- Create: `server/db/index.js`, `server/db/schema.sqlite.js`, `server/db/driver.sqlite.js`, `server/db/interface.md` +- Modify: `server/db.js` (becomes a re-export shim), `tests/db.test.js`, `tests/db.events.test.js` + +**Interfaces:** +- Consumes: the async interface from Task 1. +- Produces: `createSqliteDriver({ path }) → Promise`, where `Driver` is an object whose keys are exactly the ~37 interface function names from Task 1. Also `driver.__raw` (the `better-sqlite3` handle) and `driver.__backend === 'sqlite'`, used only by tests for schema assertions. + +- [ ] **Step 1: Write the failing test for the facade contract** + +```js +// tests/db.interface.test.js +process.env.DB_PATH = ':memory:'; +import { describe, it, expect } from 'vitest'; + +const INTERFACE = [ + 'upsertUser', 'getUserById', 'getAllUsersWithSaves', 'getSave', 'putSave', + 'deleteSave', 'getRoles', 'setRoles', 'getToursCompleted', 'setToursCompleted', + 'setUsername', 'dedupeUsernames', 'createMinigameSession', 'getMinigameSession', + 'getOpenMinigameSession', 'finishMinigameSession', 'getConfigRow', 'putConfigRow', + 'getConfigHistory', 'listEvents', 'getEvent', 'getActiveEvent', 'putEvent', + 'setEventStatus', 'deleteEvent', 'upsertParticipation', 'getParticipation', + 'updateParticipationProgress', 'listParticipation', 'setLeaderboardOptOut', + 'listLeaderboard', 'getLatestEventId', 'seedSeasonalEvents', +]; + +describe('db facade', () => { + it('exports every interface function', async () => { + const mod = await import('../server/db/index.js'); + for (const name of INTERFACE) { + expect(typeof mod[name], `missing export: ${name}`).toBe('function'); + } + }); + + it('every interface function returns a promise', async () => { + const mod = await import('../server/db/index.js'); + const result = mod.getUserById('nobody'); + expect(typeof result.then).toBe('function'); + await result; + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/db.interface.test.js` +Expected: FAIL — `Cannot find module '../server/db/index.js'` + +- [ ] **Step 3: Extract the SQLite schema module** + +Move all DDL from `server/db.js` into `server/db/schema.sqlite.js`, exporting one function. Add a `schema_migrations` table — the codebase is past the point where `CREATE TABLE IF NOT EXISTS` plus guarded `ALTER` can express schema evolution honestly, and Task 5 needs a real version marker. + +```js +// server/db/schema.sqlite.js +export function applySchema(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + `); + // ...every CREATE TABLE currently in server/db.js, verbatim... + // ...the guardedAddColumn calls, verbatim... + dedupeUsernamesSync(db); + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); +} + +export function appliedVersions(db) { + return new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version)); +} + +export function markApplied(db, version) { + db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?)') + .run(version, Date.now()); +} +``` + +`dedupeUsernamesSync` moves here too — it must still run before the unique index is created, for exactly the reason its existing comment gives. + +- [ ] **Step 4: Extract the SQLite driver** + +`server/db/driver.sqlite.js` exports `createSqliteDriver({ path })`. Move every function body from `server/db.js` in unchanged, returning them as an object. + +```js +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import { applySchema } from './schema.sqlite.js'; + +export async function createSqliteDriver({ path: dbPath }) { + if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + applySchema(db); + + return { + __backend: 'sqlite', + __raw: db, + async getSave(userId) { + return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); + }, + // ...every other interface function, bodies unchanged from Task 1... + }; +} +``` + +- [ ] **Step 5: Write the facade** + +```js +// server/db/index.js +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createSqliteDriver } from './driver.sqlite.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'rackstack.db'); + +// Top-level await: every consumer does `import { getSave } from './db.js'`, +// so the driver must be resolved before this module finishes evaluating. +// Task 4 adds the DATABASE_URL branch here. +const driver = await createSqliteDriver({ path: DB_PATH }); + +export const { + upsertUser, getUserById, getAllUsersWithSaves, getSave, putSave, deleteSave, + getRoles, setRoles, getToursCompleted, setToursCompleted, setUsername, + dedupeUsernames, createMinigameSession, getMinigameSession, + getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow, + getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, + setEventStatus, deleteEvent, upsertParticipation, getParticipation, + updateParticipationProgress, listParticipation, setLeaderboardOptOut, + listLeaderboard, getLatestEventId, seedSeasonalEvents, +} = driver; + +export { driver }; +``` + +Then reduce `server/db.js` to a shim so no existing import path changes: + +```js +// server/db.js +export * from './db/index.js'; +``` + +- [ ] **Step 6: Replace raw `db.prepare` in the two schema tests** + +`tests/db.test.js` and `tests/db.events.test.js` assert on table existence via `sqlite_master`. Route them through the driver handle so the intent stays explicit about being backend-specific: + +```js +import { driver } from '../server/db/index.js'; + +function tableNames() { + if (driver.__backend !== 'sqlite') return null; // pg variant added in Task 4 + return driver.__raw.prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all().map((r) => r.name); +} +``` + +- [ ] **Step 7: Run the full suite** + +Run: `npm test` +Expected: PASS — all 451 tests plus the 2 new facade tests. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "Split db.js into a facade, a SQLite driver, and a schema module + +Pure refactor - no SQL changes. server/db.js is now a re-export shim so every +existing import path keeps working, while server/db/ holds the seam the +Postgres driver plugs into next. + +Adds a schema_migrations table: the schema has outgrown what CREATE TABLE IF +NOT EXISTS plus guarded ALTERs can honestly express, and the identities split +needs a real version marker." +``` + +--- + +### Task 3: Postgres test harness + +Stand up the infrastructure to run the suite against a real Postgres, before there is a driver to run. + +**Files:** +- Create: `tests/setup/pg-global.js`, `tests/helpers/backend.js`, `vitest.config.js` +- Modify: `package.json`, `.github/workflows/` (new `test.yml`) + +**Interfaces:** +- Consumes: nothing from prior tasks. +- Produces: `provisionDatabase() → Promise<{ url|path, backend, cleanup }>` from `tests/helpers/backend.js`. Reads `TEST_BACKEND` (`'pg'` default, or `'sqlite'`). For pg it creates a uniquely-named database on the shared container and returns its URL; for sqlite it returns `{ path: ':memory:' }`. + +- [ ] **Step 1: Add dependencies** + +```bash +npm install pg +npm install --save-dev @testcontainers/postgresql +``` + +- [ ] **Step 2: Write the global setup** + +```js +// tests/setup/pg-global.js +import { PostgreSqlContainer } from '@testcontainers/postgresql'; + +let container; + +export async function setup() { + if (process.env.TEST_BACKEND === 'sqlite') return; + if (process.env.TEST_DATABASE_URL) return; // CI supplies a service container + container = await new PostgreSqlContainer('postgres:16').start(); + process.env.TEST_DATABASE_URL = container.getConnectionUri(); +} + +export async function teardown() { + if (container) await container.stop(); +} +``` + +- [ ] **Step 3: Write the per-file provisioner** + +Each test file needs an isolated database so the 19 files cannot see each other's rows. Creating a database per file is the simplest isolation that also exercises the real schema-creation path. + +```js +// tests/helpers/backend.js +import pg from 'pg'; +import { randomUUID } from 'node:crypto'; + +export async function provisionDatabase() { + const backend = process.env.TEST_BACKEND === 'sqlite' ? 'sqlite' : 'pg'; + if (backend === 'sqlite') { + return { backend, path: ':memory:', cleanup: async () => {} }; + } + + const adminUrl = process.env.TEST_DATABASE_URL; + if (!adminUrl) throw new Error('TEST_DATABASE_URL not set - is the vitest globalSetup running?'); + + const name = `rackstack_test_${randomUUID().replace(/-/g, '')}`; + const admin = new pg.Client({ connectionString: adminUrl }); + await admin.connect(); + await admin.query(`CREATE DATABASE ${name}`); + await admin.end(); + + const url = new URL(adminUrl); + url.pathname = `/${name}`; + return { + backend, + url: url.toString(), + cleanup: async () => { + const a = new pg.Client({ connectionString: adminUrl }); + await a.connect(); + await a.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + await a.end(); + }, + }; +} +``` + +- [ ] **Step 4: Wire the vitest config** + +```js +// vitest.config.js +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globalSetup: ['./tests/setup/pg-global.js'], + // Each file gets its own database and its own module registry, so the + // db facade's top-level await resolves per-file against that database. + isolate: true, + pool: 'forks', + testTimeout: 30_000, // container start on a cold machine + }, +}); +``` + +- [ ] **Step 5: Add the backend matrix scripts** + +```json +"scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "test": "vitest run", + "test:sqlite": "TEST_BACKEND=sqlite vitest run", + "test:all": "npm run test:sqlite && npm test", + "migrate:pg": "node server/db/migrate.js" +} +``` + +- [ ] **Step 6: Write a harness smoke test** + +```js +// tests/harness.test.js +import { describe, it, expect, afterAll } from 'vitest'; +import pg from 'pg'; +import { provisionDatabase } from './helpers/backend.js'; + +const provisioned = await provisionDatabase(); +afterAll(() => provisioned.cleanup()); + +describe('test harness', () => { + it('provisions an isolated database for the configured backend', async () => { + if (provisioned.backend === 'sqlite') { + expect(provisioned.path).toBe(':memory:'); + return; + } + const client = new pg.Client({ connectionString: provisioned.url }); + await client.connect(); + const { rows } = await client.query('SELECT 1 AS ok'); + expect(rows[0].ok).toBe(1); + await client.end(); + }); +}); +``` + +- [ ] **Step 7: Run it on both backends** + +Run: `npx vitest run tests/harness.test.js` +Expected: PASS (boots a Postgres container — the first run pulls the image) + +Run: `TEST_BACKEND=sqlite npx vitest run tests/harness.test.js` +Expected: PASS, no container started + +- [ ] **Step 8: Add the CI workflow** + +```yaml +# .github/workflows/test.yml +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + backend: [pg, sqlite] + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test + env: + TEST_BACKEND: ${{ matrix.backend }} + TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres +``` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "Add a Postgres test harness and a two-backend CI matrix + +Testcontainers locally, a service container in CI, and a per-test-file +database so the 19 db-touching suites cannot see each other's rows. + +The matrix exists because we ship two backends: a SQLite driver that CI never +exercises would drift from the Postgres one, and dialect drift is the bug +class that loses data." +``` + +--- + +### Task 4: Postgres schema and driver + +**Files:** +- Create: `server/db/schema.pg.js`, `server/db/driver.pg.js` +- Modify: `server/db/index.js`, `tests/db.test.js`, `tests/db.events.test.js`, every test file's db provisioning + +**Interfaces:** +- Consumes: `createSqliteDriver` (Task 2), `provisionDatabase` (Task 3). +- Produces: `createPgDriver({ url }) → Promise` with the same ~37 keys, plus `__backend === 'pg'`, `__raw` (the `pg.Pool`). + +- [ ] **Step 1: Configure the `int8` parser before anything else** + +This is the single highest-value line in the task. Put it at the top of `server/db/driver.pg.js`, at module scope, so it runs on import. + +```js +import pg from 'pg'; + +// pg returns int8/BIGINT as a STRING by default, to avoid precision loss +// above 2^53. Every BIGINT in this schema is an epoch-millisecond timestamp +// (created_at, last_save, starts_at, ends_at, ...) - all far below 2^53, and +// every consumer does arithmetic or comparison on them. Left as strings, +// `now > state.server.anomalyExpiresAt` and every offline-gap calculation +// silently misbehave. Parse them back to numbers. +pg.types.setTypeParser(pg.types.builtins.INT8, (v) => (v === null ? null : Number(v))); +``` + +- [ ] **Step 2: Write the failing parity test** + +This test runs against whichever backend is configured and asserts the behaviours that differ between dialects. It is the guard for every landmine in the spec's §4.3 table. + +```js +// tests/db.parity.test.js +import { describe, it, expect, beforeAll } from 'vitest'; + +let db; +beforeAll(async () => { db = await import('../server/db/index.js'); }); + +describe('cross-dialect parity', () => { + it('returns undefined - not null - for a missing row', async () => { + expect(await db.getUserById('nope:1')).toBeUndefined(); + expect(await db.getSave('nope:1')).toBeUndefined(); + expect(await db.getEvent('nope')).toBeUndefined(); + }); + + it('round-trips epoch-ms timestamps as numbers', async () => { + const now = 1784859388645; + await db.upsertUser({ provider: 'github', providerId: 't1', username: 'ts', avatarUrl: null }); + await db.putSave('github:t1', { hello: 'world' }, now); + const row = await db.getSave('github:t1'); + expect(typeof row.last_save).toBe('number'); + expect(row.last_save).toBe(now); + }); + + it('round-trips a save byte-for-byte, including key order', async () => { + const payload = { z: 1, a: { nested: [1, 2, 3] }, m: 'x' }; + await db.upsertUser({ provider: 'github', providerId: 't2', username: 'bytes', avatarUrl: null }); + await db.putSave('github:t2', payload, 1); + const row = await db.getSave('github:t2'); + expect(row.data).toBe(JSON.stringify(payload)); + }); + + it('enforces case-insensitive username uniqueness', async () => { + await db.upsertUser({ provider: 'github', providerId: 'c1', username: 'CaseTest', avatarUrl: null }); + await db.upsertUser({ provider: 'discord', providerId: 'c2', username: 'casetest', avatarUrl: null }); + const a = await db.getUserById('github:c1'); + const b = await db.getUserById('discord:c2'); + expect(a.username).toBe('CaseTest'); + expect(b.username).toBe('casetest-2'); // suffixed, not rejected + }); + + it('setUsername rejects a case-variant of another user\'s name', async () => { + await db.upsertUser({ provider: 'github', providerId: 'c3', username: 'Taken', avatarUrl: null }); + await db.upsertUser({ provider: 'github', providerId: 'c4', username: 'Other', avatarUrl: null }); + expect(await db.setUsername('github:c4', 'taken')).toEqual({ ok: false, error: 'taken' }); + }); + + it('returns config history newest-first', async () => { + await db.putConfigRow(1, { v: 1 }, null); + await db.putConfigRow(2, { v: 2 }, null); + await db.putConfigRow(3, { v: 3 }, null); + const history = await db.getConfigHistory(); + expect(history.map((h) => h.version)).toEqual([3, 2, 1]); + }); + + it('preserves camelCase aliases in listLeaderboard', async () => { + await db.upsertUser({ provider: 'github', providerId: 'lb', username: 'lbuser', avatarUrl: null }); + await db.putEvent({ id: 'ev-lb', name: 'LB', modifiers: [], ladder: [], status: 'active' }); + await db.upsertParticipation({ + userId: 'github:lb', eventId: 'ev-lb', startedAt: 1, endsAt: 2, rungsClaimed: 3, lastProgressAt: 4, + }); + const [row] = await db.listLeaderboard('ev-lb'); + expect(row.userId).toBe('github:lb'); + expect(row.rungsClaimed).toBe(3); + expect(row.lastProgressAt).toBe(4); + expect(row).not.toHaveProperty('userid'); + }); + + it('seedSeasonalEvents is idempotent across boots', async () => { + await db.seedSeasonalEvents(); + const first = (await db.listEvents()).length; + await db.seedSeasonalEvents(); + expect((await db.listEvents()).length).toBe(first); + }); +}); +``` + +- [ ] **Step 3: Run it against SQLite to confirm it passes there** + +Run: `TEST_BACKEND=sqlite npx vitest run tests/db.parity.test.js` +Expected: PASS — these behaviours already hold on SQLite, which is what makes them the parity contract. + +- [ ] **Step 4: Run it against Postgres to confirm it fails** + +Run: `npx vitest run tests/db.parity.test.js` +Expected: FAIL — no Postgres driver exists yet. + +- [ ] **Step 5: Write the Postgres schema** + +```js +// server/db/schema.pg.js +export async function applySchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + username TEXT, + avatar_url TEXT, + created_at BIGINT NOT NULL, + roles TEXT DEFAULT '[]', + custom_username SMALLINT DEFAULT 0, + leaderboard_opt_out SMALLINT DEFAULT 0, + tours_completed TEXT DEFAULT '[]', + UNIQUE (provider, provider_id) + ); + + CREATE TABLE IF NOT EXISTS saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, + last_save BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + data TEXT NOT NULL, + updated_at BIGINT NOT NULL, + updated_by TEXT + ); + + -- SQLite ordered this by rowid. Postgres has no rowid, so history order + -- - which the admin rollback UI depends on - needs a real key. + CREATE TABLE IF NOT EXISTS config_history ( + id BIGSERIAL PRIMARY KEY, + version INTEGER NOT NULL, + data TEXT NOT NULL, + updated_at BIGINT NOT NULL, + updated_by TEXT + ); + + CREATE TABLE IF NOT EXISTS minigame_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + game TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + score INTEGER + ); + + CREATE TABLE IF NOT EXISTS live_events ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + theme TEXT, + modifiers TEXT NOT NULL, + ladder TEXT NOT NULL, + status TEXT NOT NULL, + starts_at BIGINT, + ends_at BIGINT, + recurrence TEXT, + created_at BIGINT NOT NULL, + created_by TEXT + ); + + CREATE TABLE IF NOT EXISTS event_participation ( + user_id TEXT NOT NULL REFERENCES users(id), + event_id TEXT NOT NULL REFERENCES live_events(id), + started_at BIGINT NOT NULL, + ends_at BIGINT NOT NULL, + rungs_claimed INTEGER NOT NULL DEFAULT 0, + last_progress_at BIGINT, + opted_out SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, event_id) + ); + `); + + // COLLATE NOCASE has no Postgres equivalent; a functional index on lower() + // gives the same guarantee. Every username lookup must use lower() to match. + await pool.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (lower(username))', + ); +} +``` + +- [ ] **Step 6: Write the Postgres driver** + +Translate each function. The mechanical rules: `?` → `$n`, `@named` → `$n` with an explicit argument array, `INSERT OR IGNORE` → `ON CONFLICT DO NOTHING`, and `.get()` → `rows[0]`. + +```js +// server/db/driver.pg.js (after the setTypeParser block from Step 1) +import { applySchema } from './schema.pg.js'; +import { randomUUID } from 'node:crypto'; +import { SEASONAL_EVENTS } from '../data/seasonalEvents.js'; + +export async function createPgDriver({ url }) { + const pool = new pg.Pool({ connectionString: url }); + await applySchema(pool); + + const one = async (sql, params = []) => (await pool.query(sql, params)).rows[0]; + const all = async (sql, params = []) => (await pool.query(sql, params)).rows; + const run = async (sql, params = []) => { await pool.query(sql, params); }; + + async function getEvent(id) { + return parseEventRow(await one('SELECT * FROM live_events WHERE id = $1', [id])); + } + + async function isUsernameTakenInDb(name) { + return !!(await one('SELECT id FROM users WHERE lower(username) = lower($1)', [name])); + } + + async function isUsernameTakenByOtherUser(name, excludeId) { + return !!(await one( + 'SELECT id FROM users WHERE lower(username) = lower($1) AND id != $2', [name, excludeId], + )); + } + + return { + __backend: 'pg', + __raw: pool, + + async getSave(userId) { + return one('SELECT * FROM saves WHERE user_id = $1', [userId]); + }, + + async putSave(userId, data, lastSave) { + await run(` + INSERT INTO saves (user_id, data, last_save) VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET data = excluded.data, last_save = excluded.last_save + `, [userId, JSON.stringify(data), lastSave]); + }, + + async setUsername(userId, name) { + const collision = await one( + 'SELECT id FROM users WHERE lower(username) = lower($1) AND id != $2', [name, userId], + ); + if (collision) return { ok: false, error: 'taken' }; + await run('UPDATE users SET username = $1, custom_username = 1 WHERE id = $2', [name, userId]); + return { ok: true }; + }, + + async getConfigHistory() { + return all('SELECT * FROM config_history ORDER BY id DESC'); + }, + + async listLeaderboard(eventId, limit = 50) { + // Every camelCase alias MUST be double-quoted: Postgres folds unquoted + // identifiers to lowercase, which would hand the client `userid` and + // `rungsClaimed` would arrive as undefined. + return all(` + SELECT ep.user_id AS "userId", u.username AS "username", + ep.rungs_claimed AS "rungsClaimed", ep.last_progress_at AS "lastProgressAt" + FROM event_participation ep + LEFT JOIN users u ON u.id = ep.user_id + WHERE ep.event_id = $1 AND COALESCE(u.leaderboard_opt_out, 0) = 0 + ORDER BY ep.rungs_claimed DESC, ep.last_progress_at ASC + LIMIT $2 + `, [eventId, limit]); + }, + + async seedSeasonalEvents() { + for (const evt of SEASONAL_EVENTS) { + await run(` + INSERT INTO live_events (id, name, description, theme, modifiers, ladder, status, + starts_at, ends_at, recurrence, created_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, 'draft', NULL, NULL, $7, $8, NULL) + ON CONFLICT (id) DO NOTHING + `, [ + evt.id, evt.name, evt.description ?? null, JSON.stringify(evt.theme ?? null), + JSON.stringify(evt.modifiers ?? []), JSON.stringify(evt.ladder ?? []), + JSON.stringify(evt.recurrence ?? null), Date.now(), + ]); + } + }, + + // ...remaining interface functions, same translation rules... + }; +} +``` + +`upsertUser`'s two collision-retry paths must be preserved exactly, with the error check changed from SQLite codes to the Postgres SQLSTATE. Get this wrong and a user whose OAuth display name collides with another player's is locked out of their account on every login: + +```js +async upsertUser({ provider, providerId, username, avatarUrl }) { + const id = `${provider}:${providerId}`; + const existing = await one('SELECT * FROM users WHERE id = $1', [id]); + if (existing) { + const desiredUsername = existing.custom_username ? existing.username : username; + let nextUsername = desiredUsername; + try { + await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', + [nextUsername, avatarUrl, id]); + } catch (e) { + if (e.code !== '23505') throw e; // unique_violation + nextUsername = await findAvailableUsername(desiredUsername, + (n) => isUsernameTakenByOtherUser(n, id)); + await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', + [nextUsername, avatarUrl, id]); + } + return { ...existing, username: nextUsername, avatar_url: avatarUrl }; + } + // ...INSERT path, same 23505 retry against isUsernameTakenInDb... +} +``` + +`findAvailableUsername` becomes async in this driver because its `isTaken` predicate now hits the database. Keep the same `-2`, `-3` suffixing convention as the SQLite driver — `dedupeUsernames` and `upsertUser` must agree on it. + +The functions shown above are the ones with dialect-specific traps. Translate the remainder by the same mechanical rules (`?` → `$n`, `@named` → `$n` with an argument array, `.get()` → `rows[0]`, `.all()` → `rows`, `.run()` → `await pool.query`). Tick each off — a missing key on the driver object is a `TypeError` at the call site, not a test failure with a useful message: + +`getUserById`, `getAllUsersWithSaves`, `deleteSave`, `getRoles`, `setRoles`, `getToursCompleted`, `setToursCompleted`, `dedupeUsernames`, `createMinigameSession`, `getMinigameSession`, `getOpenMinigameSession`, `finishMinigameSession`, `getConfigRow`, `putConfigRow`, `listEvents`, `getActiveEvent`, `putEvent`, `setEventStatus`, `deleteEvent`, `upsertParticipation`, `getParticipation`, `updateParticipationProgress`, `listParticipation`, `setLeaderboardOptOut`, `getLatestEventId`. + +Three carry non-obvious detail worth restating: + +- `putConfigRow` must keep writing **both** the `config` upsert and the `config_history` append, in that order. +- `setEventStatus` builds its `SET` clause dynamically — a key present but `null` clears the column, a key absent leaves it untouched. Preserve that distinction; collapsing it wipes an event's window. +- `parseEventRow` is shared logic, not SQL. Copy it verbatim so `theme`/`modifiers`/`ladder`/`recurrence` arrive parsed, exactly as the SQLite driver returns them. + +- [ ] **Step 7: Wire driver selection into the facade** + +```js +// server/db/index.js +import { createSqliteDriver } from './driver.sqlite.js'; +import { createPgDriver } from './driver.pg.js'; + +const driver = process.env.DATABASE_URL + ? await createPgDriver({ url: process.env.DATABASE_URL }) + : await createSqliteDriver({ path: DB_PATH }); +``` + +- [ ] **Step 8: Point the test files at the provisioner** + +Replace `process.env.DB_PATH = ':memory:'` at the top of each db-touching test file: + +```js +import { provisionDatabase } from './helpers/backend.js'; + +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; + +// Import AFTER the env vars are set - the facade resolves its driver at +// module-evaluation time. +const db = await import('../server/db/index.js'); +``` + +Give `tests/db.test.js` and `tests/db.events.test.js` a Postgres branch for their schema assertions: + +```js +async function tableNames() { + if (driver.__backend === 'sqlite') { + return driver.__raw.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name); + } + const { rows } = await driver.__raw.query( + "SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'", + ); + return rows.map((r) => r.name); +} +``` + +- [ ] **Step 9: Run the whole suite on both backends** + +Run: `npm run test:all` +Expected: PASS on both. Fix parity failures in the driver, never by weakening a test. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "Add the Postgres schema and driver + +Both backends now satisfy the same interface and the full suite runs against +each. tests/db.parity.test.js pins the behaviours that differ between the +dialects and would otherwise fail silently in production: + +- pg returns BIGINT as a string by default; every epoch-ms column in this + schema is BIGINT, so the int8 type parser is registered before any query. +- Postgres folds unquoted identifiers to lowercase, so listLeaderboard's + camelCase aliases are double-quoted - unquoted, the client receives + 'userid' and every rungsClaimed reads undefined. +- config_history ordered by rowid on SQLite; Postgres has none, so it gains + a BIGSERIAL key and the admin rollback UI keeps its newest-first order. +- COLLATE NOCASE becomes a unique functional index on lower(username). +- SQLITE_CONSTRAINT_UNIQUE becomes SQLSTATE 23505 - that retry path is what + keeps a display-name collision from locking a player out on login." +``` + +--- + +### Task 5: The `identities` auth split + +Both backends gain an `identities` table, and existing databases are migrated in place. `users.id` does not change. + +**Files:** +- Modify: `server/db/schema.sqlite.js`, `server/db/schema.pg.js`, `server/db/driver.sqlite.js`, `server/db/driver.pg.js` +- Create: `tests/db.identities.test.js` + +**Interfaces:** +- Consumes: both drivers from Tasks 2 and 4. +- Produces: an `identities` table keyed `(provider, provider_id)` with `user_id`, nullable `supertokens_user_id`, `created_at`, `last_login_at`. `upsertUser` resolves through it. `getAllUsersWithSaves()` keeps returning a `provider` field. New export `listIdentities(userId) → Promise>`, consumed by v1.8. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/db.identities.test.js +describe('identities split', () => { + it('creates exactly one identity per user on first login', async () => { + await db.upsertUser({ provider: 'github', providerId: '37058311', username: 'nec', avatarUrl: null }); + const ids = await db.listIdentities('github:37058311'); + expect(ids).toHaveLength(1); + expect(ids[0]).toMatchObject({ provider: 'github', provider_id: '37058311' }); + expect(ids[0].supertokens_user_id).toBeNull(); + }); + + it('keeps users.id as provider:providerId', async () => { + await db.upsertUser({ provider: 'discord', providerId: '536626725380161537', username: 'st', avatarUrl: null }); + const user = await db.getUserById('discord:536626725380161537'); + expect(user).toBeDefined(); + expect(user.id).toBe('discord:536626725380161537'); + }); + + it('resolves a returning login through identities to the same user', async () => { + await db.upsertUser({ provider: 'github', providerId: 'ret', username: 'first', avatarUrl: null }); + await db.putSave('github:ret', { marker: 'keep-me' }, 123); + await db.upsertUser({ provider: 'github', providerId: 'ret', username: 'renamed', avatarUrl: 'a.png' }); + const save = await db.getSave('github:ret'); + expect(JSON.parse(save.data).marker).toBe('keep-me'); + expect(await db.listIdentities('github:ret')).toHaveLength(1); + }); + + it('getAllUsersWithSaves still exposes provider', async () => { + await db.upsertUser({ provider: 'discord', providerId: 'agg', username: 'aggu', avatarUrl: null }); + const rows = await db.getAllUsersWithSaves(); + expect(rows.find((r) => r.id === 'discord:agg').provider).toBe('discord'); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm run test:all -- tests/db.identities.test.js` +Expected: FAIL on both backends — `db.listIdentities is not a function` + +- [ ] **Step 3: Add the table and the in-place migration to the Postgres schema** + +```js +// server/db/schema.pg.js - migration version 1 +await pool.query(` + CREATE TABLE IF NOT EXISTS identities ( + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + supertokens_user_id TEXT UNIQUE, + created_at BIGINT NOT NULL, + last_login_at BIGINT, + PRIMARY KEY (provider, provider_id) + ); + CREATE INDEX IF NOT EXISTS idx_identities_user ON identities (user_id); +`); + +// Backfill from users, then drop the migrated columns. Guarded so it is a +// no-op on a database that has already been through this. +const hasProvider = await pool.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'provider' +`); +if (hasProvider.rowCount > 0) { + await pool.query(` + INSERT INTO identities (provider, provider_id, user_id, created_at) + SELECT provider, provider_id, id, created_at FROM users + ON CONFLICT (provider, provider_id) DO NOTHING + `); + await pool.query('ALTER TABLE users DROP COLUMN provider, DROP COLUMN provider_id'); +} +``` + +- [ ] **Step 4: Add the table and the in-place migration to the SQLite schema** + +SQLite cannot `DROP COLUMN` when the column participates in a table-level `UNIQUE` constraint, which `users` has on `(provider, provider_id)`. The documented table-rebuild procedure is required. Follow it exactly — the ordering matters. + +```js +// server/db/schema.sqlite.js - migration version 1 +function migrateIdentities(db) { + const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + if (!cols.includes('provider')) return; // already migrated + + db.pragma('foreign_keys = OFF'); + const tx = db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS identities ( + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + supertokens_user_id TEXT UNIQUE, + created_at INTEGER NOT NULL, + last_login_at INTEGER, + PRIMARY KEY (provider, provider_id) + ); + + INSERT OR IGNORE INTO identities (provider, provider_id, user_id, created_at) + SELECT provider, provider_id, id, created_at FROM users; + + CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + username TEXT, + avatar_url TEXT, + created_at INTEGER NOT NULL, + roles TEXT DEFAULT '[]', + custom_username INTEGER DEFAULT 0, + leaderboard_opt_out INTEGER DEFAULT 0, + tours_completed TEXT DEFAULT '[]' + ); + + INSERT INTO users_new (id, username, avatar_url, created_at, roles, + custom_username, leaderboard_opt_out, tours_completed) + SELECT id, username, avatar_url, created_at, + COALESCE(roles, '[]'), COALESCE(custom_username, 0), + COALESCE(leaderboard_opt_out, 0), COALESCE(tours_completed, '[]') + FROM users; + + DROP TABLE users; + ALTER TABLE users_new RENAME TO users; + `); + // The unique index lived on the dropped table - recreate it. + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_identities_user ON identities (user_id)'); + }); + tx(); + db.pragma('foreign_keys = ON'); + + // saves/minigame_sessions/event_participation declare their foreign keys + // against the *name* `users`, so the rename re-points them. Verify rather + // than assume - a violation here means orphaned saves. + const violations = db.pragma('foreign_key_check'); + if (violations.length > 0) { + throw new Error(`identities migration left ${violations.length} FK violations`); + } +} +``` + +- [ ] **Step 5: Update `upsertUser` in both drivers** + +Resolution now goes through `identities`. The username-collision retry logic from Task 4 is preserved verbatim; only the lookup changes. + +```js +async upsertUser({ provider, providerId, username, avatarUrl }) { + const identity = await one( + 'SELECT * FROM identities WHERE provider = $1 AND provider_id = $2', [provider, providerId], + ); + + if (identity) { + const existing = await one('SELECT * FROM users WHERE id = $1', [identity.user_id]); + await run('UPDATE identities SET last_login_at = $1 WHERE provider = $2 AND provider_id = $3', + [Date.now(), provider, providerId]); + // ...unchanged username/avatar refresh with the 23505 retry... + return { ...existing, username: nextUsername, avatar_url: avatarUrl }; + } + + const id = `${provider}:${providerId}`; + const now = Date.now(); + // ...unchanged INSERT-into-users path with the 23505 retry... + await run(`INSERT INTO identities (provider, provider_id, user_id, created_at) + VALUES ($1, $2, $3, $4)`, [provider, providerId, id, now]); + return user; +} +``` + +- [ ] **Step 6: Update `getAllUsersWithSaves` and add `listIdentities`** + +The primary identity is the earliest by `created_at`, ties broken by provider name, so output is deterministic and identical to today's for every single-identity user. + +```js +async getAllUsersWithSaves() { + return all(` + SELECT u.id, u.username, u.avatar_url, u.created_at, + s.data, s.last_save, + (SELECT i.provider FROM identities i + WHERE i.user_id = u.id + ORDER BY i.created_at ASC, i.provider ASC + LIMIT 1) AS provider + FROM users u + LEFT JOIN saves s ON s.user_id = u.id + ORDER BY u.created_at DESC + `); +} + +async listIdentities(userId) { + return all('SELECT * FROM identities WHERE user_id = $1 ORDER BY created_at ASC', [userId]); +} +``` + +Add `listIdentities` to the facade's export list in `server/db/index.js` and to `INTERFACE` in `tests/db.interface.test.js`. + +- [ ] **Step 7: Write the upgrade-path test** + +The in-place migration is what runs against real player data. Test it directly rather than only testing a fresh schema. + +```js +it('migrates a pre-split SQLite database without losing saves', async () => { + // Build a database in the OLD shape, then let applySchema() upgrade it. + const Database = (await import('better-sqlite3')).default; + const raw = new Database(':memory:'); + raw.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_id TEXT NOT NULL, + username TEXT, avatar_url TEXT, created_at INTEGER NOT NULL, + UNIQUE(provider, provider_id) + ); + CREATE TABLE saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, last_save INTEGER NOT NULL + ); + INSERT INTO users VALUES ('github:37058311','github','37058311','nec',NULL,1784859388645); + INSERT INTO saves VALUES ('github:37058311','{"wafers":42}',1784859388999); + `); + + const { applySchema } = await import('../server/db/schema.sqlite.js'); + applySchema(raw); + + const user = raw.prepare('SELECT * FROM users WHERE id = ?').get('github:37058311'); + expect(user.username).toBe('nec'); + expect(user).not.toHaveProperty('provider'); + + const identity = raw.prepare('SELECT * FROM identities WHERE user_id = ?').get('github:37058311'); + expect(identity).toMatchObject({ provider: 'github', provider_id: '37058311' }); + + const save = raw.prepare('SELECT * FROM saves WHERE user_id = ?').get('github:37058311'); + expect(JSON.parse(save.data).wafers).toBe(42); + expect(save.last_save).toBe(1784859388999); +}); +``` + +- [ ] **Step 8: Run the whole suite on both backends** + +Run: `npm run test:all` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "Split login methods out of users into an identities table + +users.id stays 'provider:providerId', so all three foreign keys and +SUPER_ADMIN_IDS keep working untouched - the split is additive from the +application's point of view. upsertUser now resolves through identities and +records last_login_at. + +Existing databases are migrated in place on boot. SQLite needs the full +table-rebuild dance because DROP COLUMN is refused while the column +participates in a table-level UNIQUE constraint, and the rebuild is followed +by a foreign_key_check - a violation there would mean orphaned saves. + +supertokens_user_id ships nullable and unused; v1.8 fills it in." +``` + +--- + +### Task 6: The SQLite → Postgres migrator + +**Files:** +- Create: `server/db/migrate.js`, `tests/migrate.test.js`, `tests/fixtures/v11-sqlite.db` +- Modify: `package.json` (the `migrate:pg` script already added in Task 3) + +**Interfaces:** +- Consumes: `applySchema` from both schema modules. +- Produces: `migrateSqliteToPostgres({ sqlitePath, databaseUrl, logger }) → Promise<{ migrated: boolean, counts: Record, reason?: string }>`. Resolves `{ migrated: false, reason }` when it declines to act; rejects on verification failure. + +- [ ] **Step 1: Build the v1.1-era fixture** + +The stale `~/Downloads` copy has only `users` and `saves` — exactly the old-schema path the migrator must tolerate. Recreate it as a committed fixture rather than depending on a file outside the repo. + +```bash +node -e " +const Database = require('better-sqlite3'); +const db = new Database('tests/fixtures/v11-sqlite.db'); +db.exec(\` + CREATE TABLE users ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_id TEXT NOT NULL, + username TEXT, avatar_url TEXT, created_at INTEGER NOT NULL, + UNIQUE(provider, provider_id) + ); + CREATE TABLE saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, last_save INTEGER NOT NULL + ); + INSERT INTO users VALUES + ('github:37058311','github','37058311','NeverEndingCode',NULL,1784859388645), + ('discord:536626725380161537','discord','536626725380161537','short_techy97',NULL,1784860210484); + INSERT INTO saves VALUES + ('github:37058311','{\\\"wafers\\\":1439,\\\"racks\\\":[1,2,3]}',1784859999000), + ('discord:536626725380161537','{\\\"wafers\\\":1423}',1784860999000); +\`); +db.close(); +" +``` + +- [ ] **Step 2: Write the failing tests** + +```js +// tests/migrate.test.js +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import pg from 'pg'; +import Database from 'better-sqlite3'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { provisionDatabase } from './helpers/backend.js'; +import { migrateSqliteToPostgres } from '../server/db/migrate.js'; + +// This suite is Postgres-only by nature - it tests moving data INTO Postgres. +const provisioned = await provisionDatabase(); +const skip = provisioned.backend !== 'pg'; +afterAll(() => provisioned.cleanup()); + +describe.skipIf(skip)('sqlite -> postgres migration', () => { + it('migrates a v1.1-era two-table database', async () => { + const result = await migrateSqliteToPostgres({ + sqlitePath: 'tests/fixtures/v11-sqlite.db', + databaseUrl: provisioned.url, + }); + expect(result.migrated).toBe(true); + expect(result.counts.users).toBe(2); + expect(result.counts.saves).toBe(2); + + const client = new pg.Client({ connectionString: provisioned.url }); + await client.connect(); + const { rows } = await client.query('SELECT data, last_save FROM saves WHERE user_id = $1', + ['github:37058311']); + // Byte-exact, not deep-equal: the point is that the JSON text is untouched. + expect(rows[0].data).toBe('{"wafers":1439,"racks":[1,2,3]}'); + expect(rows[0].last_save).toBe(1784859999000); + + const ids = await client.query('SELECT * FROM identities ORDER BY provider'); + expect(ids.rows).toHaveLength(2); + expect(ids.rows[0]).toMatchObject({ provider: 'discord', user_id: 'discord:536626725380161537' }); + await client.end(); + }); + + it('declines when the target already has data', async () => { + await migrateSqliteToPostgres({ sqlitePath: 'tests/fixtures/v11-sqlite.db', databaseUrl: provisioned.url }); + const second = await migrateSqliteToPostgres({ sqlitePath: 'tests/fixtures/v11-sqlite.db', databaseUrl: provisioned.url }); + expect(second.migrated).toBe(false); + expect(second.reason).toMatch(/not empty/i); + }); + + it('declines when there is no sqlite file', async () => { + const result = await migrateSqliteToPostgres({ + sqlitePath: '/nonexistent/rackstack.db', databaseUrl: provisioned.url, + }); + expect(result.migrated).toBe(false); + expect(result.reason).toMatch(/no sqlite/i); + }); + + it('rolls back and throws when verification fails', async () => { + // Corrupt the copy mid-flight via the injectable hook, proving the + // verifier actually guards rather than always agreeing with itself. + const tmp = path.join(os.tmpdir(), `verify-${Date.now()}.db`); + fs.copyFileSync('tests/fixtures/v11-sqlite.db', tmp); + + await expect(migrateSqliteToPostgres({ + sqlitePath: tmp, + databaseUrl: provisioned.url, + __corruptForTest: async (client) => { + await client.query("UPDATE saves SET data = '{\"wafers\":0}' WHERE user_id = 'github:37058311'"); + }, + })).rejects.toThrow(/verification failed/i); + + const client = new pg.Client({ connectionString: provisioned.url }); + await client.connect(); + const { rows } = await client.query('SELECT count(*)::int AS n FROM saves'); + expect(rows[0].n).toBe(0); // rolled back - nothing committed + await client.end(); + }); +}); +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `npx vitest run tests/migrate.test.js` +Expected: FAIL — `Cannot find module '../server/db/migrate.js'` + +- [ ] **Step 4: Implement the migrator** + +```js +// server/db/migrate.js +import pg from 'pg'; +import Database from 'better-sqlite3'; +import fs from 'fs'; +import crypto from 'node:crypto'; +import { applySchema as applyPgSchema } from './schema.pg.js'; + +const TABLES = [ + { name: 'users', key: ['id'] }, + { name: 'identities', key: ['provider', 'provider_id'] }, + { name: 'saves', key: ['user_id'] }, + { name: 'config', key: ['id'] }, + { name: 'config_history', key: ['version', 'updated_at'] }, + { name: 'minigame_sessions', key: ['id'] }, + { name: 'live_events', key: ['id'] }, + { name: 'event_participation', key: ['user_id', 'event_id'] }, +]; + +function tableExists(db, name) { + return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name); +} + +// A stable fingerprint of one table's contents, computed identically on both +// sides. Rows are sorted by primary key and columns by name so neither side's +// natural ordering can make identical data look different. +function fingerprint(rows, columns) { + const hash = crypto.createHash('sha256'); + for (const row of rows) { + for (const col of columns) { + const v = row[col]; + hash.update(col); + hash.update('\x01'); + hash.update(v === null || v === undefined ? '\x00NULL' : String(v)); + hash.update('\x02'); + } + hash.update('\x03'); + } + return hash.digest('hex'); +} + +export async function migrateSqliteToPostgres({ + sqlitePath, databaseUrl, logger = console, __corruptForTest, +}) { + if (!fs.existsSync(sqlitePath)) { + return { migrated: false, reason: `no sqlite database at ${sqlitePath}` }; + } + + const pool = new pg.Pool({ connectionString: databaseUrl }); + await applyPgSchema(pool); + + const { rows: [{ n }] } = await pool.query('SELECT count(*)::int AS n FROM users'); + if (n > 0) { + await pool.end(); + return { migrated: false, reason: 'target database is not empty; refusing to migrate' }; + } + + // Recent commits live in the -wal file. Checkpointing folds them into the + // main database so we cannot silently migrate stale data. This is the only + // write the migrator ever makes to the SQLite side. + const sqlite = new Database(sqlitePath); + try { + sqlite.pragma('wal_checkpoint(TRUNCATE)'); + } catch (e) { + sqlite.close(); + await pool.end(); + throw new Error(`could not checkpoint the SQLite WAL (${e.message}). Stop the container and retry.`); + } + + const client = await pool.connect(); + const counts = {}; + try { + await client.query('BEGIN'); + + for (const { name } of TABLES) { + if (!tableExists(sqlite, name)) { + logger.log(`[migrate] ${name}: absent in source, skipping`); + counts[name] = 0; + continue; + } + const rows = sqlite.prepare(`SELECT * FROM ${name}`).all(); + counts[name] = rows.length; + if (rows.length === 0) { logger.log(`[migrate] ${name}: 0 rows`); continue; } + + const columns = Object.keys(rows[0]); + const colList = columns.map((c) => `"${c}"`).join(', '); + for (const row of rows) { + const params = columns.map((c) => row[c]); + const placeholders = columns.map((_, i) => `$${i + 1}`).join(', '); + await client.query( + `INSERT INTO ${name} (${colList}) VALUES (${placeholders})`, params, + ); + } + logger.log(`[migrate] ${name}: ${rows.length} rows`); + } + + // A pre-split source has provider/provider_id on users and no identities + // table; synthesise one identity per user so the target lands fully migrated. + if (!tableExists(sqlite, 'identities') && tableExists(sqlite, 'users')) { + const users = sqlite.prepare('SELECT id, provider, provider_id, created_at FROM users').all(); + for (const u of users) { + await client.query( + `INSERT INTO identities (provider, provider_id, user_id, created_at) + VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, + [u.provider, u.provider_id, u.id, u.created_at], + ); + } + counts.identities = users.length; + logger.log(`[migrate] identities: ${users.length} rows synthesised from users`); + } + + if (__corruptForTest) await __corruptForTest(client); + + // Verify BEFORE committing. Anything that disagrees rolls the lot back. + for (const { name, key } of TABLES) { + if (!tableExists(sqlite, name) && name !== 'identities') continue; + + const order = key.map((k) => `"${k}"`).join(', '); + const pgRows = (await client.query(`SELECT * FROM ${name} ORDER BY ${order}`)).rows; + + let srcRows; + if (name === 'identities' && !tableExists(sqlite, 'identities')) { + srcRows = sqlite.prepare( + 'SELECT provider, provider_id, id AS user_id, created_at FROM users ORDER BY provider, provider_id', + ).all().map((r) => ({ ...r, supertokens_user_id: null, last_login_at: null })); + } else { + srcRows = sqlite.prepare(`SELECT * FROM ${name} ORDER BY ${key.join(', ')}`).all(); + } + + if (srcRows.length !== pgRows.length) { + throw new Error( + `verification failed for ${name}: source has ${srcRows.length} rows, target has ${pgRows.length}`, + ); + } + if (srcRows.length === 0) continue; + + const columns = Object.keys(srcRows[0]).sort(); + const srcFp = fingerprint(srcRows, columns); + const pgFp = fingerprint(pgRows, columns); + if (srcFp !== pgFp) { + throw new Error(`verification failed for ${name}: content fingerprint mismatch`); + } + logger.log(`[migrate] ${name}: verified ${srcRows.length} rows (${srcFp.slice(0, 12)})`); + } + + await client.query('COMMIT'); + logger.log('[migrate] committed'); + return { migrated: true, counts }; + } catch (e) { + await client.query('ROLLBACK'); + logger.error(`[migrate] ROLLED BACK: ${e.message}`); + throw e; + } finally { + client.release(); + sqlite.close(); + await pool.end(); + } +} + +// Runnable by hand: npm run migrate:pg +if (import.meta.url === `file://${process.argv[1]}`) { + const sqlitePath = process.env.DB_PATH || '/app/data/rackstack.db'; + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { console.error('DATABASE_URL is required'); process.exit(1); } + migrateSqliteToPostgres({ sqlitePath, databaseUrl }) + .then((r) => { + console.log(r.migrated ? `[migrate] done: ${JSON.stringify(r.counts)}` : `[migrate] skipped: ${r.reason}`); + process.exit(0); + }) + .catch((e) => { console.error('[migrate] FAILED:', e.message); process.exit(1); }); +} +``` + +- [ ] **Step 5: Run the migration tests** + +Run: `npx vitest run tests/migrate.test.js` +Expected: PASS, all four cases — including the rollback case, which proves the verifier can actually fail. + +- [ ] **Step 6: Run the whole suite on both backends** + +Run: `npm run test:all` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "Add the SQLite to Postgres migrator + +Copies every table inside one transaction and verifies before COMMIT: row +counts plus a SHA-256 content fingerprint computed identically on both sides, +with rows sorted by primary key and columns by name so neither side's natural +ordering can make identical data look different. Any mismatch rolls back. + +Checkpoints the WAL first. Recent commits live in rackstack.db-wal, so a +migrator that read only the main file would quietly move stale data - the +copy sitting in the owner's Downloads has a -wal beside it, so this is a real +failure mode rather than a theoretical one. + +Tolerates old schemas: a v1.1-era source with only users and saves migrates +cleanly and gets its identities synthesised. The SQLite file is never +modified beyond the checkpoint and never deleted - it is the rollback path. + +The verification-failure test corrupts the copy mid-transaction on purpose, +so we know the verifier guards rather than always agreeing with itself." +``` + +--- + +### Task 7: Boot integration + +**Files:** +- Modify: `server/index.js`, `server/db/index.js` +- Create: `tests/boot.test.js` + +**Interfaces:** +- Consumes: `migrateSqliteToPostgres` (Task 6). +- Produces: `maybeAutoMigrate({ logger }) → Promise<{ migrated, reason? }>`, exported from `server/db/migrate.js` and awaited by `server/index.js` before `buildApp()`. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/boot.test.js +describe('auto-migration guards', () => { + it('does nothing when DATABASE_URL is unset', async () => { + const result = await maybeAutoMigrate({ env: {}, logger: silent }); + expect(result.migrated).toBe(false); + expect(result.reason).toMatch(/DATABASE_URL/); + }); + + it('does nothing when the sqlite file is absent', async () => { + const result = await maybeAutoMigrate({ + env: { DATABASE_URL: provisioned.url, DB_PATH: '/nonexistent/x.db' }, logger: silent, + }); + expect(result.migrated).toBe(false); + }); + + it('migrates when postgres is empty and a sqlite file exists', async () => { + const result = await maybeAutoMigrate({ + env: { DATABASE_URL: provisioned.url, DB_PATH: 'tests/fixtures/v11-sqlite.db' }, logger: silent, + }); + expect(result.migrated).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/boot.test.js` +Expected: FAIL — `maybeAutoMigrate is not exported` + +- [ ] **Step 3: Implement the guard** + +```js +// server/db/migrate.js +export async function maybeAutoMigrate({ env = process.env, logger = console } = {}) { + if (!env.DATABASE_URL) return { migrated: false, reason: 'DATABASE_URL not set; using SQLite' }; + const sqlitePath = env.DB_PATH || '/app/data/rackstack.db'; + if (!fs.existsSync(sqlitePath)) { + return { migrated: false, reason: 'no SQLite database to migrate; fresh Postgres install' }; + } + logger.log('[migrate] SQLite database found and DATABASE_URL is set - checking whether to migrate'); + return migrateSqliteToPostgres({ sqlitePath, databaseUrl: env.DATABASE_URL, logger }); +} +``` + +- [ ] **Step 4: Wire it into boot** + +It must run before `buildApp()` and before the facade's driver ever serves a request. + +```js +// server/index.js +import 'dotenv/config'; +import { maybeAutoMigrate } from './db/migrate.js'; + +try { + const result = await maybeAutoMigrate(); + if (result.migrated) { + console.log(`[migrate] migration complete: ${JSON.stringify(result.counts)}`); + } else { + console.log(`[migrate] skipped - ${result.reason}`); + } +} catch (e) { + // Deliberately fatal. Serving an empty game to real players is worse than + // being down: a stopped container gets investigated, an empty leaderboard + // might not be noticed until saves have been overwritten. + console.error('[migrate] FATAL - refusing to start:', e.message); + process.exit(1); +} + +const { ensureConfig } = await import('./configService.js'); +const { seedSeasonalEvents } = await import('./db.js'); +const { runScheduler } = await import('./eventService.js'); +const { buildApp } = await import('./app.js'); + +await ensureConfig(); +await seedSeasonalEvents(); +await runScheduler(Date.now()); +setInterval(() => { + runScheduler(Date.now()).catch((e) => console.error('[scheduler]', e)); +}, 3600_000).unref(); + +const app = buildApp(); +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => console.log(`RACKSTACK server listening on :${PORT}`)); +``` + +The dynamic imports are required: `server/db.js` resolves its driver at module-evaluation time, so a static import would connect before the migration had run. + +- [ ] **Step 5: Run the full suite on both backends** + +Run: `npm run test:all` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Auto-migrate on boot behind guards + +Migrates only when DATABASE_URL is set, a SQLite file exists, and Postgres is +empty. A populated target is left alone, so a restart can never re-import over +live data. + +A migration failure is fatal by design. Serving an empty game is worse than +being down - a stopped container gets investigated, an empty leaderboard might +not be noticed until saves have been overwritten on top of it. + +server/index.js imports the db modules dynamically because the facade resolves +its driver at module-evaluation time; a static import would open the pool +before the migration had a chance to run." +``` + +--- + +### Task 8: Deployment and documentation + +**Files:** +- Modify: `Dockerfile`, `docker-compose.yml`, `unraid-template.xml`, `.env.example`, `README.md`, `CHANGELOG.md`, `package.json` + +- [ ] **Step 1: Update `docker-compose.yml`** + +```yaml +services: + postgres: + image: postgres:16 + container_name: rackstack-db + restart: unless-stopped + environment: + POSTGRES_USER: rackstack + POSTGRES_PASSWORD: rackstack + POSTGRES_DB: rackstack + volumes: + - ./pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rackstack"] + interval: 10s + timeout: 5s + retries: 5 + + rackstack: + build: . + container_name: rackstack + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - "3000:3000" + volumes: + - ./data:/app/data + environment: + DATABASE_URL: postgresql://rackstack:rackstack@postgres:5432/rackstack + env_file: + - .env +``` + +- [ ] **Step 2: Update `.env.example`** + +```bash +# --- Database --- +# Set this to use Postgres (recommended). Leave blank to keep using the +# SQLite file at DB_PATH. +# +# On first boot with this set, if the SQLite database still exists and the +# Postgres database is empty, the server migrates your data across +# automatically, verifies it, and only then starts serving. Your SQLite file +# is never modified or deleted - to roll back, blank this out and restart. +# +# Must be postgresql:// - not postgres://. +DATABASE_URL= + +# Where the SQLite database lives. Still used as the migration source even +# after DATABASE_URL is set, so do not remove the volume mapping. +DB_PATH=/app/data/rackstack.db +``` + +- [ ] **Step 3: Update `unraid-template.xml`** + +Add the `DATABASE_URL` variable and correct the now-inaccurate SQLite-only wording: + +```xml + +``` + +Update `` to say persistence is Postgres (recommended) or SQLite, and amend the Data path description: it is still required as the migration source and rollback artifact. + +- [ ] **Step 4: Add the runbook to `README.md`** + +```markdown +### Migrating from SQLite to Postgres + +1. Add a `postgres:16` container in Unraid with its own appdata path, and + create a `rackstack` database. +2. **Stop the rackstack container.** +3. Back up `/mnt/user/appdata/rackstack-server/data/`. Copy **all three** + files - `rackstack.db`, `rackstack.db-wal`, `rackstack.db-shm`. Recent + progress lives in the `-wal` file; copying only `rackstack.db` loses it. +4. Set `DATABASE_URL` on the rackstack container. Leave the `/app/data` + mapping in place - it is the migration source and your rollback path. +5. Start the container and watch the log for `[migrate]`. You should see a + verified row count for each table, then `committed`. + +If the migration fails the container refuses to start, on purpose - it will +not serve an empty game over your save data. The log line names the table +that failed. Your SQLite data is untouched. + +**Rollback:** blank out `DATABASE_URL` and restart. +``` + +- [ ] **Step 5: Update the `Dockerfile`** + +`better-sqlite3` is still a dependency, so the native build stage stays. Bump the version label and document why `DB_PATH` keeps its default. + +```dockerfile +LABEL org.opencontainers.image.version="1.7.0" + +# Still the default so a container without DATABASE_URL behaves exactly as +# before, and so the migrator knows where to find the source database. +ENV DB_PATH=/app/data/rackstack.db +``` + +- [ ] **Step 6: Update `CHANGELOG.md` and bump the version** + +Set `package.json` to `1.7.0` and add a `## 1.7.0` entry covering: Postgres support with auto-migration, the `identities` split, the two-backend test matrix, and the operator-facing note that the `/app/data` mapping must be retained. + +- [ ] **Step 7: Verify the compose stack end to end** + +```bash +docker compose up --build -d +docker compose logs rackstack | grep -E "\[migrate\]|listening" +curl -sf http://localhost:3000/ > /dev/null && echo "serving OK" +docker compose down +``` + +Expected: `[migrate] skipped - no SQLite database to migrate; fresh Postgres install`, then `listening on :3000`. + +- [ ] **Step 8: Run the e2e smoke suites against Postgres** + +The 6 suites in `tests/e2e/` boot a real `node server/index.js` and drive it over HTTP. They currently point at a scratch SQLite file and seed through `server/db.js`, so Task 1 already made them `await`; this step proves they also pass against Postgres, which is the spec's §4.8 requirement. + +Give each suite a database via the same env vars the server reads, and add scripts: + +```json +"smoke": "for f in tests/e2e/smoke-v1*.mjs; do node \"$f\" || exit 1; done", +"smoke:pg": "TEST_BACKEND=pg npm run smoke" +``` + +Run each suite against a scratch Postgres database (create one, export `DATABASE_URL` pointing at it, run, drop it): + +```bash +docker run -d --rm --name rs-smoke -e POSTGRES_PASSWORD=p -p 55432:5432 postgres:16 +until docker exec rs-smoke pg_isready -U postgres; do sleep 1; done +for f in tests/e2e/smoke-v1*.mjs; do + db="smoke_$(basename "$f" .mjs | tr -d '-')" + docker exec rs-smoke psql -U postgres -c "CREATE DATABASE $db" + DATABASE_URL="postgresql://postgres:p@localhost:55432/$db" node "$f" || { echo "FAILED: $f"; break; } +done +docker stop rs-smoke +``` + +Expected: all 6 suites pass. Playwright-dependent checks may report SKIP if no browser resolves — that is the existing documented behaviour and is not a failure. + +- [ ] **Step 9: Run the full suite one last time on both backends** + +Run: `npm run test:all` +Expected: PASS on both. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "v1.7.0: Postgres support, deployment config and runbook + +Adds a postgres service to compose, DATABASE_URL to the Unraid template and +.env.example, and the migration runbook to the README. + +The runbook leads with copying all three rackstack.db* files: recent progress +lives in the -wal, and copying only the .db is the most likely way to lose +data during this migration." +``` + +--- + +## Verification Before Handoff + +Before opening the PR for review, confirm each of these and paste the actual output: + +- [ ] `npm run test:all` passes on both backends +- [ ] `npx vitest run tests/migrate.test.js` passes, including the rollback case +- [ ] The Task 1 Step 7 grep for un-awaited db calls produces no output +- [ ] All 6 `tests/e2e/smoke-v1*.mjs` suites pass against Postgres (Task 8, Step 8) +- [ ] `docker compose up --build` reaches `listening on :3000` against Postgres +- [ ] A real migration has been run against the owner's production export and every table's verified row count matches + +The last item needs the owner's Unraid export (all three `rackstack.db*` files, container stopped). It is the only step that cannot be completed from the repository alone — flag it explicitly rather than marking the plan complete without it. From 7fe755b047f593d2c8cf294f0435e735cb27ff6e Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 13:23:46 -0400 Subject: [PATCH 03/14] Make the db interface async ahead of the Postgres port Every server/db.js export returns a Promise; all call sites across server/ and tests/ now await. better-sqlite3 remains the implementation, so behaviour is unchanged - this is purely the shape change that lets a pg driver slot in behind the same interface. shared/ is deliberately untouched and stays synchronous: the client bundles it, and an async reducer would be a far larger change than this migration needs. Co-Authored-By: Claude Opus 5 --- server/auth.js | 26 +- server/configService.js | 36 +- server/db.js | 79 +-- server/eventService.js | 52 +- server/index.js | 10 +- server/leaderboardService.js | 14 +- server/routes/api.js | 930 +++++++++++++++------------- server/stateService.js | 24 +- tests/api.events.hotfix.test.js | 36 +- tests/api.events.test.js | 56 +- tests/api.finalfix.test.js | 80 +-- tests/api.social.test.js | 32 +- tests/api.test.js | 90 +-- tests/api.tutorial.test.js | 34 +- tests/db.events.test.js | 144 ++--- tests/db.test.js | 188 +++--- tests/e2e/smoke-v12.mjs | 26 +- tests/e2e/smoke-v13.mjs | 34 +- tests/e2e/smoke-v14-events.mjs | 14 +- tests/e2e/smoke-v14.mjs | 42 +- tests/e2e/smoke-v15.mjs | 32 +- tests/e2e/smoke-v16.mjs | 22 +- tests/eventService.finalfix.test.js | 224 +++---- tests/eventService.test.js | 208 +++---- tests/stateService.events.test.js | 91 +-- tests/stateService.social.test.js | 50 +- 26 files changed, 1322 insertions(+), 1252 deletions(-) diff --git a/server/auth.js b/server/auth.js index 20d7a97..1d6ebbd 100644 --- a/server/auth.js +++ b/server/auth.js @@ -29,9 +29,9 @@ export function isOwner(id) { * the DB-stored roles, with 'admin' implying 'event_coordinator' (admin is * a superset - we don't require both to be granted separately). */ -export function getEffectiveRoles(id) { +export async function getEffectiveRoles(id) { if (isOwner(id)) return ['admin', 'event_coordinator']; - const stored = getRoles(id); + const stored = await getRoles(id); const effective = new Set(stored); if (effective.has('admin')) effective.add('event_coordinator'); return [...effective]; @@ -41,12 +41,20 @@ export function getEffectiveRoles(id) { * Express middleware factory: 403s unless the requester's effective roles * include `role`. Always re-derived from the DB (and env for ownership) on * every request - never trusted from the client or cached on req.user. + * + * Express 4 does not route an async middleware's rejection to the error + * handler, so this must catch its own. */ export function requireRole(role) { - return (req, res, next) => { + return async (req, res, next) => { if (!req.user) return res.status(401).json({ error: 'not authenticated' }); - if (getEffectiveRoles(req.user.sub).includes(role)) return next(); - return res.status(403).json({ error: 'forbidden' }); + try { + const roles = await getEffectiveRoles(req.user.sub); + if (roles.includes(role)) return next(); + return res.status(403).json({ error: 'forbidden' }); + } catch (e) { + return next(e); + } }; } @@ -59,9 +67,9 @@ export function configurePassport() { clientSecret: process.env.DISCORD_CLIENT_SECRET, callbackURL: process.env.DISCORD_CALLBACK_URL, scope: ['identify'], - }, (accessToken, refreshToken, profile, done) => { + }, async (accessToken, refreshToken, profile, done) => { try { - const user = upsertUser({ + const user = await upsertUser({ provider: 'discord', providerId: profile.id, username: profile.username, @@ -80,9 +88,9 @@ export function configurePassport() { clientID: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, callbackURL: process.env.GITHUB_CALLBACK_URL, - }, (accessToken, refreshToken, profile, done) => { + }, async (accessToken, refreshToken, profile, done) => { try { - const user = upsertUser({ + const user = await upsertUser({ provider: 'github', providerId: profile.id, username: profile.username || profile.displayName, diff --git a/server/configService.js b/server/configService.js index 17ca31f..ca416d4 100644 --- a/server/configService.js +++ b/server/configService.js @@ -32,11 +32,11 @@ let effectiveCache = null; * stored, persist it as a new version (audit trail via config_history) * so the drift is visible, not silent. */ -export function ensureConfig() { - const row = getConfigRow(); +export async function ensureConfig() { + const row = await getConfigRow(); if (!row) { - putConfigRow(1, DEFAULT_CONFIG, null); + await putConfigRow(1, DEFAULT_CONFIG, null); cache = { version: 1, data: structuredClone(DEFAULT_CONFIG) }; return cache; } @@ -61,7 +61,7 @@ export function ensureConfig() { const changed = JSON.stringify(data) !== JSON.stringify(raw); if (changed) { const nextVersion = row.version + 1; - putConfigRow(nextVersion, data, row.updated_by || null); + await putConfigRow(nextVersion, data, row.updated_by || null); cache = { version: nextVersion, data }; } else { cache = { version: row.version, data }; @@ -70,8 +70,8 @@ export function ensureConfig() { } /** Cached { version, data }. Lazily calls ensureConfig() on first access. */ -export function getConfig() { - if (!cache) return ensureConfig(); +export async function getConfig() { + if (!cache) return await ensureConfig(); return cache; } @@ -108,9 +108,9 @@ export function invalidateEffectiveConfig() { * mergeEventModifiers has already run - validateConfig() never sees it, and * it must never be written back to the config table. */ -export function getEffectiveConfig() { - const { version, data } = getConfig(); - const activeEvent = getActiveEvent(); +export async function getEffectiveConfig() { + const { version, data } = await getConfig(); + const activeEvent = await getActiveEvent(); const eventId = activeEvent ? activeEvent.id : null; if (effectiveCache && effectiveCache.version === version && effectiveCache.eventId === eventId) { @@ -131,13 +131,13 @@ export function getEffectiveConfig() { * Validates and stores a brand-new config document as the next version. * Returns { ok: true, version } or { ok: false, errors }. */ -export function updateConfig(data, userId) { +export async function updateConfig(data, userId) { const check = validateConfig(data); if (!check.ok) return { ok: false, errors: check.errors }; - const current = getConfig(); + const current = await getConfig(); const nextVersion = current.version + 1; - putConfigRow(nextVersion, data, userId ?? null); + await putConfigRow(nextVersion, data, userId ?? null); cache = { version: nextVersion, data: structuredClone(data) }; return { ok: true, version: nextVersion }; } @@ -147,8 +147,8 @@ export function updateConfig(data, userId) { * itself forward motion in the version counter, never a version rewind - * keeps config_history a true append-only audit log). */ -export function rollbackConfig(version, userId) { - const history = getConfigHistory(); +export async function rollbackConfig(version, userId) { + const history = await getConfigHistory(); const found = history.find((h) => h.version === version); if (!found) return { ok: false, error: 'not_found' }; @@ -162,15 +162,15 @@ export function rollbackConfig(version, userId) { const check = validateConfig(data); if (!check.ok) return { ok: false, errors: check.errors }; - const current = getConfig(); + const current = await getConfig(); const nextVersion = current.version + 1; - putConfigRow(nextVersion, data, userId ?? null); + await putConfigRow(nextVersion, data, userId ?? null); cache = { version: nextVersion, data: structuredClone(data) }; return { ok: true, version: nextVersion }; } -export function getHistory() { - return getConfigHistory().map((h) => ({ +export async function getHistory() { + return (await getConfigHistory()).map((h) => ({ version: h.version, data: JSON.parse(h.data), updatedAt: h.updated_at, diff --git a/server/db.js b/server/db.js index dd6fb39..b504d1a 100644 --- a/server/db.js +++ b/server/db.js @@ -133,7 +133,7 @@ function findAvailableUsername(desiredName, isTaken) { // index below was introduced. Must run before the CREATE UNIQUE INDEX or // that statement would fail on any pre-existing collision. No-op when there // are no duplicates, so it's cheap to run unconditionally on every boot. -export function dedupeUsernames() { +function dedupeUsernamesSync() { const rows = db.prepare( 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', ).all(); @@ -150,7 +150,14 @@ export function dedupeUsernames() { } } -dedupeUsernames(); +// Transitional: module init still needs this to run synchronously before the +// unique index below is created. The exported async wrapper is what callers +// (Task 2 moves this into schema init properly) will use going forward. +export async function dedupeUsernames() { + return dedupeUsernamesSync(); +} + +dedupeUsernamesSync(); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); @@ -171,7 +178,7 @@ const insertUserStmt = db.prepare(` VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) `); -export function upsertUser({ provider, providerId, username, avatarUrl }) { +export async function upsertUser({ provider, providerId, username, avatarUrl }) { const id = `${provider}:${providerId}`; const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if (existing) { @@ -217,11 +224,11 @@ export function upsertUser({ provider, providerId, username, avatarUrl }) { return user; } -export function getUserById(id) { +export async function getUserById(id) { return db.prepare('SELECT * FROM users WHERE id = ?').get(id); } -export function getAllUsersWithSaves() { +export async function getAllUsersWithSaves() { return db.prepare(` SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, u.leaderboard_opt_out, @@ -232,18 +239,18 @@ export function getAllUsersWithSaves() { `).all(); } -export function getSave(userId) { +export async function getSave(userId) { return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); } -export function putSave(userId, data, lastSave) { +export async function putSave(userId, data, lastSave) { db.prepare(` INSERT INTO saves (user_id, data, last_save) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, last_save = excluded.last_save `).run(userId, JSON.stringify(data), lastSave); } -export function deleteSave(userId) { +export async function deleteSave(userId) { db.prepare('DELETE FROM saves WHERE user_id = ?').run(userId); } @@ -253,7 +260,7 @@ export function deleteSave(userId) { * duplicates are not deduped here; callers (server/auth.js, Task 8) treat * this as a plain set. */ -export function getRoles(userId) { +export async function getRoles(userId) { const row = db.prepare('SELECT roles FROM users WHERE id = ?').get(userId); if (!row || !row.roles) return []; try { @@ -263,7 +270,7 @@ export function getRoles(userId) { } } -export function setRoles(userId, roles) { +export async function setRoles(userId, roles) { db.prepare('UPDATE users SET roles = ? WHERE id = ?').run(JSON.stringify(roles), userId); } @@ -273,7 +280,7 @@ export function setRoles(userId, roles) { * contract as users.roles above. Callers treat it as a plain set; the route * layer owns validation against shared/tours.js. */ -export function getToursCompleted(userId) { +export async function getToursCompleted(userId) { const row = db.prepare('SELECT tours_completed FROM users WHERE id = ?').get(userId); if (!row || !row.tours_completed) return []; try { @@ -284,7 +291,7 @@ export function getToursCompleted(userId) { } } -export function setToursCompleted(userId, ids) { +export async function setToursCompleted(userId, ids) { db.prepare('UPDATE users SET tours_completed = ? WHERE id = ?').run(JSON.stringify(ids), userId); } @@ -294,7 +301,7 @@ export function setToursCompleted(userId, ids) { * themself, and marks the username as user-chosen so upsertUser stops * overwriting it from the OAuth profile on future logins. */ -export function setUsername(userId, name) { +export async function setUsername(userId, name) { const collision = db.prepare( 'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?', ).get(name, userId); @@ -303,7 +310,7 @@ export function setUsername(userId, name) { return { ok: true }; } -export function createMinigameSession(userId, game) { +export async function createMinigameSession(userId, game) { const session = { id: randomUUID(), user_id: userId, @@ -319,7 +326,7 @@ export function createMinigameSession(userId, game) { return session; } -export function getMinigameSession(id) { +export async function getMinigameSession(id) { return db.prepare('SELECT * FROM minigame_sessions WHERE id = ?').get(id); } @@ -332,7 +339,7 @@ export function getMinigameSession(id) { * Used to block a burst of concurrently-open sessions for the same game * (each of which would otherwise dodge the win cooldown independently). */ -export function getOpenMinigameSession(userId, game, minStartedAt) { +export async function getOpenMinigameSession(userId, game, minStartedAt) { return db.prepare(` SELECT * FROM minigame_sessions WHERE user_id = ? AND game = ? AND finished_at IS NULL AND started_at >= ? @@ -340,7 +347,7 @@ export function getOpenMinigameSession(userId, game, minStartedAt) { `).get(userId, game, minStartedAt); } -export function finishMinigameSession(id, score) { +export async function finishMinigameSession(id, score) { db.prepare('UPDATE minigame_sessions SET finished_at = ?, score = ? WHERE id = ?').run(Date.now(), score, id); } @@ -350,7 +357,7 @@ export function finishMinigameSession(id, score) { * returned as the raw JSON text exactly as stored - mirroring getSave's * convention, callers JSON.parse it themselves. */ -export function getConfigRow() { +export async function getConfigRow() { return db.prepare('SELECT * FROM config WHERE id = 1').get(); } @@ -360,7 +367,7 @@ export function getConfigRow() { * is a plain JS object; it is JSON.stringify'd here (the same convention * putSave uses) - callers never pass pre-stringified JSON. */ -export function putConfigRow(version, data, userId) { +export async function putConfigRow(version, data, userId) { const text = JSON.stringify(data); const now = Date.now(); db.prepare(` @@ -373,7 +380,7 @@ export function putConfigRow(version, data, userId) { `).run(version, text, now, userId); } -export function getConfigHistory() { +export async function getConfigHistory() { return db.prepare('SELECT * FROM config_history ORDER BY rowid DESC').all(); } @@ -398,11 +405,11 @@ function parseEventRow(row) { }; } -export function listEvents() { +export async function listEvents() { return db.prepare('SELECT * FROM live_events ORDER BY created_at ASC').all().map(parseEventRow); } -export function getEvent(id) { +export async function getEvent(id) { return parseEventRow(db.prepare('SELECT * FROM live_events WHERE id = ?').get(id)); } @@ -412,7 +419,7 @@ export function getEvent(id) { * invariant enforced by the lifecycle/scheduler (Task 4), not a DB * constraint - this just reads the first match. */ -export function getActiveEvent() { +export async function getActiveEvent() { return parseEventRow(db.prepare("SELECT * FROM live_events WHERE status = 'active' LIMIT 1").get()); } @@ -438,7 +445,7 @@ const putEventStmt = db.prepare(` * fields, since callers may pass back a row previously read via getEvent * (snake_case) or freshly authored data (camelCase). */ -export function putEvent(event) { +export async function putEvent(event) { const row = { id: event.id, name: event.name, @@ -454,7 +461,7 @@ export function putEvent(event) { created_by: event.createdBy ?? event.created_by ?? null, }; putEventStmt.run(row); - return getEvent(row.id); + return await getEvent(row.id); } /** @@ -464,7 +471,7 @@ export function putEvent(event) { * scheduler flip status alone (e.g. active -> ended) without needing to * re-supply - or accidentally wipe - the event's window. */ -export function setEventStatus(id, status, { startsAt, endsAt } = {}) { +export async function setEventStatus(id, status, { startsAt, endsAt } = {}) { const sets = ['status = @status']; const params = { id, status }; if (startsAt !== undefined) { sets.push('starts_at = @starts_at'); params.starts_at = startsAt; } @@ -477,7 +484,7 @@ export function setEventStatus(id, status, { startsAt, endsAt } = {}) { * only" - the route layer (Task 6) is responsible for rejecting deletes of * scheduled/active/ended events before calling this. */ -export function deleteEvent(id) { +export async function deleteEvent(id) { db.prepare('DELETE FROM live_events WHERE id = ?').run(id); } @@ -495,7 +502,7 @@ const upsertParticipationStmt = db.prepare(` * (user_id, event_id). Accepts camelCase or snake_case keys, same rationale * as putEvent. */ -export function upsertParticipation(row) { +export async function upsertParticipation(row) { const params = { user_id: row.userId ?? row.user_id, event_id: row.eventId ?? row.event_id, @@ -506,10 +513,10 @@ export function upsertParticipation(row) { opted_out: (row.optedOut ?? row.opted_out) ? 1 : 0, }; upsertParticipationStmt.run(params); - return getParticipation(params.user_id, params.event_id); + return await getParticipation(params.user_id, params.event_id); } -export function getParticipation(userId, eventId) { +export async function getParticipation(userId, eventId) { return db.prepare('SELECT * FROM event_participation WHERE user_id = ? AND event_id = ?').get(userId, eventId); } @@ -532,7 +539,7 @@ const updateParticipationProgressStmt = db.prepare(` * doesn't exist for some reason (e.g. the event was deleted out from under * an in-flight claim). */ -export function updateParticipationProgress(userId, eventId, rungsClaimed, lastProgressAt) { +export async function updateParticipationProgress(userId, eventId, rungsClaimed, lastProgressAt) { updateParticipationProgressStmt.run(rungsClaimed, lastProgressAt, userId, eventId); } @@ -541,13 +548,13 @@ export function updateParticipationProgress(userId, eventId, rungsClaimed, lastP * most rungs claimed first, ties broken by whoever reached their current * progress earliest. */ -export function listParticipation(eventId) { +export async function listParticipation(eventId) { return db.prepare( 'SELECT * FROM event_participation WHERE event_id = ? ORDER BY rungs_claimed DESC, last_progress_at ASC', ).all(eventId); } -export function setLeaderboardOptOut(userId, optOut) { +export async function setLeaderboardOptOut(userId, optOut) { db.prepare('UPDATE users SET leaderboard_opt_out = ? WHERE id = ?').run(optOut ? 1 : 0, userId); } @@ -564,7 +571,7 @@ export function setLeaderboardOptOut(userId, optOut) { * that change immediately on the very next read - no re-sync step needed. * Capped at `limit` rows (default 50, per the route's leaderboard contract). */ -export function listLeaderboard(eventId, limit = 50) { +export async function listLeaderboard(eventId, limit = 50) { return db.prepare(` SELECT ep.user_id AS userId, u.username AS username, ep.rungs_claimed AS rungsClaimed, ep.last_progress_at AS lastProgressAt @@ -581,7 +588,7 @@ export function listLeaderboard(eventId, limit = 50) { * 'draft', which by definition has no window). Backs the v1.5 leaderboard's * latest-event board. Returns null when no event has ever been scheduled. */ -export function getLatestEventId() { +export async function getLatestEventId() { const row = db.prepare( "SELECT id FROM live_events WHERE status != 'draft' AND starts_at IS NOT NULL ORDER BY starts_at DESC LIMIT 1", ).get(); @@ -601,7 +608,7 @@ const seedEventStmt = db.prepare(` * summer-surge's modifiers or already scheduled it). Called from * ensureConfig-adjacent boot code (Task 4). */ -export function seedSeasonalEvents() { +export async function seedSeasonalEvents() { const now = Date.now(); for (const evt of SEASONAL_EVENTS) { seedEventStmt.run({ diff --git a/server/eventService.js b/server/eventService.js index 69721c2..dcce222 100644 --- a/server/eventService.js +++ b/server/eventService.js @@ -57,20 +57,20 @@ const MAX_PENDING_EVENT_CLAIMS = 3; * * Returns `{ ok: true }` or `{ ok: false, error }`. */ -export function activateEvent(id, now = Date.now()) { - const event = getEvent(id); +export async function activateEvent(id, now = Date.now()) { + const event = await getEvent(id); if (!event) return { ok: false, error: 'not_found' }; if (event.ends_at == null) return { ok: false, error: 'not_scheduled' }; if (event.ends_at <= now) return { ok: false, error: 'invalid_target' }; - for (const other of listEvents()) { + for (const other of await listEvents()) { if (other.status === 'active' && other.id !== id) { - setEventStatus(other.id, 'ended'); + await setEventStatus(other.id, 'ended'); } } const startsAt = event.starts_at ?? now; - setEventStatus(id, 'active', { startsAt, endsAt: event.ends_at }); + await setEventStatus(id, 'active', { startsAt, endsAt: event.ends_at }); invalidateEffectiveConfig(); return { ok: true }; } @@ -82,10 +82,10 @@ export function activateEvent(id, now = Date.now()) { * through their own 48h grace period; they're only force-cleared when a * *different* event next goes active (joinEventIfEligible, below). */ -export function endEvent(id, now = Date.now()) { - const event = getEvent(id); +export async function endEvent(id, now = Date.now()) { + const event = await getEvent(id); if (!event) return { ok: false, error: 'not_found' }; - setEventStatus(id, 'ended'); + await setEventStatus(id, 'ended'); invalidateEffectiveConfig(); return { ok: true }; } @@ -143,8 +143,8 @@ function nextOccurrence({ month, day, durationDays }, now) { * only ever fires on rows written before that validation existed, or edited * directly in the DB. */ -function materializeRecurrences(now) { - for (const event of listEvents()) { +async function materializeRecurrences(now) { + for (const event of await listEvents()) { if (!isValidRecurrence(event.recurrence)) continue; const isUnscheduledDraft = event.status === 'draft' @@ -154,7 +154,7 @@ function materializeRecurrences(now) { if (!isUnscheduledDraft && !isElapsedRecurring) continue; const { startsAt, endsAt } = nextOccurrence(event.recurrence, now); - setEventStatus(event.id, 'scheduled', { startsAt, endsAt }); + await setEventStatus(event.id, 'scheduled', { startsAt, endsAt }); } } @@ -185,25 +185,25 @@ function materializeRecurrences(now) { * materializeRecurrences above, which re-arms it for next year if it * recurs. */ -export function runScheduler(now = Date.now()) { - materializeRecurrences(now); +export async function runScheduler(now = Date.now()) { + await materializeRecurrences(now); - for (const event of listEvents()) { + for (const event of await listEvents()) { if (event.status === 'active' && event.ends_at != null && event.ends_at <= now) { - endEvent(event.id, now); + await endEvent(event.id, now); } } - const arrived = listEvents() + const arrived = (await listEvents()) .filter((e) => e.status === 'scheduled' && e.starts_at != null && e.starts_at <= now) .sort((a, b) => (a.starts_at - b.starts_at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); for (const event of arrived) { if (event.ends_at == null || event.ends_at <= now) { - endEvent(event.id, now); + await endEvent(event.id, now); continue; } - activateEvent(event.id, now); + await activateEvent(event.id, now); } } @@ -244,14 +244,14 @@ export function runScheduler(now = Date.now()) { * (stateService) don't need a second DB round-trip to build the API * response's `activeEvent` field. */ -export function joinEventIfEligible(userId, state, now = Date.now()) { - const activeEvent = getActiveEvent(); +export async function joinEventIfEligible(userId, state, now = Date.now()) { + const activeEvent = await getActiveEvent(); const progress = state.meta.eventProgress; if (activeEvent && progress && progress.eventId !== activeEvent.id) { // The OUTGOING event, not the incoming one - supersede needs its ladder to // freeze the met-but-unclaimed rung set before the window is force-ended. - supersedeEventProgress(state.meta, now, getEvent(progress.eventId)); + supersedeEventProgress(state.meta, now, await getEvent(progress.eventId)); } // Runs on every load, event active or not, so records age out of the save @@ -279,8 +279,8 @@ export function joinEventIfEligible(userId, state, now = Date.now()) { rungsClaimed: [], }; - const user = getUserById(userId); - upsertParticipation({ + const user = await getUserById(userId); + await upsertParticipation({ userId, eventId: activeEvent.id, startedAt: now, @@ -314,19 +314,19 @@ export function joinEventIfEligible(userId, state, now = Date.now()) { * (its documented past-grace code) instead of falling through to * `invalid_target`; the presentation routes gate on inClaimGrace() below. */ -export function resolvePlayerEvents(state) { +export async function resolvePlayerEvents(state) { const result = { current: null, pending: [] }; const ep = state.meta.eventProgress; if (ep && typeof ep.eventId === 'string') { - const event = getEvent(ep.eventId); + const event = await getEvent(ep.eventId); if (event) result.current = { event, progress: ep }; } const pending = Array.isArray(state.meta.pendingEventClaims) ? state.meta.pendingEventClaims : []; for (const p of pending) { if (!p || typeof p.eventId !== 'string') continue; - const event = getEvent(p.eventId); + const event = await getEvent(p.eventId); if (event) result.pending.push({ event, progress: p }); } diff --git a/server/index.js b/server/index.js index 00daad5..20ee728 100644 --- a/server/index.js +++ b/server/index.js @@ -4,8 +4,8 @@ import { seedSeasonalEvents } from './db.js'; import { runScheduler } from './eventService.js'; import { buildApp } from './app.js'; -ensureConfig(); -seedSeasonalEvents(); +await ensureConfig(); +await seedSeasonalEvents(); // Live Events (v1.4) scheduler: run once at boot to catch anything that // should already be active/ended/materialized while the server was down, @@ -13,8 +13,10 @@ seedSeasonalEvents(); // process alive (matters for tests/tools that import this module and want // a clean exit; it's harmless in production where the HTTP server's own // listening socket already keeps the event loop open). -runScheduler(Date.now()); -setInterval(() => runScheduler(Date.now()), 3600_000).unref(); +await runScheduler(Date.now()); +// The .catch is required: an async runScheduler rejecting inside +// setInterval is an unhandled rejection that crashes Node 20 by default. +setInterval(() => { runScheduler(Date.now()).catch((e) => console.error('[scheduler]', e)); }, 3600_000).unref(); const app = buildApp(); diff --git a/server/leaderboardService.js b/server/leaderboardService.js index 2a1b83b..582ae62 100644 --- a/server/leaderboardService.js +++ b/server/leaderboardService.js @@ -28,9 +28,9 @@ const BOARDS = [ ['tapes', (meta) => (meta.coldStorage && meta.coldStorage.tapes) || 0], ]; -function buildBoards(limit) { +async function buildBoards(limit) { const players = []; - for (const row of getAllUsersWithSaves()) { + for (const row of await getAllUsersWithSaves()) { // The LIVE opt-out column (users.leaderboard_opt_out), the one v1.4 // shipped for exactly this - never event_participation.opted_out, which is // a join-time snapshot that never updates afterwards. @@ -70,11 +70,11 @@ function buildBoards(limit) { // The latest event's board comes from event_participation rather than saves: // listLeaderboard already applies the same live opt-out filter and the same // ranking the Event tab uses, so the two can never disagree. - const eventId = getLatestEventId(); + const eventId = await getLatestEventId(); const badgesByUser = new Map(players.map((p) => [p.userId, p.badges])); const avatarByUser = new Map(players.map((p) => [p.userId, p.avatarUrl])); boards.latestEventRung = eventId - ? listLeaderboard(eventId, limit).map((r) => ({ + ? (await listLeaderboard(eventId, limit)).map((r) => ({ userId: r.userId, username: r.username, avatarUrl: avatarByUser.get(r.userId) || null, @@ -93,9 +93,9 @@ function buildBoards(limit) { * letting a live event's overlay quietly change how a SHARED cross-user cache * behaves would be surprising. */ -export function getLeaderboards(now = Date.now()) { - const { data: config } = getConfig(); +export async function getLeaderboards(now = Date.now()) { + const { data: config } = await getConfig(); if (cache && now - cache.generatedAt < config.social.leaderboardCacheMs) return cache; - cache = { generatedAt: now, boards: buildBoards(config.social.leaderboardLimit) }; + cache = { generatedAt: now, boards: await buildBoards(config.social.leaderboardLimit) }; return cache; } diff --git a/server/routes/api.js b/server/routes/api.js index c06b2ba..b95e78d 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -75,85 +75,91 @@ router.post('/auth/logout', (req, res) => { res.json({ ok: true }); }); -router.get('/api/me', requireAuth, (req, res) => { - const dbUser = getUserById(req.user.sub); - res.json({ - id: req.user.sub, - username: req.user.username, - avatarUrl: req.user.avatarUrl, - memberSince: dbUser ? dbUser.created_at : null, - roles: getEffectiveRoles(req.user.sub), - isOwner: isOwner(req.user.sub), - toursCompleted: getToursCompleted(req.user.sub), - }); +router.get('/api/me', requireAuth, async (req, res, next) => { + try { + const dbUser = await getUserById(req.user.sub); + res.json({ + id: req.user.sub, + username: req.user.username, + avatarUrl: req.user.avatarUrl, + memberSince: dbUser ? dbUser.created_at : null, + roles: await getEffectiveRoles(req.user.sub), + isOwner: isOwner(req.user.sub), + toursCompleted: await getToursCompleted(req.user.sub), + }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- // State & actions (server-authoritative economy) // --------------------------------------------------------------------------- -router.get('/api/state', requireAuth, (req, res) => { - const now = Date.now(); - const { state, gained, activeEvent, unlockedAchievements } = loadAndEvaluate(req.user.sub, now); - const { version } = getConfig(); - const { current } = resolvePlayerEvents(state); - const claimable = current && inClaimGrace(current.progress, now) ? current.event : null; - res.json({ - run: state.run, - meta: state.meta, - server: state.server, - offlineGain: gained, - configVersion: version, - serverTime: now, - // The event THIS player can still see/claim against, which is not the - // same thing as `activeEvent` below: `activeEvent` is the globally - // active row and goes null the instant an event ends, while a player's - // own 48h claim grace (spec §5.3) can outlive that by two days. - // Resolved from their own meta.eventProgress, so the client can seed its - // ladder from a page load made entirely within grace - previously the - // ladder existed only in memory and a single reload stranded every - // outstanding Claim button. - claimableEvent: claimable ? { - id: claimable.id, - name: claimable.name, - description: claimable.description, - theme: claimable.theme, - ladder: claimable.ladder, - startsAt: claimable.starts_at, - endsAt: claimable.ends_at, - } : null, - // Live Events (v1.4): a client-friendly view of the currently active - // event (null when none) and this user's own progress against it - - // eventProgress is also nested at state.meta.eventProgress (it's part - // of canonical state), duplicated at the top level here purely for - // client convenience (Task 7 reads both `activeEvent` and - // `eventProgress` directly off this response). - activeEvent: activeEvent ? { - id: activeEvent.id, - name: activeEvent.name, - description: activeEvent.description, - theme: activeEvent.theme, - ladder: activeEvent.ladder, - startsAt: activeEvent.starts_at, - endsAt: activeEvent.ends_at, - } : null, - eventProgress: state.meta.eventProgress, - // Social (v1.5): achievements the load-path sweep unlocked on THIS - // request - typically thresholds crossed by offline accrual since the - // player was last seen. The client toasts them; meta.achievements above - // is the durable record either way. - unlockedAchievements, - }); +router.get('/api/state', requireAuth, async (req, res, next) => { + try { + const now = Date.now(); + const { state, gained, activeEvent, unlockedAchievements } = await loadAndEvaluate(req.user.sub, now); + const { version } = await getConfig(); + const { current } = await resolvePlayerEvents(state); + const claimable = current && inClaimGrace(current.progress, now) ? current.event : null; + res.json({ + run: state.run, + meta: state.meta, + server: state.server, + offlineGain: gained, + configVersion: version, + serverTime: now, + // The event THIS player can still see/claim against, which is not the + // same thing as `activeEvent` below: `activeEvent` is the globally + // active row and goes null the instant an event ends, while a player's + // own 48h claim grace (spec §5.3) can outlive that by two days. + // Resolved from their own meta.eventProgress, so the client can seed its + // ladder from a page load made entirely within grace - previously the + // ladder existed only in memory and a single reload stranded every + // outstanding Claim button. + claimableEvent: claimable ? { + id: claimable.id, + name: claimable.name, + description: claimable.description, + theme: claimable.theme, + ladder: claimable.ladder, + startsAt: claimable.starts_at, + endsAt: claimable.ends_at, + } : null, + // Live Events (v1.4): a client-friendly view of the currently active + // event (null when none) and this user's own progress against it - + // eventProgress is also nested at state.meta.eventProgress (it's part + // of canonical state), duplicated at the top level here purely for + // client convenience (Task 7 reads both `activeEvent` and + // `eventProgress` directly off this response). + activeEvent: activeEvent ? { + id: activeEvent.id, + name: activeEvent.name, + description: activeEvent.description, + theme: activeEvent.theme, + ladder: activeEvent.ladder, + startsAt: activeEvent.starts_at, + endsAt: activeEvent.ends_at, + } : null, + eventProgress: state.meta.eventProgress, + // Social (v1.5): achievements the load-path sweep unlocked on THIS + // request - typically thresholds crossed by offline accrual since the + // player was last seen. The client toasts them; meta.achievements above + // is the durable record either way. + unlockedAchievements, + }); + } catch (e) { next(e); } }); -router.post('/api/actions', requireAuth, (req, res) => { - const { actions } = req.body || {}; - if (!Array.isArray(actions) || actions.length > 100) { - return res.status(400).json({ error: 'actions must be an array of at most 100 items' }); - } - const now = Date.now(); - const { state, results, unlockedAchievements } = applyActions(req.user.sub, actions, now); - res.json({ state, results, serverTime: now, unlockedAchievements }); +router.post('/api/actions', requireAuth, async (req, res, next) => { + try { + const { actions } = req.body || {}; + if (!Array.isArray(actions) || actions.length > 100) { + return res.status(400).json({ error: 'actions must be an array of at most 100 items' }); + } + const now = Date.now(); + const { state, results, unlockedAchievements } = await applyActions(req.user.sub, actions, now); + res.json({ state, results, serverTime: now, unlockedAchievements }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- @@ -181,9 +187,11 @@ router.post('/api/actions', requireAuth, (req, res) => { // VERSION does not change when an event flips active/ended, so a client // watching `version` alone would never notice the overlay appearing or // disappearing underneath it. -router.get('/api/config', requireAuth, (req, res) => { - const { version, data, eventId } = getEffectiveConfig(); - res.json({ version, activeEventId: eventId, data: stripRuntimeFields(data) }); +router.get('/api/config', requireAuth, async (req, res, next) => { + try { + const { version, data, eventId } = await getEffectiveConfig(); + res.json({ version, activeEventId: eventId, data: stripRuntimeFields(data) }); + } catch (e) { next(e); } }); // Runtime-only fields (`__activeEvent`, and anything else prefixed `__`) are @@ -204,45 +212,55 @@ function stripRuntimeFields(data) { // document the Balancing tab edits and PUTs back to /api/admin/config, so it // has to be the stored, admin-authored one - the stored config document is // never written with event modifiers merged in. -router.get('/api/admin/config', requireAuth, requireRole('admin'), (req, res) => { - const { version, data } = getConfig(); - res.json({ version, data }); +router.get('/api/admin/config', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + const { version, data } = await getConfig(); + res.json({ version, data }); + } catch (e) { next(e); } }); -router.put('/api/admin/config', requireAuth, requireRole('admin'), (req, res) => { - const { data } = req.body || {}; - const result = updateConfig(data, req.user.sub); - if (!result.ok) return res.status(400).json({ errors: result.errors }); - res.json({ version: result.version }); +router.put('/api/admin/config', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + const { data } = req.body || {}; + const result = await updateConfig(data, req.user.sub); + if (!result.ok) return res.status(400).json({ errors: result.errors }); + res.json({ version: result.version }); + } catch (e) { next(e); } }); -router.get('/api/admin/config/history', requireAuth, requireRole('admin'), (req, res) => { - res.json({ history: getHistory() }); +router.get('/api/admin/config/history', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + res.json({ history: await getHistory() }); + } catch (e) { next(e); } }); -router.post('/api/admin/config/rollback', requireAuth, requireRole('admin'), (req, res) => { - const { version } = req.body || {}; - if (typeof version !== 'number') return res.status(400).json({ error: 'version required' }); - const result = rollbackConfig(version, req.user.sub); - if (!result.ok) { - return res.status(400).json(result.errors ? { errors: result.errors } : { error: result.error }); - } - res.json({ version: result.version }); +router.post('/api/admin/config/rollback', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + const { version } = req.body || {}; + if (typeof version !== 'number') return res.status(400).json({ error: 'version required' }); + const result = await rollbackConfig(version, req.user.sub); + if (!result.ok) { + return res.status(400).json(result.errors ? { errors: result.errors } : { error: result.error }); + } + res.json({ version: result.version }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- // Roles // --------------------------------------------------------------------------- -router.get('/api/admin/roles', requireAuth, requireRole('admin'), (req, res) => { - const rows = getAllUsersWithSaves(); - const users = rows.map((row) => ({ - id: row.id, - username: row.username, - roles: getEffectiveRoles(row.id), - isOwner: isOwner(row.id), - })); - res.json({ users }); +router.get('/api/admin/roles', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + const rows = await getAllUsersWithSaves(); + const users = await Promise.all(rows.map(async (row) => ({ + id: row.id, + username: row.username, + roles: await getEffectiveRoles(row.id), + isOwner: isOwner(row.id), + }))); + res.json({ users }); + } catch (e) { next(e); } }); // Granting/revoking 'admin' requires owner; 'event_coordinator' requires @@ -250,127 +268,135 @@ router.get('/api/admin/roles', requireAuth, requireRole('admin'), (req, res) => // half - owner passes it too since 'admin' is one of an owner's effective // roles). An owner id's own roles are env-derived, not DB-stored, so there // is nothing to grant/revoke on them - reject outright. -router.post('/api/admin/roles', requireAuth, requireRole('admin'), (req, res) => { - const { userId, role, op } = req.body || {}; - if (!userId || !['admin', 'event_coordinator'].includes(role) || !['grant', 'revoke'].includes(op)) { - return res.status(400).json({ error: 'invalid_request' }); - } - if (role === 'admin' && !isOwner(req.user.sub)) { - return res.status(403).json({ error: 'owner_required' }); - } - if (isOwner(userId)) { - return res.status(400).json({ error: 'cannot_modify_owner' }); - } +router.post('/api/admin/roles', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + const { userId, role, op } = req.body || {}; + if (!userId || !['admin', 'event_coordinator'].includes(role) || !['grant', 'revoke'].includes(op)) { + return res.status(400).json({ error: 'invalid_request' }); + } + if (role === 'admin' && !isOwner(req.user.sub)) { + return res.status(403).json({ error: 'owner_required' }); + } + if (isOwner(userId)) { + return res.status(400).json({ error: 'cannot_modify_owner' }); + } - const current = new Set(getRoles(userId)); - if (op === 'grant') current.add(role); else current.delete(role); - setRoles(userId, [...current]); - res.json({ ok: true, roles: getEffectiveRoles(userId) }); + const current = new Set(await getRoles(userId)); + if (op === 'grant') current.add(role); else current.delete(role); + await setRoles(userId, [...current]); + res.json({ ok: true, roles: await getEffectiveRoles(userId) }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- // Username // --------------------------------------------------------------------------- -router.put('/api/me/username', requireAuth, (req, res) => { - const { username } = req.body || {}; - if (typeof username !== 'string' || !USERNAME_RE.test(username)) { - return res.status(400).json({ error: 'invalid_username' }); - } - const result = setUsername(req.user.sub, username); - if (!result.ok) return res.status(409).json({ error: result.error }); - res.json({ ok: true, username }); +router.put('/api/me/username', requireAuth, async (req, res, next) => { + try { + const { username } = req.body || {}; + if (typeof username !== 'string' || !USERNAME_RE.test(username)) { + return res.status(400).json({ error: 'invalid_username' }); + } + const result = await setUsername(req.user.sub, username); + if (!result.ok) return res.status(409).json({ error: result.error }); + res.json({ ok: true, username }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- // Minigames // --------------------------------------------------------------------------- -router.post('/api/minigame/start', requireAuth, (req, res) => { - const { game } = req.body || {}; - if (!MINIGAMES.includes(game)) return res.status(400).json({ error: 'unknown_game' }); +router.post('/api/minigame/start', requireAuth, async (req, res, next) => { + try { + const { game } = req.body || {}; + if (!MINIGAMES.includes(game)) return res.status(400).json({ error: 'unknown_game' }); - const now = Date.now(); - const { state } = loadAndEvaluate(req.user.sub, now); - const cooldownUntil = state.server.gameCooldowns[game] || 0; - if (now < cooldownUntil) { - return res.status(429).json({ error: 'cooldown_active', retryAt: cooldownUntil }); - } + const now = Date.now(); + const { state } = await loadAndEvaluate(req.user.sub, now); + const cooldownUntil = state.server.gameCooldowns[game] || 0; + if (now < cooldownUntil) { + return res.status(429).json({ error: 'cooldown_active', retryAt: cooldownUntil }); + } - // Block a second concurrently-open session for the same game: without - // this, a burst of back-to-back starts (none finished yet, so none has - // set the cooldown) could each be redeemed independently once opened, - // multiplying payouts far past the intended ~1 win per winCooldownMs. - const { data: config } = getConfig(); - const gameConf = config.minigames[game]; - const minStartedAt = now - (gameConf.durationSec * 1000 + 10000); - const openSession = getOpenMinigameSession(req.user.sub, game, minStartedAt); - if (openSession) { - return res.status(409).json({ error: 'session_open' }); - } + // Block a second concurrently-open session for the same game: without + // this, a burst of back-to-back starts (none finished yet, so none has + // set the cooldown) could each be redeemed independently once opened, + // multiplying payouts far past the intended ~1 win per winCooldownMs. + const { data: config } = await getConfig(); + const gameConf = config.minigames[game]; + const minStartedAt = now - (gameConf.durationSec * 1000 + 10000); + const openSession = await getOpenMinigameSession(req.user.sub, game, minStartedAt); + if (openSession) { + return res.status(409).json({ error: 'session_open' }); + } - const session = createMinigameSession(req.user.sub, game); - res.json({ sessionId: session.id }); + const session = await createMinigameSession(req.user.sub, game); + res.json({ sessionId: session.id }); + } catch (e) { next(e); } }); -router.post('/api/minigame/finish', requireAuth, (req, res) => { - const { sessionId, metric } = req.body || {}; - if (typeof metric !== 'number' || !Number.isFinite(metric)) { - return res.status(400).json({ error: 'invalid_metric' }); - } +router.post('/api/minigame/finish', requireAuth, async (req, res, next) => { + try { + const { sessionId, metric } = req.body || {}; + if (typeof metric !== 'number' || !Number.isFinite(metric)) { + return res.status(400).json({ error: 'invalid_metric' }); + } - const session = getMinigameSession(sessionId); - if (!session || session.user_id !== req.user.sub) return res.status(404).json({ error: 'not_found' }); - if (session.finished_at !== null) return res.status(410).json({ error: 'gone' }); - - const now = Date.now(); - const { data: config } = getConfig(); - const gameConf = config.minigames[session.game]; - const windowEnd = session.started_at + gameConf.durationSec * 1000 + 10000; - if (now > windowEnd) return res.status(410).json({ error: 'gone' }); - - let clamped; - let won = true; - if (session.game === 'rush') { - clamped = Math.min(metric, gameConf.durationSec * gameConf.maxTapsPerSec); - } else if (session.game === 'debug') { - clamped = Math.min(metric, (gameConf.durationSec * 1000) / gameConf.spawnMinMs); - } else if (session.game === 'match') { - clamped = Math.min(metric, gameConf.pairCount); - won = clamped === gameConf.pairCount; - } else { - // balance - clamped = Math.min(metric, gameConf.maxScore); - } - clamped = Math.max(0, clamped); - - // Single load+evaluate (not persisted yet) so wafer/stat/cooldown - // mutations below land in one putSave, matching applyActions' pattern. - const { state } = loadEvaluateAndSchedule(req.user.sub, now); - - // Re-check the cooldown against the freshly-evaluated state, not just at - // start time: a burst of sessions opened concurrently for the same game - // (each individually valid when it was opened) must not all be redeemable - // once the first win of the batch sets the cooldown. The session is still - // marked finished below either way, so a blocked attempt can't be replayed. - const cooldownUntil = state.server.gameCooldowns[session.game] || 0; - const onCooldown = now < cooldownUntil; - const wafers = !onCooldown && won ? minigameWafers(session.game, clamped, state.meta, config) : 0; - - if (wafers > 0) { - state.meta.wafers += wafers; - state.meta.stats.minigamesWon += 1; - state.meta.stats.totalWafersEarned += wafers; - state.server.gameCooldowns[session.game] = now + config.minigames.winCooldownMs; - } + const session = await getMinigameSession(sessionId); + if (!session || session.user_id !== req.user.sub) return res.status(404).json({ error: 'not_found' }); + if (session.finished_at !== null) return res.status(410).json({ error: 'gone' }); + + const now = Date.now(); + const { data: config } = await getConfig(); + const gameConf = config.minigames[session.game]; + const windowEnd = session.started_at + gameConf.durationSec * 1000 + 10000; + if (now > windowEnd) return res.status(410).json({ error: 'gone' }); + + let clamped; + let won = true; + if (session.game === 'rush') { + clamped = Math.min(metric, gameConf.durationSec * gameConf.maxTapsPerSec); + } else if (session.game === 'debug') { + clamped = Math.min(metric, (gameConf.durationSec * 1000) / gameConf.spawnMinMs); + } else if (session.game === 'match') { + clamped = Math.min(metric, gameConf.pairCount); + won = clamped === gameConf.pairCount; + } else { + // balance + clamped = Math.min(metric, gameConf.maxScore); + } + clamped = Math.max(0, clamped); + + // Single load+evaluate (not persisted yet) so wafer/stat/cooldown + // mutations below land in one putSave, matching applyActions' pattern. + const { state } = await loadEvaluateAndSchedule(req.user.sub, now); + + // Re-check the cooldown against the freshly-evaluated state, not just at + // start time: a burst of sessions opened concurrently for the same game + // (each individually valid when it was opened) must not all be redeemable + // once the first win of the batch sets the cooldown. The session is still + // marked finished below either way, so a blocked attempt can't be replayed. + const cooldownUntil = state.server.gameCooldowns[session.game] || 0; + const onCooldown = now < cooldownUntil; + const wafers = !onCooldown && won ? minigameWafers(session.game, clamped, state.meta, config) : 0; + + if (wafers > 0) { + state.meta.wafers += wafers; + state.meta.stats.minigamesWon += 1; + state.meta.stats.totalWafersEarned += wafers; + state.server.gameCooldowns[session.game] = now + config.minigames.winCooldownMs; + } - putSave(req.user.sub, state, now); - finishMinigameSession(sessionId, clamped); + await putSave(req.user.sub, state, now); + await finishMinigameSession(sessionId, clamped); - if (onCooldown) { - return res.status(429).json({ error: 'cooldown_active' }); - } - res.json({ state, wafers }); + if (onCooldown) { + return res.status(429).json({ error: 'cooldown_active' }); + } + res.json({ state, wafers }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- @@ -413,41 +439,43 @@ function progressView(event, progress, meta) { }; } -router.get('/api/event', requireAuth, (req, res) => { - const now = Date.now(); - const { state } = loadAndEvaluate(req.user.sub, now); - - // Resolved from the PLAYER's own save, not from getActiveEvent(). Gating - // this route on "is an event globally active" is what made spec §5.3's 48h - // claim grace unreachable: the response flipped to event:null the instant - // the event ended globally, and since the client's only copy of the ladder - // was in-memory React state seeded at boot, a player who closed the tab and - // reopened it inside their grace window got no ladder and no Claim buttons - // at all - their rewards expired unclaimable - even though posting the - // identical claim straight to /api/actions still succeeded. - const { current, pending } = resolvePlayerEvents(state); - const live = current && inClaimGrace(current.progress, now) ? current : null; - - // Windows force-ended early by a newer event activating, still inside - // their own grace (spec §5.2 ends the window, §5.3 keeps the claim open). - const pendingClaims = pending - .filter((p) => inClaimGrace(p.progress, now)) - .map((p) => ({ event: eventView(p.event), progress: progressView(p.event, p.progress, state.meta) })); - - if (!live) { - return res.json({ event: null, progress: null, leaderboard: [], pendingClaims }); - } +router.get('/api/event', requireAuth, async (req, res, next) => { + try { + const now = Date.now(); + const { state } = await loadAndEvaluate(req.user.sub, now); + + // Resolved from the PLAYER's own save, not from getActiveEvent(). Gating + // this route on "is an event globally active" is what made spec §5.3's 48h + // claim grace unreachable: the response flipped to event:null the instant + // the event ended globally, and since the client's only copy of the ladder + // was in-memory React state seeded at boot, a player who closed the tab and + // reopened it inside their grace window got no ladder and no Claim buttons + // at all - their rewards expired unclaimable - even though posting the + // identical claim straight to /api/actions still succeeded. + const { current, pending } = await resolvePlayerEvents(state); + const live = current && inClaimGrace(current.progress, now) ? current : null; + + // Windows force-ended early by a newer event activating, still inside + // their own grace (spec §5.2 ends the window, §5.3 keeps the claim open). + const pendingClaims = pending + .filter((p) => inClaimGrace(p.progress, now)) + .map((p) => ({ event: eventView(p.event), progress: progressView(p.event, p.progress, state.meta) })); + + if (!live) { + return res.json({ event: null, progress: null, leaderboard: [], pendingClaims }); + } - res.json({ - event: eventView(live.event), - progress: progressView(live.event, live.progress, state.meta), - // Hard requirement 1 (Task 4 review carry-forward): listLeaderboard - // live-joins users.leaderboard_opt_out rather than trusting - // event_participation.opted_out's join-time snapshot, so a user who - // opts out after joining disappears from this list immediately. - leaderboard: listLeaderboard(live.event.id, 50), - pendingClaims, - }); + res.json({ + event: eventView(live.event), + progress: progressView(live.event, live.progress, state.meta), + // Hard requirement 1 (Task 4 review carry-forward): listLeaderboard + // live-joins users.leaderboard_opt_out rather than trusting + // event_participation.opted_out's join-time snapshot, so a user who + // opts out after joining disappears from this list immediately. + leaderboard: await listLeaderboard(live.event.id, 50), + pendingClaims, + }); + } catch (e) { next(e); } }); // Mirrors the opt-out to the durable users.leaderboard_opt_out column (the @@ -455,23 +483,25 @@ router.get('/api/event', requireAuth, (req, res) => { // and also replays it through the normal action path so // meta.leaderboardOptOut (client-display-only, shared/reducer.js) stays in // sync without a second client round trip. -router.put('/api/me/leaderboard-opt-out', requireAuth, (req, res) => { - const { optOut } = req.body || {}; - if (typeof optOut !== 'boolean') return res.status(400).json({ error: 'invalid_request' }); - - setLeaderboardOptOut(req.user.sub, optOut); - applyActions(req.user.sub, [{ type: 'setLeaderboardOptOut', optOut }], Date.now()); - - // v1.5: the global boards are served from a ~60s in-memory cache, so - // without this a player who just asked to be hidden would keep appearing on - // them for up to a minute. The per-event leaderboard has always been - // immediate (it live-joins users.leaderboard_opt_out on every read - v1.4's - // "hard requirement 1"), and this control must not quietly mean something - // weaker just because the newer boards are cache-fronted. Opting out is a - // deliberate, low-frequency action, so paying for one rebuild is free. - invalidateLeaderboards(); - - res.json({ ok: true, optOut }); +router.put('/api/me/leaderboard-opt-out', requireAuth, async (req, res, next) => { + try { + const { optOut } = req.body || {}; + if (typeof optOut !== 'boolean') return res.status(400).json({ error: 'invalid_request' }); + + await setLeaderboardOptOut(req.user.sub, optOut); + await applyActions(req.user.sub, [{ type: 'setLeaderboardOptOut', optOut }], Date.now()); + + // v1.5: the global boards are served from a ~60s in-memory cache, so + // without this a player who just asked to be hidden would keep appearing on + // them for up to a minute. The per-event leaderboard has always been + // immediate (it live-joins users.leaderboard_opt_out on every read - v1.4's + // "hard requirement 1"), and this control must not quietly mean something + // weaker just because the newer boards are cache-fronted. Opting out is a + // deliberate, low-frequency action, so paying for one rebuild is free. + invalidateLeaderboards(); + + res.json({ ok: true, optOut }); + } catch (e) { next(e); } }); // v1.6 guided tours. A pure UI preference: unlike the leaderboard opt-out @@ -481,77 +511,83 @@ router.put('/api/me/leaderboard-opt-out', requireAuth, (req, res) => { // `completed: false` is the replay path (Profile -> Tutorials -> Replay). // Ids the current build doesn't know about are preserved rather than dropped: // a rolled-back deployment must not erase a completion recorded by a newer one. -router.put('/api/me/tours', requireAuth, (req, res) => { - const { tourId, completed } = req.body || {}; - if (!isValidTourId(tourId) || typeof completed !== 'boolean') { - return res.status(400).json({ error: 'invalid_request' }); - } +router.put('/api/me/tours', requireAuth, async (req, res, next) => { + try { + const { tourId, completed } = req.body || {}; + if (!isValidTourId(tourId) || typeof completed !== 'boolean') { + return res.status(400).json({ error: 'invalid_request' }); + } - const current = new Set(getToursCompleted(req.user.sub)); - if (completed) { - // Spec §4.7: onboarding is always a superset of the feature tours, so - // finishing (or skipping) it clears the whole queue. - if (tourId === ONBOARDING_TOUR_ID) for (const id of TOUR_IDS) current.add(id); - else current.add(tourId); - } else { - current.delete(tourId); - } + const current = new Set(await getToursCompleted(req.user.sub)); + if (completed) { + // Spec §4.7: onboarding is always a superset of the feature tours, so + // finishing (or skipping) it clears the whole queue. + if (tourId === ONBOARDING_TOUR_ID) for (const id of TOUR_IDS) current.add(id); + else current.add(tourId); + } else { + current.delete(tourId); + } - const toursCompleted = [...current]; - setToursCompleted(req.user.sub, toursCompleted); - res.json({ ok: true, toursCompleted }); + const toursCompleted = [...current]; + await setToursCompleted(req.user.sub, toursCompleted); + res.json({ ok: true, toursCompleted }); + } catch (e) { next(e); } }); // --- Coordinator CRUD (requireRole('event_coordinator') - 'admin' implies // it via getEffectiveRoles, owners hold every role) ------------------------- -router.get('/api/admin/events', requireAuth, requireRole('event_coordinator'), (req, res) => { - const events = listEvents().map((event) => ({ - ...event, - participationCount: listParticipation(event.id).length, - })); - res.json({ events }); +router.get('/api/admin/events', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const events = await Promise.all((await listEvents()).map(async (event) => ({ + ...event, + participationCount: (await listParticipation(event.id)).length, + }))); + res.json({ events }); + } catch (e) { next(e); } }); -router.post('/api/admin/events', requireAuth, requireRole('event_coordinator'), (req, res) => { - const { - id, name, description, theme, modifiers = [], ladder, recurrence, - } = req.body || {}; +router.post('/api/admin/events', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const { + id, name, description, theme, modifiers = [], ladder, recurrence, + } = req.body || {}; - if (!isValidEventSlug(id)) { - return res.status(400).json({ errors: ['id must be a 3-60 char lowercase, hyphen-separated slug'] }); - } - if (getEvent(id)) return res.status(409).json({ error: 'id_taken' }); - if (typeof name !== 'string' || !name.trim()) { - return res.status(400).json({ errors: ['name is required'] }); - } + if (!isValidEventSlug(id)) { + return res.status(400).json({ errors: ['id must be a 3-60 char lowercase, hyphen-separated slug'] }); + } + if (await getEvent(id)) return res.status(409).json({ error: 'id_taken' }); + if (typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ errors: ['name is required'] }); + } - const modResult = validateModifiers(modifiers); - if (!modResult.ok) return res.status(400).json({ errors: modResult.errors }); - const ladderResult = validateLadder(ladder); - if (!ladderResult.ok) return res.status(400).json({ errors: ladderResult.errors }); - // An unvalidated recurrence is not merely cosmetic: `{}` or `"weekly"` - // makes the scheduler materialize a NaN window, promoting the event to - // 'scheduled' with no usable window and no way out (DELETE is draft-only, - // activate answers not_scheduled), and `durationDays: -5` materializes - // endsAt < startsAt, handing every joiner an instantly-expired personal - // window. Both are permanent, both are silent. - const recurrenceResult = validateRecurrence(recurrence); - if (!recurrenceResult.ok) return res.status(400).json({ errors: recurrenceResult.errors }); - - const event = putEvent({ - id, - name, - description: description ?? null, - theme: theme ?? null, - modifiers, - ladder, - status: 'draft', - recurrence: recurrence ?? null, - createdAt: Date.now(), - createdBy: req.user.sub, - }); - res.status(201).json({ event }); + const modResult = validateModifiers(modifiers); + if (!modResult.ok) return res.status(400).json({ errors: modResult.errors }); + const ladderResult = validateLadder(ladder); + if (!ladderResult.ok) return res.status(400).json({ errors: ladderResult.errors }); + // An unvalidated recurrence is not merely cosmetic: `{}` or `"weekly"` + // makes the scheduler materialize a NaN window, promoting the event to + // 'scheduled' with no usable window and no way out (DELETE is draft-only, + // activate answers not_scheduled), and `durationDays: -5` materializes + // endsAt < startsAt, handing every joiner an instantly-expired personal + // window. Both are permanent, both are silent. + const recurrenceResult = validateRecurrence(recurrence); + if (!recurrenceResult.ok) return res.status(400).json({ errors: recurrenceResult.errors }); + + const event = await putEvent({ + id, + name, + description: description ?? null, + theme: theme ?? null, + modifiers, + ladder, + status: 'draft', + recurrence: recurrence ?? null, + createdAt: Date.now(), + createdBy: req.user.sub, + }); + res.status(201).json({ event }); + } catch (e) { next(e); } }); // Name/description/theme/window edits are always allowed. ladder/modifiers @@ -560,124 +596,136 @@ router.post('/api/admin/events', requireAuth, requireRole('event_coordinator'), // rungsClaimed indices (ladder) or silently reshape the effective config // underneath an in-progress run (modifiers). Drafts/scheduled/ended events // may have their ladder/modifiers freely edited. -router.put('/api/admin/events/:id', requireAuth, requireRole('event_coordinator'), (req, res) => { - const existing = getEvent(req.params.id); - if (!existing) return res.status(404).json({ error: 'not_found' }); - - const body = req.body || {}; - const touchesLadderOrModifiers = Object.prototype.hasOwnProperty.call(body, 'ladder') - || Object.prototype.hasOwnProperty.call(body, 'modifiers'); - if (existing.status === 'active' && touchesLadderOrModifiers) { - return res.status(409).json({ error: 'event_active' }); - } +router.put('/api/admin/events/:id', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const existing = await getEvent(req.params.id); + if (!existing) return res.status(404).json({ error: 'not_found' }); + + const body = req.body || {}; + const touchesLadderOrModifiers = Object.prototype.hasOwnProperty.call(body, 'ladder') + || Object.prototype.hasOwnProperty.call(body, 'modifiers'); + if (existing.status === 'active' && touchesLadderOrModifiers) { + return res.status(409).json({ error: 'event_active' }); + } - const next = { - ...existing, - name: body.name !== undefined ? body.name : existing.name, - description: body.description !== undefined ? body.description : existing.description, - theme: body.theme !== undefined ? body.theme : existing.theme, - modifiers: body.modifiers !== undefined ? body.modifiers : existing.modifiers, - ladder: body.ladder !== undefined ? body.ladder : existing.ladder, - startsAt: body.startsAt !== undefined ? body.startsAt : existing.starts_at, - endsAt: body.endsAt !== undefined ? body.endsAt : existing.ends_at, - }; + const next = { + ...existing, + name: body.name !== undefined ? body.name : existing.name, + description: body.description !== undefined ? body.description : existing.description, + theme: body.theme !== undefined ? body.theme : existing.theme, + modifiers: body.modifiers !== undefined ? body.modifiers : existing.modifiers, + ladder: body.ladder !== undefined ? body.ladder : existing.ladder, + startsAt: body.startsAt !== undefined ? body.startsAt : existing.starts_at, + endsAt: body.endsAt !== undefined ? body.endsAt : existing.ends_at, + }; - if (typeof next.name !== 'string' || !next.name.trim()) { - return res.status(400).json({ errors: ['name is required'] }); - } - const modResult = validateModifiers(next.modifiers); - if (!modResult.ok) return res.status(400).json({ errors: modResult.errors }); - const ladderResult = validateLadder(next.ladder); - if (!ladderResult.ok) return res.status(400).json({ errors: ladderResult.errors }); - if (typeof next.startsAt === 'number' && typeof next.endsAt === 'number' && next.endsAt <= next.startsAt) { - return res.status(400).json({ errors: ['endsAt must be after startsAt'] }); - } + if (typeof next.name !== 'string' || !next.name.trim()) { + return res.status(400).json({ errors: ['name is required'] }); + } + const modResult = validateModifiers(next.modifiers); + if (!modResult.ok) return res.status(400).json({ errors: modResult.errors }); + const ladderResult = validateLadder(next.ladder); + if (!ladderResult.ok) return res.status(400).json({ errors: ladderResult.errors }); + if (typeof next.startsAt === 'number' && typeof next.endsAt === 'number' && next.endsAt <= next.startsAt) { + return res.status(400).json({ errors: ['endsAt must be after startsAt'] }); + } - const saved = putEvent({ ...next, id: existing.id, status: existing.status }); - - // Hard requirement 3 (Task 4 review carry-forward): getEffectiveConfig() - // caches on (configVersion, activeEventId) - editing the CONTENTS of the - // currently-active event (window, name, ...) doesn't change that cache - // key, so without an explicit invalidation the cached - // config.__activeEvent.endsAt (read by claimEventRung's 48h grace math) - // would go stale. Called on every successful edit of an active event, not - // just window edits, in case a future field gets added to __activeEvent. - if (existing.status === 'active') { - invalidateEffectiveConfig(); - } + const saved = await putEvent({ ...next, id: existing.id, status: existing.status }); + + // Hard requirement 3 (Task 4 review carry-forward): getEffectiveConfig() + // caches on (configVersion, activeEventId) - editing the CONTENTS of the + // currently-active event (window, name, ...) doesn't change that cache + // key, so without an explicit invalidation the cached + // config.__activeEvent.endsAt (read by claimEventRung's 48h grace math) + // would go stale. Called on every successful edit of an active event, not + // just window edits, in case a future field gets added to __activeEvent. + if (existing.status === 'active') { + invalidateEffectiveConfig(); + } - res.json({ event: saved }); + res.json({ event: saved }); + } catch (e) { next(e); } }); // Drafts only - a scheduled/active/ended event may already have // participation rows and/or client-visible history, so it's ended (or left // alone), never deleted. -router.delete('/api/admin/events/:id', requireAuth, requireRole('event_coordinator'), (req, res) => { - const event = getEvent(req.params.id); - if (!event) return res.status(404).json({ error: 'not_found' }); - if (event.status !== 'draft') return res.status(409).json({ error: 'not_draft' }); - deleteEvent(event.id); - res.json({ ok: true }); +router.delete('/api/admin/events/:id', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const event = await getEvent(req.params.id); + if (!event) return res.status(404).json({ error: 'not_found' }); + if (event.status !== 'draft') return res.status(409).json({ error: 'not_draft' }); + await deleteEvent(event.id); + res.json({ ok: true }); + } catch (e) { next(e); } }); -router.post('/api/admin/events/:id/schedule', requireAuth, requireRole('event_coordinator'), (req, res) => { - const event = getEvent(req.params.id); - if (!event) return res.status(404).json({ error: 'not_found' }); - if (event.status === 'active') return res.status(409).json({ error: 'event_active' }); +router.post('/api/admin/events/:id/schedule', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const event = await getEvent(req.params.id); + if (!event) return res.status(404).json({ error: 'not_found' }); + if (event.status === 'active') return res.status(409).json({ error: 'event_active' }); - const { startsAt, endsAt } = req.body || {}; - if (typeof startsAt !== 'number' || typeof endsAt !== 'number' || endsAt <= startsAt) { - return res.status(400).json({ error: 'invalid_request' }); - } + const { startsAt, endsAt } = req.body || {}; + if (typeof startsAt !== 'number' || typeof endsAt !== 'number' || endsAt <= startsAt) { + return res.status(400).json({ error: 'invalid_request' }); + } - setEventStatus(event.id, 'scheduled', { startsAt, endsAt }); - res.json({ event: getEvent(event.id) }); + await setEventStatus(event.id, 'scheduled', { startsAt, endsAt }); + res.json({ event: await getEvent(event.id) }); + } catch (e) { next(e); } }); -router.post('/api/admin/events/:id/activate', requireAuth, requireRole('event_coordinator'), (req, res) => { - const event = getEvent(req.params.id); - if (!event) return res.status(404).json({ error: 'not_found' }); - - // Per spec: activating while a DIFFERENT event is already active must be - // rejected outright - the coordinator has to explicitly end it first. - // (eventService.activateEvent itself does NOT enforce this - it happily - // ends every other active row, because the scheduler also calls it and - // needs that behavior. This UX/permission check is this route's job.) - const active = getActiveEvent(); - if (active && active.id !== event.id) { - return res.status(409).json({ error: 'event_active' }); - } +router.post('/api/admin/events/:id/activate', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const event = await getEvent(req.params.id); + if (!event) return res.status(404).json({ error: 'not_found' }); + + // Per spec: activating while a DIFFERENT event is already active must be + // rejected outright - the coordinator has to explicitly end it first. + // (eventService.activateEvent itself does NOT enforce this - it happily + // ends every other active row, because the scheduler also calls it and + // needs that behavior. This UX/permission check is this route's job.) + const active = await getActiveEvent(); + if (active && active.id !== event.id) { + return res.status(409).json({ error: 'event_active' }); + } - if (event.ends_at == null) return res.status(400).json({ error: 'not_scheduled' }); - - // Hard requirement 2 (Task 4 review carry-forward): activating an event - // whose stored window has already fully passed would hand - // joinEventIfEligible's `endsAt = min(now + duration, ends_at + 24h)` math - // a value before `now`, silently giving a new joiner an already-expired - // personal window. Chosen fix: reject outright rather than shifting the - // window forward - the coordinator-authored dates stay authoritative and - // are never silently rewritten by an activate call; to proceed they - // re-schedule (POST .../schedule) with a fresh window, then activate. - const now = Date.now(); - if (event.ends_at <= now) return res.status(400).json({ error: 'invalid_target' }); - - const result = activateEvent(event.id, now); - if (!result.ok) return res.status(400).json({ error: result.error }); - res.json({ event: getEvent(event.id) }); + if (event.ends_at == null) return res.status(400).json({ error: 'not_scheduled' }); + + // Hard requirement 2 (Task 4 review carry-forward): activating an event + // whose stored window has already fully passed would hand + // joinEventIfEligible's `endsAt = min(now + duration, ends_at + 24h)` math + // a value before `now`, silently giving a new joiner an already-expired + // personal window. Chosen fix: reject outright rather than shifting the + // window forward - the coordinator-authored dates stay authoritative and + // are never silently rewritten by an activate call; to proceed they + // re-schedule (POST .../schedule) with a fresh window, then activate. + const now = Date.now(); + if (event.ends_at <= now) return res.status(400).json({ error: 'invalid_target' }); + + const result = await activateEvent(event.id, now); + if (!result.ok) return res.status(400).json({ error: result.error }); + res.json({ event: await getEvent(event.id) }); + } catch (e) { next(e); } }); -router.post('/api/admin/events/:id/end', requireAuth, requireRole('event_coordinator'), (req, res) => { - const event = getEvent(req.params.id); - if (!event) return res.status(404).json({ error: 'not_found' }); - const result = endEvent(event.id, Date.now()); - if (!result.ok) return res.status(400).json({ error: result.error }); - res.json({ event: getEvent(event.id) }); +router.post('/api/admin/events/:id/end', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const event = await getEvent(req.params.id); + if (!event) return res.status(404).json({ error: 'not_found' }); + const result = await endEvent(event.id, Date.now()); + if (!result.ok) return res.status(400).json({ error: result.error }); + res.json({ event: await getEvent(event.id) }); + } catch (e) { next(e); } }); -router.get('/api/admin/events/:id/participation', requireAuth, requireRole('event_coordinator'), (req, res) => { - const event = getEvent(req.params.id); - if (!event) return res.status(404).json({ error: 'not_found' }); - res.json({ participation: listParticipation(event.id) }); +router.get('/api/admin/events/:id/participation', requireAuth, requireRole('event_coordinator'), async (req, res, next) => { + try { + const event = await getEvent(req.params.id); + if (!event) return res.status(404).json({ error: 'not_found' }); + res.json({ participation: await listParticipation(event.id) }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- @@ -689,9 +737,11 @@ router.get('/api/admin/events/:id/participation', requireAuth, requireRole('even // (server/leaderboardService.js), so this is cheap to poll - the client // throttles it anyway. Respects users.leaderboard_opt_out, the same live // column the per-event leaderboard filters on. -router.get('/api/leaderboard', requireAuth, (req, res) => { - const { generatedAt, boards } = getLeaderboards(Date.now()); - res.json({ generatedAt, boards }); +router.get('/api/leaderboard', requireAuth, async (req, res, next) => { + try { + const { generatedAt, boards } = await getLeaderboards(Date.now()); + res.json({ generatedAt, boards }); + } catch (e) { next(e); } }); // --------------------------------------------------------------------------- @@ -713,33 +763,35 @@ router.get('/api/changelog', requireAuth, (req, res) => { // Lists every user and their save stats. Gated on the requireRole('admin') // middleware - never a client-trusted flag - re-checked on every request. -router.get('/api/admin/users', requireAuth, requireRole('admin'), (req, res) => { - const rows = getAllUsersWithSaves(); - const users = rows.map((row) => { - let meta = null; - if (row.data) { - try { - meta = JSON.parse(row.data).meta || null; - } catch (e) { - meta = null; +router.get('/api/admin/users', requireAuth, requireRole('admin'), async (req, res, next) => { + try { + const rows = await getAllUsersWithSaves(); + const users = rows.map((row) => { + let meta = null; + if (row.data) { + try { + meta = JSON.parse(row.data).meta || null; + } catch (e) { + meta = null; + } } - } - return { - id: row.id, - provider: row.provider, - username: row.username, - avatarUrl: row.avatar_url, - createdAt: row.created_at, - lastSave: row.last_save || null, - level: meta ? meta.level : null, - xp: meta ? meta.xp : null, - wafers: meta ? meta.wafers : null, - legacyCores: meta ? meta.legacyCores : null, - singularityShards: meta ? meta.singularityShards : null, - stats: meta ? meta.stats : null, - }; - }); - res.json({ users }); + return { + id: row.id, + provider: row.provider, + username: row.username, + avatarUrl: row.avatar_url, + createdAt: row.created_at, + lastSave: row.last_save || null, + level: meta ? meta.level : null, + xp: meta ? meta.xp : null, + wafers: meta ? meta.wafers : null, + legacyCores: meta ? meta.legacyCores : null, + singularityShards: meta ? meta.singularityShards : null, + stats: meta ? meta.stats : null, + }; + }); + res.json({ users }); + } catch (e) { next(e); } }); export default router; diff --git a/server/stateService.js b/server/stateService.js index 4a5fbc0..bda9d1c 100644 --- a/server/stateService.js +++ b/server/stateService.js @@ -33,7 +33,7 @@ function safeParse(text, userId) { * state further (crediting wafers, setting a cooldown) before a single * putSave, the same one-write pattern applyActions uses. */ -export function loadEvaluateAndSchedule(userId, now) { +export async function loadEvaluateAndSchedule(userId, now) { // getEffectiveConfig() (server/configService.js) is getConfig()'s admin // baseline with the currently active live event's modifiers merged on // top (Task 4) - never the other way around. This is the ONLY read of @@ -53,9 +53,9 @@ export function loadEvaluateAndSchedule(userId, now) { // itself - mutating the shared cached object would leak one user's // claimable event onto every other user's request that hits the same // cache before it next invalidates. - const effectiveConfig = getEffectiveConfig(); + const effectiveConfig = await getEffectiveConfig(); const config = { ...effectiveConfig.data }; - const row = getSave(userId); + const row = await getSave(userId); const raw = row ? safeParse(row.data, userId) : null; const lastEvaluatedAt = row ? row.last_save : now; @@ -77,7 +77,7 @@ export function loadEvaluateAndSchedule(userId, now) { // window; if their in-flight progress belongs to a now-superseded event, // clear it. Mutates state.meta.eventProgress in place, same convention as // scheduleAnomaly above. - const activeEvent = joinEventIfEligible(userId, state, now); + const activeEvent = await joinEventIfEligible(userId, state, now); // Resolve the per-user claimable event(s), if any, AFTER join-on-login has // had a chance to settle state.meta.eventProgress/pendingEventClaims (new @@ -90,7 +90,7 @@ export function loadEvaluateAndSchedule(userId, now) { // getEvent() returns undefined, the entry is simply dropped, and // claimEventRung's own `!activeEvent` guard fails closed with // invalid_target - never throws. - const { current, pending } = resolvePlayerEvents(state); + const { current, pending } = await resolvePlayerEvents(state); if (current) { config.__claimableEvent = { id: current.event.id, ladder: current.event.ladder, endsAt: current.event.ends_at, @@ -123,9 +123,9 @@ export function loadEvaluateAndSchedule(userId, now) { } /** Loads, evaluates, persists, and returns { state, gained, activeEvent } for GET /api/state. */ -export function loadAndEvaluate(userId, now = Date.now()) { - const { state, gained, activeEvent, unlockedAchievements } = loadEvaluateAndSchedule(userId, now); - putSave(userId, state, now); +export async function loadAndEvaluate(userId, now = Date.now()) { + const { state, gained, activeEvent, unlockedAchievements } = await loadEvaluateAndSchedule(userId, now); + await putSave(userId, state, now); return { state, gained, activeEvent, unlockedAchievements }; } @@ -140,10 +140,10 @@ export function loadAndEvaluate(userId, now = Date.now()) { * pass `{ type, id: }`) - echoing back `action.id` * here instead used to silently clobber those actions' own id client-side. */ -export function applyActions(userId, actions, now = Date.now()) { +export async function applyActions(userId, actions, now = Date.now()) { const { state: loaded, config, unlockedAchievements: loadUnlocked, - } = loadEvaluateAndSchedule(userId, now); + } = await loadEvaluateAndSchedule(userId, now); let state = loaded; const results = []; @@ -153,7 +153,7 @@ export function applyActions(userId, actions, now = Date.now()) { results.push({ ...result, _cid: action && action._cid }); } - putSave(userId, state, now); + await putSave(userId, state, now); // Hotfix: event_participation.rungs_claimed was previously only ever // written once, at join time (joinEventIfEligible -> upsertParticipation, @@ -183,7 +183,7 @@ export function applyActions(userId, actions, now = Date.now()) { ? state.meta.eventProgress : (state.meta.pendingEventClaims || []).find((p) => p && p.eventId === eventId); if (record && Array.isArray(record.rungsClaimed)) { - updateParticipationProgress(userId, eventId, record.rungsClaimed.length, now); + await updateParticipationProgress(userId, eventId, record.rungsClaimed.length, now); } } diff --git a/tests/api.events.hotfix.test.js b/tests/api.events.hotfix.test.js index a1f2211..562b67c 100644 --- a/tests/api.events.hotfix.test.js +++ b/tests/api.events.hotfix.test.js @@ -25,20 +25,20 @@ const { const { activateEvent } = await import('../server/eventService.js'); const { COOKIE_NAME } = await import('../server/auth.js'); -ensureConfig(); +await ensureConfig(); const app = buildApp(); let seq = 0; -function makeUser() { +async function makeUser() { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `hf${seq}`, username: `hfuser${seq}`, avatarUrl: null, }); } -function makeCoordinator() { - const u = makeUser(); - setRoles(u.id, ['event_coordinator']); +async function makeCoordinator() { + const u = await makeUser(); + await setRoles(u.id, ['event_coordinator']); return u; } @@ -79,12 +79,12 @@ function sampleEvent(overrides = {}) { describe('bug (b): event_participation.rungs_claimed stays in sync after claims, end-to-end over HTTP', () => { it('leaderboard reflects real rungsClaimed counts (not frozen at 0) and orders more-progressed players first', async () => { const now = Date.now(); - const coordinator = makeCoordinator(); - putEvent(sampleEvent({ id: 'leaderboard-sync-evt', startsAt: now - 1000, endsAt: now + 100000 })); - expect(activateEvent('leaderboard-sync-evt', now)).toEqual({ ok: true }); + const coordinator = await makeCoordinator(); + await putEvent(sampleEvent({ id: 'leaderboard-sync-evt', startsAt: now - 1000, endsAt: now + 100000 })); + expect(await activateEvent('leaderboard-sync-evt', now)).toEqual({ ok: true }); - const alice = makeUser(); - const bob = makeUser(); + const alice = await makeUser(); + const bob = await makeUser(); // Both join by hitting a state-reading route (GET /api/event, real // join-on-login path). @@ -94,7 +94,7 @@ describe('bug (b): event_participation.rungs_claimed stays in sync after claims, // Sanity: the participation row is still frozen at 0 pre-claim (this is // the pre-fix state, and remains correct post-fix too - nothing to sync // yet). - expect(getParticipation(alice.id, 'leaderboard-sync-evt').rungs_claimed).toBe(0); + expect((await getParticipation(alice.id, 'leaderboard-sync-evt')).rungs_claimed).toBe(0); // Alice claims BOTH rungs over two separate POST /api/actions calls - // the real client action-queue path, not a hand-built reducer call. @@ -115,11 +115,11 @@ describe('bug (b): event_participation.rungs_claimed stays in sync after claims, // Bob never claims anything. // The DB row itself must now read 2, not the join-time 0. - const aliceParticipation = getParticipation(alice.id, 'leaderboard-sync-evt'); + const aliceParticipation = await getParticipation(alice.id, 'leaderboard-sync-evt'); expect(aliceParticipation.rungs_claimed).toBe(2); expect(aliceParticipation.last_progress_at).toBeGreaterThanOrEqual(now); - const bobParticipation = getParticipation(bob.id, 'leaderboard-sync-evt'); + const bobParticipation = await getParticipation(bob.id, 'leaderboard-sync-evt'); expect(bobParticipation.rungs_claimed).toBe(0); // GET /api/event's leaderboard (listLeaderboard, `ORDER BY rungs_claimed @@ -151,11 +151,11 @@ async function endEventReq(coordinator, id) { describe('bug (a) over real HTTP: claim during grace succeeds and pays out; past grace returns cooldown_active', () => { it('a claim made after the event globally ends (within the players own grace window) succeeds; a claim past grace returns cooldown_active', async () => { const now = Date.now(); - const coordinator = makeCoordinator(); - putEvent(sampleEvent({ id: 'grace-http-evt', startsAt: now - 1000, endsAt: now + 5000 })); - expect(activateEvent('grace-http-evt', now)).toEqual({ ok: true }); + const coordinator = await makeCoordinator(); + await putEvent(sampleEvent({ id: 'grace-http-evt', startsAt: now - 1000, endsAt: now + 5000 })); + expect(await activateEvent('grace-http-evt', now)).toEqual({ ok: true }); - const player = makeUser(); + const player = await makeUser(); const joinRes = await request(app).get('/api/event').set('Cookie', cookieFor(player)); expect(joinRes.status).toBe(200); expect(joinRes.body.progress.eventId ?? 'grace-http-evt').toBeTruthy(); diff --git a/tests/api.events.test.js b/tests/api.events.test.js index 9882fa7..c6c546d 100644 --- a/tests/api.events.test.js +++ b/tests/api.events.test.js @@ -13,13 +13,13 @@ const { ensureConfig, getEffectiveConfig } = await import('../server/configServi const { upsertUser, setRoles } = await import('../server/db.js'); const { COOKIE_NAME } = await import('../server/auth.js'); -ensureConfig(); +await ensureConfig(); const app = buildApp(); let seq = 0; -function makeUser(overrides = {}) { +async function makeUser(overrides = {}) { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `ev${seq}`, username: `evuser${seq}`, @@ -28,9 +28,9 @@ function makeUser(overrides = {}) { }); } -function makeCoordinator() { - const u = makeUser(); - setRoles(u.id, ['event_coordinator']); +async function makeCoordinator() { + const u = await makeUser(); + await setRoles(u.id, ['event_coordinator']); return u; } @@ -101,7 +101,7 @@ async function endEventReq(coordinator, id) { describe('GET /api/event (no active event, must run first)', () => { it('returns event: null, progress: null, leaderboard: [] when nothing is active', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app).get('/api/event').set('Cookie', cookieFor(user)); expect(res.status).toBe(200); expect(res.body).toEqual({ @@ -112,8 +112,8 @@ describe('GET /api/event (no active event, must run first)', () => { describe('coordinator gating: every admin event route 403s for a non-coordinator', () => { it('403s across the board', async () => { - const plain = makeUser(); - const coordinator = makeCoordinator(); + const plain = await makeUser(); + const coordinator = await makeCoordinator(); const event = await createEvent(coordinator); const attempts = [ @@ -142,7 +142,7 @@ describe('coordinator gating: every admin event route 403s for a non-coordinator describe('coordinator lifecycle: create -> schedule -> activate -> end', () => { it('walks the full happy path and is reflected in GET /api/event for a player', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); const created = await createEvent(coordinator, { id: 'lifecycle-evt' }); @@ -162,7 +162,7 @@ describe('coordinator lifecycle: create -> schedule -> activate -> end', () => { expect(activateRes.status).toBe(200); expect(activateRes.body.event.status).toBe('active'); - const player = makeUser(); + const player = await makeUser(); const playerRes = await request(app).get('/api/event').set('Cookie', cookieFor(player)); expect(playerRes.status).toBe(200); expect(playerRes.body.event).toMatchObject({ @@ -183,7 +183,7 @@ describe('coordinator lifecycle: create -> schedule -> activate -> end', () => { }); it('GET /api/admin/events lists events with participation counts', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const created = await createEvent(coordinator); const res = await request(app).get('/api/admin/events').set('Cookie', cookieFor(coordinator)); expect(res.status).toBe(200); @@ -194,7 +194,7 @@ describe('coordinator lifecycle: create -> schedule -> activate -> end', () => { }); it('GET /api/admin/events/:id/participation returns the coordinator view', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const created = await createEvent(coordinator); const res = await request(app) .get(`/api/admin/events/${created.id}/participation`) @@ -206,7 +206,7 @@ describe('coordinator lifecycle: create -> schedule -> activate -> end', () => { describe('activating a second event while one is active -> 409', () => { it('rejects; ending the first, then activating the second, succeeds', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); await createEvent(coordinator, { id: 'conflict-a' }); @@ -234,7 +234,7 @@ describe('activating a second event while one is active -> 409', () => { describe('rejecting activation of an event whose window has already fully passed (hard requirement 2)', () => { it('400s with invalid_target rather than activating with an already-expired window', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); await createEvent(coordinator, { id: 'past-window' }); @@ -251,7 +251,7 @@ describe('rejecting activation of an event whose window has already fully passed describe('editing an active event (ladder/modifiers locked, cache invalidation on window edit)', () => { it('rejects ladder/modifiers edits on an active event with 409, but allows name edits', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); await createEvent(coordinator, { id: 'edit-locked' }); @@ -283,7 +283,7 @@ describe('editing an active event (ladder/modifiers locked, cache invalidation o }); it('invalidates the effective-config cache when an active event window edit changes endsAt (hard requirement 3)', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); await createEvent(coordinator, { id: 'cache-invalidate' }); @@ -291,7 +291,7 @@ describe('editing an active event (ladder/modifiers locked, cache invalidation o await activateEventReq(coordinator, 'cache-invalidate'); // Populate the effective-config cache with the original endsAt. - const before = getEffectiveConfig(); + const before = await getEffectiveConfig(); expect(before.data.__activeEvent.id).toBe('cache-invalidate'); expect(before.data.__activeEvent.endsAt).toBe(now + 100000); @@ -302,7 +302,7 @@ describe('editing an active event (ladder/modifiers locked, cache invalidation o .send({ startsAt: now - 1000, endsAt: newEndsAt }); expect(windowEdit.status).toBe(200); - const after = getEffectiveConfig(); + const after = await getEffectiveConfig(); expect(after.data.__activeEvent.endsAt).toBe(newEndsAt); await endEventReq(coordinator, 'cache-invalidate'); @@ -311,7 +311,7 @@ describe('editing an active event (ladder/modifiers locked, cache invalidation o describe('deleting a non-draft event -> 409', () => { it('rejects deleting a scheduled event, allows deleting a draft', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); const scheduled = await createEvent(coordinator, { id: 'delete-scheduled' }); @@ -337,7 +337,7 @@ describe('deleting a non-draft event -> 409', () => { describe('POST /api/admin/events validation', () => { it('400s with errors[] for invalid modifiers', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const res = await request(app) .post('/api/admin/events') .set('Cookie', cookieFor(coordinator)) @@ -348,7 +348,7 @@ describe('POST /api/admin/events validation', () => { }); it('400s with errors[] for invalid ladder', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const res = await request(app) .post('/api/admin/events') .set('Cookie', cookieFor(coordinator)) @@ -359,7 +359,7 @@ describe('POST /api/admin/events validation', () => { }); it('409s on a duplicate id', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); await createEvent(coordinator, { id: 'dup-id' }); const res = await request(app) .post('/api/admin/events') @@ -369,7 +369,7 @@ describe('POST /api/admin/events validation', () => { }); it('400s on a non-slug-safe id', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const res = await request(app) .post('/api/admin/events') .set('Cookie', cookieFor(coordinator)) @@ -380,15 +380,15 @@ describe('POST /api/admin/events validation', () => { describe('leaderboard opt-out must be live, not snapshot-only (hard requirement 1)', () => { it('a user who opts out AFTER joining an active event vanishes from the leaderboard', async () => { - const coordinator = makeCoordinator(); + const coordinator = await makeCoordinator(); const now = Date.now(); await createEvent(coordinator, { id: 'optout-evt' }); await scheduleEvent(coordinator, 'optout-evt', { startsAt: now - 1000, endsAt: now + 100000 }); await activateEventReq(coordinator, 'optout-evt'); - const alice = makeUser(); - const bob = makeUser(); + const alice = await makeUser(); + const bob = await makeUser(); // Both join by hitting a state-reading route. await request(app).get('/api/event').set('Cookie', cookieFor(alice)); @@ -417,7 +417,7 @@ describe('leaderboard opt-out must be live, not snapshot-only (hard requirement describe('PUT /api/me/leaderboard-opt-out', () => { it('400s on a non-boolean optOut', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app) .put('/api/me/leaderboard-opt-out') .set('Cookie', cookieFor(user)) diff --git a/tests/api.finalfix.test.js b/tests/api.finalfix.test.js index 7121dae..540b308 100644 --- a/tests/api.finalfix.test.js +++ b/tests/api.finalfix.test.js @@ -19,28 +19,28 @@ const { const { COOKIE_NAME } = await import('../server/auth.js'); const { activateEvent, endEvent } = await import('../server/eventService.js'); -ensureConfig(); +await ensureConfig(); const app = buildApp(); const DAY_MS = 24 * 60 * 60 * 1000; let seq = 0; -function makeUser() { +async function makeUser() { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `ffa${seq}`, username: `ffauser${seq}`, avatarUrl: null, }); } -function makeCoordinator() { - const u = makeUser(); - setRoles(u.id, ['event_coordinator']); +async function makeCoordinator() { + const u = await makeUser(); + await setRoles(u.id, ['event_coordinator']); return u; } -function makeAdmin() { - const u = makeUser(); - setRoles(u.id, ['admin']); +async function makeAdmin() { + const u = await makeUser(); + await setRoles(u.id, ['admin']); return u; } @@ -53,9 +53,9 @@ function cookieFor(user) { return `${COOKIE_NAME}=${token}`; } -function makeEvent(overrides = {}) { +async function makeEvent(overrides = {}) { seq += 1; - return putEvent({ + return await putEvent({ id: `ffa-evt-${seq}`, name: `Final Fix API Event ${seq}`, description: 'desc', @@ -80,21 +80,21 @@ function makeEvent(overrides = {}) { describe('GET /api/event and GET /api/state during the post-end claim grace', () => { it('still serves the ladder, progress and a claimable rung after the event ends globally', async () => { const now = Date.now(); - const user = makeUser(); - const evt = makeEvent(); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(evt.id, now); + const user = await makeUser(); + const evt = await makeEvent(); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(evt.id, now); // Join, then earn enough to meet rung 0. await request(app).get('/api/event').set('Cookie', cookieFor(user)); - const row = getSave(user.id); + const row = await getSave(user.id); const data = JSON.parse(row.data); data.meta.stats.lifetimeFlopsAllTime += 300; - putSave(user.id, data, Date.now()); + await putSave(user.id, data, Date.now()); // The event ends globally. The player's personal window (and its 48h // grace) is untouched. - endEvent(evt.id, Date.now()); + await endEvent(evt.id, Date.now()); // Pre-fix this returned { event: null, progress: null, leaderboard: [] } // because the route gated the entire response on getActiveEvent(), so a @@ -127,18 +127,18 @@ describe('GET /api/event and GET /api/state during the post-end claim grace', () it('reports no event once the grace window itself has lapsed', async () => { const now = Date.now(); - const user = makeUser(); - const evt = makeEvent(); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(evt.id, now); + const user = await makeUser(); + const evt = await makeEvent(); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(evt.id, now); await request(app).get('/api/event').set('Cookie', cookieFor(user)); - endEvent(evt.id, Date.now()); + await endEvent(evt.id, Date.now()); // Push the personal window (and its grace) fully into the past. - const row = getSave(user.id); + const row = await getSave(user.id); const data = JSON.parse(row.data); data.meta.eventProgress.endsAt = Date.now() - 100 * DAY_MS; - putSave(user.id, data, Date.now()); + await putSave(user.id, data, Date.now()); const res = await request(app).get('/api/event').set('Cookie', cookieFor(user)); expect(res.body.event).toBeNull(); @@ -154,16 +154,16 @@ describe('GET /api/event and GET /api/state during the post-end claim grace', () describe('GET /api/config (gameplay) vs GET /api/admin/config (baseline)', () => { it('serves the event-overlaid config to players and the untouched baseline to admins', async () => { const now = Date.now(); - const player = makeUser(); - const admin = makeAdmin(); - const evt = makeEvent({ + const player = await makeUser(); + const admin = await makeAdmin(); + const evt = await makeEvent({ modifiers: [ { path: 'production.gridMult', value: 3 }, { path: 'heat.capacity', value: 4000 }, ], }); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(evt.id, now); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(evt.id, now); // Pre-fix BOTH of these read the baseline, so the client predicted // production from gridMult 1 while the server used 3, and crossed its own @@ -186,7 +186,7 @@ describe('GET /api/config (gameplay) vs GET /api/admin/config (baseline)', () => expect(baseline.body.data.production.gridMult).toBe(1); expect(baseline.body.data.heat.capacity).toBe(2000); - endEvent(evt.id, Date.now()); + await endEvent(evt.id, Date.now()); // Once the event ends, the gameplay read falls back to the baseline and // its cache key changes - which is the client's refetch signal, since the @@ -198,17 +198,17 @@ describe('GET /api/config (gameplay) vs GET /api/admin/config (baseline)', () => }); it('gates the baseline route on admin', async () => { - const plain = makeUser(); + const plain = await makeUser(); const res = await request(app).get('/api/admin/config').set('Cookie', cookieFor(plain)); expect(res.status).toBe(403); }); it('an admin round-tripping the baseline never bakes event modifiers into the stored config', async () => { const now = Date.now(); - const admin = makeAdmin(); - const evt = makeEvent({ modifiers: [{ path: 'production.gridMult', value: 3 }] }); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(evt.id, now); + const admin = await makeAdmin(); + const evt = await makeEvent({ modifiers: [{ path: 'production.gridMult', value: 3 }] }); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(evt.id, now); const loaded = await request(app).get('/api/admin/config').set('Cookie', cookieFor(admin)); const saved = await request(app) @@ -217,7 +217,7 @@ describe('GET /api/config (gameplay) vs GET /api/admin/config (baseline)', () => .send({ data: loaded.body.data }); expect(saved.status).toBe(200); - endEvent(evt.id, Date.now()); + await endEvent(evt.id, Date.now()); const stored = await request(app).get('/api/admin/config').set('Cookie', cookieFor(admin)); expect(stored.body.data.production.gridMult).toBe(1); @@ -241,7 +241,7 @@ describe('POST /api/admin/events: recurrence validation', () => { } it('accepts a well-formed annual recurrence', async () => { - const coord = makeCoordinator(); + const coord = await makeCoordinator(); const res = await request(app) .post('/api/admin/events') .set('Cookie', cookieFor(coord)) @@ -250,7 +250,7 @@ describe('POST /api/admin/events: recurrence validation', () => { }); it('rejects shapes that would strand the event or expire it instantly', async () => { - const coord = makeCoordinator(); + const coord = await makeCoordinator(); for (const recurrence of [{}, 'weekly', [], { month: 7, day: 1, durationDays: -5 }, { month: 0, day: 1, durationDays: 5 }]) { // eslint-disable-next-line no-await-in-loop const res = await request(app) @@ -263,7 +263,7 @@ describe('POST /api/admin/events: recurrence validation', () => { }); it('still accepts an event with no recurrence at all', async () => { - const coord = makeCoordinator(); + const coord = await makeCoordinator(); const res = await request(app) .post('/api/admin/events') .set('Cookie', cookieFor(coord)) diff --git a/tests/api.social.test.js b/tests/api.social.test.js index 7dd0484..58f8795 100644 --- a/tests/api.social.test.js +++ b/tests/api.social.test.js @@ -12,15 +12,15 @@ const { invalidateLeaderboards } = await import('../server/leaderboardService.js const { COOKIE_NAME } = await import('../server/auth.js'); const { initialState } = await import('../shared/state.js'); -ensureConfig(); +await ensureConfig(); const app = buildApp(); let seq = 0; -function seedPlayer({ +async function seedPlayer({ flops = 0, level = 0, cores = 0, singularities = 0, tapes = 0, achievements = {}, } = {}) { seq += 1; - const u = upsertUser({ + const u = await upsertUser({ provider: 'discord', providerId: `lb${seq}`, username: `lbuser${seq}`, avatarUrl: `https://x/${seq}.png`, }); @@ -31,7 +31,7 @@ function seedPlayer({ s.meta.stats.singularities = singularities; s.meta.coldStorage.tapes = tapes; s.meta.achievements = achievements; - putSave(u.id, s, Date.now()); + await putSave(u.id, s, Date.now()); return u; } @@ -49,8 +49,8 @@ describe('GET /api/leaderboard', () => { }); it('returns every board, ranked descending', async () => { - const low = seedPlayer({ flops: 100, level: 1 }); - const high = seedPlayer({ flops: 999999, level: 40 }); + const low = await seedPlayer({ flops: 100, level: 1 }); + const high = await seedPlayer({ flops: 999999, level: 40 }); invalidateLeaderboards(); const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(low)); expect(res.status).toBe(200); @@ -67,9 +67,9 @@ describe('GET /api/leaderboard', () => { }); it('excludes opted-out players from every board', async () => { - const shy = seedPlayer({ flops: 1e12, level: 90 }); - const seen = seedPlayer({ flops: 5, level: 1 }); - setLeaderboardOptOut(shy.id, true); + const shy = await seedPlayer({ flops: 1e12, level: 90 }); + const seen = await seedPlayer({ flops: 5, level: 1 }); + await setLeaderboardOptOut(shy.id, true); invalidateLeaderboards(); const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(seen)); for (const board of Object.values(res.body.boards)) { @@ -78,7 +78,7 @@ describe('GET /api/leaderboard', () => { }); it('surfaces up to three badges per row, gold first', async () => { - const decorated = seedPlayer({ + const decorated = await seedPlayer({ flops: 42, achievements: { first_migrate: 1, first_singularity: 2, level_10: 3, jackpot: 4, level_50: 5, @@ -92,13 +92,13 @@ describe('GET /api/leaderboard', () => { }); it('serves a cached payload within the TTL and rebuilds after invalidation', async () => { - const u = seedPlayer({ flops: 1 }); + const u = await seedPlayer({ flops: 1 }); invalidateLeaderboards(); const first = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); const second = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); expect(second.body.generatedAt).toBe(first.body.generatedAt); // same cached build - seedPlayer({ flops: 1e15 }); + await seedPlayer({ flops: 1e15 }); const stale = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); expect(stale.body.generatedAt).toBe(first.body.generatedAt); // still cached @@ -116,8 +116,8 @@ describe('GET /api/leaderboard', () => { // and this control must not silently mean something weaker on the newer // boards. Caught by tests/e2e/smoke-v15.mjs before it was fixed. it('opting out through the route takes effect immediately, not after the cache TTL', async () => { - const shy = seedPlayer({ flops: 7e14 }); - const observer = seedPlayer({ flops: 3 }); + const shy = await seedPlayer({ flops: 7e14 }); + const observer = await seedPlayer({ flops: 3 }); invalidateLeaderboards(); const before = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(observer)); @@ -137,8 +137,8 @@ describe('GET /api/leaderboard', () => { }); it('skips users with no save row without throwing', async () => { - const u = seedPlayer({ flops: 1 }); - upsertUser({ + const u = await seedPlayer({ flops: 1 }); + await upsertUser({ provider: 'discord', providerId: 'lb-nosave', username: 'nosave', avatarUrl: null, }); invalidateLeaderboards(); diff --git a/tests/api.test.js b/tests/api.test.js index 8a611b8..95561cc 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -20,13 +20,13 @@ const { COOKIE_NAME } = await import('../server/auth.js'); const v11Fixture = JSON.parse(readFileSync(new URL('./fixtures/v11-save.json', import.meta.url))); -ensureConfig(); +await ensureConfig(); const app = buildApp(); let seq = 0; -function makeUser(overrides = {}) { +async function makeUser(overrides = {}) { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `u${seq}`, username: `user${seq}`, @@ -49,8 +49,8 @@ function cookieFor(user) { describe('GET /api/state', () => { it('migrates a v1.1-shape save into canonical state, preserving credits/wafers', async () => { - const user = makeUser(); - putSave(user.id, v11Fixture, Date.now()); + const user = await makeUser(); + await putSave(user.id, v11Fixture, Date.now()); const res = await request(app).get('/api/state').set('Cookie', cookieFor(user)); @@ -71,7 +71,7 @@ describe('GET /api/state', () => { describe('POST /api/actions', () => { it('400s when actions is not an array', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app) .post('/api/actions') .set('Cookie', cookieFor(user)) @@ -80,7 +80,7 @@ describe('POST /api/actions', () => { }); it('400s when actions has more than 100 entries', async () => { - const user = makeUser(); + const user = await makeUser(); const actions = Array.from({ length: 101 }, (_, i) => ({ _cid: i, type: 'collectAll' })); const res = await request(app) .post('/api/actions') @@ -90,7 +90,7 @@ describe('POST /api/actions', () => { }); it('buy path mutates canon; a second identical unaffordable buy fails without undoing the first', async () => { - const user = makeUser(); // fresh state: credits = 10 + const user = await makeUser(); // fresh state: credits = 10 const res = await request(app) .post('/api/actions') @@ -112,7 +112,7 @@ describe('POST /api/actions', () => { }); it('rejects a malicious non-numeric index payload with a normal ok:false result, not a 500', async () => { - const user = makeUser(); // fresh state: credits = 10 + const user = await makeUser(); // fresh state: credits = 10 const res = await request(app) .post('/api/actions') @@ -132,7 +132,7 @@ describe('POST /api/actions', () => { }); it('rejects a prototype-key action type (__proto__) with a normal ok:false result, not a 500', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app) .post('/api/actions') @@ -156,12 +156,12 @@ describe('POST /api/actions', () => { // prove the generic HTTP dispatch pipeline reaches them. describe('POST /api/actions: Cold Storage', () => { it('dispatches claimBlock through the existing generic action pipeline', async () => { - const user = makeUser(); + const user = await makeUser(); const state = initialState(); // DEFAULT_CONFIG.batchQueue.blockDurationMs is 6h; starting the track 20h // ago means floor(20/6) = 3 blocks (indices 0-2) have arrived. state.meta.coldStorage.trackStartedAt = Date.now() - 20 * 3600 * 1000; - putSave(user.id, state, Date.now()); + await putSave(user.id, state, Date.now()); const res = await request(app) .post('/api/actions') @@ -174,10 +174,10 @@ describe('POST /api/actions: Cold Storage', () => { }); it('dispatches claimAllBlocks, resetTrack, startJob, and cancelJob in one batch', async () => { - const user = makeUser(); + const user = await makeUser(); const state = initialState(); state.meta.coldStorage.trackStartedAt = Date.now() - 200 * 3600 * 1000; // all 16 blocks arrived - putSave(user.id, state, Date.now()); + await putSave(user.id, state, Date.now()); const res = await request(app) .post('/api/actions') @@ -212,11 +212,11 @@ describe('POST /api/actions: Cold Storage', () => { // this test includes `_cid` too to prove the split doesn't disturb the // semantic `id` the reducer actually keys off of. it('rejects buyTapeUpgrade at max level via the existing config-driven check', async () => { - const user = makeUser(); + const user = await makeUser(); const state = initialState(); state.meta.coldStorage.upgrades.headstart = 5; // DEFAULT_CONFIG.upgrades.maxLevels.headstart state.meta.coldStorage.tapes = 1e9; // plenty of tapes - proves this is max_level, not insufficient_credits - putSave(user.id, state, Date.now()); + await putSave(user.id, state, Date.now()); const res = await request(app) .post('/api/actions') @@ -231,7 +231,7 @@ describe('POST /api/actions: Cold Storage', () => { describe('config: admin gating and owner bump', () => { it('non-admin PUT /api/admin/config -> 403', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app) .put('/api/admin/config') .set('Cookie', cookieFor(user)) @@ -240,7 +240,7 @@ describe('config: admin gating and owner bump', () => { }); it('owner (via SUPER_ADMIN_IDS) can update config, and GET /api/config reflects the bump', async () => { - const owner = upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); + const owner = await upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); const before = await request(app).get('/api/config').set('Cookie', cookieFor(owner)); const nextData = structuredClone(DEFAULT_CONFIG); nextData.heat.capacity = 2500; @@ -258,7 +258,7 @@ describe('config: admin gating and owner bump', () => { }); it('PUT /api/admin/config with an invalid doc -> 400 with errors[]', async () => { - const owner = upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); + const owner = await upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); const bad = structuredClone(DEFAULT_CONFIG); bad.heat.capacity = -5; const res = await request(app) @@ -273,9 +273,9 @@ describe('config: admin gating and owner bump', () => { describe('roles', () => { it('a non-owner admin cannot grant the admin role (owner-only) -> 403', async () => { - const admin = makeUser(); - setRoles(admin.id, ['admin']); - const target = makeUser(); + const admin = await makeUser(); + await setRoles(admin.id, ['admin']); + const target = await makeUser(); const res = await request(app) .post('/api/admin/roles') @@ -285,8 +285,8 @@ describe('roles', () => { }); it('a non-admin cannot use the roles endpoint at all -> 403', async () => { - const user = makeUser(); - const target = makeUser(); + const user = await makeUser(); + const target = await makeUser(); const res = await request(app) .post('/api/admin/roles') .set('Cookie', cookieFor(user)) @@ -295,9 +295,9 @@ describe('roles', () => { }); it('a non-owner admin CAN grant event_coordinator', async () => { - const admin = makeUser(); - setRoles(admin.id, ['admin']); - const target = makeUser(); + const admin = await makeUser(); + await setRoles(admin.id, ['admin']); + const target = await makeUser(); const res = await request(app) .post('/api/admin/roles') @@ -308,8 +308,8 @@ describe('roles', () => { }); it('owner can grant admin', async () => { - const owner = upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); - const target = makeUser(); + const owner = await upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); + const target = await makeUser(); const res = await request(app) .post('/api/admin/roles') .set('Cookie', cookieFor(owner)) @@ -319,8 +319,8 @@ describe('roles', () => { }); it('unknown role -> 400', async () => { - const owner = upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); - const target = makeUser(); + const owner = await upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); + const target = await makeUser(); const res = await request(app) .post('/api/admin/roles') .set('Cookie', cookieFor(owner)) @@ -329,7 +329,7 @@ describe('roles', () => { }); it('cannot modify a super-admin id -> 400', async () => { - const owner = upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); + const owner = await upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); const res = await request(app) .post('/api/admin/roles') .set('Cookie', cookieFor(owner)) @@ -340,8 +340,8 @@ describe('roles', () => { describe('PUT /api/me/username', () => { it('sets a valid username -> 200, then a case-insensitive collision from another user -> 409', async () => { - const u1 = makeUser(); - const u2 = makeUser(); + const u1 = await makeUser(); + const u2 = await makeUser(); const res1 = await request(app) .put('/api/me/username') @@ -358,7 +358,7 @@ describe('PUT /api/me/username', () => { }); it('rejects an invalid username shape -> 400', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app) .put('/api/me/username') .set('Cookie', cookieFor(user)) @@ -370,7 +370,7 @@ describe('PUT /api/me/username', () => { describe('minigames', () => { it('start -> finish pays clamped wafers; a second start within cooldown -> 429', async () => { - const user = makeUser(); + const user = await makeUser(); const startRes = await request(app) .post('/api/minigame/start') @@ -400,7 +400,7 @@ describe('minigames', () => { }); it('a second concurrently-open session for the same game is rejected -> 409 (burst-start regression)', async () => { - const user = makeUser(); + const user = await makeUser(); const first = await request(app) .post('/api/minigame/start') @@ -426,7 +426,7 @@ describe('minigames', () => { }); it('a session that predates a win cannot be redeemed once the win cooldown is set -> 429, no credit, still marked finished (burst-finish regression)', async () => { - const user = makeUser(); + const user = await makeUser(); // Win once, which sets server.gameCooldowns.rush. const startRes = await request(app) @@ -444,7 +444,7 @@ describe('minigames', () => { // Craft a second session directly via the db layer, bypassing the // start-time session_open/cooldown gate entirely, to simulate one that // was opened concurrently before the win landed. - const craftedSession = createMinigameSession(user.id, 'rush'); + const craftedSession = await createMinigameSession(user.id, 'rush'); const blockedFinish = await request(app) .post('/api/minigame/finish') @@ -467,7 +467,7 @@ describe('minigames', () => { }); it('an unknown session id -> 404', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app) .post('/api/minigame/finish') .set('Cookie', cookieFor(user)) @@ -476,7 +476,7 @@ describe('minigames', () => { }); it('finishing the same session twice -> 410 on the second call', async () => { - const user = makeUser(); + const user = await makeUser(); const startRes = await request(app) .post('/api/minigame/start') .set('Cookie', cookieFor(user)) @@ -497,7 +497,7 @@ describe('minigames', () => { }); it('match pays 0 and no cooldown/win-bump when not won', async () => { - const user = makeUser(); + const user = await makeUser(); const startRes = await request(app) .post('/api/minigame/start') .set('Cookie', cookieFor(user)) @@ -518,7 +518,7 @@ describe('minigames', () => { describe('GET /api/me', () => { it('includes effective roles and isOwner', async () => { - const owner = upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); + const owner = await upsertUser({ provider: 'test', providerId: 'owner', username: 'owner', avatarUrl: null }); const res = await request(app).get('/api/me').set('Cookie', cookieFor(owner)); expect(res.status).toBe(200); expect(res.body.isOwner).toBe(true); @@ -528,7 +528,7 @@ describe('GET /api/me', () => { describe('GET /api/changelog', () => { it('returns text/plain, falling back gracefully if CHANGELOG.md does not exist yet', async () => { - const user = makeUser(); + const user = await makeUser(); const res = await request(app).get('/api/changelog').set('Cookie', cookieFor(user)); expect(res.status).toBe(200); expect(res.text.length).toBeGreaterThan(0); @@ -537,7 +537,7 @@ describe('GET /api/changelog', () => { describe('retired routes', () => { it('GET/POST/DELETE /api/save no longer exist', async () => { - const user = makeUser(); + const user = await makeUser(); const get = await request(app).get('/api/save').set('Cookie', cookieFor(user)); const post = await request(app).post('/api/save').set('Cookie', cookieFor(user)).send({}); const del = await request(app).delete('/api/save').set('Cookie', cookieFor(user)); diff --git a/tests/api.tutorial.test.js b/tests/api.tutorial.test.js index a3c4ed4..8db3bf9 100644 --- a/tests/api.tutorial.test.js +++ b/tests/api.tutorial.test.js @@ -11,13 +11,13 @@ const { upsertUser, getToursCompleted, setToursCompleted } = await import('../se const { COOKIE_NAME } = await import('../server/auth.js'); const { TOUR_IDS, ONBOARDING_TOUR_ID } = await import('../shared/tours.js'); -ensureConfig(); +await ensureConfig(); const app = buildApp(); let seq = 0; -function seedUser() { +async function seedUser() { seq += 1; - const u = upsertUser({ + const u = await upsertUser({ provider: 'discord', providerId: `tour${seq}`, username: `touruser${seq}`, avatarUrl: null, }); const token = jwt.sign( @@ -29,15 +29,15 @@ function seedUser() { describe('GET /api/me toursCompleted', () => { it('reports an empty set for a fresh user', async () => { - const { cookie } = seedUser(); + const { cookie } = await seedUser(); const res = await request(app).get('/api/me').set('Cookie', cookie); expect(res.status).toBe(200); expect(res.body.toursCompleted).toEqual([]); }); it('reflects what is stored', async () => { - const { user, cookie } = seedUser(); - setToursCompleted(user.id, [ONBOARDING_TOUR_ID]); + const { user, cookie } = await seedUser(); + await setToursCompleted(user.id, [ONBOARDING_TOUR_ID]); const res = await request(app).get('/api/me').set('Cookie', cookie); expect(res.body.toursCompleted).toEqual([ONBOARDING_TOUR_ID]); }); @@ -50,28 +50,28 @@ describe('PUT /api/me/tours', () => { }); it('rejects a non-string tourId', async () => { - const { cookie } = seedUser(); + const { cookie } = await seedUser(); const res = await request(app).put('/api/me/tours').set('Cookie', cookie).send({ tourId: 7, completed: true }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('rejects a non-boolean completed', async () => { - const { cookie } = seedUser(); + const { cookie } = await seedUser(); const res = await request(app).put('/api/me/tours').set('Cookie', cookie).send({ tourId: ONBOARDING_TOUR_ID, completed: 'yes' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('rejects an unregistered tour id', async () => { - const { cookie } = seedUser(); + const { cookie } = await seedUser(); const res = await request(app).put('/api/me/tours').set('Cookie', cookie).send({ tourId: 'made-up', completed: true }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('rejects a missing body', async () => { - const { cookie } = seedUser(); + const { cookie } = await seedUser(); const res = await request(app).put('/api/me/tours').set('Cookie', cookie).send({}); expect(res.status).toBe(400); }); @@ -79,30 +79,30 @@ describe('PUT /api/me/tours', () => { // Spec §4.7: onboarding is a superset of every feature tour, so finishing // it must not leave a brand-new player queued up for all of them. it('completing onboarding marks every registered tour complete', async () => { - const { user, cookie } = seedUser(); + const { user, cookie } = await seedUser(); const res = await request(app).put('/api/me/tours').set('Cookie', cookie) .send({ tourId: ONBOARDING_TOUR_ID, completed: true }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); expect([...res.body.toursCompleted].sort()).toEqual([...TOUR_IDS].sort()); - expect([...getToursCompleted(user.id)].sort()).toEqual([...TOUR_IDS].sort()); + expect([...(await getToursCompleted(user.id))].sort()).toEqual([...TOUR_IDS].sort()); }); it('removes a single id when completed is false (replay)', async () => { - const { user, cookie } = seedUser(); - setToursCompleted(user.id, [ONBOARDING_TOUR_ID]); + const { user, cookie } = await seedUser(); + await setToursCompleted(user.id, [ONBOARDING_TOUR_ID]); const res = await request(app).put('/api/me/tours').set('Cookie', cookie) .send({ tourId: ONBOARDING_TOUR_ID, completed: false }); expect(res.status).toBe(200); expect(res.body.toursCompleted).not.toContain(ONBOARDING_TOUR_ID); - expect(getToursCompleted(user.id)).not.toContain(ONBOARDING_TOUR_ID); + expect(await getToursCompleted(user.id)).not.toContain(ONBOARDING_TOUR_ID); }); it('deduplicates and preserves ids from newer deployments', async () => { - const { user, cookie } = seedUser(); + const { user, cookie } = await seedUser(); // 'v99-future' is not in this build's registry - a rolled-back deploy // must not erase a completion recorded by a newer one. - setToursCompleted(user.id, [ONBOARDING_TOUR_ID, ONBOARDING_TOUR_ID, 'v99-future']); + await setToursCompleted(user.id, [ONBOARDING_TOUR_ID, ONBOARDING_TOUR_ID, 'v99-future']); const res = await request(app).put('/api/me/tours').set('Cookie', cookie) .send({ tourId: ONBOARDING_TOUR_ID, completed: false }); expect(res.status).toBe(200); diff --git a/tests/db.events.test.js b/tests/db.events.test.js index eff5dc6..75f718a 100644 --- a/tests/db.events.test.js +++ b/tests/db.events.test.js @@ -45,18 +45,18 @@ function sampleEvent(overrides = {}) { } describe('db schema v1.4', () => { - it('creates live_events and event_participation tables', () => { + it('creates live_events and event_participation tables', async () => { const names = tableNames(); expect(names).toContain('live_events'); expect(names).toContain('event_participation'); }); - it('adds leaderboard_opt_out column to users', () => { + it('adds leaderboard_opt_out column to users', async () => { const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); expect(cols).toContain('leaderboard_opt_out'); }); - it('re-running the guarded ALTER does not throw on a second boot', () => { + it('re-running the guarded ALTER does not throw on a second boot', async () => { expect(() => { try { db.exec('ALTER TABLE users ADD COLUMN leaderboard_opt_out INTEGER DEFAULT 0'); @@ -68,9 +68,9 @@ describe('db schema v1.4', () => { }); describe('live_events CRUD', () => { - it('putEvent + getEvent round-trips with JSON fields parsed', () => { - putEvent(sampleEvent()); - const row = getEvent('test-event'); + it('putEvent + getEvent round-trips with JSON fields parsed', async () => { + await putEvent(sampleEvent()); + const row = await getEvent('test-event'); expect(row.id).toBe('test-event'); expect(row.name).toBe('Test Event'); expect(row.theme).toEqual({ icon: '🧪', color: '#123456' }); @@ -83,22 +83,22 @@ describe('live_events CRUD', () => { expect(row.status).toBe('draft'); }); - it('getEvent returns undefined for an unknown id', () => { - expect(getEvent('nope')).toBeUndefined(); + it('getEvent returns undefined for an unknown id', async () => { + expect(await getEvent('nope')).toBeUndefined(); }); - it('putEvent upserts (insert-or-replace) on a second call with the same id', () => { - putEvent(sampleEvent({ id: 'upsert-me', name: 'Original' })); - putEvent(sampleEvent({ id: 'upsert-me', name: 'Renamed' })); - const row = getEvent('upsert-me'); + it('putEvent upserts (insert-or-replace) on a second call with the same id', async () => { + await putEvent(sampleEvent({ id: 'upsert-me', name: 'Original' })); + await putEvent(sampleEvent({ id: 'upsert-me', name: 'Renamed' })); + const row = await getEvent('upsert-me'); expect(row.name).toBe('Renamed'); - expect(listEvents().filter((e) => e.id === 'upsert-me').length).toBe(1); + expect((await listEvents()).filter((e) => e.id === 'upsert-me').length).toBe(1); }); - it('listEvents returns all events with parsed JSON fields', () => { - putEvent(sampleEvent({ id: 'list-a' })); - putEvent(sampleEvent({ id: 'list-b' })); - const events = listEvents(); + it('listEvents returns all events with parsed JSON fields', async () => { + await putEvent(sampleEvent({ id: 'list-a' })); + await putEvent(sampleEvent({ id: 'list-b' })); + const events = await listEvents(); const ids = events.map((e) => e.id); expect(ids).toContain('list-a'); expect(ids).toContain('list-b'); @@ -108,95 +108,95 @@ describe('live_events CRUD', () => { } }); - it('getActiveEvent returns at most one event, only when status is active', () => { - putEvent(sampleEvent({ id: 'active-none', status: 'draft' })); - expect(getActiveEvent()).toBeUndefined(); + it('getActiveEvent returns at most one event, only when status is active', async () => { + await putEvent(sampleEvent({ id: 'active-none', status: 'draft' })); + expect(await getActiveEvent()).toBeUndefined(); - putEvent(sampleEvent({ id: 'active-one', status: 'active' })); - const active = getActiveEvent(); + await putEvent(sampleEvent({ id: 'active-one', status: 'active' })); + const active = await getActiveEvent(); expect(active).toBeDefined(); expect(active.id).toBe('active-one'); expect(active.status).toBe('active'); }); - it('setEventStatus updates status and window', () => { - putEvent(sampleEvent({ id: 'status-me', status: 'draft' })); - setEventStatus('status-me', 'scheduled', { startsAt: 1000, endsAt: 2000 }); - const row = getEvent('status-me'); + it('setEventStatus updates status and window', async () => { + await putEvent(sampleEvent({ id: 'status-me', status: 'draft' })); + await setEventStatus('status-me', 'scheduled', { startsAt: 1000, endsAt: 2000 }); + const row = await getEvent('status-me'); expect(row.status).toBe('scheduled'); expect(row.starts_at).toBe(1000); expect(row.ends_at).toBe(2000); - setEventStatus('status-me', 'active'); - expect(getEvent('status-me').status).toBe('active'); + await setEventStatus('status-me', 'active'); + expect((await getEvent('status-me')).status).toBe('active'); // window untouched by the status-only call - expect(getEvent('status-me').starts_at).toBe(1000); - expect(getEvent('status-me').ends_at).toBe(2000); + expect((await getEvent('status-me')).starts_at).toBe(1000); + expect((await getEvent('status-me')).ends_at).toBe(2000); }); - it('deleteEvent removes the row', () => { - putEvent(sampleEvent({ id: 'delete-me' })); - expect(getEvent('delete-me')).toBeDefined(); - deleteEvent('delete-me'); - expect(getEvent('delete-me')).toBeUndefined(); + it('deleteEvent removes the row', async () => { + await putEvent(sampleEvent({ id: 'delete-me' })); + expect(await getEvent('delete-me')).toBeDefined(); + await deleteEvent('delete-me'); + expect(await getEvent('delete-me')).toBeUndefined(); }); }); describe('event_participation', () => { - it('upsertParticipation round-trips and getParticipation reads it back', () => { - const u = upsertUser({ provider: 'discord', providerId: 'ep1', username: 'participant1', avatarUrl: null }); - putEvent(sampleEvent({ id: 'part-event' })); + it('upsertParticipation round-trips and getParticipation reads it back', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'ep1', username: 'participant1', avatarUrl: null }); + await putEvent(sampleEvent({ id: 'part-event' })); - upsertParticipation({ + await upsertParticipation({ userId: u.id, eventId: 'part-event', startedAt: 100, endsAt: 200, rungsClaimed: 2, lastProgressAt: 150, optedOut: false, }); - const row = getParticipation(u.id, 'part-event'); + const row = await getParticipation(u.id, 'part-event'); expect(row.user_id).toBe(u.id); expect(row.event_id).toBe('part-event'); expect(row.rungs_claimed).toBe(2); expect(row.opted_out).toBe(0); - upsertParticipation({ + await upsertParticipation({ userId: u.id, eventId: 'part-event', startedAt: 100, endsAt: 200, rungsClaimed: 5, lastProgressAt: 180, optedOut: false, }); - const updated = getParticipation(u.id, 'part-event'); + const updated = await getParticipation(u.id, 'part-event'); expect(updated.rungs_claimed).toBe(5); expect(updated.last_progress_at).toBe(180); }); - it('listParticipation orders by rungs_claimed DESC, last_progress_at ASC', () => { - const a = upsertUser({ provider: 'discord', providerId: 'ep2', username: 'participant2', avatarUrl: null }); - const b = upsertUser({ provider: 'discord', providerId: 'ep3', username: 'participant3', avatarUrl: null }); - const c = upsertUser({ provider: 'discord', providerId: 'ep4', username: 'participant4', avatarUrl: null }); - putEvent(sampleEvent({ id: 'leaderboard-event' })); + it('listParticipation orders by rungs_claimed DESC, last_progress_at ASC', async () => { + const a = await upsertUser({ provider: 'discord', providerId: 'ep2', username: 'participant2', avatarUrl: null }); + const b = await upsertUser({ provider: 'discord', providerId: 'ep3', username: 'participant3', avatarUrl: null }); + const c = await upsertUser({ provider: 'discord', providerId: 'ep4', username: 'participant4', avatarUrl: null }); + await putEvent(sampleEvent({ id: 'leaderboard-event' })); - upsertParticipation({ userId: a.id, eventId: 'leaderboard-event', startedAt: 0, endsAt: 1000, rungsClaimed: 3, lastProgressAt: 500 }); - upsertParticipation({ userId: b.id, eventId: 'leaderboard-event', startedAt: 0, endsAt: 1000, rungsClaimed: 5, lastProgressAt: 900 }); - upsertParticipation({ userId: c.id, eventId: 'leaderboard-event', startedAt: 0, endsAt: 1000, rungsClaimed: 5, lastProgressAt: 400 }); + await upsertParticipation({ userId: a.id, eventId: 'leaderboard-event', startedAt: 0, endsAt: 1000, rungsClaimed: 3, lastProgressAt: 500 }); + await upsertParticipation({ userId: b.id, eventId: 'leaderboard-event', startedAt: 0, endsAt: 1000, rungsClaimed: 5, lastProgressAt: 900 }); + await upsertParticipation({ userId: c.id, eventId: 'leaderboard-event', startedAt: 0, endsAt: 1000, rungsClaimed: 5, lastProgressAt: 400 }); - const rows = listParticipation('leaderboard-event'); + const rows = await listParticipation('leaderboard-event'); expect(rows.map((r) => r.user_id)).toEqual([c.id, b.id, a.id]); }); - it('setLeaderboardOptOut round-trips on the users table', () => { - const u = upsertUser({ provider: 'discord', providerId: 'ep5', username: 'participant5', avatarUrl: null }); + it('setLeaderboardOptOut round-trips on the users table', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'ep5', username: 'participant5', avatarUrl: null }); const before = db.prepare('SELECT leaderboard_opt_out FROM users WHERE id = ?').get(u.id); expect(before.leaderboard_opt_out).toBe(0); - setLeaderboardOptOut(u.id, true); + await setLeaderboardOptOut(u.id, true); const after = db.prepare('SELECT leaderboard_opt_out FROM users WHERE id = ?').get(u.id); expect(after.leaderboard_opt_out).toBe(1); - setLeaderboardOptOut(u.id, false); + await setLeaderboardOptOut(u.id, false); expect(db.prepare('SELECT leaderboard_opt_out FROM users WHERE id = ?').get(u.id).leaderboard_opt_out).toBe(0); }); }); describe('seedSeasonalEvents', () => { - it('inserts all seasonal events as drafts with NULL window', () => { - seedSeasonalEvents(); + it('inserts all seasonal events as drafts with NULL window', async () => { + await seedSeasonalEvents(); for (const evt of SEASONAL_EVENTS) { - const row = getEvent(evt.id); + const row = await getEvent(evt.id); expect(row).toBeDefined(); expect(row.status).toBe('draft'); expect(row.starts_at).toBeNull(); @@ -206,34 +206,34 @@ describe('seedSeasonalEvents', () => { } }); - it('is idempotent across two calls', () => { - seedSeasonalEvents(); - const first = listEvents().length; - seedSeasonalEvents(); - const second = listEvents().length; + it('is idempotent across two calls', async () => { + await seedSeasonalEvents(); + const first = (await listEvents()).length; + await seedSeasonalEvents(); + const second = (await listEvents()).length; expect(second).toBe(first); }); - it('does not clobber an admin-edited copy of a seeded event', () => { - seedSeasonalEvents(); - const edited = { ...getEvent('summer-surge'), name: 'Admin Edited Summer Surge', status: 'active' }; - putEvent(edited); + it('does not clobber an admin-edited copy of a seeded event', async () => { + await seedSeasonalEvents(); + const edited = { ...(await getEvent('summer-surge')), name: 'Admin Edited Summer Surge', status: 'active' }; + await putEvent(edited); - seedSeasonalEvents(); + await seedSeasonalEvents(); - const row = getEvent('summer-surge'); + const row = await getEvent('summer-surge'); expect(row.name).toBe('Admin Edited Summer Surge'); expect(row.status).toBe('active'); }); }); describe('SEASONAL_EVENTS content', () => { - it('every seasonal event has 4 known entries', () => { + it('every seasonal event has 4 known entries', async () => { const ids = SEASONAL_EVENTS.map((e) => e.id).sort(); expect(ids).toEqual(['black-frame-friday', 'frost-uptime', 'spooky-packets', 'summer-surge']); }); - it('every seasonal event passes validateModifiers and validateLadder', () => { + it('every seasonal event passes validateModifiers and validateLadder', async () => { for (const evt of SEASONAL_EVENTS) { const modResult = validateModifiers(evt.modifiers); expect(modResult.ok, `${evt.id} modifiers: ${JSON.stringify(modResult.errors)}`).toBe(true); @@ -243,7 +243,7 @@ describe('SEASONAL_EVENTS content', () => { } }); - it('every seasonal event has a well-formed recurrence', () => { + it('every seasonal event has a well-formed recurrence', async () => { for (const evt of SEASONAL_EVENTS) { expect(evt.recurrence).toBeDefined(); expect(evt.recurrence.month).toBeGreaterThanOrEqual(1); diff --git a/tests/db.test.js b/tests/db.test.js index 29c3d36..1cfbf20 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -26,7 +26,7 @@ function tableNames() { } describe('db schema v1.2', () => { - it('creates config, config_history, and minigame_sessions tables', () => { + it('creates config, config_history, and minigame_sessions tables', async () => { const names = tableNames(); expect(names).toContain('users'); expect(names).toContain('saves'); @@ -35,13 +35,13 @@ describe('db schema v1.2', () => { expect(names).toContain('minigame_sessions'); }); - it('adds roles and custom_username columns to users', () => { + it('adds roles and custom_username columns to users', async () => { const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); expect(cols).toContain('roles'); expect(cols).toContain('custom_username'); }); - it('re-importing (guarded ALTER) does not throw on a second boot', () => { + it('re-importing (guarded ALTER) does not throw on a second boot', async () => { // Simulates a second server boot against the same DB file: the module // already ran its ALTERs once for this connection: rerun the exact same // guarded statements directly to prove the duplicate-column path is safe. @@ -56,97 +56,97 @@ describe('db schema v1.2', () => { }); describe('setUsername', () => { - it('is case-insensitive: Neo then neo collide', () => { - const u1 = upsertUser({ provider: 'discord', providerId: 'u1', username: 'u1', avatarUrl: 'a1' }); - const u2 = upsertUser({ provider: 'discord', providerId: 'u2', username: 'u2', avatarUrl: 'a2' }); + it('is case-insensitive: Neo then neo collide', async () => { + const u1 = await upsertUser({ provider: 'discord', providerId: 'u1', username: 'u1', avatarUrl: 'a1' }); + const u2 = await upsertUser({ provider: 'discord', providerId: 'u2', username: 'u2', avatarUrl: 'a2' }); - const r1 = setUsername(u1.id, 'Neo'); + const r1 = await setUsername(u1.id, 'Neo'); expect(r1).toEqual({ ok: true }); - expect(getUserById(u1.id).username).toBe('Neo'); - expect(getUserById(u1.id).custom_username).toBe(1); + expect((await getUserById(u1.id)).username).toBe('Neo'); + expect((await getUserById(u1.id)).custom_username).toBe(1); - const r2 = setUsername(u2.id, 'neo'); + const r2 = await setUsername(u2.id, 'neo'); expect(r2).toEqual({ ok: false, error: 'taken' }); - expect(getUserById(u2.id).username).toBe('u2'); + expect((await getUserById(u2.id)).username).toBe('u2'); }); - it('excludes the user themself from the collision check (re-saving own name is fine)', () => { - const u1 = upsertUser({ provider: 'discord', providerId: 'u3', username: 'u3', avatarUrl: 'a3' }); - expect(setUsername(u1.id, 'Trinity')).toEqual({ ok: true }); - expect(setUsername(u1.id, 'Trinity')).toEqual({ ok: true }); - expect(setUsername(u1.id, 'trinity')).toEqual({ ok: true }); + it('excludes the user themself from the collision check (re-saving own name is fine)', async () => { + const u1 = await upsertUser({ provider: 'discord', providerId: 'u3', username: 'u3', avatarUrl: 'a3' }); + expect(await setUsername(u1.id, 'Trinity')).toEqual({ ok: true }); + expect(await setUsername(u1.id, 'Trinity')).toEqual({ ok: true }); + expect(await setUsername(u1.id, 'trinity')).toEqual({ ok: true }); }); }); describe('upsertUser', () => { - it('preserves a custom username on re-login but updates avatar_url', () => { - const u = upsertUser({ provider: 'github', providerId: 'g1', username: 'ghname', avatarUrl: 'old-avatar' }); - setUsername(u.id, 'MyCoolName'); + it('preserves a custom username on re-login but updates avatar_url', async () => { + const u = await upsertUser({ provider: 'github', providerId: 'g1', username: 'ghname', avatarUrl: 'old-avatar' }); + await setUsername(u.id, 'MyCoolName'); - const relogged = upsertUser({ provider: 'github', providerId: 'g1', username: 'ghname-changed', avatarUrl: 'new-avatar' }); + const relogged = await upsertUser({ provider: 'github', providerId: 'g1', username: 'ghname-changed', avatarUrl: 'new-avatar' }); expect(relogged.username).toBe('MyCoolName'); expect(relogged.avatar_url).toBe('new-avatar'); - const stored = getUserById(u.id); + const stored = await getUserById(u.id); expect(stored.username).toBe('MyCoolName'); expect(stored.avatar_url).toBe('new-avatar'); expect(stored.custom_username).toBe(1); }); - it('still overwrites username from the OAuth profile when custom_username is 0', () => { - const u = upsertUser({ provider: 'github', providerId: 'g2', username: 'first', avatarUrl: 'a' }); - const relogged = upsertUser({ provider: 'github', providerId: 'g2', username: 'second', avatarUrl: 'b' }); + it('still overwrites username from the OAuth profile when custom_username is 0', async () => { + const u = await upsertUser({ provider: 'github', providerId: 'g2', username: 'first', avatarUrl: 'a' }); + const relogged = await upsertUser({ provider: 'github', providerId: 'g2', username: 'second', avatarUrl: 'b' }); expect(relogged.username).toBe('second'); - expect(getUserById(u.id).username).toBe('second'); + expect((await getUserById(u.id)).username).toBe('second'); }); - it('a returning user whose provider-supplied name now collides with a DIFFERENT user does not get locked out', () => { + it('a returning user whose provider-supplied name now collides with a DIFFERENT user does not get locked out', async () => { // User A registers as Neo. User B registers as bob. B later renames // their OAuth display name to "neo" on the provider and logs in again - // upsertUser's UPDATE path must not throw SQLITE_CONSTRAINT_UNIQUE, and // B must come away with some available username, not be permanently // locked out of login. - upsertUser({ provider: 'discord', providerId: 'lockout-a', username: 'Neo', avatarUrl: null }); - const b = upsertUser({ provider: 'discord', providerId: 'lockout-b', username: 'bob', avatarUrl: null }); + await upsertUser({ provider: 'discord', providerId: 'lockout-a', username: 'Neo', avatarUrl: null }); + const b = await upsertUser({ provider: 'discord', providerId: 'lockout-b', username: 'bob', avatarUrl: null }); + // Rewritten from expect(() => {...}).not.toThrow(): upsertUser is now + // async, so a synchronous wrapper can never observe a throw - any + // rejection here fails the test the same way a synchronous throw would + // have under the old assertion. let relogged; - expect(() => { - relogged = upsertUser({ provider: 'discord', providerId: 'lockout-b', username: 'neo', avatarUrl: 'new-avatar' }); - }).not.toThrow(); + relogged = await upsertUser({ provider: 'discord', providerId: 'lockout-b', username: 'neo', avatarUrl: 'new-avatar' }); expect(relogged.username).not.toBe('Neo'); expect(relogged.username.toLowerCase()).not.toBe('neo'); expect(relogged.avatar_url).toBe('new-avatar'); - expect(getUserById(b.id).username).toBe(relogged.username); + expect((await getUserById(b.id)).username).toBe(relogged.username); // And login keeps working on subsequent attempts too (not a one-shot fix). - expect(() => { - upsertUser({ provider: 'discord', providerId: 'lockout-b', username: 'neo', avatarUrl: 'newer-avatar' }); - }).not.toThrow(); + await upsertUser({ provider: 'discord', providerId: 'lockout-b', username: 'neo', avatarUrl: 'newer-avatar' }); }); - it('a user with custom_username set is unaffected by provider-name changes (no collision risk from that path)', () => { - const a = upsertUser({ provider: 'discord', providerId: 'custom-a', username: 'agent-smith', avatarUrl: null }); - setUsername(a.id, 'Architect'); + it('a user with custom_username set is unaffected by provider-name changes (no collision risk from that path)', async () => { + const a = await upsertUser({ provider: 'discord', providerId: 'custom-a', username: 'agent-smith', avatarUrl: null }); + await setUsername(a.id, 'Architect'); // Someone else's provider now supplies the exact custom name as their // OAuth display name - since A's username is custom, upsertUser must // never even attempt to write "architect" for A, so there's nothing to // collide and A's stored username never changes on re-login. - const relogged = upsertUser({ provider: 'discord', providerId: 'custom-a', username: 'totally-different-oauth-name', avatarUrl: 'fresh' }); + const relogged = await upsertUser({ provider: 'discord', providerId: 'custom-a', username: 'totally-different-oauth-name', avatarUrl: 'fresh' }); expect(relogged.username).toBe('Architect'); - expect(getUserById(a.id).username).toBe('Architect'); - expect(getUserById(a.id).custom_username).toBe(1); + expect((await getUserById(a.id)).username).toBe('Architect'); + expect((await getUserById(a.id)).custom_username).toBe(1); }); }); describe('upsertUser username collisions on first login', () => { - it('two different providers both requesting a case-variant of the same username both succeed with distinct usernames', () => { - const a = upsertUser({ provider: 'github', providerId: 'collide-a', username: 'Morpheus', avatarUrl: null }); + it('two different providers both requesting a case-variant of the same username both succeed with distinct usernames', async () => { + const a = await upsertUser({ provider: 'github', providerId: 'collide-a', username: 'Morpheus', avatarUrl: null }); + // Rewritten from expect(() => {...}).not.toThrow(): see the lockout test + // above for why a synchronous wrapper can no longer express this. let b; - expect(() => { - b = upsertUser({ provider: 'discord', providerId: 'collide-b', username: 'morpheus', avatarUrl: null }); - }).not.toThrow(); + b = await upsertUser({ provider: 'discord', providerId: 'collide-b', username: 'morpheus', avatarUrl: null }); expect(a.username.toLowerCase()).toBe('morpheus'); expect(b.username).not.toBe(a.username); @@ -154,14 +154,14 @@ describe('upsertUser username collisions on first login', () => { expect(b.username.toLowerCase()).toBe('morpheus-2'); // Both persisted distinctly and are independently retrievable. - expect(getUserById(a.id).username).toBe(a.username); - expect(getUserById(b.id).username).toBe(b.username); + expect((await getUserById(a.id)).username).toBe(a.username); + expect((await getUserById(b.id)).username).toBe(b.username); }); - it('a three-way collision suffixes incrementally without throwing', () => { - const a = upsertUser({ provider: 'x', providerId: 'c1', username: 'zion', avatarUrl: null }); - const b = upsertUser({ provider: 'x', providerId: 'c2', username: 'Zion', avatarUrl: null }); - const c = upsertUser({ provider: 'x', providerId: 'c3', username: 'ZION', avatarUrl: null }); + it('a three-way collision suffixes incrementally without throwing', async () => { + const a = await upsertUser({ provider: 'x', providerId: 'c1', username: 'zion', avatarUrl: null }); + const b = await upsertUser({ provider: 'x', providerId: 'c2', username: 'Zion', avatarUrl: null }); + const c = await upsertUser({ provider: 'x', providerId: 'c3', username: 'ZION', avatarUrl: null }); const usernames = [a.username, b.username, c.username].map((u) => u.toLowerCase()); expect(new Set(usernames).size).toBe(3); // all distinct case-insensitively @@ -172,7 +172,7 @@ describe('upsertUser username collisions on first login', () => { }); describe('dedupeUsernames', () => { - it('suffixes later-created duplicates (case-insensitive), keeping the earliest untouched', () => { + it('suffixes later-created duplicates (case-insensitive), keeping the earliest untouched', async () => { // The unique index (created at module init, after dedupeUsernames' first // boot-time run) would reject these constructed duplicates outright. // Drop it to simulate the pre-index state dedupeUsernames is meant to @@ -189,30 +189,30 @@ describe('dedupeUsernames', () => { // A pre-existing user already squats the first suffix candidate. insert.run({ id: 'x:4', provider: 'x', provider_id: '4', username: 'duplicate-2', avatar_url: null, created_at: 50 }); - dedupeUsernames(); + await dedupeUsernames(); - expect(getUserById('x:1').username).toBe('Duplicate'); - expect(getUserById('x:2').username).toBe('duplicate-3'); - expect(getUserById('x:3').username).toBe('DUPLICATE-4'); - expect(getUserById('x:4').username).toBe('duplicate-2'); + expect((await getUserById('x:1')).username).toBe('Duplicate'); + expect((await getUserById('x:2')).username).toBe('duplicate-3'); + expect((await getUserById('x:3')).username).toBe('DUPLICATE-4'); + expect((await getUserById('x:4')).username).toBe('duplicate-2'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); }); }); describe('roles', () => { - it('round-trips through getRoles/setRoles, defaulting to []', () => { - const u = upsertUser({ provider: 'discord', providerId: 'r1', username: 'roleuser', avatarUrl: null }); - expect(getRoles(u.id)).toEqual([]); - setRoles(u.id, ['admin', 'event_coordinator']); - expect(getRoles(u.id)).toEqual(['admin', 'event_coordinator']); + it('round-trips through getRoles/setRoles, defaulting to []', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'r1', username: 'roleuser', avatarUrl: null }); + expect(await getRoles(u.id)).toEqual([]); + await setRoles(u.id, ['admin', 'event_coordinator']); + expect(await getRoles(u.id)).toEqual(['admin', 'event_coordinator']); }); }); describe('minigame sessions', () => { - it('creates, fetches, and finishes a session', () => { - const u = upsertUser({ provider: 'discord', providerId: 'm1', username: 'gamer', avatarUrl: null }); - const session = createMinigameSession(u.id, 'rush'); + it('creates, fetches, and finishes a session', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'm1', username: 'gamer', avatarUrl: null }); + const session = await createMinigameSession(u.id, 'rush'); expect(session.id).toBeTypeOf('string'); expect(session.user_id).toBe(u.id); expect(session.game).toBe('rush'); @@ -220,42 +220,42 @@ describe('minigame sessions', () => { expect(session.finished_at).toBeNull(); expect(session.score).toBeNull(); - const fetched = getMinigameSession(session.id); + const fetched = await getMinigameSession(session.id); expect(fetched.id).toBe(session.id); expect(fetched.finished_at).toBeNull(); - finishMinigameSession(session.id, 42); - const done = getMinigameSession(session.id); + await finishMinigameSession(session.id, 42); + const done = await getMinigameSession(session.id); expect(done.score).toBe(42); expect(done.finished_at).toBeTypeOf('number'); }); - it('generates unique ids per session', () => { - const u = upsertUser({ provider: 'discord', providerId: 'm2', username: 'gamer2', avatarUrl: null }); - const s1 = createMinigameSession(u.id, 'debug'); - const s2 = createMinigameSession(u.id, 'debug'); + it('generates unique ids per session', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'm2', username: 'gamer2', avatarUrl: null }); + const s1 = await createMinigameSession(u.id, 'debug'); + const s2 = await createMinigameSession(u.id, 'debug'); expect(s1.id).not.toBe(s2.id); }); }); describe('config', () => { - it('getConfigRow returns undefined before anything is seeded', () => { - expect(getConfigRow()).toBeUndefined(); + it('getConfigRow returns undefined before anything is seeded', async () => { + expect(await getConfigRow()).toBeUndefined(); }); - it('putConfigRow upserts the singleton and records history', () => { - putConfigRow(1, { schemaVersion: 1, heat: { capacity: 2000 } }, 'owner:1'); - const row = getConfigRow(); + it('putConfigRow upserts the singleton and records history', async () => { + await putConfigRow(1, { schemaVersion: 1, heat: { capacity: 2000 } }, 'owner:1'); + const row = await getConfigRow(); expect(row.version).toBe(1); expect(JSON.parse(row.data)).toEqual({ schemaVersion: 1, heat: { capacity: 2000 } }); expect(row.updated_by).toBe('owner:1'); - putConfigRow(2, { schemaVersion: 1, heat: { capacity: 3000 } }, 'owner:1'); - const row2 = getConfigRow(); + await putConfigRow(2, { schemaVersion: 1, heat: { capacity: 3000 } }, 'owner:1'); + const row2 = await getConfigRow(); expect(row2.version).toBe(2); expect(JSON.parse(row2.data).heat.capacity).toBe(3000); - const history = getConfigHistory(); + const history = await getConfigHistory(); expect(history.length).toBe(2); // newest-first expect(history[0].version).toBe(2); @@ -264,29 +264,29 @@ describe('config', () => { }); describe('db schema v1.6: tours_completed', () => { - it('adds the tours_completed column to users', () => { + it('adds the tours_completed column to users', async () => { const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); expect(cols).toContain('tours_completed'); }); - it('defaults a fresh user to an empty set', () => { - const u = upsertUser({ provider: 'discord', providerId: 'tour1', username: 'tour1', avatarUrl: null }); - expect(getToursCompleted(u.id)).toEqual([]); + it('defaults a fresh user to an empty set', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'tour1', username: 'tour1', avatarUrl: null }); + expect(await getToursCompleted(u.id)).toEqual([]); }); - it('round-trips a set of tour ids', () => { - const u = upsertUser({ provider: 'discord', providerId: 'tour2', username: 'tour2', avatarUrl: null }); - setToursCompleted(u.id, ['onboarding', 'v17-widgets']); - expect(getToursCompleted(u.id)).toEqual(['onboarding', 'v17-widgets']); + it('round-trips a set of tour ids', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'tour2', username: 'tour2', avatarUrl: null }); + await setToursCompleted(u.id, ['onboarding', 'v17-widgets']); + expect(await getToursCompleted(u.id)).toEqual(['onboarding', 'v17-widgets']); }); - it('returns [] for an unknown user', () => { - expect(getToursCompleted('no-such-user')).toEqual([]); + it('returns [] for an unknown user', async () => { + expect(await getToursCompleted('no-such-user')).toEqual([]); }); - it('returns [] rather than throwing on corrupt JSON', () => { - const u = upsertUser({ provider: 'discord', providerId: 'tour3', username: 'tour3', avatarUrl: null }); + it('returns [] rather than throwing on corrupt JSON', async () => { + const u = await upsertUser({ provider: 'discord', providerId: 'tour3', username: 'tour3', avatarUrl: null }); db.prepare('UPDATE users SET tours_completed = ? WHERE id = ?').run('{not json', u.id); - expect(getToursCompleted(u.id)).toEqual([]); + expect(await getToursCompleted(u.id)).toEqual([]); }); }); diff --git a/tests/e2e/smoke-v12.mjs b/tests/e2e/smoke-v12.mjs index 5a0009b..6ce86c7 100644 --- a/tests/e2e/smoke-v12.mjs +++ b/tests/e2e/smoke-v12.mjs @@ -203,10 +203,10 @@ let seq = 0; // start with the guided tours already completed - otherwise the onboarding // tour auto-starts over the built client and its overlay swallows the clicks // these checks depend on. New-player tour behaviour is covered by smoke-v16. -function seedUser({ provider, providerId, username }) { +async function seedUser({ provider, providerId, username }) { seq += 1; - const user = upsertUser({ provider, providerId, username, avatarUrl: null }); - setToursCompleted(user.id, TOUR_IDS); + const user = await upsertUser({ provider, providerId, username, avatarUrl: null }); + await setToursCompleted(user.id, TOUR_IDS); return user; } @@ -260,12 +260,12 @@ async function main() { browser = await pw.chromium.launch(); // --- Seed all users up front ------------------------------------------- - const owner = seedUser({ provider: 'github', providerId: '37058311', username: 'owner_e2e' }); - const fixtureUser = seedUser({ provider: 'discord', providerId: 'e2e-fixture', username: 'fixture_e2e' }); - const econUser = seedUser({ provider: 'discord', providerId: 'e2e-econ', username: 'econ_e2e' }); - const nameUser1 = seedUser({ provider: 'discord', providerId: 'e2e-name1', username: 'name1_e2e' }); - const nameUser2 = seedUser({ provider: 'discord', providerId: 'e2e-name2', username: 'name2_e2e' }); - const gamesUser = seedUser({ provider: 'discord', providerId: 'e2e-games', username: 'games_e2e' }); + const owner = await seedUser({ provider: 'github', providerId: '37058311', username: 'owner_e2e' }); + const fixtureUser = await seedUser({ provider: 'discord', providerId: 'e2e-fixture', username: 'fixture_e2e' }); + const econUser = await seedUser({ provider: 'discord', providerId: 'e2e-econ', username: 'econ_e2e' }); + const nameUser1 = await seedUser({ provider: 'discord', providerId: 'e2e-name1', username: 'name1_e2e' }); + const nameUser2 = await seedUser({ provider: 'discord', providerId: 'e2e-name2', username: 'name2_e2e' }); + const gamesUser = await seedUser({ provider: 'discord', providerId: 'e2e-games', username: 'games_e2e' }); assert(`${owner.id}` === OWNER_ID, `expected seeded owner id ${OWNER_ID}, got ${owner.id}`); @@ -273,7 +273,7 @@ async function main() { const v11Fixture = JSON.parse( readFileSync(path.join(REPO_ROOT, 'tests', 'fixtures', 'v11-save.json'), 'utf8'), ); - putSave(fixtureUser.id, v11Fixture, Date.now() - 2 * 3600 * 1000); + await putSave(fixtureUser.id, v11Fixture, Date.now() - 2 * 3600 * 1000); // econUser: flush credits so buy/collect/vent are trivially affordable // through the real action API (this is what we're testing - not the cost @@ -281,7 +281,7 @@ async function main() { { const s = initialState(); s.run.credits = 1_000_000; - putSave(econUser.id, s, Date.now()); + await putSave(econUser.id, s, Date.now()); } // nameUser1: fixed heat, zero overclock nodes (so it never changes on its @@ -292,7 +292,7 @@ async function main() { const s = initialState(); s.run.heat = 50; s.run.tiers[3].owned = 1; - putSave(nameUser1.id, s, Date.now()); + await putSave(nameUser1.id, s, Date.now()); } // --- Check A: v1.1 fixture migrates + renders, offline gain fires ------ @@ -431,7 +431,7 @@ async function main() { // itself (evaluate()'s heat-cap crossing -> cooldown, zero heat, // one-shot `overheated` flag, no owned-count change) is exactly what's // under test here, not the production math (covered by unit tests). - putSave(econUser.id, { + await putSave(econUser.id, { run: { ...beforeOverheat.body.run, heat: 95 }, meta: beforeOverheat.body.meta, server: beforeOverheat.body.server, diff --git a/tests/e2e/smoke-v13.mjs b/tests/e2e/smoke-v13.mjs index b809192..3c57c87 100644 --- a/tests/e2e/smoke-v13.mjs +++ b/tests/e2e/smoke-v13.mjs @@ -206,9 +206,9 @@ function assert(cond, message) { // start with the guided tours already completed - otherwise the onboarding // tour auto-starts over the built client and its overlay swallows the clicks // these checks depend on. New-player tour behaviour is covered by smoke-v16. -function seedUser({ provider, providerId, username }) { - const user = upsertUser({ provider, providerId, username, avatarUrl: null }); - setToursCompleted(user.id, TOUR_IDS); +async function seedUser({ provider, providerId, username }) { + const user = await upsertUser({ provider, providerId, username, avatarUrl: null }); + await setToursCompleted(user.id, TOUR_IDS); return user; } @@ -270,11 +270,11 @@ async function clickAndAwaitFlush(page, locator) { // writes it back with `putSave` - the same direct-seeding trick used // throughout this suite to short-circuit real-time mechanics (block arrival, // offline job accrual) instead of waiting on wall-clock time in a test. -function mutateSave(userId, mutator) { - const row = getSave(userId); +async function mutateSave(userId, mutator) { + const row = await getSave(userId); const data = row ? JSON.parse(row.data) : initialState(); mutator(data); - putSave(userId, data, Date.now()); + await putSave(userId, data, Date.now()); return data; } @@ -291,12 +291,12 @@ async function main() { browser = await pw.chromium.launch(); // --- Seed all users up front ------------------------------------------- - const owner = seedUser({ provider: 'github', providerId: '37058311', username: 'owner_cs_e2e' }); - const nonAdmin = seedUser({ provider: 'discord', providerId: 'e2e-cs-nonadmin', username: 'nonadmin_cs_e2e' }); - const lockedUser = seedUser({ provider: 'discord', providerId: 'e2e-cs-locked', username: 'locked_cs_e2e' }); - const trackUser = seedUser({ provider: 'discord', providerId: 'e2e-cs-track', username: 'track_cs_e2e' }); - const jobUser = seedUser({ provider: 'discord', providerId: 'e2e-cs-job', username: 'job_cs_e2e' }); - const tapeUser = seedUser({ provider: 'discord', providerId: 'e2e-cs-tape', username: 'tape_cs_e2e' }); + const owner = await seedUser({ provider: 'github', providerId: '37058311', username: 'owner_cs_e2e' }); + const nonAdmin = await seedUser({ provider: 'discord', providerId: 'e2e-cs-nonadmin', username: 'nonadmin_cs_e2e' }); + const lockedUser = await seedUser({ provider: 'discord', providerId: 'e2e-cs-locked', username: 'locked_cs_e2e' }); + const trackUser = await seedUser({ provider: 'discord', providerId: 'e2e-cs-track', username: 'track_cs_e2e' }); + const jobUser = await seedUser({ provider: 'discord', providerId: 'e2e-cs-job', username: 'job_cs_e2e' }); + const tapeUser = await seedUser({ provider: 'discord', providerId: 'e2e-cs-tape', username: 'tape_cs_e2e' }); assert(`${owner.id}` === OWNER_ID, `expected seeded owner id ${OWNER_ID}, got ${owner.id}`); @@ -306,7 +306,7 @@ async function main() { { const s = initialState(); s.run.credits = 1_000_000; - putSave(lockedUser.id, s, Date.now()); + await putSave(lockedUser.id, s, Date.now()); } // trackUser: Server Room owned (unlocks the tab) + trackStartedAt pushed @@ -316,7 +316,7 @@ async function main() { const s = initialState(); s.run.tiers[4].owned = 1; s.meta.coldStorage.trackStartedAt = Date.now() - 17 * 6 * 3600 * 1000; - putSave(trackUser.id, s, Date.now()); + await putSave(trackUser.id, s, Date.now()); } // jobUser: Server Room owned, otherwise fresh - the job itself is started @@ -325,7 +325,7 @@ async function main() { { const s = initialState(); s.run.tiers[4].owned = 1; - putSave(jobUser.id, s, Date.now()); + await putSave(jobUser.id, s, Date.now()); } // tapeUser: Server Room owned + a pile of tapes so the upgrade-purchase @@ -336,7 +336,7 @@ async function main() { const s = initialState(); s.run.tiers[4].owned = 1; s.meta.coldStorage.tapes = 1_000_000; - putSave(tapeUser.id, s, Date.now()); + await putSave(tapeUser.id, s, Date.now()); } // --- Check A: Cold Storage tab locked until Server Room owned, then @@ -454,7 +454,7 @@ async function main() { // time-based mechanics like heatCooldownUntil). const cfg = await apiFetch(page, '/api/config', { method: 'GET' }); const durationSec = jobDurationSec('defrag', cfg.body.data); - mutateSave(jobUser.id, (data) => { + await mutateSave(jobUser.id, (data) => { data.meta.coldStorage.job = { type: 'defrag', accruedOfflineSec: durationSec, startedAt: Date.now() - durationSec * 1000 }; }); diff --git a/tests/e2e/smoke-v14-events.mjs b/tests/e2e/smoke-v14-events.mjs index 365283f..8a34fe2 100644 --- a/tests/e2e/smoke-v14-events.mjs +++ b/tests/e2e/smoke-v14-events.mjs @@ -161,9 +161,9 @@ function assert(cond, message) { // start with the guided tours already completed - otherwise the onboarding // tour auto-starts over the built client and its overlay swallows the clicks // these checks depend on. New-player tour behaviour is covered by smoke-v16. -function seedUser({ provider, providerId, username }) { - const user = upsertUser({ provider, providerId, username, avatarUrl: null }); - setToursCompleted(user.id, TOUR_IDS); +async function seedUser({ provider, providerId, username }) { + const user = await upsertUser({ provider, providerId, username, avatarUrl: null }); + await setToursCompleted(user.id, TOUR_IDS); return user; } @@ -207,18 +207,18 @@ async function main() { browser = await pw.chromium.launch(); // --- Seed users ----------------------------------------------------------- - const owner = seedUser({ provider: 'github', providerId: '37058311', username: 'owner_ev_e2e' }); + const owner = await seedUser({ provider: 'github', providerId: '37058311', username: 'owner_ev_e2e' }); assert(`${owner.id}` === OWNER_ID, `expected seeded owner id ${OWNER_ID}, got ${owner.id}`); // A PURE event_coordinator - explicitly NOT granted 'admin' - proving the // brief's requirement that a coordinator-only account can reach the panel // and see ONLY the Events tab. - const coordinator = seedUser({ provider: 'discord', providerId: 'e2e-ev-coord', username: 'coord_ev_e2e' }); - setRoles(coordinator.id, ['event_coordinator']); + const coordinator = await seedUser({ provider: 'discord', providerId: 'e2e-ev-coord', username: 'coord_ev_e2e' }); + await setRoles(coordinator.id, ['event_coordinator']); // A plain, non-admin, non-coordinator player - used to confirm the event // becomes visible to a genuinely unprivileged second user once activated. - const player = seedUser({ provider: 'discord', providerId: 'e2e-ev-player', username: 'player_ev_e2e' }); + const player = await seedUser({ provider: 'discord', providerId: 'e2e-ev-player', username: 'player_ev_e2e' }); const eventId = `e2e-surge-${Date.now()}`; diff --git a/tests/e2e/smoke-v14.mjs b/tests/e2e/smoke-v14.mjs index 5d57209..0102afa 100644 --- a/tests/e2e/smoke-v14.mjs +++ b/tests/e2e/smoke-v14.mjs @@ -209,9 +209,9 @@ function assert(cond, message) { // start with the guided tours already completed - otherwise the onboarding // tour auto-starts over the built client and its overlay swallows the clicks // these checks depend on. New-player tour behaviour is covered by smoke-v16. -function seedUser({ provider, providerId, username }) { - const user = upsertUser({ provider, providerId, username, avatarUrl: null }); - setToursCompleted(user.id, TOUR_IDS); +async function seedUser({ provider, providerId, username }) { + const user = await upsertUser({ provider, providerId, username, avatarUrl: null }); + await setToursCompleted(user.id, TOUR_IDS); return user; } @@ -285,7 +285,7 @@ async function endEventApi(page, id) { async function measureGridGain(page, userId, owned, elapsedMs) { const s = initialState(); s.run.grid[0].owned = owned; - putSave(userId, s, Date.now() - elapsedMs); + await putSave(userId, s, Date.now() - elapsedMs); const res = await apiFetch(page, '/api/state', { method: 'GET' }); if (res.status !== 200) throw new Error(`GET /api/state failed while measuring grid gain: ${res.status} ${JSON.stringify(res.body)}`); return res.body.run.credits - 10; @@ -334,16 +334,16 @@ async function main() { // The owner id doubles as our event-authoring "coordinator" persona - an // owner's effective roles always include event_coordinator (server/ // auth.js's getEffectiveRoles), so no separate setRoles call is needed. - const coordinator = seedUser({ provider: 'github', providerId: '37058311', username: 'coord_v14_e2e' }); + const coordinator = await seedUser({ provider: 'github', providerId: '37058311', username: 'coord_v14_e2e' }); assert(`${coordinator.id}` === OWNER_ID, `expected seeded owner id ${OWNER_ID}, got ${coordinator.id}`); - const ladderPlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-ladder', username: 'ladder_v14_e2e' }); - const optOutPlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-optout', username: 'optout_v14_e2e' }); - const overlayPlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-overlay', username: 'overlay_v14_e2e' }); - const plainPlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-plain', username: 'plain_v14_e2e' }); - const ratePlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-rate', username: 'rate_v14_e2e' }); - const heatPlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-heat', username: 'heat_v14_e2e' }); - const gracePlayer = seedUser({ provider: 'discord', providerId: 'e2e-v14-grace', username: 'grace_v14_e2e' }); + const ladderPlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-ladder', username: 'ladder_v14_e2e' }); + const optOutPlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-optout', username: 'optout_v14_e2e' }); + const overlayPlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-overlay', username: 'overlay_v14_e2e' }); + const plainPlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-plain', username: 'plain_v14_e2e' }); + const ratePlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-rate', username: 'rate_v14_e2e' }); + const heatPlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-heat', username: 'heat_v14_e2e' }); + const gracePlayer = await seedUser({ provider: 'discord', providerId: 'e2e-v14-grace', username: 'grace_v14_e2e' }); // ladderPlayer's pre-existing progress, seeded BEFORE any event exists (and // therefore before they ever join one) - Check 1 below asserts this value @@ -352,7 +352,7 @@ async function main() { { const s = initialState(); s.meta.stats.lifetimeFlopsAllTime = 500; - putSave(ladderPlayer.id, s, Date.now()); + await putSave(ladderPlayer.id, s, Date.now()); } const coordCtx = await browser.newContext(); @@ -490,10 +490,10 @@ async function main() { // smoke-v13.mjs's mutateSave uses for other time-based mechanics) - the // invariant under test is the baseline subtraction, not how the stat // itself got incremented. - const row = getSave(ladderPlayer.id); + const row = await getSave(ladderPlayer.id); const data = JSON.parse(row.data); data.meta.stats.lifetimeFlopsAllTime += 300; - putSave(ladderPlayer.id, data, Date.now()); + await putSave(ladderPlayer.id, data, Date.now()); const postAct = await apiFetch(ladderPage, '/api/event', { method: 'GET' }); assert(postAct.body.progress.rungs[0].current === 300, `expected rung 0's current progress to read 300 (only the post-join delta - not the pre-existing 500, not the raw 800 total), got ${postAct.body.progress.rungs[0].current}`); @@ -646,7 +646,7 @@ async function main() { { const s = initialState(); s.run.grid[0].owned = GRID_OWNED; - putSave(ratePlayer.id, s, Date.now()); + await putSave(ratePlayer.id, s, Date.now()); } await bootAndGetState(ratePage); // event 2 (gridMult 3) is active here await ratePage.waitForTimeout(500); @@ -695,7 +695,7 @@ async function main() { const s = initialState(); s.run.tiers[3].owned = 1; // Colo Rack Unit - unlocks the Overclock tab s.run.heat = 2100; - putSave(heatPlayer.id, s, Date.now()); + await putSave(heatPlayer.id, s, Date.now()); } await bootAndGetState(heatPage); // Several 250ms production ticks, i.e. several chances for the client's @@ -748,15 +748,15 @@ async function main() { // the personal window an hour into the past - i.e. 47h of claim grace // left, the exact situation spec §5.3 exists for. { - const data = JSON.parse(getSave(gracePlayer.id).data); + const data = JSON.parse((await getSave(gracePlayer.id)).data); data.meta.stats.lifetimeFlopsAllTime += 300; - putSave(gracePlayer.id, data, Date.now()); + await putSave(gracePlayer.id, data, Date.now()); } await endEventApi(coordPage, event4Id); { - const data = JSON.parse(getSave(gracePlayer.id).data); + const data = JSON.parse((await getSave(gracePlayer.id)).data); data.meta.eventProgress.endsAt = Date.now() - 3600 * 1000; - putSave(gracePlayer.id, data, Date.now()); + await putSave(gracePlayer.id, data, Date.now()); } // THE reload. Pre-fix the ladder existed only as in-memory React state diff --git a/tests/e2e/smoke-v15.mjs b/tests/e2e/smoke-v15.mjs index 51d4744..a87dfa0 100644 --- a/tests/e2e/smoke-v15.mjs +++ b/tests/e2e/smoke-v15.mjs @@ -191,19 +191,19 @@ function assert(cond, message) { // --------------------------------------------------------------------------- let seq = 0; -function seedUser(mutate) { +async function seedUser(mutate) { seq += 1; - const user = upsertUser({ + const user = await upsertUser({ provider: 'discord', providerId: `v15-${seq}`, username: `v15user${seq}`, avatarUrl: null, }); const s = initialState(); if (mutate) mutate(s); - putSave(user.id, s, Date.now()); + await putSave(user.id, s, Date.now()); // v1.6: this suite exercises v1.5 features over the built client, so the // player starts with the guided tours already completed - otherwise the // onboarding tour auto-starts and its overlay swallows the clicks these // checks depend on. New-player tour behaviour is covered by smoke-v16. - setToursCompleted(user.id, TOUR_IDS); + await setToursCompleted(user.id, TOUR_IDS); return user; } @@ -276,8 +276,8 @@ async function main() { // --- 1 + 2: determinism, per-player scaling, snapshotting ---------------- await check('two players on the same day get the same three contract types', async () => { - const a = seedUser(withRacks(20)); - const b = seedUser(withRacks(5000)); + const a = await seedUser(withRacks(20)); + const b = await seedUser(withRacks(5000)); const { ctx: ca, page: pa } = await newPageFor(a); track(ca); const { ctx: cb, page: pb } = await newPageFor(b); track(cb); const sa = await boot(pa); @@ -306,7 +306,7 @@ async function main() { }); await check('a contract target does not move when output changes mid-day', async () => { - const u = seedUser(withRacks(20)); + const u = await seedUser(withRacks(20)); const { ctx, page } = await newPageFor(u); track(ctx); const before = await boot(page); const targetsBefore = before.meta.contracts.targets; @@ -329,7 +329,7 @@ async function main() { // --- 7: locked Cold Storage never yields a dead contract ----------------- await check('a player without Cold Storage gets only base-lane contracts', async () => { - const u = seedUser(withRacks(20, { cold: false })); + const u = await seedUser(withRacks(20, { cold: false })); const { ctx, page } = await newPageFor(u); track(ctx); const state = await boot(page); const resolved = contractsForState(state.meta); @@ -347,7 +347,7 @@ async function main() { await check('claiming a met contract through the UI pays once and cannot repeat', async () => { // Seed a player whose c_minigames-style counter is already far past any // plausible target, so at least one slot is claimable on first load. - const u = seedUser((s) => { + const u = await seedUser((s) => { withRacks(20)(s); s.meta.stats.minigamesWon = 0; }); @@ -355,11 +355,11 @@ async function main() { const state = await boot(page); // Push every selected metric past its target, server-side, then reload. - const saved = JSON.parse(getSave(u.id).data); + const saved = JSON.parse((await getSave(u.id)).data); for (const c of contractsForState(saved.meta)) { saved.meta.stats[c.def.metric] = (saved.meta.contracts.baseline[c.def.metric] || 0) + c.target; } - putSave(u.id, saved, Date.now()); + await putSave(u.id, saved, Date.now()); await boot(page); await openSocialTab(page); @@ -412,7 +412,7 @@ async function main() { // --- 4: the streak banner ------------------------------------------------ await check('the streak banner pays on day 1 and refuses a second same-day claim', async () => { - const u = seedUser(withRacks(50)); + const u = await seedUser(withRacks(50)); const { ctx, page } = await newPageFor(u); track(ctx); const before = await boot(page); assert(before.meta.streak.count === 0, 'expected a fresh streak'); @@ -447,13 +447,13 @@ async function main() { // --- 5: achievements unlock automatically, with no payout ---------------- await check('an achievement unlocks automatically with no payout and shows in the badge case', async () => { - const u = seedUser((s) => { + const u = await seedUser((s) => { withRacks(20)(s); s.meta.stats.singularities = 1; // 'first_singularity' condition met }); const { ctx, page } = await newPageFor(u); track(ctx); - const saved = JSON.parse(getSave(u.id).data); + const saved = JSON.parse((await getSave(u.id)).data); const before = { wafers: saved.meta.wafers, xp: saved.meta.xp, @@ -492,8 +492,8 @@ async function main() { // --- 6: leaderboards + opt-out ------------------------------------------ await check('the leaderboard ranks players and honours the opt-out checkbox', async () => { - const low = seedUser((s) => { s.meta.stats.lifetimeFlopsAllTime = 1000; s.meta.level = 2; }); - const high = seedUser((s) => { s.meta.stats.lifetimeFlopsAllTime = 5e9; s.meta.level = 30; }); + const low = await seedUser((s) => { s.meta.stats.lifetimeFlopsAllTime = 1000; s.meta.level = 2; }); + const high = await seedUser((s) => { s.meta.stats.lifetimeFlopsAllTime = 5e9; s.meta.level = 30; }); const { ctx: cl, page: pl } = await newPageFor(low); track(cl); await boot(pl); diff --git a/tests/e2e/smoke-v16.mjs b/tests/e2e/smoke-v16.mjs index 2889e9b..9efd91a 100644 --- a/tests/e2e/smoke-v16.mjs +++ b/tests/e2e/smoke-v16.mjs @@ -164,14 +164,14 @@ function assert(cond, message) { } let seq = 0; -function seedUser(mutate) { +async function seedUser(mutate) { seq += 1; - const user = upsertUser({ + const user = await upsertUser({ provider: 'discord', providerId: `v16-${seq}`, username: `v16user${seq}`, avatarUrl: null, }); const s = initialState(); if (mutate) mutate(s); - putSave(user.id, s, Date.now()); + await putSave(user.id, s, Date.now()); return user; } @@ -201,7 +201,7 @@ async function main() { // --- 1-4: the tours round trip ------------------------------------------- await check('GET /api/me returns an empty toursCompleted for a fresh user', async () => { - const u = seedUser(); + const u = await seedUser(); const res = await api(u, '/api/me'); assert(res.status === 200, `expected 200, got ${res.status}`); assert(Array.isArray(res.body.toursCompleted), 'toursCompleted is not an array'); @@ -209,7 +209,7 @@ async function main() { }); await check('completing onboarding marks every registered tour, and /api/me reflects it', async () => { - const u = seedUser(); + const u = await seedUser(); const put = await api(u, '/api/me/tours', { method: 'PUT', body: JSON.stringify({ tourId: ONBOARDING_TOUR_ID, completed: true }), @@ -231,7 +231,7 @@ async function main() { }); await check('completed:false removes the tour (replay path)', async () => { - const u = seedUser(); + const u = await seedUser(); await api(u, '/api/me/tours', { method: 'PUT', body: JSON.stringify({ tourId: ONBOARDING_TOUR_ID, completed: true }), @@ -252,7 +252,7 @@ async function main() { // --- 5: validation -------------------------------------------------------- await check('an unregistered tour id is rejected with 400', async () => { - const u = seedUser(); + const u = await seedUser(); const res = await api(u, '/api/me/tours', { method: 'PUT', body: JSON.stringify({ tourId: 'bogus-tour', completed: true }), @@ -264,7 +264,7 @@ async function main() { // --- 6: the new heat tunables -------------------------------------------- await check('GET /api/config exposes ventPercent + overheatPopupMs and drops ventAmount', async () => { - const u = seedUser(); + const u = await seedUser(); const res = await api(u, '/api/config'); assert(res.status === 200, `expected 200, got ${res.status}`); const heat = res.body.data.heat; @@ -276,7 +276,7 @@ async function main() { // --- 7: percentage venting through the real action path ------------------ await check('a vent action sheds 25% of heat capacity', async () => { - const u = seedUser((s) => { s.run.heat = 1500; }); + const u = await seedUser((s) => { s.run.heat = 1500; }); const before = await api(u, '/api/state'); assert(before.status === 200, `expected 200, got ${before.status}`); const capacity = 2000; // DEFAULT_CONFIG.heat.capacity, no Cold Storage bonus @@ -311,7 +311,7 @@ async function main() { const browser = await pw.chromium.launch(); try { await check('a fresh player sees the tour, and Next advances it', async () => { - const u = seedUser(); + const u = await seedUser(); const ctx = await browser.newContext(); await ctx.addCookies([{ name: COOKIE_NAME, @@ -346,7 +346,7 @@ async function main() { }); await check('Skip closes the tour and persists across a reload', async () => { - const u = seedUser(); + const u = await seedUser(); const ctx = await browser.newContext(); await ctx.addCookies([{ name: COOKIE_NAME, diff --git a/tests/eventService.finalfix.test.js b/tests/eventService.finalfix.test.js index cd174e0..49f76e3 100644 --- a/tests/eventService.finalfix.test.js +++ b/tests/eventService.finalfix.test.js @@ -19,7 +19,7 @@ const { applyAction, EVENT_CLAIM_GRACE_MS } = await import('../shared/reducer.js const { validateRecurrence } = await import('../shared/events.js'); const { DEFAULT_CONFIG } = await import('../shared/configSchema.js'); -ensureConfig(); +await ensureConfig(); // applyAction needs a FULL config document, not just the __claimableEvent / // __pendingClaimables runtime fields these tests care about: v1.5's automatic @@ -35,16 +35,16 @@ function withDefaults(runtimeFields) { const DAY_MS = 24 * 60 * 60 * 1000; let seq = 0; -function makeUser() { +async function makeUser() { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `ff${seq}`, username: `ffuser${seq}`, avatarUrl: null, }); } -function makeEvent(overrides = {}) { +async function makeEvent(overrides = {}) { seq += 1; - return putEvent({ + return await putEvent({ id: `ff-evt-${seq}`, name: `Final Fix Event ${seq}`, description: null, @@ -60,8 +60,8 @@ function makeEvent(overrides = {}) { } /** Clears every event row's status back to 'draft' with no window. */ -function quiesce() { - for (const e of dbMod.listEvents()) setEventStatus(e.id, 'draft', { startsAt: null, endsAt: null }); +async function quiesce() { + for (const e of await dbMod.listEvents()) await setEventStatus(e.id, 'draft', { startsAt: null, endsAt: null }); } // --------------------------------------------------------------------------- @@ -69,26 +69,26 @@ function quiesce() { // --------------------------------------------------------------------------- describe('activateEvent: refuses an already-elapsed window (guard in the primitive, so BOTH callers inherit it)', () => { - it('rejects invalid_target when ends_at is already past', () => { - quiesce(); + it('rejects invalid_target when ends_at is already past', async () => { + await quiesce(); const now = Date.now(); - const evt = makeEvent(); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 20 * DAY_MS, endsAt: now - 10 * DAY_MS }); + const evt = await makeEvent(); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 20 * DAY_MS, endsAt: now - 10 * DAY_MS }); - const result = activateEvent(evt.id, now); + const result = await activateEvent(evt.id, now); expect(result).toEqual({ ok: false, error: 'invalid_target' }); - expect(getEvent(evt.id).status).toBe('scheduled'); + expect((await getEvent(evt.id)).status).toBe('scheduled'); }); - it('still activates a window that is currently open', () => { - quiesce(); + it('still activates a window that is currently open', async () => { + await quiesce(); const now = Date.now(); - const evt = makeEvent(); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + const evt = await makeEvent(); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - expect(activateEvent(evt.id, now)).toEqual({ ok: true }); - expect(getEvent(evt.id).status).toBe('active'); - endEvent(evt.id, now); + expect(await activateEvent(evt.id, now)).toEqual({ ok: true }); + expect((await getEvent(evt.id)).status).toBe('active'); + await endEvent(evt.id, now); }); }); @@ -99,30 +99,30 @@ describe('runScheduler: a scheduled window that fully elapsed while the server w // (host reboot, container redeploy, Unraid update) would activate the dead // event on next boot - wiping mid-grace players' progress and joining them // to a window that expired days earlier. - it('marks it ended instead of activating it, and never activates it on a later tick', () => { - quiesce(); + it('marks it ended instead of activating it, and never activates it on a later tick', async () => { + await quiesce(); const now = Date.now(); - const evt = makeEvent(); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 20 * DAY_MS, endsAt: now - 10 * DAY_MS }); + const evt = await makeEvent(); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 20 * DAY_MS, endsAt: now - 10 * DAY_MS }); - runScheduler(now); - expect(getEvent(evt.id).status).toBe('ended'); + await runScheduler(now); + expect((await getEvent(evt.id)).status).toBe('ended'); // Idempotent: it must not bounce back to 'scheduled' and become a // candidate again on the next hourly tick. - runScheduler(now + 3600 * 1000); - expect(getEvent(evt.id).status).toBe('ended'); + await runScheduler(now + 3600 * 1000); + expect((await getEvent(evt.id)).status).toBe('ended'); }); - it('still activates a scheduled event whose window is genuinely open', () => { - quiesce(); + it('still activates a scheduled event whose window is genuinely open', async () => { + await quiesce(); const now = Date.now(); - const evt = makeEvent(); - setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + const evt = await makeEvent(); + await setEventStatus(evt.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - runScheduler(now); - expect(getEvent(evt.id).status).toBe('active'); - endEvent(evt.id, now); + await runScheduler(now); + expect((await getEvent(evt.id)).status).toBe('active'); + await endEvent(evt.id, now); }); }); @@ -131,55 +131,55 @@ describe('runScheduler: a scheduled window that fully elapsed while the server w // --------------------------------------------------------------------------- describe('runScheduler: annual recurrence re-arms after the event has ended', () => { - it('re-schedules an ended recurring event to next year once its window is past + grace', () => { - quiesce(); + it('re-schedules an ended recurring event to next year once its window is past + grace', async () => { + await quiesce(); // A July 1-15 recurrence that already ran in 2026 and ended. - const evt = makeEvent({ recurrence: { month: 7, day: 1, durationDays: 14 } }); + const evt = await makeEvent({ recurrence: { month: 7, day: 1, durationDays: 14 } }); const ran2026Start = Date.UTC(2026, 6, 1); const ran2026End = ran2026Start + 14 * DAY_MS; - setEventStatus(evt.id, 'ended', { startsAt: ran2026Start, endsAt: ran2026End }); + await setEventStatus(evt.id, 'ended', { startsAt: ran2026Start, endsAt: ran2026End }); // Pre-fix this stayed 'ended' with its 2026 window forever, so every // seasonal was permanently dead after its first year despite the README // promising annual materialization. - runScheduler(Date.UTC(2026, 8, 1)); - const rearmed = getEvent(evt.id); + await runScheduler(Date.UTC(2026, 8, 1)); + const rearmed = await getEvent(evt.id); expect(rearmed.status).toBe('scheduled'); expect(rearmed.starts_at).toBe(Date.UTC(2027, 6, 1)); expect(rearmed.ends_at).toBe(Date.UTC(2027, 6, 1) + 14 * DAY_MS); }); - it('does not re-arm inside the claim-grace settle period right after ending', () => { - quiesce(); - const evt = makeEvent({ recurrence: { month: 7, day: 1, durationDays: 14 } }); + it('does not re-arm inside the claim-grace settle period right after ending', async () => { + await quiesce(); + const evt = await makeEvent({ recurrence: { month: 7, day: 1, durationDays: 14 } }); const endedAt = Date.UTC(2026, 6, 15); - setEventStatus(evt.id, 'ended', { startsAt: Date.UTC(2026, 6, 1), endsAt: endedAt }); + await setEventStatus(evt.id, 'ended', { startsAt: Date.UTC(2026, 6, 1), endsAt: endedAt }); - runScheduler(endedAt + EVENT_CLAIM_GRACE_MS - 1000); - expect(getEvent(evt.id).status).toBe('ended'); + await runScheduler(endedAt + EVENT_CLAIM_GRACE_MS - 1000); + expect((await getEvent(evt.id)).status).toBe('ended'); }); - it('exempts an event a coordinator ended EARLY, mid-window', () => { - quiesce(); + it('exempts an event a coordinator ended EARLY, mid-window', async () => { + await quiesce(); const now = Date.UTC(2026, 6, 5); - const evt = makeEvent({ recurrence: { month: 7, day: 1, durationDays: 14 } }); + const evt = await makeEvent({ recurrence: { month: 7, day: 1, durationDays: 14 } }); // Window still open (ends 2026-07-15) but the coordinator ended it now. - setEventStatus(evt.id, 'ended', { startsAt: Date.UTC(2026, 6, 1), endsAt: Date.UTC(2026, 6, 15) }); + await setEventStatus(evt.id, 'ended', { startsAt: Date.UTC(2026, 6, 1), endsAt: Date.UTC(2026, 6, 15) }); - runScheduler(now); - expect(getEvent(evt.id).status).toBe('ended'); - expect(getEvent(evt.id).starts_at).toBe(Date.UTC(2026, 6, 1)); + await runScheduler(now); + expect((await getEvent(evt.id)).status).toBe('ended'); + expect((await getEvent(evt.id)).starts_at).toBe(Date.UTC(2026, 6, 1)); }); - it('skips a row whose stored recurrence is not a valid {month, day, durationDays}', () => { - quiesce(); - const evt = makeEvent({ recurrence: 'weekly' }); - setEventStatus(evt.id, 'ended', { startsAt: Date.UTC(2026, 0, 1), endsAt: Date.UTC(2026, 0, 8) }); + it('skips a row whose stored recurrence is not a valid {month, day, durationDays}', async () => { + await quiesce(); + const evt = await makeEvent({ recurrence: 'weekly' }); + await setEventStatus(evt.id, 'ended', { startsAt: Date.UTC(2026, 0, 1), endsAt: Date.UTC(2026, 0, 8) }); - runScheduler(Date.UTC(2027, 0, 1)); + await runScheduler(Date.UTC(2027, 0, 1)); // Not re-armed with a NaN window - left alone. - expect(getEvent(evt.id).status).toBe('ended'); - expect(getEvent(evt.id).ends_at).toBe(Date.UTC(2026, 0, 8)); + expect((await getEvent(evt.id)).status).toBe('ended'); + expect((await getEvent(evt.id)).ends_at).toBe(Date.UTC(2026, 0, 8)); }); }); @@ -205,42 +205,42 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { return s; } - it('moves the superseded window into meta.pendingEventClaims instead of destroying it', () => { - quiesce(); + it('moves the superseded window into meta.pendingEventClaims instead of destroying it', async () => { + await quiesce(); const now = Date.now(); - const eventA = makeEvent(); - const eventB = makeEvent(); - setEventStatus(eventA.id, 'ended', { startsAt: now - 3 * DAY_MS, endsAt: now - DAY_MS }); - setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - expect(activateEvent(eventB.id, now)).toEqual({ ok: true }); + const eventA = await makeEvent(); + const eventB = await makeEvent(); + await setEventStatus(eventA.id, 'ended', { startsAt: now - 3 * DAY_MS, endsAt: now - DAY_MS }); + await setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + expect(await activateEvent(eventB.id, now)).toEqual({ ok: true }); - const user = makeUser(); + const user = await makeUser(); // Personal window for A ended an hour ago -> still 47h of claim grace. const state = joinedState(eventA.id, now - 3600 * 1000); - joinEventIfEligible(user.id, state, now); + await joinEventIfEligible(user.id, state, now); expect(state.meta.eventProgress.eventId).toBe(eventB.id); expect(state.meta.pendingEventClaims).toHaveLength(1); expect(state.meta.pendingEventClaims[0].eventId).toBe(eventA.id); - endEvent(eventB.id, now); + await endEvent(eventB.id, now); }); - it('the preserved rung is still claimable, and pays out', () => { - quiesce(); + it('the preserved rung is still claimable, and pays out', async () => { + await quiesce(); const now = Date.now(); - const eventA = makeEvent(); - const eventB = makeEvent(); - setEventStatus(eventA.id, 'ended', { startsAt: now - 3 * DAY_MS, endsAt: now - DAY_MS }); - setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(eventB.id, now); + const eventA = await makeEvent(); + const eventB = await makeEvent(); + await setEventStatus(eventA.id, 'ended', { startsAt: now - 3 * DAY_MS, endsAt: now - DAY_MS }); + await setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(eventB.id, now); - const user = makeUser(); + const user = await makeUser(); const state = joinedState(eventA.id, now - 3600 * 1000); - joinEventIfEligible(user.id, state, now); + await joinEventIfEligible(user.id, state, now); // Exactly what stateService attaches for this player. - const { current, pending } = resolvePlayerEvents(state); + const { current, pending } = await resolvePlayerEvents(state); const config = withDefaults({ __claimableEvent: { id: current.event.id, ladder: current.event.ladder, endsAt: current.event.ends_at, @@ -262,33 +262,33 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { const second = applyAction(after, { type: 'claimEventRung', index: 0, eventId: eventA.id }, config, now).result; expect(second).toEqual({ ok: false, error: 'invalid_target' }); - endEvent(eventB.id, now); + await endEvent(eventB.id, now); }); // The window is FORCE-ENDED, not merely preserved: a superseded ladder must // stop climbing. Otherwise it keeps advancing against live meta alongside // the new event's ladder and a single grind pays out BOTH - spec §5.3 keeps // open only the rungs already qualified at the moment of the supersede. - it('freezes the superseded ladder: rungs met only AFTER the supersede are not claimable', () => { - quiesce(); + it('freezes the superseded ladder: rungs met only AFTER the supersede are not claimable', async () => { + await quiesce(); const now = Date.now(); // Rung 0 is met at supersede time (500 lifetime FLOPS vs target 100); // rung 1 is nowhere near it, and only becomes met from post-supersede // earnings that belong entirely to the NEW event. - const eventA = makeEvent({ + const eventA = await makeEvent({ ladder: [ { metric: 'flopsEarned', target: 100, reward: { wafers: 20 } }, { metric: 'flopsEarned', target: 10000, reward: { wafers: 40 } }, ], }); - const eventB = makeEvent(); - setEventStatus(eventA.id, 'ended', { startsAt: now - 3 * DAY_MS, endsAt: now - DAY_MS }); - setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(eventB.id, now); + const eventB = await makeEvent(); + await setEventStatus(eventA.id, 'ended', { startsAt: now - 3 * DAY_MS, endsAt: now - DAY_MS }); + await setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(eventB.id, now); - const user = makeUser(); + const user = await makeUser(); const state = joinedState(eventA.id, now - 3600 * 1000); - joinEventIfEligible(user.id, state, now); + await joinEventIfEligible(user.id, state, now); const record = state.meta.pendingEventClaims[0]; expect(record.claimableRungs).toEqual([0]); @@ -298,7 +298,7 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { // Grind 20k FLOPS entirely under event B. Rung 1 is now met on live meta. state.meta.stats.lifetimeFlopsAllTime += 20000; - const { current, pending } = resolvePlayerEvents(state); + const { current, pending } = await resolvePlayerEvents(state); const config = withDefaults({ __claimableEvent: { id: current.event.id, ladder: current.event.ladder, endsAt: current.event.ends_at, @@ -319,39 +319,39 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { ).result; expect(early).toMatchObject({ ok: true, rungIndex: 0, eventId: eventA.id }); - endEvent(eventB.id, now); + await endEvent(eventB.id, now); }); - it('drops a superseded window whose own grace has already run out', () => { - quiesce(); + it('drops a superseded window whose own grace has already run out', async () => { + await quiesce(); const now = Date.now(); - const eventA = makeEvent(); - const eventB = makeEvent(); - setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); - activateEvent(eventB.id, now); + const eventA = await makeEvent(); + const eventB = await makeEvent(); + await setEventStatus(eventB.id, 'scheduled', { startsAt: now - 1000, endsAt: now + DAY_MS }); + await activateEvent(eventB.id, now); - const user = makeUser(); + const user = await makeUser(); const state = joinedState(eventA.id, now - EVENT_CLAIM_GRACE_MS - DAY_MS); - joinEventIfEligible(user.id, state, now); + await joinEventIfEligible(user.id, state, now); expect(state.meta.pendingEventClaims).toEqual([]); - endEvent(eventB.id, now); + await endEvent(eventB.id, now); }); - it('prunes pending records once their grace lapses, on a later load', () => { - quiesce(); + it('prunes pending records once their grace lapses, on a later load', async () => { + await quiesce(); const now = Date.now(); - const user = makeUser(); - const evt = makeEvent(); + const user = await makeUser(); + const evt = await makeEvent(); const state = initialState(); state.meta.pendingEventClaims = [{ eventId: evt.id, joinedAt: now - DAY_MS, endsAt: now - 1000, baseline: {}, rungsClaimed: [], }]; - joinEventIfEligible(user.id, state, now); + await joinEventIfEligible(user.id, state, now); expect(state.meta.pendingEventClaims).toHaveLength(1); - joinEventIfEligible(user.id, state, now + EVENT_CLAIM_GRACE_MS + 1000); + await joinEventIfEligible(user.id, state, now + EVENT_CLAIM_GRACE_MS + 1000); expect(state.meta.pendingEventClaims).toEqual([]); }); }); @@ -361,16 +361,16 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { // --------------------------------------------------------------------------- describe('validateRecurrence', () => { - it('accepts null/undefined (no recurrence at all)', () => { + it('accepts null/undefined (no recurrence at all)', async () => { expect(validateRecurrence(null)).toEqual({ ok: true }); expect(validateRecurrence(undefined)).toEqual({ ok: true }); }); - it('accepts a well-formed annual recurrence', () => { + it('accepts a well-formed annual recurrence', async () => { expect(validateRecurrence({ month: 7, day: 1, durationDays: 14 })).toEqual({ ok: true }); }); - it('rejects the shapes that permanently stranded an event', () => { + it('rejects the shapes that permanently stranded an event', async () => { // `{}` and a bare string both promoted the event to 'scheduled' with a // NaN window it could never leave (DELETE is draft-only, activate answers // not_scheduled). @@ -385,7 +385,7 @@ describe('validateRecurrence', () => { expect(validateRecurrence({ month: 7, day: 1, durationDays: 1.5 }).ok).toBe(false); }); - it('rejects an authoring typo rather than silently defaulting it to NaN', () => { + it('rejects an authoring typo rather than silently defaulting it to NaN', async () => { const res = validateRecurrence({ month: 7, day: 1, durationDay: 14 }); expect(res.ok).toBe(false); expect(res.errors.some((e) => e.includes('durationDay'))).toBe(true); diff --git a/tests/eventService.test.js b/tests/eventService.test.js index a3c44aa..6492552 100644 --- a/tests/eventService.test.js +++ b/tests/eventService.test.js @@ -14,12 +14,12 @@ const { } = eventService; const { initialState } = await import('../shared/state.js'); -ensureConfig(); +await ensureConfig(); let seq = 0; -function makeUser() { +async function makeUser() { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `es${seq}`, username: `esuser${seq}`, avatarUrl: null, }); } @@ -52,33 +52,33 @@ const DAY_MS = 24 * 60 * 60 * 1000; // --------------------------------------------------------------------------- describe('no active event (must run first)', () => { - it('getEffectiveConfig returns the base config unchanged when no event is active', () => { - const eff = getEffectiveConfig(); - const base = getConfig(); + it('getEffectiveConfig returns the base config unchanged when no event is active', async () => { + const eff = await getEffectiveConfig(); + const base = await getConfig(); expect(eff.eventId).toBeNull(); expect(eff.data).toBe(base.data); }); - it('joinEventIfEligible does nothing when no event is active and there is no prior progress', () => { - const u = makeUser(); + it('joinEventIfEligible does nothing when no event is active and there is no prior progress', async () => { + const u = await makeUser(); const state = initialState(); - const result = joinEventIfEligible(u.id, state, Date.now()); + const result = await joinEventIfEligible(u.id, state, Date.now()); expect(result).toBeFalsy(); expect(state.meta.eventProgress).toBeNull(); }); }); describe('getEffectiveConfig', () => { - it('merges the active events modifiers without mutating the stored config row or baseline cache', () => { + it('merges the active events modifiers without mutating the stored config row or baseline cache', async () => { const now = Date.now(); - const before = getConfigRow().data; - const baseData = getConfig().data; + const before = (await getConfigRow()).data; + const baseData = (await getConfig()).data; expect(baseData.production.gridMult).not.toBe(3); - putEvent(sampleEvent({ id: 'ev-merge', startsAt: now - 1000, endsAt: now + 100000 })); - expect(activateEvent('ev-merge', now)).toEqual({ ok: true }); + await putEvent(sampleEvent({ id: 'ev-merge', startsAt: now - 1000, endsAt: now + 100000 })); + expect(await activateEvent('ev-merge', now)).toEqual({ ok: true }); - const eff = getEffectiveConfig(); + const eff = await getEffectiveConfig(); expect(eff.eventId).toBe('ev-merge'); expect(eff.data.production.gridMult).toBe(3); expect(eff.data.__activeEvent).toEqual({ @@ -88,106 +88,106 @@ describe('getEffectiveConfig', () => { }); // baseline untouched, both in the in-process cache and in the DB row - expect(getConfig().data).toBe(baseData); + expect((await getConfig()).data).toBe(baseData); expect(baseData.production.gridMult).not.toBe(3); - expect(getConfigRow().data).toBe(before); + expect((await getConfigRow()).data).toBe(before); }); - it('reverts to the base config once the active event ends', () => { + it('reverts to the base config once the active event ends', async () => { const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-revert', startsAt: now - 1000, endsAt: now + 100000 })); - activateEvent('ev-revert', now); - expect(getEffectiveConfig().eventId).toBe('ev-revert'); + await putEvent(sampleEvent({ id: 'ev-revert', startsAt: now - 1000, endsAt: now + 100000 })); + await activateEvent('ev-revert', now); + expect((await getEffectiveConfig()).eventId).toBe('ev-revert'); - endEvent('ev-revert', now); - const eff = getEffectiveConfig(); + await endEvent('ev-revert', now); + const eff = await getEffectiveConfig(); expect(eff.eventId).toBeNull(); expect(eff.data.__activeEvent).toBeUndefined(); }); }); describe('activateEvent', () => { - it('rejects an unknown id', () => { - expect(activateEvent('nope-at-all', Date.now())).toEqual({ ok: false, error: 'not_found' }); + it('rejects an unknown id', async () => { + expect(await activateEvent('nope-at-all', Date.now())).toEqual({ ok: false, error: 'not_found' }); }); - it('rejects an event with no scheduled window', () => { - putEvent(sampleEvent({ id: 'ev-unscheduled' })); - expect(activateEvent('ev-unscheduled', Date.now())).toEqual({ ok: false, error: 'not_scheduled' }); + it('rejects an event with no scheduled window', async () => { + await putEvent(sampleEvent({ id: 'ev-unscheduled' })); + expect(await activateEvent('ev-unscheduled', Date.now())).toEqual({ ok: false, error: 'not_scheduled' }); }); - it('ends any currently active event before activating the new one', () => { + it('ends any currently active event before activating the new one', async () => { const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-1', startsAt: now - 1000, endsAt: now + 100000 })); - putEvent(sampleEvent({ id: 'ev-2', startsAt: now - 1000, endsAt: now + 100000 })); + await putEvent(sampleEvent({ id: 'ev-1', startsAt: now - 1000, endsAt: now + 100000 })); + await putEvent(sampleEvent({ id: 'ev-2', startsAt: now - 1000, endsAt: now + 100000 })); - expect(activateEvent('ev-1', now)).toEqual({ ok: true }); - expect(getEvent('ev-1').status).toBe('active'); + expect(await activateEvent('ev-1', now)).toEqual({ ok: true }); + expect((await getEvent('ev-1')).status).toBe('active'); - expect(activateEvent('ev-2', now)).toEqual({ ok: true }); - expect(getEvent('ev-2').status).toBe('active'); - expect(getEvent('ev-1').status).toBe('ended'); + expect(await activateEvent('ev-2', now)).toEqual({ ok: true }); + expect((await getEvent('ev-2')).status).toBe('active'); + expect((await getEvent('ev-1')).status).toBe('ended'); }); }); describe('endEvent', () => { - it('sets status to ended without touching the window, and invalidates the effective config', () => { + it('sets status to ended without touching the window, and invalidates the effective config', async () => { const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-end', startsAt: now - 1000, endsAt: now + 100000 })); - activateEvent('ev-end', now); - expect(getEffectiveConfig().eventId).toBe('ev-end'); + await putEvent(sampleEvent({ id: 'ev-end', startsAt: now - 1000, endsAt: now + 100000 })); + await activateEvent('ev-end', now); + expect((await getEffectiveConfig()).eventId).toBe('ev-end'); - expect(endEvent('ev-end', now)).toEqual({ ok: true }); - const row = getEvent('ev-end'); + expect(await endEvent('ev-end', now)).toEqual({ ok: true }); + const row = await getEvent('ev-end'); expect(row.status).toBe('ended'); expect(row.starts_at).toBe(now - 1000); expect(row.ends_at).toBe(now + 100000); - expect(getEffectiveConfig().eventId).toBeNull(); + expect((await getEffectiveConfig()).eventId).toBeNull(); }); - it('rejects an unknown id', () => { - expect(endEvent('nope-at-all', Date.now())).toEqual({ ok: false, error: 'not_found' }); + it('rejects an unknown id', async () => { + expect(await endEvent('nope-at-all', Date.now())).toEqual({ ok: false, error: 'not_found' }); }); }); describe('runScheduler', () => { - it('activates scheduled events whose window has arrived and ends expired active ones, idempotently', () => { + it('activates scheduled events whose window has arrived and ends expired active ones, idempotently', async () => { const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-sched', status: 'scheduled', startsAt: now - 1000, endsAt: now + 100000 })); - putEvent(sampleEvent({ id: 'ev-expiring', status: 'active', startsAt: now - 200000, endsAt: now - 1000 })); + await putEvent(sampleEvent({ id: 'ev-sched', status: 'scheduled', startsAt: now - 1000, endsAt: now + 100000 })); + await putEvent(sampleEvent({ id: 'ev-expiring', status: 'active', startsAt: now - 200000, endsAt: now - 1000 })); - runScheduler(now); - expect(getEvent('ev-sched').status).toBe('active'); - expect(getEvent('ev-expiring').status).toBe('ended'); + await runScheduler(now); + expect((await getEvent('ev-sched')).status).toBe('active'); + expect((await getEvent('ev-expiring')).status).toBe('ended'); - const snapshot1 = { sched: getEvent('ev-sched'), expiring: getEvent('ev-expiring') }; - runScheduler(now); - const snapshot2 = { sched: getEvent('ev-sched'), expiring: getEvent('ev-expiring') }; + const snapshot1 = { sched: await getEvent('ev-sched'), expiring: await getEvent('ev-expiring') }; + await runScheduler(now); + const snapshot2 = { sched: await getEvent('ev-sched'), expiring: await getEvent('ev-expiring') }; expect(snapshot2).toEqual(snapshot1); }); - it('when two scheduled events windows both arrive before a run, exactly one ends up active and the other is ended', () => { + it('when two scheduled events windows both arrive before a run, exactly one ends up active and the other is ended', async () => { const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-early', status: 'scheduled', startsAt: now - 5000, endsAt: now + 100000 })); - putEvent(sampleEvent({ id: 'ev-late', status: 'scheduled', startsAt: now - 1000, endsAt: now + 100000 })); + await putEvent(sampleEvent({ id: 'ev-early', status: 'scheduled', startsAt: now - 5000, endsAt: now + 100000 })); + await putEvent(sampleEvent({ id: 'ev-late', status: 'scheduled', startsAt: now - 1000, endsAt: now + 100000 })); - runScheduler(now); + await runScheduler(now); // Documented choice: within one scheduler tick, the later-starting // candidate (ties broken by id) is processed last and wins, since // activateEvent always ends whatever else is active. The earlier one is // left 'ended', not 'scheduled'. - expect(getEvent('ev-late').status).toBe('active'); - expect(getEvent('ev-early').status).toBe('ended'); + expect((await getEvent('ev-late')).status).toBe('active'); + expect((await getEvent('ev-early')).status).toBe('ended'); - runScheduler(now); - expect(getEvent('ev-late').status).toBe('active'); - expect(getEvent('ev-early').status).toBe('ended'); + await runScheduler(now); + expect((await getEvent('ev-late')).status).toBe('active'); + expect((await getEvent('ev-early')).status).toBe('ended'); }); - it('materializes a draft recurrence into the next occurrence window', () => { + it('materializes a draft recurrence into the next occurrence window', async () => { const now = Date.UTC(2026, 0, 1); // Jan 1 2026, well before a July event - putEvent(sampleEvent({ + await putEvent(sampleEvent({ id: 'ev-recur', status: 'draft', startsAt: null, @@ -195,20 +195,20 @@ describe('runScheduler', () => { recurrence: { month: 7, day: 1, durationDays: 14 }, })); - runScheduler(now); - const row = getEvent('ev-recur'); + await runScheduler(now); + const row = await getEvent('ev-recur'); expect(row.status).toBe('scheduled'); expect(row.starts_at).toBe(Date.UTC(2026, 6, 1)); expect(row.ends_at).toBe(Date.UTC(2026, 6, 1) + 14 * DAY_MS); // idempotent - a second call with the same `now` doesn't re-materialize - runScheduler(now); - expect(getEvent('ev-recur')).toEqual(row); + await runScheduler(now); + expect(await getEvent('ev-recur')).toEqual(row); }); - it('materializes into next year once this years occurrence has fully passed', () => { + it('materializes into next year once this years occurrence has fully passed', async () => { const now = Date.UTC(2026, 11, 31); // Dec 31 2026 - well past a July window - putEvent(sampleEvent({ + await putEvent(sampleEvent({ id: 'ev-recur-past', status: 'draft', startsAt: null, @@ -216,13 +216,13 @@ describe('runScheduler', () => { recurrence: { month: 7, day: 1, durationDays: 14 }, })); - runScheduler(now); - expect(getEvent('ev-recur-past').starts_at).toBe(Date.UTC(2027, 6, 1)); + await runScheduler(now); + expect((await getEvent('ev-recur-past')).starts_at).toBe(Date.UTC(2027, 6, 1)); }); - it('does not clobber a coordinators hand-set window on a draft that also carries a recurrence', () => { + it('does not clobber a coordinators hand-set window on a draft that also carries a recurrence', async () => { const now = Date.now(); - putEvent(sampleEvent({ + await putEvent(sampleEvent({ id: 'ev-recur-handset', status: 'draft', startsAt: now + 500000, @@ -230,8 +230,8 @@ describe('runScheduler', () => { recurrence: { month: 7, day: 1, durationDays: 14 }, })); - runScheduler(now); - const row = getEvent('ev-recur-handset'); + await runScheduler(now); + const row = await getEvent('ev-recur-handset'); expect(row.status).toBe('draft'); expect(row.starts_at).toBe(now + 500000); expect(row.ends_at).toBe(now + 600000); @@ -239,17 +239,17 @@ describe('runScheduler', () => { }); describe('joinEventIfEligible', () => { - it('snapshots baselines and writes participation on first join, and is a no-op on a second call', () => { - const u = makeUser(); + it('snapshots baselines and writes participation on first join, and is a no-op on a second call', async () => { + const u = await makeUser(); const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-join', startsAt: now, endsAt: now + 7 * DAY_MS })); - activateEvent('ev-join', now); + await putEvent(sampleEvent({ id: 'ev-join', startsAt: now, endsAt: now + 7 * DAY_MS })); + await activateEvent('ev-join', now); const state = initialState(); state.meta.stats.lifetimeFlopsAllTime = 42; state.meta.stats.minigamesWon = 1; - const active1 = joinEventIfEligible(u.id, state, now); + const active1 = await joinEventIfEligible(u.id, state, now); expect(active1.id).toBe('ev-join'); expect(state.meta.eventProgress).toEqual({ eventId: 'ev-join', @@ -261,7 +261,7 @@ describe('joinEventIfEligible', () => { rungsClaimed: [], }); - const participation = getParticipation(u.id, 'ev-join'); + const participation = await getParticipation(u.id, 'ev-join'); expect(participation).toBeDefined(); expect(participation.started_at).toBe(now); expect(participation.ends_at).toBe(now + 7 * DAY_MS); @@ -270,21 +270,21 @@ describe('joinEventIfEligible', () => { const snapshot = { ...state.meta.eventProgress }; // second call, later `now`, more stats accrued - must be a pure no-op state.meta.stats.lifetimeFlopsAllTime = 999; - const active2 = joinEventIfEligible(u.id, state, now + 5000); + const active2 = await joinEventIfEligible(u.id, state, now + 5000); expect(active2.id).toBe('ev-join'); expect(state.meta.eventProgress).toEqual(snapshot); }); - it('caps personal endsAt at 24h past the global end for a late joiner', () => { - const u = makeUser(); + it('caps personal endsAt at 24h past the global end for a late joiner', async () => { + const u = await makeUser(); const now = Date.now(); const start = now - 6 * DAY_MS; const end = now + 1 * DAY_MS; // 7-day event, 6 days already elapsed - putEvent(sampleEvent({ id: 'ev-late-join', startsAt: start, endsAt: end })); - activateEvent('ev-late-join', now); + await putEvent(sampleEvent({ id: 'ev-late-join', startsAt: start, endsAt: end })); + await activateEvent('ev-late-join', now); const state = initialState(); - joinEventIfEligible(u.id, state, now); + await joinEventIfEligible(u.id, state, now); const eventDurationMs = end - start; const uncapped = now + eventDurationMs; @@ -293,39 +293,39 @@ describe('joinEventIfEligible', () => { expect(state.meta.eventProgress.endsAt).toBe(cap); }); - it('clears a superseded events progress once a different event is active, and reuses fresh baselines', () => { - const u = makeUser(); + it('clears a superseded events progress once a different event is active, and reuses fresh baselines', async () => { + const u = await makeUser(); const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-old', startsAt: now - 10000, endsAt: now + 10000 })); - activateEvent('ev-old', now); + await putEvent(sampleEvent({ id: 'ev-old', startsAt: now - 10000, endsAt: now + 10000 })); + await activateEvent('ev-old', now); const state = initialState(); - joinEventIfEligible(u.id, state, now); + await joinEventIfEligible(u.id, state, now); expect(state.meta.eventProgress.eventId).toBe('ev-old'); - putEvent(sampleEvent({ id: 'ev-new', startsAt: now, endsAt: now + 50000 })); - activateEvent('ev-new', now); // force-ends ev-old + await putEvent(sampleEvent({ id: 'ev-new', startsAt: now, endsAt: now + 50000 })); + await activateEvent('ev-new', now); // force-ends ev-old const later = now + 1000; - const active = joinEventIfEligible(u.id, state, later); + const active = await joinEventIfEligible(u.id, state, later); expect(active.id).toBe('ev-new'); expect(state.meta.eventProgress.eventId).toBe('ev-new'); expect(state.meta.eventProgress.joinedAt).toBe(later); }); - it('leaves a lingering eventProgress untouched during its grace period once nothing is currently active', () => { - const u = makeUser(); + it('leaves a lingering eventProgress untouched during its grace period once nothing is currently active', async () => { + const u = await makeUser(); const now = Date.now(); - putEvent(sampleEvent({ id: 'ev-grace', startsAt: now - 10000, endsAt: now + 1000 })); - activateEvent('ev-grace', now); + await putEvent(sampleEvent({ id: 'ev-grace', startsAt: now - 10000, endsAt: now + 1000 })); + await activateEvent('ev-grace', now); const state = initialState(); - joinEventIfEligible(u.id, state, now); + await joinEventIfEligible(u.id, state, now); expect(state.meta.eventProgress.eventId).toBe('ev-grace'); - endEvent('ev-grace', now + 2000); // ends, nothing new activated + await endEvent('ev-grace', now + 2000); // ends, nothing new activated - const result = joinEventIfEligible(u.id, state, now + 3000); + const result = await joinEventIfEligible(u.id, state, now + 3000); expect(result).toBeFalsy(); expect(state.meta.eventProgress.eventId).toBe('ev-grace'); // untouched }); diff --git a/tests/stateService.events.test.js b/tests/stateService.events.test.js index b2a600f..9f655af 100644 --- a/tests/stateService.events.test.js +++ b/tests/stateService.events.test.js @@ -40,12 +40,12 @@ const { loadEvaluateAndSchedule, loadAndEvaluate, applyActions } = stateService; const { EVENT_CLAIM_GRACE_MS } = await import('../shared/reducer.js'); const { initialState } = await import('../shared/state.js'); -ensureConfig(); +await ensureConfig(); let seq = 0; -function makeUser() { +async function makeUser() { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `ss${seq}`, username: `ssuser${seq}`, avatarUrl: null, }); } @@ -75,17 +75,17 @@ function sampleEvent(overrides = {}) { // cache before it next invalidates. // --------------------------------------------------------------------------- describe('loadEvaluateAndSchedule: __claimableEvent per-user isolation (hotfix bug a safety property)', () => { - it('does not mutate configService\'s shared effective-config cache when attaching a per-user __claimableEvent', () => { + it('does not mutate configService\'s shared effective-config cache when attaching a per-user __claimableEvent', async () => { const now = Date.now(); - const alice = makeUser(); + const alice = await makeUser(); - putEvent(sampleEvent({ id: 'isolation-evt', startsAt: now - 1000, endsAt: now + 100000 })); - activateEvent('isolation-evt', now); + await putEvent(sampleEvent({ id: 'isolation-evt', startsAt: now - 1000, endsAt: now + 100000 })); + await activateEvent('isolation-evt', now); - const sharedBefore = getEffectiveConfig(); + const sharedBefore = await getEffectiveConfig(); expect(sharedBefore.data.__claimableEvent).toBeUndefined(); - const { config: configA } = loadEvaluateAndSchedule(alice.id, now); + const { config: configA } = await loadEvaluateAndSchedule(alice.id, now); expect(configA.__claimableEvent).toEqual({ id: 'isolation-evt', ladder: sampleEvent().ladder, @@ -96,7 +96,7 @@ describe('loadEvaluateAndSchedule: __claimableEvent per-user isolation (hotfix b // getEffectiveConfig() hands back the identical cached reference - must // remain untouched by attaching Alice's claimable event to her own // per-request copy. - const sharedAfter = getEffectiveConfig(); + const sharedAfter = await getEffectiveConfig(); expect(sharedAfter.data).toBe(sharedBefore.data); expect(sharedAfter.data.__claimableEvent).toBeUndefined(); @@ -105,18 +105,18 @@ describe('loadEvaluateAndSchedule: __claimableEvent per-user isolation (hotfix b expect(configA).not.toBe(sharedAfter.data); }); - it('two different users never see each other\'s __claimableEvent', () => { + it('two different users never see each other\'s __claimableEvent', async () => { const now = Date.now(); - const alice = makeUser(); - const bob = makeUser(); + const alice = await makeUser(); + const bob = await makeUser(); - putEvent(sampleEvent({ id: 'leak-evt', startsAt: now - 1000, endsAt: now + 5000 })); - activateEvent('leak-evt', now); + await putEvent(sampleEvent({ id: 'leak-evt', startsAt: now - 1000, endsAt: now + 5000 })); + await activateEvent('leak-evt', now); // Alice joins while the event is active (persisted via loadAndEvaluate). - const { state: aliceState } = loadAndEvaluate(alice.id, now); + const { state: aliceState } = await loadAndEvaluate(alice.id, now); expect(aliceState.meta.eventProgress.eventId).toBe('leak-evt'); - const { config: configAAfterJoin } = loadEvaluateAndSchedule(alice.id, now); + const { config: configAAfterJoin } = await loadEvaluateAndSchedule(alice.id, now); expect(configAAfterJoin.__claimableEvent.id).toBe('leak-evt'); // The event ends globally with nothing new activated, and Bob loads his @@ -125,14 +125,14 @@ describe('loadEvaluateAndSchedule: __claimableEvent per-user isolation (hotfix b // eventProgress null. If the shared cache had somehow been mutated by // Alice's request, Bob's config could incorrectly inherit her // __claimableEvent. - endEvent('leak-evt', now + 6000); - const { state: bobState, config: configB } = loadEvaluateAndSchedule(bob.id, now + 7000); + await endEvent('leak-evt', now + 6000); + const { state: bobState, config: configB } = await loadEvaluateAndSchedule(bob.id, now + 7000); expect(bobState.meta.eventProgress).toBeNull(); expect(configB.__claimableEvent).toBeUndefined(); // Alice's own lingering grace-period progress must still resolve // correctly and independently of Bob's load. - const { config: configAAfterBob } = loadEvaluateAndSchedule(alice.id, now + 7000); + const { config: configAAfterBob } = await loadEvaluateAndSchedule(alice.id, now + 7000); expect(configAAfterBob.__claimableEvent).toEqual({ id: 'leak-evt', ladder: sampleEvent().ladder, @@ -146,22 +146,22 @@ describe('loadEvaluateAndSchedule: __claimableEvent per-user isolation (hotfix b // Bug (a): the 48h claim grace period itself. // --------------------------------------------------------------------------- describe('claimEventRung end-to-end: 48h grace period reachable after the event globally ends (hotfix bug a)', () => { - it('an ended event stops applying its modifiers immediately, but its ladder stays claimable (and actually pays out) within the grace period', () => { + it('an ended event stops applying its modifiers immediately, but its ladder stays claimable (and actually pays out) within the grace period', async () => { const now = Date.now(); - const user = makeUser(); - const baseGridMult = getConfig().data.production.gridMult; + const user = await makeUser(); + const baseGridMult = (await getConfig()).data.production.gridMult; - putEvent(sampleEvent({ + await putEvent(sampleEvent({ id: 'grace-evt', startsAt: now - 1000, endsAt: now + 5000, modifiers: [{ path: 'production.gridMult', value: baseGridMult + 5 }], })); - activateEvent('grace-evt', now); - expect(getEffectiveConfig().data.production.gridMult).toBe(baseGridMult + 5); + await activateEvent('grace-evt', now); + expect((await getEffectiveConfig()).data.production.gridMult).toBe(baseGridMult + 5); // Join while the event is still active. - const { state: joined } = loadAndEvaluate(user.id, now); + const { state: joined } = await loadAndEvaluate(user.id, now); expect(joined.meta.eventProgress.eventId).toBe('grace-evt'); const startingWafers = joined.meta.wafers; @@ -169,16 +169,16 @@ describe('claimEventRung end-to-end: 48h grace period reachable after the event // elapsed - this is precisely the case that returned invalid_target // before the fix, because config.__activeEvent disappeared the instant // status left 'active'. - endEvent('grace-evt', now + 2000); + await endEvent('grace-evt', now + 2000); // The modifier overlay must be gone immediately - that part already // worked correctly and must not regress. - expect(getEffectiveConfig().data.__activeEvent).toBeUndefined(); - expect(getEffectiveConfig().data.production.gridMult).toBe(baseGridMult); + expect((await getEffectiveConfig()).data.__activeEvent).toBeUndefined(); + expect((await getEffectiveConfig()).data.production.gridMult).toBe(baseGridMult); // The claim, made after the global end but well within grace, must now // succeed and actually pay out. - const { state: claimed, results } = applyActions( + const { state: claimed, results } = await applyActions( user.id, [{ type: 'claimEventRung', index: 0 }], now + 3000, @@ -188,28 +188,28 @@ describe('claimEventRung end-to-end: 48h grace period reachable after the event expect(claimed.meta.eventProgress.rungsClaimed).toEqual([0]); }); - it('rejects with cooldown_active - not invalid_target - once the 48h grace period after the personal endsAt has truly passed', () => { + it('rejects with cooldown_active - not invalid_target - once the 48h grace period after the personal endsAt has truly passed', async () => { const now = Date.now(); - const user = makeUser(); + const user = await makeUser(); - putEvent(sampleEvent({ + await putEvent(sampleEvent({ id: 'grace-expired-evt', startsAt: now - 1000, endsAt: now + 1000, })); - activateEvent('grace-expired-evt', now); + await activateEvent('grace-expired-evt', now); - const { state: joined } = loadAndEvaluate(user.id, now); + const { state: joined } = await loadAndEvaluate(user.id, now); const personalEndsAt = joined.meta.eventProgress.endsAt; - endEvent('grace-expired-evt', now + 1500); + await endEvent('grace-expired-evt', now + 1500); const pastGrace = personalEndsAt + EVENT_CLAIM_GRACE_MS + 1; - const { results } = applyActions(user.id, [{ type: 'claimEventRung', index: 0 }], pastGrace); + const { results } = await applyActions(user.id, [{ type: 'claimEventRung', index: 0 }], pastGrace); expect(results[0]).toEqual({ ok: false, error: 'cooldown_active' }); }); - it('fails closed with invalid_target (never throws) when eventProgress references an event id no longer in the DB at all', () => { + it('fails closed with invalid_target (never throws) when eventProgress references an event id no longer in the DB at all', async () => { // Simulates a save whose eventProgress points at an event id that has // since been deleted (or, as constructed here, simply never existed) - // event_participation's FK to live_events(id) means a row that was ever @@ -217,7 +217,7 @@ describe('claimEventRung end-to-end: 48h grace period reachable after the event // this exercises the same code path (getEvent(id) returns undefined) // directly via a hand-placed save rather than via a real delete. const now = Date.now(); - const user = makeUser(); + const user = await makeUser(); const s = initialState(); s.meta.eventProgress = { @@ -227,12 +227,13 @@ describe('claimEventRung end-to-end: 48h grace period reachable after the event baseline: { flopsEarned: 0 }, rungsClaimed: [], }; - putSave(user.id, s, now); + await putSave(user.id, s, now); - let results; - expect(() => { - ({ results } = applyActions(user.id, [{ type: 'claimEventRung', index: 0 }], now + 1000)); - }).not.toThrow(); + // Rewritten from expect(() => {...}).not.toThrow(): applyActions is now + // async, so a synchronous wrapper can never observe a throw - any + // rejection here fails the test the same way a synchronous throw would + // have under the old assertion. + const { results } = await applyActions(user.id, [{ type: 'claimEventRung', index: 0 }], now + 1000); expect(results[0]).toEqual(expect.objectContaining({ ok: false, error: 'invalid_target' })); }); }); diff --git a/tests/stateService.social.test.js b/tests/stateService.social.test.js index da78716..d5f1f1d 100644 --- a/tests/stateService.social.test.js +++ b/tests/stateService.social.test.js @@ -9,67 +9,67 @@ const { loadAndEvaluate } = await import('../server/stateService.js'); const { initialState } = await import('../shared/state.js'); const { utcDateKey } = await import('../shared/daily.js'); -ensureConfig(); +await ensureConfig(); let seq = 0; -function makeUser() { +async function makeUser() { seq += 1; - return upsertUser({ + return await upsertUser({ provider: 'discord', providerId: `soc${seq}`, username: `socuser${seq}`, avatarUrl: null, }); } describe('contracts roll over on the load path', () => { - it("populates today's board for a user who has never had one", () => { - const u = makeUser(); + it("populates today's board for a user who has never had one", async () => { + const u = await makeUser(); const now = Date.now(); - const { state } = loadAndEvaluate(u.id, now); + const { state } = await loadAndEvaluate(u.id, now); expect(state.meta.contracts.dateKey).toBe(utcDateKey(now)); expect(state.meta.contracts.targets).toHaveLength(3); }); - it('persists the rolled-over board, so a reload sees the same targets', () => { - const u = makeUser(); + it('persists the rolled-over board, so a reload sees the same targets', async () => { + const u = await makeUser(); const now = Date.now(); - const first = loadAndEvaluate(u.id, now).state; - const second = loadAndEvaluate(u.id, now + 60_000).state; + const first = (await loadAndEvaluate(u.id, now)).state; + const second = (await loadAndEvaluate(u.id, now + 60_000)).state; expect(second.meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); expect(second.meta.contracts.targets).toEqual(first.meta.contracts.targets); - expect(JSON.parse(getSave(u.id).data).meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); + expect(JSON.parse((await getSave(u.id)).data).meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); }); - it('rolls over and clears claims when the UTC day advances', () => { - const u = makeUser(); + it('rolls over and clears claims when the UTC day advances', async () => { + const u = await makeUser(); const now = Date.now(); - const first = loadAndEvaluate(u.id, now).state; + const first = (await loadAndEvaluate(u.id, now)).state; first.meta.contracts.claimed = [true, true, true]; - putSave(u.id, first, now); - const next = loadAndEvaluate(u.id, now + 24 * 3600 * 1000).state; + await putSave(u.id, first, now); + const next = (await loadAndEvaluate(u.id, now + 24 * 3600 * 1000)).state; expect(next.meta.contracts.dateKey).not.toBe(first.meta.contracts.dateKey); expect(next.meta.contracts.claimed).toEqual([false, false, false]); }); }); describe('achievements unlock from offline accrual', () => { - it('sweeps after evaluate, so a threshold crossed while away unlocks on next load', () => { - const u = makeUser(); + it('sweeps after evaluate, so a threshold crossed while away unlocks on next load', async () => { + const u = await makeUser(); const now = Date.now(); const s = initialState(); s.meta.stats.lifetimeFlopsAllTime = 1e9; // 'flops_g' condition met - putSave(u.id, s, now); - const { state, unlockedAchievements } = loadAndEvaluate(u.id, now + 1000); + await putSave(u.id, s, now); + const { state, unlockedAchievements } = await loadAndEvaluate(u.id, now + 1000); expect(state.meta.achievements.flops_g).toBeDefined(); expect(unlockedAchievements).toContain('flops_g'); }); - it('reports nothing on a subsequent load once already held', () => { - const u = makeUser(); + it('reports nothing on a subsequent load once already held', async () => { + const u = await makeUser(); const now = Date.now(); const s = initialState(); s.meta.stats.singularities = 1; - putSave(u.id, s, now); - loadAndEvaluate(u.id, now + 1000); - const { unlockedAchievements } = loadAndEvaluate(u.id, now + 2000); + await putSave(u.id, s, now); + await loadAndEvaluate(u.id, now + 1000); + const { unlockedAchievements } = await loadAndEvaluate(u.id, now + 2000); expect(unlockedAchievements).toEqual([]); }); }); From 8c4fb26dbad762a5e9a798f1c4b9eb22e05a153a Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 13:36:37 -0400 Subject: [PATCH 04/14] Split db.js into a facade, a SQLite driver, and a schema module Pure refactor - no SQL changes. server/db.js is now a re-export shim so every existing import path keeps working, while server/db/ holds the seam the Postgres driver plugs into next. Adds a schema_migrations table: the schema has outgrown what CREATE TABLE IF NOT EXISTS plus guarded ALTERs can honestly express, and the identities split needs a real version marker. Also extracts server/db/shared.js: findAvailableUsername, parseEventRow, and the putEvent/upsertParticipation row normalizers are dialect-free logic the Postgres driver will need identically, so they live once instead of being duplicated (and drifting) per driver. findAvailableUsername is now async and awaits its isTaken predicate - harmless for SQLite's synchronous predicate, required for Postgres's async one - so one implementation serves both. --- server/db.js | 626 +------------------------------------ server/db/driver.sqlite.js | 449 ++++++++++++++++++++++++++ server/db/index.js | 24 ++ server/db/interface.md | 76 +++++ server/db/schema.sqlite.js | 147 +++++++++ server/db/shared.js | 93 ++++++ tests/db.events.test.js | 9 +- tests/db.interface.test.js | 29 ++ tests/db.test.js | 9 +- 9 files changed, 835 insertions(+), 627 deletions(-) create mode 100644 server/db/driver.sqlite.js create mode 100644 server/db/index.js create mode 100644 server/db/interface.md create mode 100644 server/db/schema.sqlite.js create mode 100644 server/db/shared.js create mode 100644 tests/db.interface.test.js diff --git a/server/db.js b/server/db.js index b504d1a..1726711 100644 --- a/server/db.js +++ b/server/db.js @@ -1,625 +1 @@ -import Database from 'better-sqlite3'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; -import { randomUUID } from 'node:crypto'; -import { SEASONAL_EVENTS } from './data/seasonalEvents.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'rackstack.db'); -fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); - -export const db = new Database(DB_PATH); -db.pragma('journal_mode = WAL'); -db.pragma('foreign_keys = ON'); - -db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - provider_id TEXT NOT NULL, - username TEXT, - avatar_url TEXT, - created_at INTEGER NOT NULL, - UNIQUE(provider, provider_id) - ); - - CREATE TABLE IF NOT EXISTS saves ( - user_id TEXT PRIMARY KEY REFERENCES users(id), - data TEXT NOT NULL, - last_save INTEGER NOT NULL - ); -`); - -// Guarded ALTERs: SQLite has no "ADD COLUMN IF NOT EXISTS", so on every boot -// we attempt the ALTER and swallow only the "duplicate column name" error -// (the column already exists from a prior boot) - anything else rethrows. -function guardedAddColumn(sql) { - try { - db.exec(sql); - } catch (err) { - if (!/duplicate column name/i.test(err.message)) throw err; - } -} - -guardedAddColumn("ALTER TABLE users ADD COLUMN roles TEXT DEFAULT '[]'"); -guardedAddColumn('ALTER TABLE users ADD COLUMN custom_username INTEGER DEFAULT 0'); -// v1.4 Live Events: the opt-out ships here (spec §5.2) and is reused by -// v1.5's global leaderboards - it's a per-user preference, not event-scoped. -guardedAddColumn('ALTER TABLE users ADD COLUMN leaderboard_opt_out INTEGER DEFAULT 0'); -// v1.6 tours: a JSON array of completed tour ids (shared/tours.js owns the id -// list). Existing players default to '[]' - an empty completed-set is exactly -// what makes the onboarding tour fire once for them. -guardedAddColumn("ALTER TABLE users ADD COLUMN tours_completed TEXT DEFAULT '[]'"); - -db.exec(` - CREATE TABLE IF NOT EXISTS config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - version INTEGER NOT NULL, - data TEXT NOT NULL, - updated_at INTEGER NOT NULL, - updated_by TEXT - ); - - CREATE TABLE IF NOT EXISTS config_history ( - version INTEGER NOT NULL, - data TEXT NOT NULL, - updated_at INTEGER NOT NULL, - updated_by TEXT - ); - - CREATE TABLE IF NOT EXISTS minigame_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id), - game TEXT NOT NULL, - started_at INTEGER NOT NULL, - finished_at INTEGER, - score INTEGER - ); -`); - -// v1.4 Live Events schema (additive). live_events holds both admin-authored -// and seeded (server/data/seasonalEvents.js) events; event_participation -// tracks each user's progress through one event's ladder while it's live. -db.exec(` - CREATE TABLE IF NOT EXISTS live_events ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - theme TEXT, - modifiers TEXT NOT NULL, - ladder TEXT NOT NULL, - status TEXT NOT NULL, - starts_at INTEGER, - ends_at INTEGER, - recurrence TEXT, - created_at INTEGER NOT NULL, - created_by TEXT - ); - - CREATE TABLE IF NOT EXISTS event_participation ( - user_id TEXT NOT NULL REFERENCES users(id), - event_id TEXT NOT NULL REFERENCES live_events(id), - started_at INTEGER NOT NULL, - ends_at INTEGER NOT NULL, - rungs_claimed INTEGER NOT NULL DEFAULT 0, - last_progress_at INTEGER, - opted_out INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, event_id) - ); -`); - -/** - * Returns a username derived from `desiredName` that `isTaken` reports as - * free, suffixing `-2`, `-3`, ... until one is. `desiredName` itself is - * returned unchanged if it's already free. Shared by dedupeUsernames (bulk - * cleanup, checks an in-memory Set) and upsertUser (per-insert retry, - * checks the DB) so both use the same suffixing convention against the - * same COLLATE NOCASE uniqueness rule. - */ -function findAvailableUsername(desiredName, isTaken) { - if (!isTaken(desiredName)) return desiredName; - let n = 2; - let candidate = `${desiredName}-${n}`; - while (isTaken(candidate)) { - n += 1; - candidate = `${desiredName}-${n}`; - } - return candidate; -} - -// Duplicate usernames (case-insensitively) can exist from before the unique -// index below was introduced. Must run before the CREATE UNIQUE INDEX or -// that statement would fail on any pre-existing collision. No-op when there -// are no duplicates, so it's cheap to run unconditionally on every boot. -function dedupeUsernamesSync() { - const rows = db.prepare( - 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', - ).all(); - const taken = new Set(); - for (const row of rows) { - const lower = row.username.toLowerCase(); - if (!taken.has(lower)) { - taken.add(lower); - continue; - } - const candidate = findAvailableUsername(row.username, (name) => taken.has(name.toLowerCase())); - db.prepare('UPDATE users SET username = ? WHERE id = ?').run(candidate, row.id); - taken.add(candidate.toLowerCase()); - } -} - -// Transitional: module init still needs this to run synchronously before the -// unique index below is created. The exported async wrapper is what callers -// (Task 2 moves this into schema init properly) will use going forward. -export async function dedupeUsernames() { - return dedupeUsernamesSync(); -} - -dedupeUsernamesSync(); - -db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); - -function isUsernameTakenInDb(name) { - return !!db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(name); -} - -// Same check as isUsernameTakenInDb, but excludes the given user's own row - -// used by upsertUser's UPDATE (returning-user) path, where the row being -// updated already "has" the old username and must not be treated as its own -// collision. -function isUsernameTakenByOtherUser(name, excludeId) { - return !!db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?').get(name, excludeId); -} - -const insertUserStmt = db.prepare(` - INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) - VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) -`); - -export async function upsertUser({ provider, providerId, username, avatarUrl }) { - const id = `${provider}:${providerId}`; - const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id); - if (existing) { - // A user who has set a custom username keeps it on re-login; only the - // avatar (which the user doesn't control) is refreshed from the profile. - const desiredUsername = existing.custom_username ? existing.username : username; - let nextUsername = desiredUsername; - try { - db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?').run(nextUsername, avatarUrl, id); - } catch (e) { - // The provider-supplied name can change between logins (e.g. the user - // renamed their display name on the OAuth provider) and collide - // case-insensitively with a DIFFERENT user's username. Without this - // catch, that error would propagate to a 500 and - since upsertUser - // runs on every login - permanently lock the account out until the - // provider-side name changed back. Same suffixing convention/helper as - // the INSERT path, excluding this user's own row from the collision - // check (their old value isn't a collision against their new one). - if (e.code !== 'SQLITE_CONSTRAINT_UNIQUE' && e.code !== 'SQLITE_CONSTRAINT') throw e; - nextUsername = findAvailableUsername(desiredUsername, (name) => isUsernameTakenByOtherUser(name, id)); - db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?').run(nextUsername, avatarUrl, id); - } - return { ...existing, username: nextUsername, avatar_url: avatarUrl }; - } - - const user = { - id, provider, provider_id: providerId, username, avatar_url: avatarUrl, created_at: Date.now(), - }; - try { - insertUserStmt.run(user); - } catch (e) { - // Two different brand-new OAuth accounts can independently supply the - // same (or case-variant) username - the COLLATE NOCASE unique index - // rejects the second insert with SQLITE_CONSTRAINT_UNIQUE. Without this - // catch, that error would propagate to a 500 and - since upsertUser - // runs on every login, not just the first - permanently block that - // account from ever logging in. Pick a free variant using the same - // suffixing convention as dedupeUsernames and retry once. - if (e.code !== 'SQLITE_CONSTRAINT_UNIQUE' && e.code !== 'SQLITE_CONSTRAINT') throw e; - user.username = findAvailableUsername(username, isUsernameTakenInDb); - insertUserStmt.run(user); - } - return user; -} - -export async function getUserById(id) { - return db.prepare('SELECT * FROM users WHERE id = ?').get(id); -} - -export async function getAllUsersWithSaves() { - return db.prepare(` - SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, - u.leaderboard_opt_out, - s.data, s.last_save - FROM users u - LEFT JOIN saves s ON s.user_id = u.id - ORDER BY u.created_at DESC - `).all(); -} - -export async function getSave(userId) { - return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); -} - -export async function putSave(userId, data, lastSave) { - db.prepare(` - INSERT INTO saves (user_id, data, last_save) VALUES (?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, last_save = excluded.last_save - `).run(userId, JSON.stringify(data), lastSave); -} - -export async function deleteSave(userId) { - db.prepare('DELETE FROM saves WHERE user_id = ?').run(userId); -} - -/** - * Roles are stored as a JSON array string in users.roles (default '[]'). - * Membership in the array is the only thing that matters - ordering and - * duplicates are not deduped here; callers (server/auth.js, Task 8) treat - * this as a plain set. - */ -export async function getRoles(userId) { - const row = db.prepare('SELECT roles FROM users WHERE id = ?').get(userId); - if (!row || !row.roles) return []; - try { - return JSON.parse(row.roles); - } catch (e) { - return []; - } -} - -export async function setRoles(userId, roles) { - db.prepare('UPDATE users SET roles = ? WHERE id = ?').run(JSON.stringify(roles), userId); -} - -/** - * Completed guided tours, stored as a JSON array string in - * users.tours_completed (default '[]') - the same shape and defensive-read - * contract as users.roles above. Callers treat it as a plain set; the route - * layer owns validation against shared/tours.js. - */ -export async function getToursCompleted(userId) { - const row = db.prepare('SELECT tours_completed FROM users WHERE id = ?').get(userId); - if (!row || !row.tours_completed) return []; - try { - const parsed = JSON.parse(row.tours_completed); - return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []; - } catch (e) { - return []; - } -} - -export async function setToursCompleted(userId, ids) { - db.prepare('UPDATE users SET tours_completed = ? WHERE id = ?').run(JSON.stringify(ids), userId); -} - -/** - * Sets a user's username, format-agnostic (the route layer owns the regex). - * Performs its own case-insensitive availability check excluding the user - * themself, and marks the username as user-chosen so upsertUser stops - * overwriting it from the OAuth profile on future logins. - */ -export async function setUsername(userId, name) { - const collision = db.prepare( - 'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?', - ).get(name, userId); - if (collision) return { ok: false, error: 'taken' }; - db.prepare('UPDATE users SET username = ?, custom_username = 1 WHERE id = ?').run(name, userId); - return { ok: true }; -} - -export async function createMinigameSession(userId, game) { - const session = { - id: randomUUID(), - user_id: userId, - game, - started_at: Date.now(), - finished_at: null, - score: null, - }; - db.prepare(` - INSERT INTO minigame_sessions (id, user_id, game, started_at, finished_at, score) - VALUES (@id, @user_id, @game, @started_at, @finished_at, @score) - `).run(session); - return session; -} - -export async function getMinigameSession(id) { - return db.prepare('SELECT * FROM minigame_sessions WHERE id = ?').get(id); -} - -/** - * Finds the most recent still-open (unfinished, not yet expired) session - * for `userId`+`game`, if any. "Not yet expired" is caller-supplied as - * `minStartedAt` (a session's `started_at` must be >= this to count) since - * the expiry window depends on `config.minigames[game].durationSec`, which - * this module doesn't have access to - the route layer computes it. - * Used to block a burst of concurrently-open sessions for the same game - * (each of which would otherwise dodge the win cooldown independently). - */ -export async function getOpenMinigameSession(userId, game, minStartedAt) { - return db.prepare(` - SELECT * FROM minigame_sessions - WHERE user_id = ? AND game = ? AND finished_at IS NULL AND started_at >= ? - ORDER BY started_at DESC LIMIT 1 - `).get(userId, game, minStartedAt); -} - -export async function finishMinigameSession(id, score) { - db.prepare('UPDATE minigame_sessions SET finished_at = ?, score = ? WHERE id = ?').run(Date.now(), score, id); -} - -/** - * Returns the singleton config row (id=1): { id, version, data, updated_at, - * updated_by }, or undefined if no config has been seeded yet. `data` is - * returned as the raw JSON text exactly as stored - mirroring getSave's - * convention, callers JSON.parse it themselves. - */ -export async function getConfigRow() { - return db.prepare('SELECT * FROM config WHERE id = 1').get(); -} - -/** - * Upserts the singleton config row (id=1) to `{ version, data, userId }` - * and appends a matching row to config_history for audit/rollback. `data` - * is a plain JS object; it is JSON.stringify'd here (the same convention - * putSave uses) - callers never pass pre-stringified JSON. - */ -export async function putConfigRow(version, data, userId) { - const text = JSON.stringify(data); - const now = Date.now(); - db.prepare(` - INSERT INTO config (id, version, data, updated_at, updated_by) VALUES (1, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET version = excluded.version, data = excluded.data, - updated_at = excluded.updated_at, updated_by = excluded.updated_by - `).run(version, text, now, userId); - db.prepare(` - INSERT INTO config_history (version, data, updated_at, updated_by) VALUES (?, ?, ?, ?) - `).run(version, text, now, userId); -} - -export async function getConfigHistory() { - return db.prepare('SELECT * FROM config_history ORDER BY rowid DESC').all(); -} - -// --- Live Events (v1.4) ----------------------------------------------- -// -// Unlike getSave/getConfigRow (which hand back their JSON columns as raw -// text and let the caller JSON.parse), the event getters below parse -// `theme`, `modifiers`, `ladder`, and `recurrence` before returning. That's -// a deliberate departure from the rest of this module's convention: every -// caller of these getters (route layer, scheduler, reducer-side effective -// config merge) needs the structured value, never the raw text, so parsing -// once here avoids repeating (and re-risking) JSON.parse at every call site. - -function parseEventRow(row) { - if (!row) return row; - return { - ...row, - theme: JSON.parse(row.theme ?? 'null'), - modifiers: JSON.parse(row.modifiers), - ladder: JSON.parse(row.ladder), - recurrence: JSON.parse(row.recurrence ?? 'null'), - }; -} - -export async function listEvents() { - return db.prepare('SELECT * FROM live_events ORDER BY created_at ASC').all().map(parseEventRow); -} - -export async function getEvent(id) { - return parseEventRow(db.prepare('SELECT * FROM live_events WHERE id = ?').get(id)); -} - -/** - * Returns the single event currently in status 'active', or undefined if - * none is. Keeping at most one event active is an application-level - * invariant enforced by the lifecycle/scheduler (Task 4), not a DB - * constraint - this just reads the first match. - */ -export async function getActiveEvent() { - return parseEventRow(db.prepare("SELECT * FROM live_events WHERE status = 'active' LIMIT 1").get()); -} - -const putEventStmt = db.prepare(` - INSERT INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) - VALUES (@id, @name, @description, @theme, @modifiers, @ladder, @status, @starts_at, @ends_at, @recurrence, @created_at, @created_by) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, description = excluded.description, theme = excluded.theme, - modifiers = excluded.modifiers, ladder = excluded.ladder, status = excluded.status, - starts_at = excluded.starts_at, ends_at = excluded.ends_at, recurrence = excluded.recurrence, - created_by = excluded.created_by -`); - -/** - * Insert-or-replace for a single event, keyed on `event.id`. `theme`, - * `modifiers`, `ladder`, and `recurrence` are plain JS values here (arrays/ - * objects/null); this function JSON.stringify's them for storage, mirroring - * putSave/putConfigRow's convention of stringifying at the write boundary. - * On conflict, `created_at` is intentionally left untouched (it's the - * original creation time, not a "last written" timestamp) - everything else - * is fully replaced. Accepts either camelCase (startsAt/createdAt/createdBy) - * or snake_case (starts_at/created_at/created_by) keys for the non-JSON - * fields, since callers may pass back a row previously read via getEvent - * (snake_case) or freshly authored data (camelCase). - */ -export async function putEvent(event) { - const row = { - id: event.id, - name: event.name, - description: event.description ?? null, - theme: JSON.stringify(event.theme ?? null), - modifiers: JSON.stringify(event.modifiers ?? []), - ladder: JSON.stringify(event.ladder ?? []), - status: event.status ?? 'draft', - starts_at: event.startsAt ?? event.starts_at ?? null, - ends_at: event.endsAt ?? event.ends_at ?? null, - recurrence: JSON.stringify(event.recurrence ?? null), - created_at: event.createdAt ?? event.created_at ?? Date.now(), - created_by: event.createdBy ?? event.created_by ?? null, - }; - putEventStmt.run(row); - return await getEvent(row.id); -} - -/** - * Updates only `status`, plus `starts_at`/`ends_at` when explicitly passed - * in the options object (a key present but `null` clears that column; a key - * simply absent leaves the existing value untouched). This lets the - * scheduler flip status alone (e.g. active -> ended) without needing to - * re-supply - or accidentally wipe - the event's window. - */ -export async function setEventStatus(id, status, { startsAt, endsAt } = {}) { - const sets = ['status = @status']; - const params = { id, status }; - if (startsAt !== undefined) { sets.push('starts_at = @starts_at'); params.starts_at = startsAt; } - if (endsAt !== undefined) { sets.push('ends_at = @ends_at'); params.ends_at = endsAt; } - db.prepare(`UPDATE live_events SET ${sets.join(', ')} WHERE id = @id`).run(params); -} - -/** - * Deletes an event row outright. This module does not enforce "drafts - * only" - the route layer (Task 6) is responsible for rejecting deletes of - * scheduled/active/ended events before calling this. - */ -export async function deleteEvent(id) { - db.prepare('DELETE FROM live_events WHERE id = ?').run(id); -} - -const upsertParticipationStmt = db.prepare(` - INSERT INTO event_participation (user_id, event_id, started_at, ends_at, rungs_claimed, last_progress_at, opted_out) - VALUES (@user_id, @event_id, @started_at, @ends_at, @rungs_claimed, @last_progress_at, @opted_out) - ON CONFLICT(user_id, event_id) DO UPDATE SET - started_at = excluded.started_at, ends_at = excluded.ends_at, - rungs_claimed = excluded.rungs_claimed, last_progress_at = excluded.last_progress_at, - opted_out = excluded.opted_out -`); - -/** - * Insert-or-replace for one user's participation row in one event, keyed on - * (user_id, event_id). Accepts camelCase or snake_case keys, same rationale - * as putEvent. - */ -export async function upsertParticipation(row) { - const params = { - user_id: row.userId ?? row.user_id, - event_id: row.eventId ?? row.event_id, - started_at: row.startedAt ?? row.started_at, - ends_at: row.endsAt ?? row.ends_at, - rungs_claimed: row.rungsClaimed ?? row.rungs_claimed ?? 0, - last_progress_at: row.lastProgressAt ?? row.last_progress_at ?? null, - opted_out: (row.optedOut ?? row.opted_out) ? 1 : 0, - }; - upsertParticipationStmt.run(params); - return await getParticipation(params.user_id, params.event_id); -} - -export async function getParticipation(userId, eventId) { - return db.prepare('SELECT * FROM event_participation WHERE user_id = ? AND event_id = ?').get(userId, eventId); -} - -const updateParticipationProgressStmt = db.prepare(` - UPDATE event_participation SET rungs_claimed = ?, last_progress_at = ? - WHERE user_id = ? AND event_id = ? -`); - -/** - * Narrow, idempotent progress sync used by stateService.applyActions after a - * successful claimEventRung (hotfix for the "rungs_claimed frozen at 0" bug - - * upsertParticipation was only ever called once, at join time, from - * joinEventIfEligible). Deliberately NOT a call to upsertParticipation: that - * function's ON CONFLICT clause overwrites every column, including - * `opted_out` and `started_at`/`ends_at` - a caller here that doesn't have - * (or doesn't want to re-fetch) the user's current opt-out flag would - * silently un-opt-out them on every claim. A plain UPDATE touching only - * `rungs_claimed`/`last_progress_at` leaves every other column alone, and is - * a harmless no-op (0 rows affected, no throw) if the participation row - * doesn't exist for some reason (e.g. the event was deleted out from under - * an in-flight claim). - */ -export async function updateParticipationProgress(userId, eventId, rungsClaimed, lastProgressAt) { - updateParticipationProgressStmt.run(rungsClaimed, lastProgressAt, userId, eventId); -} - -/** - * All participants in an event, ranked for the coordinator view / leaderboard: - * most rungs claimed first, ties broken by whoever reached their current - * progress earliest. - */ -export async function listParticipation(eventId) { - return db.prepare( - 'SELECT * FROM event_participation WHERE event_id = ? ORDER BY rungs_claimed DESC, last_progress_at ASC', - ).all(eventId); -} - -export async function setLeaderboardOptOut(userId, optOut) { - db.prepare('UPDATE users SET leaderboard_opt_out = ? WHERE id = ?').run(optOut ? 1 : 0, userId); -} - -/** - * Leaderboard rows for `eventId`, same ranking as listParticipation (most - * rungs claimed first, ties broken by earliest last_progress_at), but - - * unlike listParticipation - LEFT JOINed against `users` and filtered on - * the LIVE `users.leaderboard_opt_out`, not the value snapshotted into - * `event_participation.opted_out` at join time. That snapshot is written - * once by joinEventIfEligible and never updated again, so a user who opts - * out AFTER joining would otherwise keep appearing here (Task 6 review - * carry-forward, hard requirement 1). PUT /api/me/leaderboard-opt-out - * writes straight to `users.leaderboard_opt_out`, so this query picks up - * that change immediately on the very next read - no re-sync step needed. - * Capped at `limit` rows (default 50, per the route's leaderboard contract). - */ -export async function listLeaderboard(eventId, limit = 50) { - return db.prepare(` - SELECT ep.user_id AS userId, u.username AS username, - ep.rungs_claimed AS rungsClaimed, ep.last_progress_at AS lastProgressAt - FROM event_participation ep - LEFT JOIN users u ON u.id = ep.user_id - WHERE ep.event_id = ? AND COALESCE(u.leaderboard_opt_out, 0) = 0 - ORDER BY ep.rungs_claimed DESC, ep.last_progress_at ASC - LIMIT ? - `).all(eventId, limit); -} - -/** - * The most recently-STARTED event that has actually run (any status except - * 'draft', which by definition has no window). Backs the v1.5 leaderboard's - * latest-event board. Returns null when no event has ever been scheduled. - */ -export async function getLatestEventId() { - const row = db.prepare( - "SELECT id FROM live_events WHERE status != 'draft' AND starts_at IS NOT NULL ORDER BY starts_at DESC LIMIT 1", - ).get(); - return row ? row.id : null; -} - -const seedEventStmt = db.prepare(` - INSERT OR IGNORE INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) - VALUES (@id, @name, @description, @theme, @modifiers, @ladder, 'draft', NULL, NULL, @recurrence, @created_at, NULL) -`); - -/** - * Inserts each SEASONAL_EVENTS entry as status 'draft' with no window, but - * only when that id isn't already present - INSERT OR IGNORE makes this - * safe to call on every boot (idempotent) without ever clobbering an - * admin-edited copy of a seeded event (e.g. a coordinator tweaked - * summer-surge's modifiers or already scheduled it). Called from - * ensureConfig-adjacent boot code (Task 4). - */ -export async function seedSeasonalEvents() { - const now = Date.now(); - for (const evt of SEASONAL_EVENTS) { - seedEventStmt.run({ - id: evt.id, - name: evt.name, - description: evt.description ?? null, - theme: JSON.stringify(evt.theme ?? null), - modifiers: JSON.stringify(evt.modifiers ?? []), - ladder: JSON.stringify(evt.ladder ?? []), - recurrence: JSON.stringify(evt.recurrence ?? null), - created_at: now, - }); - } -} +export * from './db/index.js'; diff --git a/server/db/driver.sqlite.js b/server/db/driver.sqlite.js new file mode 100644 index 0000000..a5a32ed --- /dev/null +++ b/server/db/driver.sqlite.js @@ -0,0 +1,449 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'node:crypto'; +import { SEASONAL_EVENTS } from '../data/seasonalEvents.js'; +import { applySchema, dedupeUsernamesSync } from './schema.sqlite.js'; +import { + findAvailableUsername, parseEventRow, normalizeEventRow, normalizeParticipationRow, +} from './shared.js'; + +export async function createSqliteDriver({ path: dbPath }) { + if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + await applySchema(db); + + function isUsernameTakenInDb(name) { + return !!db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(name); + } + + // Same check as isUsernameTakenInDb, but excludes the given user's own row - + // used by upsertUser's UPDATE (returning-user) path, where the row being + // updated already "has" the old username and must not be treated as its own + // collision. + function isUsernameTakenByOtherUser(name, excludeId) { + return !!db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?').get(name, excludeId); + } + + const insertUserStmt = db.prepare(` + INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) + VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) + `); + + const putEventStmt = db.prepare(` + INSERT INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) + VALUES (@id, @name, @description, @theme, @modifiers, @ladder, @status, @starts_at, @ends_at, @recurrence, @created_at, @created_by) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, description = excluded.description, theme = excluded.theme, + modifiers = excluded.modifiers, ladder = excluded.ladder, status = excluded.status, + starts_at = excluded.starts_at, ends_at = excluded.ends_at, recurrence = excluded.recurrence, + created_by = excluded.created_by + `); + + const upsertParticipationStmt = db.prepare(` + INSERT INTO event_participation (user_id, event_id, started_at, ends_at, rungs_claimed, last_progress_at, opted_out) + VALUES (@user_id, @event_id, @started_at, @ends_at, @rungs_claimed, @last_progress_at, @opted_out) + ON CONFLICT(user_id, event_id) DO UPDATE SET + started_at = excluded.started_at, ends_at = excluded.ends_at, + rungs_claimed = excluded.rungs_claimed, last_progress_at = excluded.last_progress_at, + opted_out = excluded.opted_out + `); + + const updateParticipationProgressStmt = db.prepare(` + UPDATE event_participation SET rungs_claimed = ?, last_progress_at = ? + WHERE user_id = ? AND event_id = ? + `); + + const seedEventStmt = db.prepare(` + INSERT OR IGNORE INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) + VALUES (@id, @name, @description, @theme, @modifiers, @ladder, 'draft', NULL, NULL, @recurrence, @created_at, NULL) + `); + + const driver = { + __backend: 'sqlite', + __raw: db, + + async upsertUser({ provider, providerId, username, avatarUrl }) { + const id = `${provider}:${providerId}`; + const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (existing) { + // A user who has set a custom username keeps it on re-login; only the + // avatar (which the user doesn't control) is refreshed from the profile. + const desiredUsername = existing.custom_username ? existing.username : username; + let nextUsername = desiredUsername; + try { + db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?').run(nextUsername, avatarUrl, id); + } catch (e) { + // The provider-supplied name can change between logins (e.g. the user + // renamed their display name on the OAuth provider) and collide + // case-insensitively with a DIFFERENT user's username. Without this + // catch, that error would propagate to a 500 and - since upsertUser + // runs on every login - permanently lock the account out until the + // provider-side name changed back. Same suffixing convention/helper as + // the INSERT path, excluding this user's own row from the collision + // check (their old value isn't a collision against their new one). + if (e.code !== 'SQLITE_CONSTRAINT_UNIQUE' && e.code !== 'SQLITE_CONSTRAINT') throw e; + nextUsername = await findAvailableUsername( + desiredUsername, + (name) => isUsernameTakenByOtherUser(name, id), + ); + db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?').run(nextUsername, avatarUrl, id); + } + return { ...existing, username: nextUsername, avatar_url: avatarUrl }; + } + + const user = { + id, provider, provider_id: providerId, username, avatar_url: avatarUrl, created_at: Date.now(), + }; + try { + insertUserStmt.run(user); + } catch (e) { + // Two different brand-new OAuth accounts can independently supply the + // same (or case-variant) username - the COLLATE NOCASE unique index + // rejects the second insert with SQLITE_CONSTRAINT_UNIQUE. Without this + // catch, that error would propagate to a 500 and - since upsertUser + // runs on every login, not just the first - permanently block that + // account from ever logging in. Pick a free variant using the same + // suffixing convention as dedupeUsernames and retry once. + if (e.code !== 'SQLITE_CONSTRAINT_UNIQUE' && e.code !== 'SQLITE_CONSTRAINT') throw e; + user.username = await findAvailableUsername(username, isUsernameTakenInDb); + insertUserStmt.run(user); + } + return user; + }, + + async getUserById(id) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); + }, + + async getAllUsersWithSaves() { + return db.prepare(` + SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, + u.leaderboard_opt_out, + s.data, s.last_save + FROM users u + LEFT JOIN saves s ON s.user_id = u.id + ORDER BY u.created_at DESC + `).all(); + }, + + async getSave(userId) { + return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); + }, + + async putSave(userId, data, lastSave) { + db.prepare(` + INSERT INTO saves (user_id, data, last_save) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, last_save = excluded.last_save + `).run(userId, JSON.stringify(data), lastSave); + }, + + async deleteSave(userId) { + db.prepare('DELETE FROM saves WHERE user_id = ?').run(userId); + }, + + /** + * Roles are stored as a JSON array string in users.roles (default '[]'). + * Membership in the array is the only thing that matters - ordering and + * duplicates are not deduped here; callers (server/auth.js, Task 8) treat + * this as a plain set. + */ + async getRoles(userId) { + const row = db.prepare('SELECT roles FROM users WHERE id = ?').get(userId); + if (!row || !row.roles) return []; + try { + return JSON.parse(row.roles); + } catch (e) { + return []; + } + }, + + async setRoles(userId, roles) { + db.prepare('UPDATE users SET roles = ? WHERE id = ?').run(JSON.stringify(roles), userId); + }, + + /** + * Completed guided tours, stored as a JSON array string in + * users.tours_completed (default '[]') - the same shape and defensive-read + * contract as users.roles above. Callers treat it as a plain set; the route + * layer owns validation against shared/tours.js. + */ + async getToursCompleted(userId) { + const row = db.prepare('SELECT tours_completed FROM users WHERE id = ?').get(userId); + if (!row || !row.tours_completed) return []; + try { + const parsed = JSON.parse(row.tours_completed); + return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []; + } catch (e) { + return []; + } + }, + + async setToursCompleted(userId, ids) { + db.prepare('UPDATE users SET tours_completed = ? WHERE id = ?').run(JSON.stringify(ids), userId); + }, + + /** + * Sets a user's username, format-agnostic (the route layer owns the regex). + * Performs its own case-insensitive availability check excluding the user + * themself, and marks the username as user-chosen so upsertUser stops + * overwriting it from the OAuth profile on future logins. + */ + async setUsername(userId, name) { + const collision = db.prepare( + 'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?', + ).get(name, userId); + if (collision) return { ok: false, error: 'taken' }; + db.prepare('UPDATE users SET username = ?, custom_username = 1 WHERE id = ?').run(name, userId); + return { ok: true }; + }, + + async dedupeUsernames() { + return dedupeUsernamesSync(db); + }, + + async createMinigameSession(userId, game) { + const session = { + id: randomUUID(), + user_id: userId, + game, + started_at: Date.now(), + finished_at: null, + score: null, + }; + db.prepare(` + INSERT INTO minigame_sessions (id, user_id, game, started_at, finished_at, score) + VALUES (@id, @user_id, @game, @started_at, @finished_at, @score) + `).run(session); + return session; + }, + + async getMinigameSession(id) { + return db.prepare('SELECT * FROM minigame_sessions WHERE id = ?').get(id); + }, + + /** + * Finds the most recent still-open (unfinished, not yet expired) session + * for `userId`+`game`, if any. "Not yet expired" is caller-supplied as + * `minStartedAt` (a session's `started_at` must be >= this to count) since + * the expiry window depends on `config.minigames[game].durationSec`, which + * this module doesn't have access to - the route layer computes it. + * Used to block a burst of concurrently-open sessions for the same game + * (each of which would otherwise dodge the win cooldown independently). + */ + async getOpenMinigameSession(userId, game, minStartedAt) { + return db.prepare(` + SELECT * FROM minigame_sessions + WHERE user_id = ? AND game = ? AND finished_at IS NULL AND started_at >= ? + ORDER BY started_at DESC LIMIT 1 + `).get(userId, game, minStartedAt); + }, + + async finishMinigameSession(id, score) { + db.prepare('UPDATE minigame_sessions SET finished_at = ?, score = ? WHERE id = ?').run(Date.now(), score, id); + }, + + /** + * Returns the singleton config row (id=1): { id, version, data, updated_at, + * updated_by }, or undefined if no config has been seeded yet. `data` is + * returned as the raw JSON text exactly as stored - mirroring getSave's + * convention, callers JSON.parse it themselves. + */ + async getConfigRow() { + return db.prepare('SELECT * FROM config WHERE id = 1').get(); + }, + + /** + * Upserts the singleton config row (id=1) to `{ version, data, userId }` + * and appends a matching row to config_history for audit/rollback. `data` + * is a plain JS object; it is JSON.stringify'd here (the same convention + * putSave uses) - callers never pass pre-stringified JSON. + */ + async putConfigRow(version, data, userId) { + const text = JSON.stringify(data); + const now = Date.now(); + db.prepare(` + INSERT INTO config (id, version, data, updated_at, updated_by) VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET version = excluded.version, data = excluded.data, + updated_at = excluded.updated_at, updated_by = excluded.updated_by + `).run(version, text, now, userId); + db.prepare(` + INSERT INTO config_history (version, data, updated_at, updated_by) VALUES (?, ?, ?, ?) + `).run(version, text, now, userId); + }, + + async getConfigHistory() { + return db.prepare('SELECT * FROM config_history ORDER BY rowid DESC').all(); + }, + + async listEvents() { + return db.prepare('SELECT * FROM live_events ORDER BY created_at ASC').all().map(parseEventRow); + }, + + async getEvent(id) { + return parseEventRow(db.prepare('SELECT * FROM live_events WHERE id = ?').get(id)); + }, + + /** + * Returns the single event currently in status 'active', or undefined if + * none is. Keeping at most one event active is an application-level + * invariant enforced by the lifecycle/scheduler (Task 4), not a DB + * constraint - this just reads the first match. + */ + async getActiveEvent() { + return parseEventRow(db.prepare("SELECT * FROM live_events WHERE status = 'active' LIMIT 1").get()); + }, + + /** + * Insert-or-replace for a single event, keyed on `event.id`. On conflict, + * `created_at` is intentionally left untouched (it's the original + * creation time, not a "last written" timestamp) - everything else is + * fully replaced. + */ + async putEvent(event) { + const row = normalizeEventRow(event); + putEventStmt.run(row); + // Reference the sibling method via the `driver` closure variable, not + // `this` - the facade (server/db/index.js) destructures these methods + // off the driver object and exports them as free functions, so a call + // site like `putEvent(...)` (no receiver) would leave `this` undefined. + return driver.getEvent(row.id); + }, + + /** + * Updates only `status`, plus `starts_at`/`ends_at` when explicitly passed + * in the options object (a key present but `null` clears that column; a key + * simply absent leaves the existing value untouched). This lets the + * scheduler flip status alone (e.g. active -> ended) without needing to + * re-supply - or accidentally wipe - the event's window. + */ + async setEventStatus(id, status, { startsAt, endsAt } = {}) { + const sets = ['status = @status']; + const params = { id, status }; + if (startsAt !== undefined) { sets.push('starts_at = @starts_at'); params.starts_at = startsAt; } + if (endsAt !== undefined) { sets.push('ends_at = @ends_at'); params.ends_at = endsAt; } + db.prepare(`UPDATE live_events SET ${sets.join(', ')} WHERE id = @id`).run(params); + }, + + /** + * Deletes an event row outright. This module does not enforce "drafts + * only" - the route layer (Task 6) is responsible for rejecting deletes of + * scheduled/active/ended events before calling this. + */ + async deleteEvent(id) { + db.prepare('DELETE FROM live_events WHERE id = ?').run(id); + }, + + /** + * Insert-or-replace for one user's participation row in one event, keyed on + * (user_id, event_id). + */ + async upsertParticipation(row) { + const params = normalizeParticipationRow(row); + upsertParticipationStmt.run(params); + return driver.getParticipation(params.user_id, params.event_id); + }, + + async getParticipation(userId, eventId) { + return db.prepare('SELECT * FROM event_participation WHERE user_id = ? AND event_id = ?').get(userId, eventId); + }, + + /** + * Narrow, idempotent progress sync used by stateService.applyActions after a + * successful claimEventRung (hotfix for the "rungs_claimed frozen at 0" bug - + * upsertParticipation was only ever called once, at join time, from + * joinEventIfEligible). Deliberately NOT a call to upsertParticipation: that + * function's ON CONFLICT clause overwrites every column, including + * `opted_out` and `started_at`/`ends_at` - a caller here that doesn't have + * (or doesn't want to re-fetch) the user's current opt-out flag would + * silently un-opt-out them on every claim. A plain UPDATE touching only + * `rungs_claimed`/`last_progress_at` leaves every other column alone, and is + * a harmless no-op (0 rows affected, no throw) if the participation row + * doesn't exist for some reason (e.g. the event was deleted out from under + * an in-flight claim). + */ + async updateParticipationProgress(userId, eventId, rungsClaimed, lastProgressAt) { + updateParticipationProgressStmt.run(rungsClaimed, lastProgressAt, userId, eventId); + }, + + /** + * All participants in an event, ranked for the coordinator view / leaderboard: + * most rungs claimed first, ties broken by whoever reached their current + * progress earliest. + */ + async listParticipation(eventId) { + return db.prepare( + 'SELECT * FROM event_participation WHERE event_id = ? ORDER BY rungs_claimed DESC, last_progress_at ASC', + ).all(eventId); + }, + + async setLeaderboardOptOut(userId, optOut) { + db.prepare('UPDATE users SET leaderboard_opt_out = ? WHERE id = ?').run(optOut ? 1 : 0, userId); + }, + + /** + * Leaderboard rows for `eventId`, same ranking as listParticipation (most + * rungs claimed first, ties broken by earliest last_progress_at), but - + * unlike listParticipation - LEFT JOINed against `users` and filtered on + * the LIVE `users.leaderboard_opt_out`, not the value snapshotted into + * `event_participation.opted_out` at join time. That snapshot is written + * once by joinEventIfEligible and never updated again, so a user who opts + * out AFTER joining would otherwise keep appearing here (Task 6 review + * carry-forward, hard requirement 1). PUT /api/me/leaderboard-opt-out + * writes straight to `users.leaderboard_opt_out`, so this query picks up + * that change immediately on the very next read - no re-sync step needed. + * Capped at `limit` rows (default 50, per the route's leaderboard contract). + */ + async listLeaderboard(eventId, limit = 50) { + return db.prepare(` + SELECT ep.user_id AS userId, u.username AS username, + ep.rungs_claimed AS rungsClaimed, ep.last_progress_at AS lastProgressAt + FROM event_participation ep + LEFT JOIN users u ON u.id = ep.user_id + WHERE ep.event_id = ? AND COALESCE(u.leaderboard_opt_out, 0) = 0 + ORDER BY ep.rungs_claimed DESC, ep.last_progress_at ASC + LIMIT ? + `).all(eventId, limit); + }, + + /** + * The most recently-STARTED event that has actually run (any status except + * 'draft', which by definition has no window). Backs the v1.5 leaderboard's + * latest-event board. Returns null when no event has ever been scheduled. + */ + async getLatestEventId() { + const row = db.prepare( + "SELECT id FROM live_events WHERE status != 'draft' AND starts_at IS NOT NULL ORDER BY starts_at DESC LIMIT 1", + ).get(); + return row ? row.id : null; + }, + + /** + * Inserts each SEASONAL_EVENTS entry as status 'draft' with no window, but + * only when that id isn't already present - INSERT OR IGNORE makes this + * safe to call on every boot (idempotent) without ever clobbering an + * admin-edited copy of a seeded event (e.g. a coordinator tweaked + * summer-surge's modifiers or already scheduled it). Called from + * ensureConfig-adjacent boot code (Task 4). + */ + async seedSeasonalEvents() { + const now = Date.now(); + for (const evt of SEASONAL_EVENTS) { + seedEventStmt.run({ + id: evt.id, + name: evt.name, + description: evt.description ?? null, + theme: JSON.stringify(evt.theme ?? null), + modifiers: JSON.stringify(evt.modifiers ?? []), + ladder: JSON.stringify(evt.ladder ?? []), + recurrence: JSON.stringify(evt.recurrence ?? null), + created_at: now, + }); + } + }, + }; + + return driver; +} diff --git a/server/db/index.js b/server/db/index.js new file mode 100644 index 0000000..2e94329 --- /dev/null +++ b/server/db/index.js @@ -0,0 +1,24 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createSqliteDriver } from './driver.sqlite.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'rackstack.db'); + +// Top-level await: every consumer does `import { getSave } from './db.js'`, +// so the driver must be resolved before this module finishes evaluating. +// Task 4 adds the DATABASE_URL branch here. +const driver = await createSqliteDriver({ path: DB_PATH }); + +export const { + upsertUser, getUserById, getAllUsersWithSaves, getSave, putSave, deleteSave, + getRoles, setRoles, getToursCompleted, setToursCompleted, setUsername, + dedupeUsernames, createMinigameSession, getMinigameSession, + getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow, + getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, + setEventStatus, deleteEvent, upsertParticipation, getParticipation, + updateParticipationProgress, listParticipation, setLeaderboardOptOut, + listLeaderboard, getLatestEventId, seedSeasonalEvents, +} = driver; + +export { driver }; diff --git a/server/db/interface.md b/server/db/interface.md new file mode 100644 index 0000000..8a6a1b6 --- /dev/null +++ b/server/db/interface.md @@ -0,0 +1,76 @@ +# `server/db/` — repository interface + +`server/db.js` is a re-export shim (`export * from './db/index.js';`) kept so +none of the existing `import { ... } from './db.js'` / `'../db.js'` call +sites need to change. New code should import from `server/db/index.js` +directly. + +## Layout + +- `index.js` — the facade. Picks a driver at boot (currently always SQLite; + Task 4 adds a `DATABASE_URL`-gated Postgres branch) and re-exports every + interface function as a top-level named export, plus `driver` itself. +- `driver.sqlite.js` — `createSqliteDriver({ path }) → Promise`. + Owns every `better-sqlite3`-specific query. +- `schema.sqlite.js` — DDL and schema-version bookkeeping for the SQLite + backend: `applySchema(db)`, `appliedVersions(db)`, `markApplied(db, version)`. +- `shared.js` — dialect-free helpers used by (eventually) every driver: + username-suffixing, event-row JSON parsing, and the camelCase/snake_case + row normalizers for `putEvent`/`upsertParticipation`. No SQL lives here. + +## The `Driver` contract + +`createSqliteDriver` (and, from Task 4, its Postgres counterpart) resolves +to an object with exactly these keys: + +- `__backend` — `'sqlite'` or `'postgres'`. Test-only; used to skip + backend-specific assertions (e.g. `sqlite_master` introspection) under the + other driver. +- `__raw` — the underlying driver handle (`better-sqlite3`'s `Database`, or + Postgres's pool/client). Test-only, for schema assertions. Application + code must never touch `__raw`; only the facade and driver files interact + with the underlying database. +- Every interface function below, all `async`. + +### Interface functions + +``` +upsertUser, getUserById, getAllUsersWithSaves, getSave, putSave, +deleteSave, getRoles, setRoles, getToursCompleted, setToursCompleted, +setUsername, dedupeUsernames, createMinigameSession, getMinigameSession, +getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow, +getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, +setEventStatus, deleteEvent, upsertParticipation, getParticipation, +updateParticipationProgress, listParticipation, setLeaderboardOptOut, +listLeaderboard, getLatestEventId, seedSeasonalEvents +``` + +`tests/db.interface.test.js` asserts this exact list is exported as +functions from `server/db/index.js`, and that each one returns a promise. +That test is the contract: a driver that adds, drops, or renames any of +these breaks every consumer. + +### Behavioural invariants a new driver must preserve + +- A missing row resolves to `undefined` (not `null`), matching + `better-sqlite3`'s `.get()` — several existing tests assert + `toBeUndefined()` specifically. +- JSON columns (`saves.data`, `config.data`, `live_events.theme` / + `modifiers` / `ladder` / `recurrence`) are stored as `TEXT`, written with + `JSON.stringify` and read back with `JSON.parse` at the same boundaries + as today. No `jsonb` or driver-side JSON typing. +- Username collision handling goes through `shared.js`'s + `findAvailableUsername` — same `-2`, `-3`, ... suffixing convention, + same COLLATE-NOCASE-equivalent case-insensitivity, regardless of driver. +- `putEvent` / `upsertParticipation` accept either camelCase or snake_case + keys on their input, via `shared.js`'s `normalizeEventRow` / + `normalizeParticipationRow`. + +## Schema versioning + +`schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)` +tracks which numbered migrations have run. `appliedVersions(db)` returns the +applied set; `markApplied(db, version)` records one. Not yet consumed by +`applySchema` itself (which still runs its full, idempotent DDL/guarded-ALTER +sequence on every boot) — Task 5 is expected to be the first real consumer of +version-gated migrations. diff --git a/server/db/schema.sqlite.js b/server/db/schema.sqlite.js new file mode 100644 index 0000000..b0f61b6 --- /dev/null +++ b/server/db/schema.sqlite.js @@ -0,0 +1,147 @@ +import { findAvailableUsername } from './shared.js'; + +/** + * Duplicate usernames (case-insensitively) can exist from before the unique + * index below was introduced. Must run before the CREATE UNIQUE INDEX or + * that statement would fail on any pre-existing collision. No-op when there + * are no duplicates, so it's cheap to run unconditionally on every boot. + * + * findAvailableUsername is async (its Postgres counterpart needs an async + * predicate), so this function is too - awaited by applySchema before it + * creates the unique index. The isTaken predicate here is itself sync (an + * in-memory Set check); awaiting a non-promise value is harmless. + */ +export async function dedupeUsernamesSync(db) { + const rows = db.prepare( + 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', + ).all(); + const taken = new Set(); + for (const row of rows) { + const lower = row.username.toLowerCase(); + if (!taken.has(lower)) { + taken.add(lower); + continue; + } + const candidate = await findAvailableUsername(row.username, (name) => taken.has(name.toLowerCase())); + db.prepare('UPDATE users SET username = ? WHERE id = ?').run(candidate, row.id); + taken.add(candidate.toLowerCase()); + } +} + +// Guarded ALTERs: SQLite has no "ADD COLUMN IF NOT EXISTS", so on every boot +// we attempt the ALTER and swallow only the "duplicate column name" error +// (the column already exists from a prior boot) - anything else rethrows. +function guardedAddColumn(db, sql) { + try { + db.exec(sql); + } catch (err) { + if (!/duplicate column name/i.test(err.message)) throw err; + } +} + +export async function applySchema(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + username TEXT, + avatar_url TEXT, + created_at INTEGER NOT NULL, + UNIQUE(provider, provider_id) + ); + + CREATE TABLE IF NOT EXISTS saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, + last_save INTEGER NOT NULL + ); + `); + + guardedAddColumn(db, "ALTER TABLE users ADD COLUMN roles TEXT DEFAULT '[]'"); + guardedAddColumn(db, 'ALTER TABLE users ADD COLUMN custom_username INTEGER DEFAULT 0'); + // v1.4 Live Events: the opt-out ships here (spec §5.2) and is reused by + // v1.5's global leaderboards - it's a per-user preference, not event-scoped. + guardedAddColumn(db, 'ALTER TABLE users ADD COLUMN leaderboard_opt_out INTEGER DEFAULT 0'); + // v1.6 tours: a JSON array of completed tour ids (shared/tours.js owns the id + // list). Existing players default to '[]' - an empty completed-set is exactly + // what makes the onboarding tour fire once for them. + guardedAddColumn(db, "ALTER TABLE users ADD COLUMN tours_completed TEXT DEFAULT '[]'"); + + db.exec(` + CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL, + updated_by TEXT + ); + + CREATE TABLE IF NOT EXISTS config_history ( + version INTEGER NOT NULL, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL, + updated_by TEXT + ); + + CREATE TABLE IF NOT EXISTS minigame_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + game TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + score INTEGER + ); + `); + + // v1.4 Live Events schema (additive). live_events holds both admin-authored + // and seeded (server/data/seasonalEvents.js) events; event_participation + // tracks each user's progress through one event's ladder while it's live. + db.exec(` + CREATE TABLE IF NOT EXISTS live_events ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + theme TEXT, + modifiers TEXT NOT NULL, + ladder TEXT NOT NULL, + status TEXT NOT NULL, + starts_at INTEGER, + ends_at INTEGER, + recurrence TEXT, + created_at INTEGER NOT NULL, + created_by TEXT + ); + + CREATE TABLE IF NOT EXISTS event_participation ( + user_id TEXT NOT NULL REFERENCES users(id), + event_id TEXT NOT NULL REFERENCES live_events(id), + started_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + rungs_claimed INTEGER NOT NULL DEFAULT 0, + last_progress_at INTEGER, + opted_out INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, event_id) + ); + `); + + await dedupeUsernamesSync(db); + + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); +} + +export function appliedVersions(db) { + return new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version)); +} + +export function markApplied(db, version) { + db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?)') + .run(version, Date.now()); +} diff --git a/server/db/shared.js b/server/db/shared.js new file mode 100644 index 0000000..769053a --- /dev/null +++ b/server/db/shared.js @@ -0,0 +1,93 @@ +// Dialect-free helpers shared by every driver (SQLite, Postgres, ...). None of +// this file touches SQL - it's pure JS logic that must behave identically +// regardless of which driver calls it, so it lives here once instead of +// being duplicated (and risking drift) per driver. + +/** + * Returns a username derived from `desiredName` that `isTaken` reports as + * free, suffixing `-2`, `-3`, ... until one is. `desiredName` itself is + * returned unchanged if it's already free. Shared by dedupeUsernames (bulk + * cleanup, checks an in-memory Set) and upsertUser (per-insert retry, + * checks the DB) so both use the same suffixing convention against the + * same COLLATE NOCASE uniqueness rule. + * + * `isTaken` may be sync (SQLite, backed by a synchronous better-sqlite3 + * call or an in-memory Set) or async (Postgres, backed by a query) - it is + * always awaited here, which is harmless when the value isn't a promise, so + * one implementation serves both drivers. + */ +export async function findAvailableUsername(desiredName, isTaken) { + if (!(await isTaken(desiredName))) return desiredName; + let n = 2; + let candidate = `${desiredName}-${n}`; + while (await isTaken(candidate)) { + n += 1; + candidate = `${desiredName}-${n}`; + } + return candidate; +} + +// --- Live Events (v1.4) ----------------------------------------------- +// +// Unlike getSave/getConfigRow (which hand back their JSON columns as raw +// text and let the caller JSON.parse), the event getters parse `theme`, +// `modifiers`, `ladder`, and `recurrence` before returning. That's a +// deliberate departure from the rest of this module's convention: every +// caller of these getters (route layer, scheduler, reducer-side effective +// config merge) needs the structured value, never the raw text, so parsing +// once here avoids repeating (and re-risking) JSON.parse at every call site. + +export function parseEventRow(row) { + if (!row) return row; + return { + ...row, + theme: JSON.parse(row.theme ?? 'null'), + modifiers: JSON.parse(row.modifiers), + ladder: JSON.parse(row.ladder), + recurrence: JSON.parse(row.recurrence ?? 'null'), + }; +} + +/** + * Normalizes a putEvent() argument into the flat, snake_case, JSON-stringified + * row shape every driver writes to storage. Accepts either camelCase + * (startsAt/createdAt/createdBy) or snake_case (starts_at/created_at/ + * created_by) keys for the non-JSON fields, since callers may pass back a row + * previously read via getEvent (snake_case) or freshly authored data + * (camelCase). On conflict, `created_at` is intentionally left to the + * caller-supplied value (or "now" if absent) - drivers decide whether to + * honor or ignore it on update. + */ +export function normalizeEventRow(event) { + return { + id: event.id, + name: event.name, + description: event.description ?? null, + theme: JSON.stringify(event.theme ?? null), + modifiers: JSON.stringify(event.modifiers ?? []), + ladder: JSON.stringify(event.ladder ?? []), + status: event.status ?? 'draft', + starts_at: event.startsAt ?? event.starts_at ?? null, + ends_at: event.endsAt ?? event.ends_at ?? null, + recurrence: JSON.stringify(event.recurrence ?? null), + created_at: event.createdAt ?? event.created_at ?? Date.now(), + created_by: event.createdBy ?? event.created_by ?? null, + }; +} + +/** + * Normalizes an upsertParticipation() argument into the flat, snake_case row + * shape every driver writes to storage. Accepts camelCase or snake_case + * keys, same rationale as normalizeEventRow. + */ +export function normalizeParticipationRow(row) { + return { + user_id: row.userId ?? row.user_id, + event_id: row.eventId ?? row.event_id, + started_at: row.startedAt ?? row.started_at, + ends_at: row.endsAt ?? row.ends_at, + rungs_claimed: row.rungsClaimed ?? row.rungs_claimed ?? 0, + last_progress_at: row.lastProgressAt ?? row.last_progress_at ?? null, + opted_out: (row.optedOut ?? row.opted_out) ? 1 : 0, + }; +} diff --git a/tests/db.events.test.js b/tests/db.events.test.js index 75f718a..08eaa2a 100644 --- a/tests/db.events.test.js +++ b/tests/db.events.test.js @@ -3,9 +3,10 @@ process.env.DB_PATH = ':memory:'; import { describe, it, expect, beforeEach } from 'vitest'; import { validateModifiers, validateLadder } from '../shared/events.js'; +import { driver } from '../server/db/index.js'; + const dbMod = await import('../server/db.js'); const { - db, upsertUser, listEvents, getEvent, @@ -21,6 +22,12 @@ const { } = dbMod; const { SEASONAL_EVENTS } = await import('../server/data/seasonalEvents.js'); +// Schema/table-existence assertions below are inherently backend-specific +// (sqlite_master, PRAGMA table_info); route them through the driver handle +// rather than a module-level `db` export so the intent stays explicit. The +// pg variant of these assertions is added in Task 4. +const db = driver.__raw; + function tableNames() { return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name); } diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js new file mode 100644 index 0000000..9e62573 --- /dev/null +++ b/tests/db.interface.test.js @@ -0,0 +1,29 @@ +process.env.DB_PATH = ':memory:'; +import { describe, it, expect } from 'vitest'; + +const INTERFACE = [ + 'upsertUser', 'getUserById', 'getAllUsersWithSaves', 'getSave', 'putSave', + 'deleteSave', 'getRoles', 'setRoles', 'getToursCompleted', 'setToursCompleted', + 'setUsername', 'dedupeUsernames', 'createMinigameSession', 'getMinigameSession', + 'getOpenMinigameSession', 'finishMinigameSession', 'getConfigRow', 'putConfigRow', + 'getConfigHistory', 'listEvents', 'getEvent', 'getActiveEvent', 'putEvent', + 'setEventStatus', 'deleteEvent', 'upsertParticipation', 'getParticipation', + 'updateParticipationProgress', 'listParticipation', 'setLeaderboardOptOut', + 'listLeaderboard', 'getLatestEventId', 'seedSeasonalEvents', +]; + +describe('db facade', () => { + it('exports every interface function', async () => { + const mod = await import('../server/db/index.js'); + for (const name of INTERFACE) { + expect(typeof mod[name], `missing export: ${name}`).toBe('function'); + } + }); + + it('every interface function returns a promise', async () => { + const mod = await import('../server/db/index.js'); + const result = mod.getUserById('nobody'); + expect(typeof result.then).toBe('function'); + await result; + }); +}); diff --git a/tests/db.test.js b/tests/db.test.js index 1cfbf20..f6e9ea6 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -2,9 +2,10 @@ process.env.DB_PATH = ':memory:'; import { describe, it, expect, beforeEach } from 'vitest'; +import { driver } from '../server/db/index.js'; + const dbMod = await import('../server/db.js'); const { - db, upsertUser, getUserById, getRoles, @@ -21,6 +22,12 @@ const { getConfigHistory, } = dbMod; +// Schema/table-existence assertions below are inherently backend-specific +// (sqlite_master, PRAGMA table_info); route them through the driver handle +// rather than a module-level `db` export so the intent stays explicit. The +// pg variant of these assertions is added in Task 4. +const db = driver.__raw; + function tableNames() { return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name); } From 722cd9ad70aec1739940d95ef7894bf7d7840ad7 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 13:39:15 -0400 Subject: [PATCH 05/14] Fix db.test.js/db.events.test.js: defer the driver import past DB_PATH Static `import { driver } from '../server/db/index.js'` is hoisted by the ESM spec above the `process.env.DB_PATH = ':memory:'` assignment earlier in the same file, so it stood up the driver (and its data/rackstack.db file) against the real default path instead of an in-memory DB - polluting a real on-disk file across test runs and producing state leakage between tests. Pull `driver` off the existing dynamic `dbMod` import instead, same as every other export these files already consume that way. Caught by rerunning tests/db.test.js in isolation during self-review: it failed deterministically (config/roles state carried over from a prior run) even though the full `npm test` run had passed, because npm test's earlier passing run happened to be the one that created the stray file. --- tests/db.events.test.js | 7 +++++-- tests/db.test.js | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/db.events.test.js b/tests/db.events.test.js index 08eaa2a..ead70cf 100644 --- a/tests/db.events.test.js +++ b/tests/db.events.test.js @@ -3,10 +3,13 @@ process.env.DB_PATH = ':memory:'; import { describe, it, expect, beforeEach } from 'vitest'; import { validateModifiers, validateLadder } from '../shared/events.js'; -import { driver } from '../server/db/index.js'; - +// Dynamic import, deferred until after DB_PATH is set above: a static +// `import { driver } from ...` would be hoisted by the ESM spec above the +// process.env assignment and stand up the driver against the real on-disk +// DB_PATH default instead of :memory:. const dbMod = await import('../server/db.js'); const { + driver, upsertUser, listEvents, getEvent, diff --git a/tests/db.test.js b/tests/db.test.js index f6e9ea6..ea975b1 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -2,10 +2,13 @@ process.env.DB_PATH = ':memory:'; import { describe, it, expect, beforeEach } from 'vitest'; -import { driver } from '../server/db/index.js'; - +// Dynamic import, deferred until after DB_PATH is set above: a static +// `import { driver } from ...` would be hoisted by the ESM spec above the +// process.env assignment and stand up the driver against the real on-disk +// DB_PATH default instead of :memory:. const dbMod = await import('../server/db.js'); const { + driver, upsertUser, getUserById, getRoles, From 8e02d50b0c8c597cef4347e1e84816443170ab9c Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 13:45:49 -0400 Subject: [PATCH 06/14] Rename dedupeUsernamesSync to dedupeUsernames; await it consistently Code review finding: the function has been async (it awaits the shared, async findAvailableUsername) since this refactor split it out of db.js, so the inherited "Sync" suffix was false - misleading for Task 4's Postgres driver author, who needs this exact seam. Renamed in schema.sqlite.js and updated its one call site in applySchema. driver.sqlite.js imports it aliased (dedupeUsernamesSchema) so it doesn't shadow the driver's own dedupeUsernames interface method, which stays a thin, now explicitly awaited, delegation to it (was a bare `return dedupeUsernamesSync(db)`, inconsistent with the explicit awaits elsewhere in the same file). --- server/db/driver.sqlite.js | 8 ++++++-- server/db/schema.sqlite.js | 10 ++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/db/driver.sqlite.js b/server/db/driver.sqlite.js index a5a32ed..99bffd8 100644 --- a/server/db/driver.sqlite.js +++ b/server/db/driver.sqlite.js @@ -3,7 +3,11 @@ import fs from 'fs'; import path from 'path'; import { randomUUID } from 'node:crypto'; import { SEASONAL_EVENTS } from '../data/seasonalEvents.js'; -import { applySchema, dedupeUsernamesSync } from './schema.sqlite.js'; +// Aliased on import: the schema-layer function and this driver's own +// `dedupeUsernames` interface method (a thin delegation to it, below) share +// a name on purpose - the alias just keeps the two from shadowing each +// other inside this file. +import { applySchema, dedupeUsernames as dedupeUsernamesSchema } from './schema.sqlite.js'; import { findAvailableUsername, parseEventRow, normalizeEventRow, normalizeParticipationRow, } from './shared.js'; @@ -201,7 +205,7 @@ export async function createSqliteDriver({ path: dbPath }) { }, async dedupeUsernames() { - return dedupeUsernamesSync(db); + return await dedupeUsernamesSchema(db); }, async createMinigameSession(userId, game) { diff --git a/server/db/schema.sqlite.js b/server/db/schema.sqlite.js index b0f61b6..e5c5bb5 100644 --- a/server/db/schema.sqlite.js +++ b/server/db/schema.sqlite.js @@ -8,10 +8,12 @@ import { findAvailableUsername } from './shared.js'; * * findAvailableUsername is async (its Postgres counterpart needs an async * predicate), so this function is too - awaited by applySchema before it - * creates the unique index. The isTaken predicate here is itself sync (an - * in-memory Set check); awaiting a non-promise value is harmless. + * creates the unique index, and by driver.sqlite.js's `dedupeUsernames` + * interface method (a thin delegation to this). The isTaken predicate here + * is itself sync (an in-memory Set check); awaiting a non-promise value is + * harmless. */ -export async function dedupeUsernamesSync(db) { +export async function dedupeUsernames(db) { const rows = db.prepare( 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', ).all(); @@ -132,7 +134,7 @@ export async function applySchema(db) { ); `); - await dedupeUsernamesSync(db); + await dedupeUsernames(db); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); } From 4faeaa51ecbc73908856ec01692e38ba2801aabc Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 18:25:57 -0400 Subject: [PATCH 07/14] Add a Postgres test harness and a two-backend CI matrix Testcontainers locally, a service container in CI, and a per-test-file database so the 19 db-touching suites cannot see each other's rows. The matrix exists because we ship two backends: a SQLite driver that CI never exercises would drift from the Postgres one, and dialect drift is the bug class that loses data. Locally, the only container runtime available is rootless Podman (no docker binary), so pg-global.js points DOCKER_HOST at the Podman socket and disables Ryuk when the developer hasn't already set either - Testcontainers then works unmodified on Docker or Podman. Ryuk's absence makes teardown() the only thing that stops/removes the container; StartedTestContainer#stop() removes volumes too, so no reaper is required as long as teardown runs. No Postgres driver exists yet (that's Task 4), so tests/harness.test.js is the only suite that actually exercises TEST_DATABASE_URL right now - the other 452 tests hardcode DB_PATH=':memory:' and keep running against SQLite regardless of TEST_BACKEND. --- .github/workflows/test.yml | 30 + README.md | 40 + package-lock.json | 2403 ++++++++++++++++++++++++++++++++++-- package.json | 9 +- tests/harness.test.js | 20 + tests/helpers/backend.js | 46 + tests/setup/pg-global.js | 74 ++ vitest.config.js | 12 + 8 files changed, 2534 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/harness.test.js create mode 100644 tests/helpers/backend.js create mode 100644 tests/setup/pg-global.js create mode 100644 vitest.config.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6a61f51 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + backend: [pg, sqlite] + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test + env: + TEST_BACKEND: ${{ matrix.backend }} + TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres diff --git a/README.md b/README.md index 77add71..8dd527f 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,46 @@ npm run dev Visit the client dev server's printed URL (usually `http://localhost:5173`). +## Running the tests + +```bash +npm test # Postgres backend (default) - boots a throwaway container +npm run test:sqlite # SQLite backend, no container needed +npm run test:all # both, sqlite then pg +``` + +The Postgres backend needs a container runtime that speaks the Docker API. +`tests/setup/pg-global.js` boots one shared Postgres 16 container via +[Testcontainers](https://node.testcontainers.org) and each test file carves +out its own database from it (`tests/helpers/backend.js`), so files can't +see each other's rows. + +- **Docker**: works out of the box, nothing to configure. +- **Podman** (what this repo's containers were validated against; no + `docker` binary required): start the user socket once per login session + and Testcontainers will find it automatically - + `tests/setup/pg-global.js` points `DOCKER_HOST` at the Podman socket + itself if `DOCKER_HOST` isn't already set, so no per-developer config is + needed: + + ```bash + systemctl --user start podman.socket + ``` + + Rootless Podman can't grant Testcontainers' Ryuk reaper the privileges it + wants, so the harness also sets `TESTCONTAINERS_RYUK_DISABLED=true` by + default when using Podman. With Ryuk off, the container is stopped and + removed by `teardown()` in `pg-global.js` at the end of the run instead - + if a run is killed hard enough to skip that (e.g. `SIGKILL`), clean up any + leftovers with `podman ps -a` / `podman rm -f`. +- **CI** sets `TEST_DATABASE_URL` directly against a Postgres service + container (see `.github/workflows/test.yml`) and never touches + Testcontainers at all. + +To point manually at a different runtime or disable Ryuk yourself, set +`DOCKER_HOST` and/or `TESTCONTAINERS_RYUK_DISABLED` before running the +tests - the harness only fills these in when they're unset. + ## Live Events At most one event is active globally at a time. Each is a set of tunable diff --git a/package-lock.json b/package-lock.json index dd707c2..f38e0c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rackstack-server", - "version": "1.1.1", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rackstack-server", - "version": "1.1.1", + "version": "1.6.0", "dependencies": { "better-sqlite3": "^11.3.0", "cookie-parser": "^1.4.6", @@ -15,13 +15,22 @@ "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", "passport-discord": "^0.1.4", - "passport-github2": "^0.1.12" + "passport-github2": "^0.1.12", + "pg": "^8.22.0" }, "devDependencies": { + "@testcontainers/postgresql": "^12.0.4", "supertest": "^7.0.0", "vitest": "^3.0.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -464,6 +473,76 @@ "node": ">=18" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -471,6 +550,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -494,6 +619,83 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -844,6 +1046,16 @@ "win32" ] }, + "node_modules/@testcontainers/postgresql": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-12.0.4.tgz", + "integrity": "sha512-a/pLU6j5lpKKAlUTPwqweqMGhOSjgTSb6HBX69TOrXn32ifU37nnQDmNFTj8ddOAw+BQL9oTRkeOxVbZkqhgZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^12.0.4" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -862,6 +1074,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -869,6 +1104,53 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -984,6 +1266,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -997,40 +1292,75 @@ "node": ">= 0.6" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, "funding": [ { "type": "github", @@ -1045,38 +1375,305 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, "engines": { - "node": ">=6.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, "license": "MIT", "dependencies": { - "file-uri-to-path": "1.0.0" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/bl": { + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", @@ -1111,6 +1708,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1135,12 +1742,42 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1222,6 +1859,120 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1245,6 +1996,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1301,6 +2111,112 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1379,18 +2295,90 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/docker-compose": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.4.2.tgz", + "integrity": "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/docker-modem/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "node_modules/docker-modem/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/dockerode": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4" + }, + "engines": { + "node": ">= 14.17" } }, "node_modules/dotenv": { @@ -1419,6 +2407,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1434,6 +2429,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1547,6 +2549,16 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1572,6 +2584,36 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1637,6 +2679,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1686,6 +2735,23 @@ "node": ">= 0.8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -1769,6 +2835,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1793,6 +2869,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -1812,6 +2901,28 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1824,6 +2935,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1937,6 +3055,59 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1993,6 +3164,66 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2035,6 +3266,13 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2042,6 +3280,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2133,6 +3378,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2142,6 +3403,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -2154,6 +3441,14 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -2200,6 +3495,16 @@ "node": ">=10" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -2239,6 +3544,13 @@ "wrappy": "1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2312,36 +3624,152 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", "engines": { - "node": ">= 0.4.0" + "node": ">= 0.4.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, "engines": { - "node": ">= 14.16" + "node": ">=4" } }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -2392,6 +3820,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2419,6 +3886,84 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/properties-reader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-3.0.1.tgz", + "integrity": "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2511,6 +4056,49 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2645,6 +4233,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -2724,6 +4335,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2779,36 +4403,208 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">=8" } }, "node_modules/strip-json-comments": { @@ -2945,6 +4741,103 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/testcontainers": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.0.4.tgz", + "integrity": "sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^4.0.1", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.4.2", + "dockerode": "^5.0.0", + "get-port": "^5.1.1", + "proper-lockfile": "^4.1.2", + "properties-reader": "^3.0.1", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.2", + "tmp": "^0.2.7", + "undici": "^8.5.0" + } + }, + "node_modules/testcontainers/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/testcontainers/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/testcontainers/node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/testcontainers/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3006,6 +4899,16 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3027,6 +4930,13 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3046,6 +4956,23 @@ "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3300,6 +5227,22 @@ "dev": true, "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3317,11 +5260,275 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } } } } diff --git a/package.json b/package.json index a2ff965..6b2b78e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "scripts": { "start": "node server/index.js", "dev": "node --watch server/index.js", - "test": "vitest run" + "test": "vitest run", + "test:sqlite": "TEST_BACKEND=sqlite vitest run", + "test:all": "npm run test:sqlite && npm test", + "migrate:pg": "node server/db/migrate.js" }, "dependencies": { "better-sqlite3": "^11.3.0", @@ -16,9 +19,11 @@ "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", "passport-discord": "^0.1.4", - "passport-github2": "^0.1.12" + "passport-github2": "^0.1.12", + "pg": "^8.22.0" }, "devDependencies": { + "@testcontainers/postgresql": "^12.0.4", "supertest": "^7.0.0", "vitest": "^3.0.0" } diff --git a/tests/harness.test.js b/tests/harness.test.js new file mode 100644 index 0000000..34f9aa1 --- /dev/null +++ b/tests/harness.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import pg from 'pg'; +import { provisionDatabase } from './helpers/backend.js'; + +const provisioned = await provisionDatabase(); +afterAll(() => provisioned.cleanup()); + +describe('test harness', () => { + it('provisions an isolated database for the configured backend', async () => { + if (provisioned.backend === 'sqlite') { + expect(provisioned.path).toBe(':memory:'); + return; + } + const client = new pg.Client({ connectionString: provisioned.url }); + await client.connect(); + const { rows } = await client.query('SELECT 1 AS ok'); + expect(rows[0].ok).toBe(1); + await client.end(); + }); +}); diff --git a/tests/helpers/backend.js b/tests/helpers/backend.js new file mode 100644 index 0000000..00f64f2 --- /dev/null +++ b/tests/helpers/backend.js @@ -0,0 +1,46 @@ +// Per-test-file database provisioning. +// +// tests/setup/pg-global.js stands up one shared Postgres container (or, in +// CI, points at the service container) and publishes it as +// TEST_DATABASE_URL. provisionDatabase() carves an isolated database out of +// that container for the calling test file, so the 19 db-touching suites +// can run in parallel (vitest pool: 'forks') without seeing each other's +// rows - and so each file exercises the real CREATE DATABASE + schema +// creation path rather than sharing state. +import pg from 'pg'; +import { randomUUID } from 'node:crypto'; + +export async function provisionDatabase() { + const backend = process.env.TEST_BACKEND === 'sqlite' ? 'sqlite' : 'pg'; + if (backend === 'sqlite') { + return { backend, path: ':memory:', cleanup: async () => {} }; + } + + const adminUrl = process.env.TEST_DATABASE_URL; + if (!adminUrl) throw new Error('TEST_DATABASE_URL not set - is the vitest globalSetup running?'); + + const name = `rackstack_test_${randomUUID().replace(/-/g, '')}`; + const admin = new pg.Client({ connectionString: adminUrl }); + await admin.connect(); + try { + await admin.query(`CREATE DATABASE ${name}`); + } finally { + await admin.end(); + } + + const url = new URL(adminUrl); + url.pathname = `/${name}`; + return { + backend, + url: url.toString(), + cleanup: async () => { + const a = new pg.Client({ connectionString: adminUrl }); + await a.connect(); + try { + await a.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + } finally { + await a.end(); + } + }, + }; +} diff --git a/tests/setup/pg-global.js b/tests/setup/pg-global.js new file mode 100644 index 0000000..077e2d0 --- /dev/null +++ b/tests/setup/pg-global.js @@ -0,0 +1,74 @@ +// Vitest globalSetup for the Postgres test backend. +// +// Boots one shared Postgres container via Testcontainers and publishes it as +// TEST_DATABASE_URL. Each test file then carves its own database out of that +// container (see tests/helpers/backend.js) so the 19 db-touching suites +// can't see each other's rows, without paying for a fresh container per file. +// +// Container runtime: this only runs locally. CI sets TEST_DATABASE_URL +// itself (a Postgres service container - see .github/workflows/test.yml), +// so `setup()` returns immediately there and Testcontainers is never +// touched in CI. +// +// Locally this machine (and most Linux dev boxes without Docker Desktop) +// has no `docker` binary, only Podman, which serves a Docker-compatible API +// over a rootless per-user socket. Testcontainers talks to whatever +// DOCKER_HOST points at, so pointing it at the Podman socket - only when the +// developer hasn't already set DOCKER_HOST themselves - is enough to make +// Testcontainers work unmodified on either engine. +// +// Ryuk (Testcontainers' orphan-container reaper) needs privileges rootless +// Podman doesn't grant it (it mounts the container socket into a privileged +// reaper container), so it's disabled below whenever it hasn't been set +// explicitly. With Ryuk off, this module's teardown() is the *only* thing +// that stops and removes the container - StartedTestContainer#stop() removes +// the container and its volumes by default, so no reaper is needed as long +// as teardown() runs. If a run is killed hard enough that globalTeardown +// never fires (e.g. SIGKILL), the container is left running; `podman ps` and +// `podman rm -f` clean that up manually. +import { existsSync } from 'node:fs'; +import { PostgreSqlContainer } from '@testcontainers/postgresql'; + +// Fully-qualified so it resolves identically on Docker and on Podman +// installs that have no unqualified-search registries configured (Podman's +// default on many distros, including Fedora). +const POSTGRES_IMAGE = 'docker.io/library/postgres:16'; + +function podmanSocketPath() { + const uid = typeof process.getuid === 'function' ? process.getuid() : 1000; + return `/run/user/${uid}/podman/podman.sock`; +} + +function configureContainerRuntime() { + if (!process.env.DOCKER_HOST) { + const socket = podmanSocketPath(); + if (existsSync(socket)) { + process.env.DOCKER_HOST = `unix://${socket}`; + } + } + // Rootless Podman can't grant Ryuk the privileges it wants; leaving Ryuk + // enabled makes container startup hang waiting for a reaper that will + // never come up. Don't override an explicit developer/CI choice. + if (process.env.TESTCONTAINERS_RYUK_DISABLED === undefined) { + process.env.TESTCONTAINERS_RYUK_DISABLED = 'true'; + } +} + +let container; + +export async function setup() { + if (process.env.TEST_BACKEND === 'sqlite') return; + if (process.env.TEST_DATABASE_URL) return; // CI supplies a service container + + configureContainerRuntime(); + + container = await new PostgreSqlContainer(POSTGRES_IMAGE).start(); + process.env.TEST_DATABASE_URL = container.getConnectionUri(); +} + +export async function teardown() { + if (!container) return; + const toStop = container; + container = undefined; + await toStop.stop(); +} diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..9f33932 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globalSetup: ['./tests/setup/pg-global.js'], + // Each file gets its own database and its own module registry, so the + // db facade's top-level await resolves per-file against that database. + isolate: true, + pool: 'forks', + testTimeout: 30_000, // container start on a cold machine + }, +}); From a683da4cff11befd03c5ee453df31d57b33f12a4 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 19:06:47 -0400 Subject: [PATCH 08/14] Add the Postgres schema and driver Both backends now satisfy the same interface and the full suite runs against each. tests/db.parity.test.js pins the behaviours that differ between the dialects and would otherwise fail silently in production: - pg returns BIGINT as a string by default; every epoch-ms column in this schema is BIGINT, so the int8 type parser is registered before any query. - Postgres folds unquoted identifiers to lowercase, so listLeaderboard's camelCase aliases are double-quoted - unquoted, the client receives 'userid' and every rungsClaimed reads undefined. - config_history ordered by rowid on SQLite; Postgres has none, so it gains a BIGSERIAL key and the admin rollback UI keeps its newest-first order. - COLLATE NOCASE becomes a unique functional index on lower(username). - SQLITE_CONSTRAINT_UNIQUE becomes SQLSTATE 23505 - that retry path is what keeps a display-name collision from locking a player out on login. Also points all 13 db-touching test files at provisionDatabase() instead of hardcoding DB_PATH=':memory:', so npm test genuinely exercises Postgres instead of silently falling back to SQLite regardless of TEST_BACKEND. tests/db.test.js and tests/db.events.test.js gain Postgres-branch schema introspection (pg_tables/information_schema vs. sqlite_master/PRAGMA); the two guarded-ALTER tests are SQLite-only (schema.pg.js has no ALTER history to replay) and skip on pg via it.runIf. Co-Authored-By: Claude Opus 5 --- server/db/driver.pg.js | 490 ++++++++++++++++++++++++++++ server/db/index.js | 6 +- server/db/interface.md | 36 +- server/db/schema.pg.js | 123 +++++++ tests/api.events.hotfix.test.js | 18 +- tests/api.events.test.js | 20 +- tests/api.finalfix.test.js | 18 +- tests/api.social.test.js | 18 +- tests/api.test.js | 23 +- tests/api.tutorial.test.js | 18 +- tests/db.events.test.js | 67 ++-- tests/db.interface.test.js | 17 +- tests/db.parity.test.js | 87 +++++ tests/db.test.js | 122 +++++-- tests/eventService.finalfix.test.js | 14 +- tests/eventService.test.js | 14 +- tests/stateService.events.test.js | 14 +- tests/stateService.social.test.js | 18 +- 18 files changed, 1020 insertions(+), 103 deletions(-) create mode 100644 server/db/driver.pg.js create mode 100644 server/db/schema.pg.js create mode 100644 tests/db.parity.test.js diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js new file mode 100644 index 0000000..d44f7ee --- /dev/null +++ b/server/db/driver.pg.js @@ -0,0 +1,490 @@ +import pg from 'pg'; +import { randomUUID } from 'node:crypto'; +import { applySchema } from './schema.pg.js'; +import { SEASONAL_EVENTS } from '../data/seasonalEvents.js'; +import { + findAvailableUsername, parseEventRow, normalizeEventRow, normalizeParticipationRow, +} from './shared.js'; + +// pg returns int8/BIGINT as a STRING by default, to avoid precision loss +// above 2^53. Every BIGINT in this schema is an epoch-millisecond timestamp +// (created_at, last_save, starts_at, ends_at, ...) - all far below 2^53, and +// every consumer does arithmetic or comparison on them. Left as strings, +// `now > state.server.anomalyExpiresAt` and every offline-gap calculation +// silently misbehave. Parse them back to numbers. Registered at module +// scope so it runs before any query, regardless of which pool imports this +// file first. +pg.types.setTypeParser(pg.types.builtins.INT8, (v) => (v === null ? null : Number(v))); + +export async function createPgDriver({ url }) { + const pool = new pg.Pool({ connectionString: url }); + // pg's Pool emits 'error' for problems on idle clients in the background + // (e.g. the network connection resetting, or - in tests - the database + // being force-dropped out from under an open pool). An unhandled 'error' + // event is fatal to the whole Node process, not just this pool, so a + // listener is required even though there's nothing driver-level to do + // about it beyond not crashing. + pool.on('error', (err) => { + // eslint-disable-next-line no-console + console.error('Unexpected error on idle Postgres client', err); + }); + await applySchema(pool); + + const one = async (sql, params = []) => (await pool.query(sql, params)).rows[0]; + const all = async (sql, params = []) => (await pool.query(sql, params)).rows; + const run = async (sql, params = []) => { await pool.query(sql, params); }; + + function isUsernameTakenInDb(name) { + return one('SELECT id FROM users WHERE lower(username) = lower($1)', [name]).then(Boolean); + } + + // Same check as isUsernameTakenInDb, but excludes the given user's own row - + // used by upsertUser's UPDATE (returning-user) path, where the row being + // updated already "has" the old username and must not be treated as its own + // collision. + function isUsernameTakenByOtherUser(name, excludeId) { + return one( + 'SELECT id FROM users WHERE lower(username) = lower($1) AND id != $2', [name, excludeId], + ).then(Boolean); + } + + const driver = { + __backend: 'pg', + __raw: pool, + + async upsertUser({ provider, providerId, username, avatarUrl }) { + const id = `${provider}:${providerId}`; + const existing = await one('SELECT * FROM users WHERE id = $1', [id]); + if (existing) { + // A user who has set a custom username keeps it on re-login; only the + // avatar (which the user doesn't control) is refreshed from the profile. + const desiredUsername = existing.custom_username ? existing.username : username; + let nextUsername = desiredUsername; + try { + await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', [nextUsername, avatarUrl, id]); + } catch (e) { + // The provider-supplied name can change between logins (e.g. the user + // renamed their display name on the OAuth provider) and collide + // case-insensitively with a DIFFERENT user's username. Without this + // catch, that error would propagate to a 500 and - since upsertUser + // runs on every login - permanently lock the account out until the + // provider-side name changed back. Same suffixing convention/helper as + // the INSERT path, excluding this user's own row from the collision + // check (their old value isn't a collision against their new one). + if (e.code !== '23505') throw e; // unique_violation + nextUsername = await findAvailableUsername( + desiredUsername, + (name) => isUsernameTakenByOtherUser(name, id), + ); + await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', [nextUsername, avatarUrl, id]); + } + return { ...existing, username: nextUsername, avatar_url: avatarUrl }; + } + + const user = { + id, provider, provider_id: providerId, username, avatar_url: avatarUrl, created_at: Date.now(), + }; + try { + await run( + 'INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4, $5, $6)', + [user.id, user.provider, user.provider_id, user.username, user.avatar_url, user.created_at], + ); + } catch (e) { + // Two different brand-new OAuth accounts can independently supply the + // same (or case-variant) username - the unique functional index on + // lower(username) rejects the second insert with SQLSTATE 23505. + // Without this catch, that error would propagate to a 500 and - since + // upsertUser runs on every login, not just the first - permanently + // block that account from ever logging in. Pick a free variant using + // the same suffixing convention as dedupeUsernames and retry once. + if (e.code !== '23505') throw e; // unique_violation + user.username = await findAvailableUsername(username, isUsernameTakenInDb); + await run( + 'INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4, $5, $6)', + [user.id, user.provider, user.provider_id, user.username, user.avatar_url, user.created_at], + ); + } + return user; + }, + + async getUserById(id) { + return one('SELECT * FROM users WHERE id = $1', [id]); + }, + + async getAllUsersWithSaves() { + return all(` + SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, + u.leaderboard_opt_out, + s.data, s.last_save + FROM users u + LEFT JOIN saves s ON s.user_id = u.id + ORDER BY u.created_at DESC + `); + }, + + async getSave(userId) { + return one('SELECT * FROM saves WHERE user_id = $1', [userId]); + }, + + async putSave(userId, data, lastSave) { + await run(` + INSERT INTO saves (user_id, data, last_save) VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET data = excluded.data, last_save = excluded.last_save + `, [userId, JSON.stringify(data), lastSave]); + }, + + async deleteSave(userId) { + await run('DELETE FROM saves WHERE user_id = $1', [userId]); + }, + + /** + * Roles are stored as a JSON array string in users.roles (default '[]'). + * Membership in the array is the only thing that matters - ordering and + * duplicates are not deduped here; callers (server/auth.js, Task 8) treat + * this as a plain set. + */ + async getRoles(userId) { + const row = await one('SELECT roles FROM users WHERE id = $1', [userId]); + if (!row || !row.roles) return []; + try { + return JSON.parse(row.roles); + } catch (e) { + return []; + } + }, + + async setRoles(userId, roles) { + await run('UPDATE users SET roles = $1 WHERE id = $2', [JSON.stringify(roles), userId]); + }, + + /** + * Completed guided tours, stored as a JSON array string in + * users.tours_completed (default '[]') - the same shape and defensive-read + * contract as users.roles above. Callers treat it as a plain set; the route + * layer owns validation against shared/tours.js. + */ + async getToursCompleted(userId) { + const row = await one('SELECT tours_completed FROM users WHERE id = $1', [userId]); + if (!row || !row.tours_completed) return []; + try { + const parsed = JSON.parse(row.tours_completed); + return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []; + } catch (e) { + return []; + } + }, + + async setToursCompleted(userId, ids) { + await run('UPDATE users SET tours_completed = $1 WHERE id = $2', [JSON.stringify(ids), userId]); + }, + + /** + * Sets a user's username, format-agnostic (the route layer owns the regex). + * Performs its own case-insensitive availability check excluding the user + * themself, and marks the username as user-chosen so upsertUser stops + * overwriting it from the OAuth profile on future logins. + */ + async setUsername(userId, name) { + const collision = await one( + 'SELECT id FROM users WHERE lower(username) = lower($1) AND id != $2', [name, userId], + ); + if (collision) return { ok: false, error: 'taken' }; + await run('UPDATE users SET username = $1, custom_username = 1 WHERE id = $2', [name, userId]); + return { ok: true }; + }, + + /** + * Duplicate usernames (case-insensitively) can exist from before the + * unique index was introduced. Applied on every boot inside applySchema + * (schema.pg.js does not currently call this - kept here so callers of + * the driver's dedupeUsernames() get the same on-demand cleanup path + * available on the SQLite driver). + */ + async dedupeUsernames() { + const rows = await all( + 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', + ); + const taken = new Set(); + for (const row of rows) { + const lower = row.username.toLowerCase(); + if (!taken.has(lower)) { + taken.add(lower); + continue; + } + const candidate = await findAvailableUsername(row.username, (name) => taken.has(name.toLowerCase())); + await run('UPDATE users SET username = $1 WHERE id = $2', [candidate, row.id]); + taken.add(candidate.toLowerCase()); + } + }, + + async createMinigameSession(userId, game) { + const session = { + id: randomUUID(), + user_id: userId, + game, + started_at: Date.now(), + finished_at: null, + score: null, + }; + await run(` + INSERT INTO minigame_sessions (id, user_id, game, started_at, finished_at, score) + VALUES ($1, $2, $3, $4, $5, $6) + `, [session.id, session.user_id, session.game, session.started_at, session.finished_at, session.score]); + return session; + }, + + async getMinigameSession(id) { + return one('SELECT * FROM minigame_sessions WHERE id = $1', [id]); + }, + + /** + * Finds the most recent still-open (unfinished, not yet expired) session + * for `userId`+`game`, if any. "Not yet expired" is caller-supplied as + * `minStartedAt` (a session's `started_at` must be >= this to count) since + * the expiry window depends on `config.minigames[game].durationSec`, which + * this module doesn't have access to - the route layer computes it. + * Used to block a burst of concurrently-open sessions for the same game + * (each of which would otherwise dodge the win cooldown independently). + */ + async getOpenMinigameSession(userId, game, minStartedAt) { + return one(` + SELECT * FROM minigame_sessions + WHERE user_id = $1 AND game = $2 AND finished_at IS NULL AND started_at >= $3 + ORDER BY started_at DESC LIMIT 1 + `, [userId, game, minStartedAt]); + }, + + async finishMinigameSession(id, score) { + await run('UPDATE minigame_sessions SET finished_at = $1, score = $2 WHERE id = $3', [Date.now(), score, id]); + }, + + /** + * Returns the singleton config row (id=1): { id, version, data, updated_at, + * updated_by }, or undefined if no config has been seeded yet. `data` is + * returned as the raw JSON text exactly as stored - mirroring getSave's + * convention, callers JSON.parse it themselves. + */ + async getConfigRow() { + return one('SELECT * FROM config WHERE id = 1'); + }, + + /** + * Upserts the singleton config row (id=1) to `{ version, data, userId }` + * and appends a matching row to config_history for audit/rollback. `data` + * is a plain JS object; it is JSON.stringify'd here (the same convention + * putSave uses) - callers never pass pre-stringified JSON. + */ + async putConfigRow(version, data, userId) { + const text = JSON.stringify(data); + const now = Date.now(); + await run(` + INSERT INTO config (id, version, data, updated_at, updated_by) VALUES (1, $1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET version = excluded.version, data = excluded.data, + updated_at = excluded.updated_at, updated_by = excluded.updated_by + `, [version, text, now, userId]); + await run( + 'INSERT INTO config_history (version, data, updated_at, updated_by) VALUES ($1, $2, $3, $4)', + [version, text, now, userId], + ); + }, + + async getConfigHistory() { + return all('SELECT * FROM config_history ORDER BY id DESC'); + }, + + async listEvents() { + return (await all('SELECT * FROM live_events ORDER BY created_at ASC')).map(parseEventRow); + }, + + async getEvent(id) { + return parseEventRow(await one('SELECT * FROM live_events WHERE id = $1', [id])); + }, + + /** + * Returns the single event currently in status 'active', or undefined if + * none is. Keeping at most one event active is an application-level + * invariant enforced by the lifecycle/scheduler, not a DB constraint - + * this just reads the first match. + */ + async getActiveEvent() { + return parseEventRow(await one("SELECT * FROM live_events WHERE status = 'active' LIMIT 1")); + }, + + /** + * Insert-or-replace for a single event, keyed on `event.id`. On conflict, + * `created_at` is intentionally left untouched (it's the original + * creation time, not a "last written" timestamp) - everything else is + * fully replaced. + */ + async putEvent(event) { + const row = normalizeEventRow(event); + await run(` + INSERT INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + name = excluded.name, description = excluded.description, theme = excluded.theme, + modifiers = excluded.modifiers, ladder = excluded.ladder, status = excluded.status, + starts_at = excluded.starts_at, ends_at = excluded.ends_at, recurrence = excluded.recurrence, + created_by = excluded.created_by + `, [ + row.id, row.name, row.description, row.theme, row.modifiers, row.ladder, row.status, + row.starts_at, row.ends_at, row.recurrence, row.created_at, row.created_by, + ]); + // Reference the sibling method via the `driver` closure variable, not + // `this` - the facade (server/db/index.js) destructures these methods + // off the driver object and exports them as free functions, so a call + // site like `putEvent(...)` (no receiver) would leave `this` undefined. + return driver.getEvent(row.id); + }, + + /** + * Updates only `status`, plus `starts_at`/`ends_at` when explicitly passed + * in the options object (a key present but `null` clears that column; a key + * simply absent leaves the existing value untouched). This lets the + * scheduler flip status alone (e.g. active -> ended) without needing to + * re-supply - or accidentally wipe - the event's window. + */ + async setEventStatus(id, status, { startsAt, endsAt } = {}) { + const sets = ['status = $1']; + const params = [status]; + if (startsAt !== undefined) { params.push(startsAt); sets.push(`starts_at = $${params.length}`); } + if (endsAt !== undefined) { params.push(endsAt); sets.push(`ends_at = $${params.length}`); } + params.push(id); + await run(`UPDATE live_events SET ${sets.join(', ')} WHERE id = $${params.length}`, params); + }, + + /** + * Deletes an event row outright. This module does not enforce "drafts + * only" - the route layer is responsible for rejecting deletes of + * scheduled/active/ended events before calling this. + */ + async deleteEvent(id) { + await run('DELETE FROM live_events WHERE id = $1', [id]); + }, + + /** + * Insert-or-replace for one user's participation row in one event, keyed on + * (user_id, event_id). + */ + async upsertParticipation(row) { + const params = normalizeParticipationRow(row); + await run(` + INSERT INTO event_participation (user_id, event_id, started_at, ends_at, rungs_claimed, last_progress_at, opted_out) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id, event_id) DO UPDATE SET + started_at = excluded.started_at, ends_at = excluded.ends_at, + rungs_claimed = excluded.rungs_claimed, last_progress_at = excluded.last_progress_at, + opted_out = excluded.opted_out + `, [ + params.user_id, params.event_id, params.started_at, params.ends_at, + params.rungs_claimed, params.last_progress_at, params.opted_out, + ]); + return driver.getParticipation(params.user_id, params.event_id); + }, + + async getParticipation(userId, eventId) { + return one('SELECT * FROM event_participation WHERE user_id = $1 AND event_id = $2', [userId, eventId]); + }, + + /** + * Narrow, idempotent progress sync used by stateService.applyActions after a + * successful claimEventRung. Deliberately NOT a call to upsertParticipation: + * that function's ON CONFLICT clause overwrites every column, including + * `opted_out` and `started_at`/`ends_at` - a caller here that doesn't have + * (or doesn't want to re-fetch) the user's current opt-out flag would + * silently un-opt-out them on every claim. A plain UPDATE touching only + * `rungs_claimed`/`last_progress_at` leaves every other column alone, and is + * a harmless no-op (0 rows affected, no throw) if the participation row + * doesn't exist for some reason (e.g. the event was deleted out from under + * an in-flight claim). + */ + async updateParticipationProgress(userId, eventId, rungsClaimed, lastProgressAt) { + await run( + 'UPDATE event_participation SET rungs_claimed = $1, last_progress_at = $2 WHERE user_id = $3 AND event_id = $4', + [rungsClaimed, lastProgressAt, userId, eventId], + ); + }, + + /** + * All participants in an event, ranked for the coordinator view / leaderboard: + * most rungs claimed first, ties broken by whoever reached their current + * progress earliest. + */ + async listParticipation(eventId) { + return all( + 'SELECT * FROM event_participation WHERE event_id = $1 ORDER BY rungs_claimed DESC, last_progress_at ASC', + [eventId], + ); + }, + + async setLeaderboardOptOut(userId, optOut) { + await run('UPDATE users SET leaderboard_opt_out = $1 WHERE id = $2', [optOut ? 1 : 0, userId]); + }, + + /** + * Leaderboard rows for `eventId`, same ranking as listParticipation (most + * rungs claimed first, ties broken by earliest last_progress_at), but - + * unlike listParticipation - LEFT JOINed against `users` and filtered on + * the LIVE `users.leaderboard_opt_out`, not the value snapshotted into + * `event_participation.opted_out` at join time. That snapshot is written + * once by joinEventIfEligible and never updated again, so a user who opts + * out AFTER joining would otherwise keep appearing here. + * PUT /api/me/leaderboard-opt-out writes straight to + * `users.leaderboard_opt_out`, so this query picks up that change + * immediately on the very next read - no re-sync step needed. + * Capped at `limit` rows (default 50, per the route's leaderboard contract). + * + * Every camelCase alias MUST be double-quoted: Postgres folds unquoted + * identifiers to lowercase, which would hand the client `userid` and + * `rungsClaimed` would arrive as undefined. + */ + async listLeaderboard(eventId, limit = 50) { + return all(` + SELECT ep.user_id AS "userId", u.username AS "username", + ep.rungs_claimed AS "rungsClaimed", ep.last_progress_at AS "lastProgressAt" + FROM event_participation ep + LEFT JOIN users u ON u.id = ep.user_id + WHERE ep.event_id = $1 AND COALESCE(u.leaderboard_opt_out, 0) = 0 + ORDER BY ep.rungs_claimed DESC, ep.last_progress_at ASC + LIMIT $2 + `, [eventId, limit]); + }, + + /** + * The most recently-STARTED event that has actually run (any status except + * 'draft', which by definition has no window). Backs the v1.5 leaderboard's + * latest-event board. Returns null when no event has ever been scheduled. + */ + async getLatestEventId() { + const row = await one( + "SELECT id FROM live_events WHERE status != 'draft' AND starts_at IS NOT NULL ORDER BY starts_at DESC LIMIT 1", + ); + return row ? row.id : null; + }, + + /** + * Inserts each SEASONAL_EVENTS entry as status 'draft' with no window, but + * only when that id isn't already present - ON CONFLICT DO NOTHING makes + * this safe to call on every boot (idempotent) without ever clobbering an + * admin-edited copy of a seeded event (e.g. a coordinator tweaked + * summer-surge's modifiers or already scheduled it). + */ + async seedSeasonalEvents() { + const now = Date.now(); + for (const evt of SEASONAL_EVENTS) { + await run(` + INSERT INTO live_events (id, name, description, theme, modifiers, ladder, status, + starts_at, ends_at, recurrence, created_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, 'draft', NULL, NULL, $7, $8, NULL) + ON CONFLICT (id) DO NOTHING + `, [ + evt.id, evt.name, evt.description ?? null, JSON.stringify(evt.theme ?? null), + JSON.stringify(evt.modifiers ?? []), JSON.stringify(evt.ladder ?? []), + JSON.stringify(evt.recurrence ?? null), now, + ]); + } + }, + }; + + return driver; +} diff --git a/server/db/index.js b/server/db/index.js index 2e94329..5ea3b11 100644 --- a/server/db/index.js +++ b/server/db/index.js @@ -1,14 +1,16 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { createSqliteDriver } from './driver.sqlite.js'; +import { createPgDriver } from './driver.pg.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'rackstack.db'); // Top-level await: every consumer does `import { getSave } from './db.js'`, // so the driver must be resolved before this module finishes evaluating. -// Task 4 adds the DATABASE_URL branch here. -const driver = await createSqliteDriver({ path: DB_PATH }); +const driver = process.env.DATABASE_URL + ? await createPgDriver({ url: process.env.DATABASE_URL }) + : await createSqliteDriver({ path: DB_PATH }); export const { upsertUser, getUserById, getAllUsersWithSaves, getSave, putSave, deleteSave, diff --git a/server/db/interface.md b/server/db/interface.md index 8a6a1b6..2330c4a 100644 --- a/server/db/interface.md +++ b/server/db/interface.md @@ -7,29 +7,37 @@ directly. ## Layout -- `index.js` — the facade. Picks a driver at boot (currently always SQLite; - Task 4 adds a `DATABASE_URL`-gated Postgres branch) and re-exports every - interface function as a top-level named export, plus `driver` itself. +- `index.js` — the facade. Picks a driver at boot (`createPgDriver` when + `DATABASE_URL` is set, `createSqliteDriver` otherwise) and re-exports + every interface function as a top-level named export, plus `driver` + itself. - `driver.sqlite.js` — `createSqliteDriver({ path }) → Promise`. Owns every `better-sqlite3`-specific query. +- `driver.pg.js` — `createPgDriver({ url }) → Promise`. Owns every + `pg`-specific query, and registers the `int8` type parser (BIGINT -> + number) at module scope, before any query runs. - `schema.sqlite.js` — DDL and schema-version bookkeeping for the SQLite backend: `applySchema(db)`, `appliedVersions(db)`, `markApplied(db, version)`. -- `shared.js` — dialect-free helpers used by (eventually) every driver: - username-suffixing, event-row JSON parsing, and the camelCase/snake_case - row normalizers for `putEvent`/`upsertParticipation`. No SQL lives here. +- `schema.pg.js` — the Postgres equivalent DDL and bookkeeping: + `applySchema(pool)`, `appliedVersions(pool)`, `markApplied(pool, version)`. + Unlike `schema.sqlite.js`, there's no guarded-ALTER history to replay - + every column ships in its `CREATE TABLE` from the start. +- `shared.js` — dialect-free helpers used by every driver: username-suffixing, + event-row JSON parsing, and the camelCase/snake_case row normalizers for + `putEvent`/`upsertParticipation`. No SQL lives here. ## The `Driver` contract -`createSqliteDriver` (and, from Task 4, its Postgres counterpart) resolves -to an object with exactly these keys: +`createSqliteDriver` and `createPgDriver` each resolve to an object with +exactly these keys: -- `__backend` — `'sqlite'` or `'postgres'`. Test-only; used to skip - backend-specific assertions (e.g. `sqlite_master` introspection) under the - other driver. +- `__backend` — `'sqlite'` or `'pg'`. Test-only; used to skip + backend-specific assertions (e.g. `sqlite_master`/`PRAGMA` introspection + vs. `pg_tables`/`information_schema`) under the other driver. - `__raw` — the underlying driver handle (`better-sqlite3`'s `Database`, or - Postgres's pool/client). Test-only, for schema assertions. Application - code must never touch `__raw`; only the facade and driver files interact - with the underlying database. + the `pg.Pool`). Test-only, for schema assertions. Application code must + never touch `__raw`; only the facade and driver files interact with the + underlying database. - Every interface function below, all `async`. ### Interface functions diff --git a/server/db/schema.pg.js b/server/db/schema.pg.js new file mode 100644 index 0000000..d6c9657 --- /dev/null +++ b/server/db/schema.pg.js @@ -0,0 +1,123 @@ +// Postgres DDL. Dialect notes vs. schema.sqlite.js: +// +// - BIGINT (never INTEGER) for every epoch-ms timestamp column - int4 +// overflows in 2038, and these are all far below 2^53 so no precision is +// lost once driver.pg.js's int8 type parser (registered at module load) +// converts them back to JS numbers on read. +// - SMALLINT 0/1 for boolean-ish flags (custom_username, leaderboard_opt_out, +// opted_out), not BOOLEAN - the codebase writes `? 1 : 0` and reads +// truthiness on both backends, so the column type must match. +// - JSON columns stay TEXT, never jsonb - jsonb reorders keys, strips +// whitespace, and rejects some escapes, so saves would not round-trip +// byte-for-byte (see tests/db.parity.test.js's byte-for-byte save test). +// - config_history gains a real primary key (BIGSERIAL): SQLite ordered +// history by rowid, which Postgres has no equivalent of, and the admin +// rollback UI depends on newest-first order. +// - COLLATE NOCASE has no Postgres equivalent; a unique functional index on +// lower(username) gives the same case-insensitive uniqueness guarantee. +// Every username lookup in driver.pg.js uses lower() to match. +// +// Unlike schema.sqlite.js, there's no guarded ALTER history to replay here - +// this is a fresh schema, so every column ships in its CREATE TABLE from the +// start (roles, custom_username, leaderboard_opt_out, tours_completed all +// arrived as guarded ALTERs on the SQLite side across v1.2-v1.6; here they're +// just columns). +export async function applySchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + username TEXT, + avatar_url TEXT, + created_at BIGINT NOT NULL, + roles TEXT DEFAULT '[]', + custom_username SMALLINT DEFAULT 0, + leaderboard_opt_out SMALLINT DEFAULT 0, + tours_completed TEXT DEFAULT '[]', + UNIQUE (provider, provider_id) + ); + + CREATE TABLE IF NOT EXISTS saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, + last_save BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + data TEXT NOT NULL, + updated_at BIGINT NOT NULL, + updated_by TEXT + ); + + -- SQLite ordered this by rowid. Postgres has no rowid, so history order + -- - which the admin rollback UI depends on - needs a real key. + CREATE TABLE IF NOT EXISTS config_history ( + id BIGSERIAL PRIMARY KEY, + version INTEGER NOT NULL, + data TEXT NOT NULL, + updated_at BIGINT NOT NULL, + updated_by TEXT + ); + + CREATE TABLE IF NOT EXISTS minigame_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + game TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + score INTEGER + ); + + CREATE TABLE IF NOT EXISTS live_events ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + theme TEXT, + modifiers TEXT NOT NULL, + ladder TEXT NOT NULL, + status TEXT NOT NULL, + starts_at BIGINT, + ends_at BIGINT, + recurrence TEXT, + created_at BIGINT NOT NULL, + created_by TEXT + ); + + CREATE TABLE IF NOT EXISTS event_participation ( + user_id TEXT NOT NULL REFERENCES users(id), + event_id TEXT NOT NULL REFERENCES live_events(id), + started_at BIGINT NOT NULL, + ends_at BIGINT NOT NULL, + rungs_claimed INTEGER NOT NULL DEFAULT 0, + last_progress_at BIGINT, + opted_out SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, event_id) + ); + `); + + // COLLATE NOCASE has no Postgres equivalent; a functional index on lower() + // gives the same guarantee. Every username lookup must use lower() to match. + await pool.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (lower(username))', + ); +} + +export async function appliedVersions(pool) { + const { rows } = await pool.query('SELECT version FROM schema_migrations'); + return new Set(rows.map((r) => r.version)); +} + +export async function markApplied(pool, version) { + await pool.query( + 'INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [version, Date.now()], + ); +} diff --git a/tests/api.events.hotfix.test.js b/tests/api.events.hotfix.test.js index 562b67c..9a7045b 100644 --- a/tests/api.events.hotfix.test.js +++ b/tests/api.events.hotfix.test.js @@ -11,16 +11,23 @@ // (not a hand-built config/state), same supertest conventions as // tests/api.events.test.js. process.env.JWT_SECRET = 'test-secret-for-hotfix-events'; -process.env.DB_PATH = ':memory:'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); const { - upsertUser, setRoles, putEvent, getParticipation, + upsertUser, setRoles, putEvent, getParticipation, driver, } = await import('../server/db.js'); const { activateEvent } = await import('../server/eventService.js'); const { COOKIE_NAME } = await import('../server/auth.js'); @@ -28,6 +35,11 @@ const { COOKIE_NAME } = await import('../server/auth.js'); await ensureConfig(); const app = buildApp(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + let seq = 0; async function makeUser() { seq += 1; diff --git a/tests/api.events.test.js b/tests/api.events.test.js index c6c546d..b61c69d 100644 --- a/tests/api.events.test.js +++ b/tests/api.events.test.js @@ -1,21 +1,31 @@ process.env.JWT_SECRET = 'test-secret-for-supertest-events'; -process.env.DB_PATH = ':memory:'; process.env.SUPER_ADMIN_IDS = 'test:events-owner'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time - same trick as tests/api.test.js. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; -// Dynamic imports: server/auth.js reads JWT_SECRET (and server/db.js reads -// DB_PATH) at module-evaluation time - same trick as tests/api.test.js. const { buildApp } = await import('../server/app.js'); const { ensureConfig, getEffectiveConfig } = await import('../server/configService.js'); -const { upsertUser, setRoles } = await import('../server/db.js'); +const { upsertUser, setRoles, driver } = await import('../server/db.js'); const { COOKIE_NAME } = await import('../server/auth.js'); await ensureConfig(); const app = buildApp(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + let seq = 0; async function makeUser(overrides = {}) { seq += 1; diff --git a/tests/api.finalfix.test.js b/tests/api.finalfix.test.js index 540b308..695f55a 100644 --- a/tests/api.finalfix.test.js +++ b/tests/api.finalfix.test.js @@ -4,17 +4,24 @@ // are covered in tests/e2e/smoke-v14.mjs, because asserting only through the // API is precisely how all six of these shipped green. process.env.JWT_SECRET = 'test-secret-for-supertest-finalfix'; -process.env.DB_PATH = ':memory:'; process.env.SUPER_ADMIN_IDS = 'test:finalfix-owner'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); const { - upsertUser, setRoles, putEvent, setEventStatus, getSave, putSave, + upsertUser, setRoles, putEvent, setEventStatus, getSave, putSave, driver, } = await import('../server/db.js'); const { COOKIE_NAME } = await import('../server/auth.js'); const { activateEvent, endEvent } = await import('../server/eventService.js'); @@ -22,6 +29,11 @@ const { activateEvent, endEvent } = await import('../server/eventService.js'); await ensureConfig(); const app = buildApp(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + const DAY_MS = 24 * 60 * 60 * 1000; let seq = 0; diff --git a/tests/api.social.test.js b/tests/api.social.test.js index 58f8795..032691c 100644 --- a/tests/api.social.test.js +++ b/tests/api.social.test.js @@ -1,13 +1,20 @@ process.env.JWT_SECRET = 'test-secret-for-supertest-social'; -process.env.DB_PATH = ':memory:'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); -const { upsertUser, putSave, setLeaderboardOptOut } = await import('../server/db.js'); +const { upsertUser, putSave, setLeaderboardOptOut, driver } = await import('../server/db.js'); const { invalidateLeaderboards } = await import('../server/leaderboardService.js'); const { COOKIE_NAME } = await import('../server/auth.js'); const { initialState } = await import('../shared/state.js'); @@ -15,6 +22,11 @@ const { initialState } = await import('../shared/state.js'); await ensureConfig(); const app = buildApp(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + let seq = 0; async function seedPlayer({ flops = 0, level = 0, cores = 0, singularities = 0, tapes = 0, achievements = {}, diff --git a/tests/api.test.js b/tests/api.test.js index 95561cc..7dcd596 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,21 +1,27 @@ process.env.JWT_SECRET = 'test-secret-for-supertest'; -process.env.DB_PATH = ':memory:'; process.env.SUPER_ADMIN_IDS = 'test:owner'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import { readFileSync } from 'node:fs'; import { DEFAULT_CONFIG } from '../shared/configSchema.js'; import { initialState } from '../shared/state.js'; +import { provisionDatabase } from './helpers/backend.js'; -// Dynamic imports: server/auth.js reads JWT_SECRET (and server/db.js reads -// DB_PATH) at module-evaluation time. Static imports get hoisted above the -// process.env assignments above per ES module semantics, so - same trick as +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below. Dynamic imports: server/auth.js reads +// JWT_SECRET (and server/db.js reads DB_PATH/DATABASE_URL) at +// module-evaluation time. Static imports get hoisted above the process.env +// assignments above per ES module semantics, so - same trick as // tests/db.test.js - these have to be dynamic to see the env vars set here. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; + const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); -const { upsertUser, putSave, setRoles, createMinigameSession } = await import('../server/db.js'); +const { upsertUser, putSave, setRoles, createMinigameSession, driver } = await import('../server/db.js'); const { COOKIE_NAME } = await import('../server/auth.js'); const v11Fixture = JSON.parse(readFileSync(new URL('./fixtures/v11-save.json', import.meta.url))); @@ -23,6 +29,11 @@ const v11Fixture = JSON.parse(readFileSync(new URL('./fixtures/v11-save.json', i await ensureConfig(); const app = buildApp(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + let seq = 0; async function makeUser(overrides = {}) { seq += 1; diff --git a/tests/api.tutorial.test.js b/tests/api.tutorial.test.js index 8db3bf9..2461ea6 100644 --- a/tests/api.tutorial.test.js +++ b/tests/api.tutorial.test.js @@ -1,19 +1,31 @@ process.env.JWT_SECRET = 'test-secret-for-supertest-tours'; -process.env.DB_PATH = ':memory:'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); -const { upsertUser, getToursCompleted, setToursCompleted } = await import('../server/db.js'); +const { upsertUser, getToursCompleted, setToursCompleted, driver } = await import('../server/db.js'); const { COOKIE_NAME } = await import('../server/auth.js'); const { TOUR_IDS, ONBOARDING_TOUR_ID } = await import('../shared/tours.js'); await ensureConfig(); const app = buildApp(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + let seq = 0; async function seedUser() { seq += 1; diff --git a/tests/db.events.test.js b/tests/db.events.test.js index ead70cf..9ec36a8 100644 --- a/tests/db.events.test.js +++ b/tests/db.events.test.js @@ -1,16 +1,23 @@ -process.env.DB_PATH = ':memory:'; - -import { describe, it, expect, beforeEach } from 'vitest'; +import { + describe, it, expect, beforeEach, afterAll, +} from 'vitest'; import { validateModifiers, validateLadder } from '../shared/events.js'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time - a static `import { driver } from ...` would be +// hoisted by the ESM spec above the provisioning call and stand up the +// driver against the wrong backend/path. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; -// Dynamic import, deferred until after DB_PATH is set above: a static -// `import { driver } from ...` would be hoisted by the ESM spec above the -// process.env assignment and stand up the driver against the real on-disk -// DB_PATH default instead of :memory:. const dbMod = await import('../server/db.js'); const { driver, upsertUser, + getUserById, listEvents, getEvent, getActiveEvent, @@ -25,14 +32,34 @@ const { } = dbMod; const { SEASONAL_EVENTS } = await import('../server/data/seasonalEvents.js'); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + // Schema/table-existence assertions below are inherently backend-specific -// (sqlite_master, PRAGMA table_info); route them through the driver handle -// rather than a module-level `db` export so the intent stays explicit. The -// pg variant of these assertions is added in Task 4. +// (sqlite_master/PRAGMA table_info vs. pg_tables/information_schema); route +// them through the driver handle rather than a module-level `db` export so +// the intent stays explicit. const db = driver.__raw; -function tableNames() { - return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name); +async function tableNames() { + if (driver.__backend === 'sqlite') { + return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name); + } + const { rows } = await db.query("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'"); + return rows.map((r) => r.name); +} + +async function columnNames(table) { + if (driver.__backend === 'sqlite') { + return db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name); + } + const { rows } = await db.query( + 'SELECT column_name AS name FROM information_schema.columns WHERE table_name = $1', + [table], + ); + return rows.map((r) => r.name); } function sampleEvent(overrides = {}) { @@ -56,17 +83,19 @@ function sampleEvent(overrides = {}) { describe('db schema v1.4', () => { it('creates live_events and event_participation tables', async () => { - const names = tableNames(); + const names = await tableNames(); expect(names).toContain('live_events'); expect(names).toContain('event_participation'); }); it('adds leaderboard_opt_out column to users', async () => { - const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + const cols = await columnNames('users'); expect(cols).toContain('leaderboard_opt_out'); }); - it('re-running the guarded ALTER does not throw on a second boot', async () => { + // SQLite-only: see the equivalent skip in tests/db.test.js - guarded ALTER + // is a SQLite-specific mechanism schema.pg.js has no counterpart for. + it.runIf(driver.__backend === 'sqlite')('re-running the guarded ALTER does not throw on a second boot', async () => { expect(() => { try { db.exec('ALTER TABLE users ADD COLUMN leaderboard_opt_out INTEGER DEFAULT 0'); @@ -190,15 +219,13 @@ describe('event_participation', () => { it('setLeaderboardOptOut round-trips on the users table', async () => { const u = await upsertUser({ provider: 'discord', providerId: 'ep5', username: 'participant5', avatarUrl: null }); - const before = db.prepare('SELECT leaderboard_opt_out FROM users WHERE id = ?').get(u.id); - expect(before.leaderboard_opt_out).toBe(0); + expect((await getUserById(u.id)).leaderboard_opt_out).toBe(0); await setLeaderboardOptOut(u.id, true); - const after = db.prepare('SELECT leaderboard_opt_out FROM users WHERE id = ?').get(u.id); - expect(after.leaderboard_opt_out).toBe(1); + expect((await getUserById(u.id)).leaderboard_opt_out).toBe(1); await setLeaderboardOptOut(u.id, false); - expect(db.prepare('SELECT leaderboard_opt_out FROM users WHERE id = ?').get(u.id).leaderboard_opt_out).toBe(0); + expect((await getUserById(u.id)).leaderboard_opt_out).toBe(0); }); }); diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js index 9e62573..6e37348 100644 --- a/tests/db.interface.test.js +++ b/tests/db.interface.test.js @@ -1,5 +1,18 @@ -process.env.DB_PATH = ':memory:'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; + +afterAll(async () => { + const mod = await import('../server/db/index.js'); + if (mod.driver.__backend === 'pg') await mod.driver.__raw.end(); + await provisioned.cleanup(); +}); const INTERFACE = [ 'upsertUser', 'getUserById', 'getAllUsersWithSaves', 'getSave', 'putSave', diff --git a/tests/db.parity.test.js b/tests/db.parity.test.js new file mode 100644 index 0000000..7977c5f --- /dev/null +++ b/tests/db.parity.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time (a static import would be hoisted above this). +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; + +let db; +beforeAll(async () => { db = await import('../server/db/index.js'); }); +afterAll(async () => { + // Close the pg pool before dropping its database: DROP DATABASE ... FORCE + // terminates any live connections, which the pool would otherwise surface + // as an unhandled 'error' event during teardown. + if (db.driver.__backend === 'pg') await db.driver.__raw.end(); + await provisioned.cleanup(); +}); + +describe('cross-dialect parity', () => { + it('returns undefined - not null - for a missing row', async () => { + expect(await db.getUserById('nope:1')).toBeUndefined(); + expect(await db.getSave('nope:1')).toBeUndefined(); + expect(await db.getEvent('nope')).toBeUndefined(); + }); + + it('round-trips epoch-ms timestamps as numbers', async () => { + const now = 1784859388645; + await db.upsertUser({ provider: 'github', providerId: 't1', username: 'ts', avatarUrl: null }); + await db.putSave('github:t1', { hello: 'world' }, now); + const row = await db.getSave('github:t1'); + expect(typeof row.last_save).toBe('number'); + expect(row.last_save).toBe(now); + }); + + it('round-trips a save byte-for-byte, including key order', async () => { + const payload = { z: 1, a: { nested: [1, 2, 3] }, m: 'x' }; + await db.upsertUser({ provider: 'github', providerId: 't2', username: 'bytes', avatarUrl: null }); + await db.putSave('github:t2', payload, 1); + const row = await db.getSave('github:t2'); + expect(row.data).toBe(JSON.stringify(payload)); + }); + + it('enforces case-insensitive username uniqueness', async () => { + await db.upsertUser({ provider: 'github', providerId: 'c1', username: 'CaseTest', avatarUrl: null }); + await db.upsertUser({ provider: 'discord', providerId: 'c2', username: 'casetest', avatarUrl: null }); + const a = await db.getUserById('github:c1'); + const b = await db.getUserById('discord:c2'); + expect(a.username).toBe('CaseTest'); + expect(b.username).toBe('casetest-2'); // suffixed, not rejected + }); + + it('setUsername rejects a case-variant of another user\'s name', async () => { + await db.upsertUser({ provider: 'github', providerId: 'c3', username: 'Taken', avatarUrl: null }); + await db.upsertUser({ provider: 'github', providerId: 'c4', username: 'Other', avatarUrl: null }); + expect(await db.setUsername('github:c4', 'taken')).toEqual({ ok: false, error: 'taken' }); + }); + + it('returns config history newest-first', async () => { + await db.putConfigRow(1, { v: 1 }, null); + await db.putConfigRow(2, { v: 2 }, null); + await db.putConfigRow(3, { v: 3 }, null); + const history = await db.getConfigHistory(); + expect(history.map((h) => h.version)).toEqual([3, 2, 1]); + }); + + it('preserves camelCase aliases in listLeaderboard', async () => { + await db.upsertUser({ provider: 'github', providerId: 'lb', username: 'lbuser', avatarUrl: null }); + await db.putEvent({ id: 'ev-lb', name: 'LB', modifiers: [], ladder: [], status: 'active' }); + await db.upsertParticipation({ + userId: 'github:lb', eventId: 'ev-lb', startedAt: 1, endsAt: 2, rungsClaimed: 3, lastProgressAt: 4, + }); + const [row] = await db.listLeaderboard('ev-lb'); + expect(row.userId).toBe('github:lb'); + expect(row.rungsClaimed).toBe(3); + expect(row.lastProgressAt).toBe(4); + expect(row).not.toHaveProperty('userid'); + }); + + it('seedSeasonalEvents is idempotent across boots', async () => { + await db.seedSeasonalEvents(); + const first = (await db.listEvents()).length; + await db.seedSeasonalEvents(); + expect((await db.listEvents()).length).toBe(first); + }); +}); diff --git a/tests/db.test.js b/tests/db.test.js index ea975b1..0e77cd4 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -1,11 +1,17 @@ -process.env.DB_PATH = ':memory:'; +import { + describe, it, expect, beforeEach, afterAll, +} from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time - a static `import { driver } from ...` would be +// hoisted by the ESM spec above the provisioning call and stand up the +// driver against the wrong backend/path. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; -import { describe, it, expect, beforeEach } from 'vitest'; - -// Dynamic import, deferred until after DB_PATH is set above: a static -// `import { driver } from ...` would be hoisted by the ESM spec above the -// process.env assignment and stand up the driver against the real on-disk -// DB_PATH default instead of :memory:. const dbMod = await import('../server/db.js'); const { driver, @@ -25,19 +31,39 @@ const { getConfigHistory, } = dbMod; +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + // Schema/table-existence assertions below are inherently backend-specific -// (sqlite_master, PRAGMA table_info); route them through the driver handle -// rather than a module-level `db` export so the intent stays explicit. The -// pg variant of these assertions is added in Task 4. +// (sqlite_master/PRAGMA table_info vs. pg_tables/information_schema); route +// them through the driver handle rather than a module-level `db` export so +// the intent stays explicit. const db = driver.__raw; -function tableNames() { - return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name); +async function tableNames() { + if (driver.__backend === 'sqlite') { + return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name); + } + const { rows } = await db.query("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'"); + return rows.map((r) => r.name); +} + +async function columnNames(table) { + if (driver.__backend === 'sqlite') { + return db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name); + } + const { rows } = await db.query( + 'SELECT column_name AS name FROM information_schema.columns WHERE table_name = $1', + [table], + ); + return rows.map((r) => r.name); } describe('db schema v1.2', () => { it('creates config, config_history, and minigame_sessions tables', async () => { - const names = tableNames(); + const names = await tableNames(); expect(names).toContain('users'); expect(names).toContain('saves'); expect(names).toContain('config'); @@ -46,12 +72,20 @@ describe('db schema v1.2', () => { }); it('adds roles and custom_username columns to users', async () => { - const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + const cols = await columnNames('users'); expect(cols).toContain('roles'); expect(cols).toContain('custom_username'); }); - it('re-importing (guarded ALTER) does not throw on a second boot', async () => { + // SQLite-only: this pins the guarded-ALTER duplicate-column path + // (schema.sqlite.js's guardedAddColumn), which exists because SQLite has + // no "ADD COLUMN IF NOT EXISTS" and the SQLite schema accreted these + // columns via ALTER across several versions. schema.pg.js has no + // equivalent - every column ships in its CREATE TABLE from the start, and + // Postgres's own idempotency (IF NOT EXISTS / ON CONFLICT DO NOTHING) is + // exercised directly by seedSeasonalEvents' and applySchema's own + // idempotency tests elsewhere in this suite. + it.runIf(driver.__backend === 'sqlite')('re-importing (guarded ALTER) does not throw on a second boot', async () => { // Simulates a second server boot against the same DB file: the module // already ran its ALTERs once for this connection: rerun the exact same // guarded statements directly to prove the duplicate-column path is safe. @@ -183,21 +217,35 @@ describe('upsertUser username collisions on first login', () => { describe('dedupeUsernames', () => { it('suffixes later-created duplicates (case-insensitive), keeping the earliest untouched', async () => { - // The unique index (created at module init, after dedupeUsernames' first - // boot-time run) would reject these constructed duplicates outright. - // Drop it to simulate the pre-index state dedupeUsernames is meant to - // clean up, then let the module recreate it afterward. - db.exec('DROP INDEX IF EXISTS idx_users_username'); - - const insert = db.prepare(` - INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) - VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) - `); - insert.run({ id: 'x:1', provider: 'x', provider_id: '1', username: 'Duplicate', avatar_url: null, created_at: 100 }); - insert.run({ id: 'x:2', provider: 'x', provider_id: '2', username: 'duplicate', avatar_url: null, created_at: 200 }); - insert.run({ id: 'x:3', provider: 'x', provider_id: '3', username: 'DUPLICATE', avatar_url: null, created_at: 300 }); - // A pre-existing user already squats the first suffix candidate. - insert.run({ id: 'x:4', provider: 'x', provider_id: '4', username: 'duplicate-2', avatar_url: null, created_at: 50 }); + // The unique index (created at driver init, after dedupeUsernames' first + // boot-time run on SQLite) would reject these constructed duplicates + // outright. Drop it to simulate the pre-index state dedupeUsernames is + // meant to clean up, then recreate it (each backend's own DDL) + // afterward. + const rows = [ + { id: 'x:1', provider: 'x', provider_id: '1', username: 'Duplicate', avatar_url: null, created_at: 100 }, + { id: 'x:2', provider: 'x', provider_id: '2', username: 'duplicate', avatar_url: null, created_at: 200 }, + { id: 'x:3', provider: 'x', provider_id: '3', username: 'DUPLICATE', avatar_url: null, created_at: 300 }, + // A pre-existing user already squats the first suffix candidate. + { id: 'x:4', provider: 'x', provider_id: '4', username: 'duplicate-2', avatar_url: null, created_at: 50 }, + ]; + if (driver.__backend === 'sqlite') { + db.exec('DROP INDEX IF EXISTS idx_users_username'); + const insert = db.prepare(` + INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) + VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) + `); + for (const row of rows) insert.run(row); + } else { + await db.query('DROP INDEX IF EXISTS idx_users_username'); + for (const row of rows) { + // eslint-disable-next-line no-await-in-loop + await db.query( + 'INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4, $5, $6)', + [row.id, row.provider, row.provider_id, row.username, row.avatar_url, row.created_at], + ); + } + } await dedupeUsernames(); @@ -206,7 +254,11 @@ describe('dedupeUsernames', () => { expect((await getUserById('x:3')).username).toBe('DUPLICATE-4'); expect((await getUserById('x:4')).username).toBe('duplicate-2'); - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); + if (driver.__backend === 'sqlite') { + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); + } else { + await db.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (lower(username))'); + } }); }); @@ -275,7 +327,7 @@ describe('config', () => { describe('db schema v1.6: tours_completed', () => { it('adds the tours_completed column to users', async () => { - const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + const cols = await columnNames('users'); expect(cols).toContain('tours_completed'); }); @@ -296,7 +348,11 @@ describe('db schema v1.6: tours_completed', () => { it('returns [] rather than throwing on corrupt JSON', async () => { const u = await upsertUser({ provider: 'discord', providerId: 'tour3', username: 'tour3', avatarUrl: null }); - db.prepare('UPDATE users SET tours_completed = ? WHERE id = ?').run('{not json', u.id); + if (driver.__backend === 'sqlite') { + db.prepare('UPDATE users SET tours_completed = ? WHERE id = ?').run('{not json', u.id); + } else { + await db.query('UPDATE users SET tours_completed = $1 WHERE id = $2', ['{not json', u.id]); + } expect(await getToursCompleted(u.id)).toEqual([]); }); }); diff --git a/tests/eventService.finalfix.test.js b/tests/eventService.finalfix.test.js index 49f76e3..0948243 100644 --- a/tests/eventService.finalfix.test.js +++ b/tests/eventService.finalfix.test.js @@ -3,12 +3,22 @@ // the pre-fix behaviour actually was - all six were reproduced against a real // server before being fixed, and all six were invisible to the pre-existing // suites. -process.env.DB_PATH = ':memory:'; +import { describe, it, expect, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; -import { describe, it, expect } from 'vitest'; +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); const { upsertUser, putEvent, getEvent, setEventStatus } = dbMod; +afterAll(async () => { + if (dbMod.driver.__backend === 'pg') await dbMod.driver.__raw.end(); + await provisioned.cleanup(); +}); const { ensureConfig } = await import('../server/configService.js'); const eventService = await import('../server/eventService.js'); const { diff --git a/tests/eventService.test.js b/tests/eventService.test.js index 6492552..ebe789f 100644 --- a/tests/eventService.test.js +++ b/tests/eventService.test.js @@ -1,11 +1,21 @@ -process.env.DB_PATH = ':memory:'; +import { describe, it, expect, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; -import { describe, it, expect } from 'vitest'; +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); const { upsertUser, putEvent, getEvent, listEvents, getConfigRow, getParticipation, } = dbMod; +afterAll(async () => { + if (dbMod.driver.__backend === 'pg') await dbMod.driver.__raw.end(); + await provisioned.cleanup(); +}); const configService = await import('../server/configService.js'); const { ensureConfig, getConfig, getEffectiveConfig } = configService; const eventService = await import('../server/eventService.js'); diff --git a/tests/stateService.events.test.js b/tests/stateService.events.test.js index 9f655af..738c53c 100644 --- a/tests/stateService.events.test.js +++ b/tests/stateService.events.test.js @@ -23,11 +23,21 @@ // than hand-constructing a config object, unlike tests/reducer.events.test.js // - hand-building config.__activeEvent directly is exactly why the original // bug was invisible to the existing suite (Task 7 report). -process.env.DB_PATH = ':memory:'; +import { describe, it, expect, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; -import { describe, it, expect } from 'vitest'; +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); +afterAll(async () => { + if (dbMod.driver.__backend === 'pg') await dbMod.driver.__raw.end(); + await provisioned.cleanup(); +}); const { upsertUser, putEvent, putSave, } = dbMod; diff --git a/tests/stateService.social.test.js b/tests/stateService.social.test.js index d5f1f1d..5982449 100644 --- a/tests/stateService.social.test.js +++ b/tests/stateService.social.test.js @@ -1,9 +1,16 @@ -process.env.DB_PATH = ':memory:'; process.env.JWT_SECRET = 'test-secret-social-state'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; -const { upsertUser, putSave, getSave } = await import('../server/db.js'); +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time. +const provisioned = await provisionDatabase(); +if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; +else process.env.DB_PATH = provisioned.path; + +const { upsertUser, putSave, getSave, driver } = await import('../server/db.js'); const { ensureConfig } = await import('../server/configService.js'); const { loadAndEvaluate } = await import('../server/stateService.js'); const { initialState } = await import('../shared/state.js'); @@ -11,6 +18,11 @@ const { utcDateKey } = await import('../shared/daily.js'); await ensureConfig(); +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + let seq = 0; async function makeUser() { seq += 1; From 7933f8151fb8c3fa3ab9f210657205eb39f6a806 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 19:19:57 -0400 Subject: [PATCH 09/14] Fix Task 4 review findings: DATABASE_URL isolation, dedupe duplication Critical: an ambient DATABASE_URL was never cleared on the sqlite test path, and server/db/index.js checks it before DB_PATH - so a developer with DATABASE_URL exported (plausible mid-migration, and the var Task 8 documents for production) would have every "sqlite" test file silently connect to that real database instead. Centralized all env-var writing for both backends inside provisionDatabase() itself (the pg branch is now the only writer of DATABASE_URL in the codebase; the sqlite branch clears it), removing the duplicated per-file if/else from all 14 test files. Covered by a new regression test in tests/harness.test.js that sets DATABASE_URL to an RFC-2606-reserved unreachable host and proves the facade still resolves to sqlite end-to-end, plus a deliberate full `npm run test:sqlite` run with that bogus DATABASE_URL exported (464/464 green in under 2s - no connection ever attempted). Important: extracted dedupeUsernames' suffixing walk (identical in both drivers apart from the SQL) into shared.js's dedupeUsernameRows, so the -2/-3 convention can't drift between drivers again. Replaced the false "exercised elsewhere in this suite" claim on db.test.js's guarded-ALTER skip with a real cross-backend test that calls applySchema() a second time against an already-populated database - the actual production restart path - on both backends. Minor: fixed db.parity.test.js's beforeAll/afterAll split (a RED-path import failure left `db` undefined, so afterAll's TypeError masked the real error and skipped cleanup) by switching to a top-level import like every other rewired file; added ORDER BY tie-breakers to driver.pg.js's listEvents/getAllUsersWithSaves so ties sort deterministically instead of arbitrarily on Postgres. Co-Authored-By: Claude Opus 5 --- server/db/driver.pg.js | 35 +++++++++++++++-------------- server/db/schema.sqlite.js | 28 +++++++++-------------- server/db/shared.js | 29 ++++++++++++++++++++++++ tests/api.events.hotfix.test.js | 2 -- tests/api.events.test.js | 2 -- tests/api.finalfix.test.js | 2 -- tests/api.social.test.js | 2 -- tests/api.test.js | 2 -- tests/api.tutorial.test.js | 2 -- tests/db.events.test.js | 2 -- tests/db.interface.test.js | 2 -- tests/db.parity.test.js | 14 +++++++----- tests/db.test.js | 32 +++++++++++++++++++++----- tests/eventService.finalfix.test.js | 2 -- tests/eventService.test.js | 2 -- tests/harness.test.js | 30 +++++++++++++++++++++++++ tests/helpers/backend.js | 14 ++++++++++++ tests/stateService.events.test.js | 2 -- tests/stateService.social.test.js | 2 -- 19 files changed, 137 insertions(+), 69 deletions(-) diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js index d44f7ee..ac8abe3 100644 --- a/server/db/driver.pg.js +++ b/server/db/driver.pg.js @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { applySchema } from './schema.pg.js'; import { SEASONAL_EVENTS } from '../data/seasonalEvents.js'; import { - findAvailableUsername, parseEventRow, normalizeEventRow, normalizeParticipationRow, + findAvailableUsername, parseEventRow, normalizeEventRow, normalizeParticipationRow, dedupeUsernameRows, } from './shared.js'; // pg returns int8/BIGINT as a STRING by default, to avoid precision loss @@ -118,7 +118,7 @@ export async function createPgDriver({ url }) { s.data, s.last_save FROM users u LEFT JOIN saves s ON s.user_id = u.id - ORDER BY u.created_at DESC + ORDER BY u.created_at DESC, u.id ASC `); }, @@ -195,25 +195,26 @@ export async function createPgDriver({ url }) { /** * Duplicate usernames (case-insensitively) can exist from before the - * unique index was introduced. Applied on every boot inside applySchema - * (schema.pg.js does not currently call this - kept here so callers of - * the driver's dedupeUsernames() get the same on-demand cleanup path - * available on the SQLite driver). + * unique index was introduced. Unlike the SQLite driver, schema.pg.js's + * applySchema does NOT call this on every boot - a fresh Postgres + * deployment has no pre-index history to clean up, since every write + * path into `users` already goes through this driver's own + * collision-checked upsertUser/setUsername. Kept as an interface method + * so callers (and a future bulk migrator, which would need to dedupe + * in-memory before inserting rather than relying on this) have the same + * on-demand cleanup path available on the SQLite driver. The suffixing + * walk itself lives in shared.js's dedupeUsernameRows so the two drivers + * can't drift on the -2/-3 convention; this method only owns the read + * and writes. */ async dedupeUsernames() { const rows = await all( 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', ); - const taken = new Set(); - for (const row of rows) { - const lower = row.username.toLowerCase(); - if (!taken.has(lower)) { - taken.add(lower); - continue; - } - const candidate = await findAvailableUsername(row.username, (name) => taken.has(name.toLowerCase())); - await run('UPDATE users SET username = $1 WHERE id = $2', [candidate, row.id]); - taken.add(candidate.toLowerCase()); + const renames = await dedupeUsernameRows(rows); + for (const { id, username } of renames) { + // eslint-disable-next-line no-await-in-loop + await run('UPDATE users SET username = $1 WHERE id = $2', [username, id]); } }, @@ -293,7 +294,7 @@ export async function createPgDriver({ url }) { }, async listEvents() { - return (await all('SELECT * FROM live_events ORDER BY created_at ASC')).map(parseEventRow); + return (await all('SELECT * FROM live_events ORDER BY created_at ASC, id ASC')).map(parseEventRow); }, async getEvent(id) { diff --git a/server/db/schema.sqlite.js b/server/db/schema.sqlite.js index e5c5bb5..6a8ba27 100644 --- a/server/db/schema.sqlite.js +++ b/server/db/schema.sqlite.js @@ -1,4 +1,4 @@ -import { findAvailableUsername } from './shared.js'; +import { dedupeUsernameRows } from './shared.js'; /** * Duplicate usernames (case-insensitively) can exist from before the unique @@ -6,27 +6,21 @@ import { findAvailableUsername } from './shared.js'; * that statement would fail on any pre-existing collision. No-op when there * are no duplicates, so it's cheap to run unconditionally on every boot. * - * findAvailableUsername is async (its Postgres counterpart needs an async - * predicate), so this function is too - awaited by applySchema before it - * creates the unique index, and by driver.sqlite.js's `dedupeUsernames` - * interface method (a thin delegation to this). The isTaken predicate here - * is itself sync (an in-memory Set check); awaiting a non-promise value is - * harmless. + * The suffixing walk itself lives in shared.js's dedupeUsernameRows (async, + * since its Postgres counterpart needs an async predicate) so the two + * drivers can't drift on the -2/-3 convention - this function only owns the + * SQLite-specific read and writes. Awaited by applySchema before it creates + * the unique index, and by driver.sqlite.js's `dedupeUsernames` interface + * method (a thin delegation to this). */ export async function dedupeUsernames(db) { const rows = db.prepare( 'SELECT id, username, created_at FROM users WHERE username IS NOT NULL ORDER BY created_at ASC, id ASC', ).all(); - const taken = new Set(); - for (const row of rows) { - const lower = row.username.toLowerCase(); - if (!taken.has(lower)) { - taken.add(lower); - continue; - } - const candidate = await findAvailableUsername(row.username, (name) => taken.has(name.toLowerCase())); - db.prepare('UPDATE users SET username = ? WHERE id = ?').run(candidate, row.id); - taken.add(candidate.toLowerCase()); + const renames = await dedupeUsernameRows(rows); + const update = db.prepare('UPDATE users SET username = ? WHERE id = ?'); + for (const { id, username } of renames) { + update.run(username, id); } } diff --git a/server/db/shared.js b/server/db/shared.js index 769053a..38d722e 100644 --- a/server/db/shared.js +++ b/server/db/shared.js @@ -27,6 +27,35 @@ export async function findAvailableUsername(desiredName, isTaken) { return candidate; } +/** + * The bulk-cleanup half of dedupeUsernames, factored out so both drivers + * share one implementation of the suffixing walk instead of each carrying + * its own copy that has to be kept in lockstep by hand. Takes `rows` (every + * user with a non-null username, already ordered earliest-created first - + * `ORDER BY created_at ASC, id ASC` on both backends, so the earliest holder + * of a name is never the one renamed) and returns the subset that need to + * change: `[{ id, username }]`, each `username` already run through + * findAvailableUsername against an in-memory Set of names claimed so far. + * Callers write these back (an UPDATE per entry) and leave every other row + * untouched; the read (SELECT ... ORDER BY) and the writes stay + * driver-specific since they're plain SQL, not dialect-free logic. + */ +export async function dedupeUsernameRows(rows) { + const taken = new Set(); + const renames = []; + for (const row of rows) { + const lower = row.username.toLowerCase(); + if (!taken.has(lower)) { + taken.add(lower); + continue; + } + const candidate = await findAvailableUsername(row.username, (name) => taken.has(name.toLowerCase())); + renames.push({ id: row.id, username: candidate }); + taken.add(candidate.toLowerCase()); + } + return renames; +} + // --- Live Events (v1.4) ----------------------------------------------- // // Unlike getSave/getConfigRow (which hand back their JSON columns as raw diff --git a/tests/api.events.hotfix.test.js b/tests/api.events.hotfix.test.js index 9a7045b..df731ce 100644 --- a/tests/api.events.hotfix.test.js +++ b/tests/api.events.hotfix.test.js @@ -21,8 +21,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); diff --git a/tests/api.events.test.js b/tests/api.events.test.js index b61c69d..91a9959 100644 --- a/tests/api.events.test.js +++ b/tests/api.events.test.js @@ -10,8 +10,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time - same trick as tests/api.test.js. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig, getEffectiveConfig } = await import('../server/configService.js'); diff --git a/tests/api.finalfix.test.js b/tests/api.finalfix.test.js index 695f55a..1060e24 100644 --- a/tests/api.finalfix.test.js +++ b/tests/api.finalfix.test.js @@ -15,8 +15,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); diff --git a/tests/api.social.test.js b/tests/api.social.test.js index 032691c..aec28d9 100644 --- a/tests/api.social.test.js +++ b/tests/api.social.test.js @@ -9,8 +9,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); diff --git a/tests/api.test.js b/tests/api.test.js index 7dcd596..053d66d 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -16,8 +16,6 @@ import { provisionDatabase } from './helpers/backend.js'; // assignments above per ES module semantics, so - same trick as // tests/db.test.js - these have to be dynamic to see the env vars set here. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); diff --git a/tests/api.tutorial.test.js b/tests/api.tutorial.test.js index 2461ea6..f02d446 100644 --- a/tests/api.tutorial.test.js +++ b/tests/api.tutorial.test.js @@ -9,8 +9,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { buildApp } = await import('../server/app.js'); const { ensureConfig } = await import('../server/configService.js'); diff --git a/tests/db.events.test.js b/tests/db.events.test.js index 9ec36a8..34529d9 100644 --- a/tests/db.events.test.js +++ b/tests/db.events.test.js @@ -10,8 +10,6 @@ import { provisionDatabase } from './helpers/backend.js'; // hoisted by the ESM spec above the provisioning call and stand up the // driver against the wrong backend/path. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); const { diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js index 6e37348..a42afa7 100644 --- a/tests/db.interface.test.js +++ b/tests/db.interface.test.js @@ -5,8 +5,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; afterAll(async () => { const mod = await import('../server/db/index.js'); diff --git a/tests/db.parity.test.js b/tests/db.parity.test.js index 7977c5f..cdf0c6e 100644 --- a/tests/db.parity.test.js +++ b/tests/db.parity.test.js @@ -1,15 +1,19 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import { provisionDatabase } from './helpers/backend.js'; // Provision before importing the facade: DATABASE_URL/DB_PATH must be set // before the dynamic import below, since the facade resolves its driver at // module-evaluation time (a static import would be hoisted above this). const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; -let db; -beforeAll(async () => { db = await import('../server/db/index.js'); }); +// Top-level await, not beforeAll: if the import below throws (e.g. the RED +// step of this task, where driver.pg.js didn't exist yet), a beforeAll-set +// `db` would still be undefined when afterAll ran, throwing a TypeError on +// `db.driver` that masks the real error and skips cleanup() - leaking the +// provisioned database. A top-level await fails the whole module before +// afterAll is ever registered, so vitest's own teardown for a failed-to-load +// file applies instead. +const db = await import('../server/db/index.js'); afterAll(async () => { // Close the pg pool before dropping its database: DROP DATABASE ... FORCE // terminates any live connections, which the pool would otherwise surface diff --git a/tests/db.test.js b/tests/db.test.js index 0e77cd4..102cd42 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -9,8 +9,6 @@ import { provisionDatabase } from './helpers/backend.js'; // hoisted by the ESM spec above the provisioning call and stand up the // driver against the wrong backend/path. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); const { @@ -81,10 +79,11 @@ describe('db schema v1.2', () => { // (schema.sqlite.js's guardedAddColumn), which exists because SQLite has // no "ADD COLUMN IF NOT EXISTS" and the SQLite schema accreted these // columns via ALTER across several versions. schema.pg.js has no - // equivalent - every column ships in its CREATE TABLE from the start, and - // Postgres's own idempotency (IF NOT EXISTS / ON CONFLICT DO NOTHING) is - // exercised directly by seedSeasonalEvents' and applySchema's own - // idempotency tests elsewhere in this suite. + // equivalent - every column ships in its CREATE TABLE from the start. The + // backend-agnostic second-boot idempotency claim this comment used to make + // (that Postgres's own idempotency was "exercised elsewhere in this + // suite") wasn't actually true - see the real cross-backend test below, + // which replaces that claim with a test that does what it described. it.runIf(driver.__backend === 'sqlite')('re-importing (guarded ALTER) does not throw on a second boot', async () => { // Simulates a second server boot against the same DB file: the module // already ran its ALTERs once for this connection: rerun the exact same @@ -97,6 +96,27 @@ describe('db schema v1.2', () => { } }).not.toThrow(); }); + + // Both backends: the production restart path is applySchema() running a + // SECOND time against an already-populated database (the driver + // constructor already ran it once, on the first import above). SQLite's + // idempotency here is the guarded-ALTER test just above; Postgres's is + // `IF NOT EXISTS` / `CREATE UNIQUE INDEX IF NOT EXISTS`, which was + // previously only claimed, not tested, anywhere in this suite - this + // proves it directly rather than relying on an inference from + // seedSeasonalEvents' unrelated ON CONFLICT DO NOTHING idempotency. + it('re-applying the full schema a second time does not throw (second-boot idempotency)', async () => { + const schemaMod = driver.__backend === 'sqlite' + ? await import('../server/db/schema.sqlite.js') + : await import('../server/db/schema.pg.js'); + await schemaMod.applySchema(db); + // And the schema is still fully intact and usable afterward. + const names = await tableNames(); + expect(names).toContain('users'); + expect(names).toContain('live_events'); + const cols = await columnNames('users'); + expect(cols).toContain('custom_username'); + }); }); describe('setUsername', () => { diff --git a/tests/eventService.finalfix.test.js b/tests/eventService.finalfix.test.js index 0948243..d87e360 100644 --- a/tests/eventService.finalfix.test.js +++ b/tests/eventService.finalfix.test.js @@ -10,8 +10,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); const { upsertUser, putEvent, getEvent, setEventStatus } = dbMod; diff --git a/tests/eventService.test.js b/tests/eventService.test.js index ebe789f..5be257e 100644 --- a/tests/eventService.test.js +++ b/tests/eventService.test.js @@ -5,8 +5,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); const { diff --git a/tests/harness.test.js b/tests/harness.test.js index 34f9aa1..b2b54e4 100644 --- a/tests/harness.test.js +++ b/tests/harness.test.js @@ -17,4 +17,34 @@ describe('test harness', () => { expect(rows[0].ok).toBe(1); await client.end(); }); + + // Regression test for a Critical Task 4 review finding: an ambient + // DATABASE_URL (the most likely env var to be exported while working on + // this migration, and the one Task 8 documents setting for production) + // must not silently redirect a "sqlite" test run at a real database. + // Before this fix, provisionDatabase()'s sqlite branch never touched + // DATABASE_URL, and server/db/index.js checks DATABASE_URL *before* + // DB_PATH - so a stray value here would have routed every db-touching + // suite at whatever DATABASE_URL pointed to, writing real rows and + // reporting green while never exercising SQLite at all. + it.runIf(provisioned.backend === 'sqlite')( + 'clears an ambient DATABASE_URL, so the facade resolves to sqlite rather than attempting a Postgres connection', + async () => { + // db.invalid is a reserved TLD (RFC 2606) guaranteed to fail DNS + // resolution immediately - if this regresses, the test fails fast + // with a connection error instead of hanging for the suite timeout. + process.env.DATABASE_URL = 'postgresql://bogus@db.invalid:5432/nope'; + + const second = await provisionDatabase(); + expect(second.backend).toBe('sqlite'); + expect(process.env.DATABASE_URL).toBeUndefined(); + + // End-to-end: the facade itself must resolve to the sqlite driver, + // not hang or throw trying to reach db.invalid. This is the first + // import of the facade in this file, so it's a genuine fresh + // module-evaluation, not a cached result from an earlier import. + const dbMod = await import('../server/db/index.js'); + expect(dbMod.driver.__backend).toBe('sqlite'); + }, + ); }); diff --git a/tests/helpers/backend.js b/tests/helpers/backend.js index 00f64f2..08f3e1a 100644 --- a/tests/helpers/backend.js +++ b/tests/helpers/backend.js @@ -10,9 +10,22 @@ import pg from 'pg'; import { randomUUID } from 'node:crypto'; +// Also responsible for setting the env var(s) server/db/index.js's facade +// reads to pick its driver (DATABASE_URL for pg, DB_PATH for sqlite), so +// that logic lives in exactly one place rather than being repeated (and +// risking drift) at the top of every db-touching test file. In particular: +// server/db/index.js checks DATABASE_URL *first*, so an ambient value left +// over from a developer's shell (or set per Task 8's docs, since it's the +// production config var) would otherwise silently redirect a "sqlite" +// backend run at a real Postgres database instead of :memory: - this +// function's sqlite branch is the one place that guards against that, and +// its pg branch is the *only* place in the codebase allowed to set +// DATABASE_URL, so there's a single point of truth for both directions. export async function provisionDatabase() { const backend = process.env.TEST_BACKEND === 'sqlite' ? 'sqlite' : 'pg'; if (backend === 'sqlite') { + delete process.env.DATABASE_URL; + process.env.DB_PATH = ':memory:'; return { backend, path: ':memory:', cleanup: async () => {} }; } @@ -30,6 +43,7 @@ export async function provisionDatabase() { const url = new URL(adminUrl); url.pathname = `/${name}`; + process.env.DATABASE_URL = url.toString(); return { backend, url: url.toString(), diff --git a/tests/stateService.events.test.js b/tests/stateService.events.test.js index 738c53c..b1cb646 100644 --- a/tests/stateService.events.test.js +++ b/tests/stateService.events.test.js @@ -30,8 +30,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const dbMod = await import('../server/db.js'); afterAll(async () => { diff --git a/tests/stateService.social.test.js b/tests/stateService.social.test.js index 5982449..b5dc2d6 100644 --- a/tests/stateService.social.test.js +++ b/tests/stateService.social.test.js @@ -7,8 +7,6 @@ import { provisionDatabase } from './helpers/backend.js'; // before the dynamic import below, since the facade resolves its driver at // module-evaluation time. const provisioned = await provisionDatabase(); -if (provisioned.backend === 'pg') process.env.DATABASE_URL = provisioned.url; -else process.env.DB_PATH = provisioned.path; const { upsertUser, putSave, getSave, driver } = await import('../server/db.js'); const { ensureConfig } = await import('../server/configService.js'); From 2f3bfe016da11ef849e51c694fabb8071ebabb4c Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 19:34:21 -0400 Subject: [PATCH 10/14] Split login methods out of users into an identities table users.id stays 'provider:providerId', so all three foreign keys and SUPER_ADMIN_IDS keep working untouched - the split is additive from the application's point of view. upsertUser now resolves through identities and records last_login_at. Existing databases are migrated in place on boot. SQLite needs the full table-rebuild dance because DROP COLUMN is refused while the column participates in a table-level UNIQUE constraint, and the rebuild is followed by a foreign_key_check - a violation there would mean orphaned saves. Both schemas leave their original users CREATE TABLE untouched and migrate the columns away as a guarded step that runs on every boot, so even a fresh database exercises the real migration path, not just the dedicated upgrade-path test. getAllUsersWithSaves() keeps exposing provider via the user's primary (earliest-created) identity - identical to the old column's output for every single-identity user today. New listIdentities(userId) export is unused until v1.8. supertokens_user_id ships nullable and unused; v1.8 fills it in. --- server/db/driver.pg.js | 63 ++++++++++++---- server/db/driver.sqlite.js | 64 ++++++++++++++--- server/db/index.js | 2 +- server/db/interface.md | 18 +++-- server/db/schema.pg.js | 66 +++++++++++++++-- server/db/schema.sqlite.js | 86 ++++++++++++++++++++++ tests/db.identities.test.js | 139 ++++++++++++++++++++++++++++++++++++ tests/db.interface.test.js | 2 +- tests/db.test.js | 19 ++--- 9 files changed, 415 insertions(+), 44 deletions(-) create mode 100644 tests/db.identities.test.js diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js index ac8abe3..f8fc401 100644 --- a/server/db/driver.pg.js +++ b/server/db/driver.pg.js @@ -53,15 +53,26 @@ export async function createPgDriver({ url }) { __raw: pool, async upsertUser({ provider, providerId, username, avatarUrl }) { - const id = `${provider}:${providerId}`; - const existing = await one('SELECT * FROM users WHERE id = $1', [id]); - if (existing) { + // Resolution goes through identities now, not a direct lookup by + // users.id - a later release attaching a second login method to the + // same account will only add a row here, never touch users.id. + const identity = await one( + 'SELECT * FROM identities WHERE provider = $1 AND provider_id = $2', [provider, providerId], + ); + + if (identity) { + const existing = await one('SELECT * FROM users WHERE id = $1', [identity.user_id]); + await run( + 'UPDATE identities SET last_login_at = $1 WHERE provider = $2 AND provider_id = $3', + [Date.now(), provider, providerId], + ); + // A user who has set a custom username keeps it on re-login; only the // avatar (which the user doesn't control) is refreshed from the profile. const desiredUsername = existing.custom_username ? existing.username : username; let nextUsername = desiredUsername; try { - await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', [nextUsername, avatarUrl, id]); + await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', [nextUsername, avatarUrl, existing.id]); } catch (e) { // The provider-supplied name can change between logins (e.g. the user // renamed their display name on the OAuth provider) and collide @@ -74,20 +85,22 @@ export async function createPgDriver({ url }) { if (e.code !== '23505') throw e; // unique_violation nextUsername = await findAvailableUsername( desiredUsername, - (name) => isUsernameTakenByOtherUser(name, id), + (name) => isUsernameTakenByOtherUser(name, existing.id), ); - await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', [nextUsername, avatarUrl, id]); + await run('UPDATE users SET username = $1, avatar_url = $2 WHERE id = $3', [nextUsername, avatarUrl, existing.id]); } return { ...existing, username: nextUsername, avatar_url: avatarUrl }; } + const id = `${provider}:${providerId}`; + const now = Date.now(); const user = { - id, provider, provider_id: providerId, username, avatar_url: avatarUrl, created_at: Date.now(), + id, username, avatar_url: avatarUrl, created_at: now, }; try { await run( - 'INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4, $5, $6)', - [user.id, user.provider, user.provider_id, user.username, user.avatar_url, user.created_at], + 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', + [user.id, user.username, user.avatar_url, user.created_at], ); } catch (e) { // Two different brand-new OAuth accounts can independently supply the @@ -100,10 +113,14 @@ export async function createPgDriver({ url }) { if (e.code !== '23505') throw e; // unique_violation user.username = await findAvailableUsername(username, isUsernameTakenInDb); await run( - 'INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4, $5, $6)', - [user.id, user.provider, user.provider_id, user.username, user.avatar_url, user.created_at], + 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', + [user.id, user.username, user.avatar_url, user.created_at], ); } + await run( + 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES ($1, $2, $3, $4)', + [provider, providerId, id, now], + ); return user; }, @@ -111,17 +128,37 @@ export async function createPgDriver({ url }) { return one('SELECT * FROM users WHERE id = $1', [id]); }, + /** + * `provider` is the user's PRIMARY identity - earliest created_at, ties + * broken by provider name - not necessarily their only one. Since no + * user has more than one identity yet, this is identical to the + * pre-split `users.provider` column for every existing row. + */ async getAllUsersWithSaves() { return all(` - SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, + SELECT u.id, u.username, u.avatar_url, u.created_at, u.leaderboard_opt_out, - s.data, s.last_save + s.data, s.last_save, + (SELECT i.provider FROM identities i + WHERE i.user_id = u.id + ORDER BY i.created_at ASC, i.provider ASC + LIMIT 1) AS provider FROM users u LEFT JOIN saves s ON s.user_id = u.id ORDER BY u.created_at DESC, u.id ASC `); }, + /** + * Every login method attached to `userId`, earliest first. Not yet + * consumed anywhere in this codebase - a thin read added ahead of v1.8, + * which will use it to let a player see (and eventually link) every + * provider they've logged in with. + */ + async listIdentities(userId) { + return all('SELECT * FROM identities WHERE user_id = $1 ORDER BY created_at ASC', [userId]); + }, + async getSave(userId) { return one('SELECT * FROM saves WHERE user_id = $1', [userId]); }, diff --git a/server/db/driver.sqlite.js b/server/db/driver.sqlite.js index 99bffd8..21429ec 100644 --- a/server/db/driver.sqlite.js +++ b/server/db/driver.sqlite.js @@ -32,8 +32,13 @@ export async function createSqliteDriver({ path: dbPath }) { } const insertUserStmt = db.prepare(` - INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) - VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) + INSERT INTO users (id, username, avatar_url, created_at) + VALUES (@id, @username, @avatar_url, @created_at) + `); + + const insertIdentityStmt = db.prepare(` + INSERT INTO identities (provider, provider_id, user_id, created_at) + VALUES (@provider, @provider_id, @user_id, @created_at) `); const putEventStmt = db.prepare(` @@ -70,15 +75,26 @@ export async function createSqliteDriver({ path: dbPath }) { __raw: db, async upsertUser({ provider, providerId, username, avatarUrl }) { - const id = `${provider}:${providerId}`; - const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id); - if (existing) { + // Resolution goes through identities now, not a direct lookup by + // users.id - a later release attaching a second login method to the + // same account will only add a row here, never touch users.id. + const identity = db.prepare( + 'SELECT * FROM identities WHERE provider = ? AND provider_id = ?', + ).get(provider, providerId); + + if (identity) { + const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(identity.user_id); + db.prepare( + 'UPDATE identities SET last_login_at = ? WHERE provider = ? AND provider_id = ?', + ).run(Date.now(), provider, providerId); + // A user who has set a custom username keeps it on re-login; only the // avatar (which the user doesn't control) is refreshed from the profile. const desiredUsername = existing.custom_username ? existing.username : username; let nextUsername = desiredUsername; try { - db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?').run(nextUsername, avatarUrl, id); + db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?') + .run(nextUsername, avatarUrl, existing.id); } catch (e) { // The provider-supplied name can change between logins (e.g. the user // renamed their display name on the OAuth provider) and collide @@ -91,15 +107,18 @@ export async function createSqliteDriver({ path: dbPath }) { if (e.code !== 'SQLITE_CONSTRAINT_UNIQUE' && e.code !== 'SQLITE_CONSTRAINT') throw e; nextUsername = await findAvailableUsername( desiredUsername, - (name) => isUsernameTakenByOtherUser(name, id), + (name) => isUsernameTakenByOtherUser(name, existing.id), ); - db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?').run(nextUsername, avatarUrl, id); + db.prepare('UPDATE users SET username = ?, avatar_url = ? WHERE id = ?') + .run(nextUsername, avatarUrl, existing.id); } return { ...existing, username: nextUsername, avatar_url: avatarUrl }; } + const id = `${provider}:${providerId}`; + const now = Date.now(); const user = { - id, provider, provider_id: providerId, username, avatar_url: avatarUrl, created_at: Date.now(), + id, username, avatar_url: avatarUrl, created_at: now, }; try { insertUserStmt.run(user); @@ -115,6 +134,9 @@ export async function createSqliteDriver({ path: dbPath }) { user.username = await findAvailableUsername(username, isUsernameTakenInDb); insertUserStmt.run(user); } + insertIdentityStmt.run({ + provider, provider_id: providerId, user_id: id, created_at: now, + }); return user; }, @@ -122,17 +144,37 @@ export async function createSqliteDriver({ path: dbPath }) { return db.prepare('SELECT * FROM users WHERE id = ?').get(id); }, + /** + * `provider` is the user's PRIMARY identity - earliest created_at, ties + * broken by provider name - not necessarily their only one. Since no + * user has more than one identity yet, this is identical to the + * pre-split `users.provider` column for every existing row. + */ async getAllUsersWithSaves() { return db.prepare(` - SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, + SELECT u.id, u.username, u.avatar_url, u.created_at, u.leaderboard_opt_out, - s.data, s.last_save + s.data, s.last_save, + (SELECT i.provider FROM identities i + WHERE i.user_id = u.id + ORDER BY i.created_at ASC, i.provider ASC + LIMIT 1) AS provider FROM users u LEFT JOIN saves s ON s.user_id = u.id ORDER BY u.created_at DESC `).all(); }, + /** + * Every login method attached to `userId`, earliest first. Not yet + * consumed anywhere in this codebase - a thin read added ahead of v1.8, + * which will use it to let a player see (and eventually link) every + * provider they've logged in with. + */ + async listIdentities(userId) { + return db.prepare('SELECT * FROM identities WHERE user_id = ? ORDER BY created_at ASC').all(userId); + }, + async getSave(userId) { return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); }, diff --git a/server/db/index.js b/server/db/index.js index 5ea3b11..53b503a 100644 --- a/server/db/index.js +++ b/server/db/index.js @@ -20,7 +20,7 @@ export const { getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, setEventStatus, deleteEvent, upsertParticipation, getParticipation, updateParticipationProgress, listParticipation, setLeaderboardOptOut, - listLeaderboard, getLatestEventId, seedSeasonalEvents, + listLeaderboard, getLatestEventId, seedSeasonalEvents, listIdentities, } = driver; export { driver }; diff --git a/server/db/interface.md b/server/db/interface.md index 2330c4a..d1098fb 100644 --- a/server/db/interface.md +++ b/server/db/interface.md @@ -50,7 +50,7 @@ getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow, getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, setEventStatus, deleteEvent, upsertParticipation, getParticipation, updateParticipationProgress, listParticipation, setLeaderboardOptOut, -listLeaderboard, getLatestEventId, seedSeasonalEvents +listLeaderboard, getLatestEventId, seedSeasonalEvents, listIdentities ``` `tests/db.interface.test.js` asserts this exact list is exported as @@ -73,12 +73,20 @@ these breaks every consumer. - `putEvent` / `upsertParticipation` accept either camelCase or snake_case keys on their input, via `shared.js`'s `normalizeEventRow` / `normalizeParticipationRow`. +- `users.id` (the literal string `provider:providerId`) never changes once + assigned — it's the target of 3 foreign keys and the value operators put + in `SUPER_ADMIN_IDS`. Login methods live in `identities`, keyed + `(provider, provider_id)` with a `user_id` FK back to `users`; `upsertUser` + resolves through it. `getAllUsersWithSaves()` still exposes a `provider` + field — the user's *primary* identity (earliest `created_at`, ties broken + by provider name), not necessarily their only one. ## Schema versioning `schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)` tracks which numbered migrations have run. `appliedVersions(db)` returns the -applied set; `markApplied(db, version)` records one. Not yet consumed by -`applySchema` itself (which still runs its full, idempotent DDL/guarded-ALTER -sequence on every boot) — Task 5 is expected to be the first real consumer of -version-gated migrations. +applied set; `markApplied(db, version)` records one. Still not consumed by +`applySchema` — Task 5 (the identities split) turned out not to need it: +both schemas self-guard their one-time migration step directly (SQLite via +`PRAGMA table_info`, Postgres via `information_schema.columns`), the same +pattern `guardedAddColumn` already used for every column added since v1.2. diff --git a/server/db/schema.pg.js b/server/db/schema.pg.js index d6c9657..ec47ffc 100644 --- a/server/db/schema.pg.js +++ b/server/db/schema.pg.js @@ -17,11 +17,14 @@ // lower(username) gives the same case-insensitive uniqueness guarantee. // Every username lookup in driver.pg.js uses lower() to match. // -// Unlike schema.sqlite.js, there's no guarded ALTER history to replay here - -// this is a fresh schema, so every column ships in its CREATE TABLE from the -// start (roles, custom_username, leaderboard_opt_out, tours_completed all -// arrived as guarded ALTERs on the SQLite side across v1.2-v1.6; here they're -// just columns). +// Unlike schema.sqlite.js's guarded ALTER history (roles, custom_username, +// leaderboard_opt_out, tours_completed all arrived that way across +// v1.2-v1.6), every column below ships in its CREATE TABLE from the start - +// this schema didn't exist before those columns did. The one exception is +// v1.7's identities split (see migrateIdentities, below): `provider`/ +// `provider_id` still ship in users' CREATE TABLE and are migrated out on +// every boot, mirroring schema.sqlite.js's evolve-via-guarded-step +// convention now that there's a real migration to run. export async function applySchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( @@ -108,6 +111,59 @@ export async function applySchema(pool) { await pool.query( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (lower(username))', ); + + await migrateIdentities(pool); +} + +/** + * v1.7 identities split: login methods (provider/provider_id) move out of + * `users` into their own `identities` table, keyed (provider, provider_id), + * so a later release can attach more than one login method to the same + * `users.id`. `users.id` itself (the literal string `provider:providerId`) + * never changes here - it's the target of 3 foreign keys and the value + * operators put in SUPER_ADMIN_IDS. + * + * The CREATE TABLE users statement above is intentionally left untouched - + * it still declares `provider`/`provider_id` and their UNIQUE constraint, + * same as it always has. That means a genuinely fresh database still gets + * those columns for one instant before this function immediately migrates + * them away in the same boot - a deliberate choice: it means every single + * test run in this suite exercises the real migration path, not just a + * dedicated upgrade-path test. Guarded on information_schema so it's a + * no-op on every boot after the first, whether that first boot was against + * a brand-new database or one already holding real player data. + */ +async function migrateIdentities(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS identities ( + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + supertokens_user_id TEXT UNIQUE, + created_at BIGINT NOT NULL, + last_login_at BIGINT, + PRIMARY KEY (provider, provider_id) + ); + CREATE INDEX IF NOT EXISTS idx_identities_user ON identities (user_id); + `); + + // Backfill from users, then drop the migrated columns. Guarded so it is a + // no-op on a database that has already been through this - DROP COLUMN + // also silently drops the UNIQUE(provider, provider_id) constraint that + // depended on them, Postgres handles that automatically (no CASCADE + // needed for a plain table constraint like this one). + const hasProvider = await pool.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'provider' + `); + if (hasProvider.rowCount > 0) { + await pool.query(` + INSERT INTO identities (provider, provider_id, user_id, created_at) + SELECT provider, provider_id, id, created_at FROM users + ON CONFLICT (provider, provider_id) DO NOTHING + `); + await pool.query('ALTER TABLE users DROP COLUMN provider, DROP COLUMN provider_id'); + } } export async function appliedVersions(pool) { diff --git a/server/db/schema.sqlite.js b/server/db/schema.sqlite.js index 6a8ba27..0c4d9d6 100644 --- a/server/db/schema.sqlite.js +++ b/server/db/schema.sqlite.js @@ -35,6 +35,86 @@ function guardedAddColumn(db, sql) { } } +/** + * v1.7 identities split: login methods (provider/provider_id) move out of + * `users` into their own `identities` table, keyed (provider, provider_id), + * so a later release can attach more than one login method to the same + * `users.id`. `users.id` itself (the literal string `provider:providerId`) + * never changes here - it's the target of 3 foreign keys and the value + * operators put in SUPER_ADMIN_IDS. + * + * The CREATE TABLE users statement above is intentionally left untouched - + * same "keep the original DDL, evolve via guarded steps that run on every + * boot" convention guardedAddColumn uses above. That means a genuinely + * fresh :memory: database still gets a `provider` column for one instant + * before this function immediately migrates it away in the same boot - a + * deliberate choice: it means every single test run in this suite exercises + * the real migration path, not just the dedicated upgrade-path test. + * + * SQLite refuses DROP COLUMN while the column participates in a + * table-level UNIQUE constraint (users has UNIQUE(provider, provider_id)), + * so the columns can't just be ALTERed away - the whole table has to be + * rebuilt. Guarded on PRAGMA table_info so it's a no-op after the first + * boot that runs it, whether that's against a genuinely fresh table or a + * pre-v1.7 database holding real player data. + */ +export async function migrateIdentities(db) { + const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + if (!cols.includes('provider')) return; // already migrated + + db.pragma('foreign_keys = OFF'); + const tx = db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS identities ( + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + supertokens_user_id TEXT UNIQUE, + created_at INTEGER NOT NULL, + last_login_at INTEGER, + PRIMARY KEY (provider, provider_id) + ); + + INSERT OR IGNORE INTO identities (provider, provider_id, user_id, created_at) + SELECT provider, provider_id, id, created_at FROM users; + + CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + username TEXT, + avatar_url TEXT, + created_at INTEGER NOT NULL, + roles TEXT DEFAULT '[]', + custom_username INTEGER DEFAULT 0, + leaderboard_opt_out INTEGER DEFAULT 0, + tours_completed TEXT DEFAULT '[]' + ); + + INSERT INTO users_new (id, username, avatar_url, created_at, roles, + custom_username, leaderboard_opt_out, tours_completed) + SELECT id, username, avatar_url, created_at, + COALESCE(roles, '[]'), COALESCE(custom_username, 0), + COALESCE(leaderboard_opt_out, 0), COALESCE(tours_completed, '[]') + FROM users; + + DROP TABLE users; + ALTER TABLE users_new RENAME TO users; + `); + // The unique index lived on the dropped table - recreate it. + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_identities_user ON identities (user_id)'); + }); + tx(); + db.pragma('foreign_keys = ON'); + + // saves/minigame_sessions/event_participation declare their foreign keys + // against the *name* `users`, so the rename re-points them. Verify rather + // than assume - a violation here means orphaned saves. + const violations = db.pragma('foreign_key_check'); + if (violations.length > 0) { + throw new Error(`identities migration left ${violations.length} FK violations`); + } +} + export async function applySchema(db) { db.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( @@ -128,7 +208,13 @@ export async function applySchema(db) { ); `); + // Dedupe BEFORE the identities rebuild: migrateIdentities recreates + // idx_users_username as part of the same transaction that builds the new + // `users` table, so any leftover case-duplicate usernames on a database + // upgrading straight from a very old pre-index version must be resolved + // first, or that CREATE UNIQUE INDEX would fail. await dedupeUsernames(db); + await migrateIdentities(db); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); } diff --git a/tests/db.identities.test.js b/tests/db.identities.test.js new file mode 100644 index 0000000..f036b27 --- /dev/null +++ b/tests/db.identities.test.js @@ -0,0 +1,139 @@ +import { + describe, it, expect, afterAll, +} from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade: DATABASE_URL/DB_PATH must be set +// before the dynamic import below, since the facade resolves its driver at +// module-evaluation time - a static `import { driver } from ...` would be +// hoisted by the ESM spec above the provisioning call and stand up the +// driver against the wrong backend/path. +const provisioned = await provisionDatabase(); + +const dbMod = await import('../server/db.js'); +const { + driver, upsertUser, getUserById, getSave, putSave, getAllUsersWithSaves, listIdentities, +} = dbMod; + +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + +describe('identities split', () => { + it('creates exactly one identity per user on first login', async () => { + await upsertUser({ + provider: 'github', providerId: '37058311', username: 'nec', avatarUrl: null, + }); + const ids = await listIdentities('github:37058311'); + expect(ids).toHaveLength(1); + expect(ids[0]).toMatchObject({ provider: 'github', provider_id: '37058311' }); + expect(ids[0].supertokens_user_id).toBeNull(); + }); + + it('keeps users.id as provider:providerId', async () => { + await upsertUser({ + provider: 'discord', providerId: '536626725380161537', username: 'st', avatarUrl: null, + }); + const user = await getUserById('discord:536626725380161537'); + expect(user).toBeDefined(); + expect(user.id).toBe('discord:536626725380161537'); + }); + + it('resolves a returning login through identities to the same user', async () => { + await upsertUser({ + provider: 'github', providerId: 'ret', username: 'first', avatarUrl: null, + }); + await putSave('github:ret', { marker: 'keep-me' }, 123); + await upsertUser({ + provider: 'github', providerId: 'ret', username: 'renamed', avatarUrl: 'a.png', + }); + const save = await getSave('github:ret'); + expect(JSON.parse(save.data).marker).toBe('keep-me'); + expect(await listIdentities('github:ret')).toHaveLength(1); + }); + + it('getAllUsersWithSaves still exposes provider', async () => { + await upsertUser({ + provider: 'discord', providerId: 'agg', username: 'aggu', avatarUrl: null, + }); + const rows = await getAllUsersWithSaves(); + expect(rows.find((r) => r.id === 'discord:agg').provider).toBe('discord'); + }); + + it('migrates a pre-split SQLite database without losing saves', async () => { + // Build a database in the OLD shape, then let applySchema() upgrade it. + // Postgres-only test runs use this too - it's a pure SQLite in-memory + // scenario (via better-sqlite3 directly), not routed through the + // provisioned backend, so it always exercises the SQLite rebuild dance + // regardless of which backend the rest of this file is running against. + const Database = (await import('better-sqlite3')).default; + const raw = new Database(':memory:'); + raw.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_id TEXT NOT NULL, + username TEXT, avatar_url TEXT, created_at INTEGER NOT NULL, + UNIQUE(provider, provider_id) + ); + CREATE TABLE saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, last_save INTEGER NOT NULL + ); + INSERT INTO users VALUES ('github:37058311','github','37058311','nec',NULL,1784859388645); + INSERT INTO saves VALUES ('github:37058311','{"wafers":42}',1784859388999); + `); + + const { applySchema } = await import('../server/db/schema.sqlite.js'); + await applySchema(raw); + + const user = raw.prepare('SELECT * FROM users WHERE id = ?').get('github:37058311'); + expect(user.username).toBe('nec'); + expect(user).not.toHaveProperty('provider'); + + const identity = raw.prepare('SELECT * FROM identities WHERE user_id = ?').get('github:37058311'); + expect(identity).toMatchObject({ provider: 'github', provider_id: '37058311' }); + + const save = raw.prepare('SELECT * FROM saves WHERE user_id = ?').get('github:37058311'); + expect(JSON.parse(save.data).wafers).toBe(42); + expect(save.last_save).toBe(1784859388999); + + // Idempotency: a second boot over the already-migrated database must be + // a pure no-op, not throw, and leave the data exactly as-is. + await applySchema(raw); + const userAgain = raw.prepare('SELECT * FROM users WHERE id = ?').get('github:37058311'); + expect(userAgain.username).toBe('nec'); + const identitiesAgain = raw.prepare('SELECT * FROM identities WHERE user_id = ?').all('github:37058311'); + expect(identitiesAgain).toHaveLength(1); + }); + + it('throws rather than silently drop an orphaned save during the rebuild', async () => { + // Proves the post-rebuild foreign_key_check actually catches a real + // violation, not just that it stays quiet on clean data. A save row + // pointing at a user id that was never inserted is exactly what "an + // orphaned save" means - foreign_keys is turned OFF for the setup so + // this insert succeeds despite violating the declared FK, simulating + // corruption that predates - or bypassed - enforcement (e.g. a raw + // import, or data written before `driver.sqlite.js` started turning + // enforcement on at boot). + const Database = (await import('better-sqlite3')).default; + const raw = new Database(':memory:'); + raw.pragma('foreign_keys = OFF'); + raw.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_id TEXT NOT NULL, + username TEXT, avatar_url TEXT, created_at INTEGER NOT NULL, + UNIQUE(provider, provider_id) + ); + CREATE TABLE saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, last_save INTEGER NOT NULL + ); + INSERT INTO users VALUES ('github:1','github','1','ok',NULL,1); + INSERT INTO saves VALUES ('github:1','{}',1); + INSERT INTO saves VALUES ('ghost:999','{}',1); + `); + + const { applySchema } = await import('../server/db/schema.sqlite.js'); + await expect(applySchema(raw)).rejects.toThrow(/FK violations/); + }); +}); diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js index a42afa7..2aa2f40 100644 --- a/tests/db.interface.test.js +++ b/tests/db.interface.test.js @@ -20,7 +20,7 @@ const INTERFACE = [ 'getConfigHistory', 'listEvents', 'getEvent', 'getActiveEvent', 'putEvent', 'setEventStatus', 'deleteEvent', 'upsertParticipation', 'getParticipation', 'updateParticipationProgress', 'listParticipation', 'setLeaderboardOptOut', - 'listLeaderboard', 'getLatestEventId', 'seedSeasonalEvents', + 'listLeaderboard', 'getLatestEventId', 'seedSeasonalEvents', 'listIdentities', ]; describe('db facade', () => { diff --git a/tests/db.test.js b/tests/db.test.js index 102cd42..947f7ec 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -242,18 +242,21 @@ describe('dedupeUsernames', () => { // outright. Drop it to simulate the pre-index state dedupeUsernames is // meant to clean up, then recreate it (each backend's own DDL) // afterward. + // users no longer carries provider/provider_id (v1.7 moved those to + // identities) - dedupeUsernames only ever reads id/username/created_at, + // so these rows only need those columns. const rows = [ - { id: 'x:1', provider: 'x', provider_id: '1', username: 'Duplicate', avatar_url: null, created_at: 100 }, - { id: 'x:2', provider: 'x', provider_id: '2', username: 'duplicate', avatar_url: null, created_at: 200 }, - { id: 'x:3', provider: 'x', provider_id: '3', username: 'DUPLICATE', avatar_url: null, created_at: 300 }, + { id: 'x:1', username: 'Duplicate', avatar_url: null, created_at: 100 }, + { id: 'x:2', username: 'duplicate', avatar_url: null, created_at: 200 }, + { id: 'x:3', username: 'DUPLICATE', avatar_url: null, created_at: 300 }, // A pre-existing user already squats the first suffix candidate. - { id: 'x:4', provider: 'x', provider_id: '4', username: 'duplicate-2', avatar_url: null, created_at: 50 }, + { id: 'x:4', username: 'duplicate-2', avatar_url: null, created_at: 50 }, ]; if (driver.__backend === 'sqlite') { db.exec('DROP INDEX IF EXISTS idx_users_username'); const insert = db.prepare(` - INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) - VALUES (@id, @provider, @provider_id, @username, @avatar_url, @created_at) + INSERT INTO users (id, username, avatar_url, created_at) + VALUES (@id, @username, @avatar_url, @created_at) `); for (const row of rows) insert.run(row); } else { @@ -261,8 +264,8 @@ describe('dedupeUsernames', () => { for (const row of rows) { // eslint-disable-next-line no-await-in-loop await db.query( - 'INSERT INTO users (id, provider, provider_id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4, $5, $6)', - [row.id, row.provider, row.provider_id, row.username, row.avatar_url, row.created_at], + 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', + [row.id, row.username, row.avatar_url, row.created_at], ); } } From c885d9d088b1275c67f9bc6945c0acc94c3a1d62 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 23:32:14 -0400 Subject: [PATCH 11/14] Add the backup, cutover and rollback runbook Written now rather than with Task 8 because the backup half is valid today and should happen before anything else touches production data. Leads with a status table making clear the migrator does not exist yet and Task 5 has open defects, so nobody follows Part B against live data by mistake. The rollback section names the one-way door explicitly: reverting to SQLite always works mechanically, because the migration never modifies or deletes that file, but it is frozen at cutover. Rolling back ten minutes later is free; rolling back three days later silently discards three days of everyone's progress with no error shown to anyone. Co-Authored-By: Claude Opus 5 --- docs/postgres-migration-runbook.md | 248 +++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/postgres-migration-runbook.md diff --git a/docs/postgres-migration-runbook.md b/docs/postgres-migration-runbook.md new file mode 100644 index 0000000..818b9e9 --- /dev/null +++ b/docs/postgres-migration-runbook.md @@ -0,0 +1,248 @@ +# Postgres Migration — Backup, Cutover and Rollback Runbook + +**Status: NOT READY TO RUN.** Parts B and C describe machinery that is still +being built (see §0). **Part A is valid today and you should do it now.** + +Applies to the Unraid install at `/mnt/user/appdata/rackstack-server/data`. + +--- + +## 0. What actually exists right now + +| Piece | Task | State | +|---|---|---| +| Async db interface | 1 | ✅ merged to branch | +| Facade + SQLite driver | 2 | ✅ merged to branch | +| Postgres test harness | 3 | ✅ merged to branch | +| Postgres schema + driver | 4 | ✅ merged to branch | +| `identities` auth split | 5 | ⚠️ **built but has 4 unfixed defects** | +| **The migrator itself** (`npm run migrate:pg`) | 6 | ❌ **not written** | +| Auto-migrate on boot | 7 | ❌ not written | +| Compose/Unraid/docs | 8 | ❌ not written | + +**There is no migration tool yet.** Task 5's open defects include one that +causes a permanent account lockout on partial write, and one where SQLite's +foreign-key check runs after `COMMIT` so it cannot roll back the rebuild it is +supposed to guard. Do not point this branch at production data. + +--- + +## Part A — Back up now (do this today) + +Valid regardless of migration timing, and it is what unblocks verification. + +### A1. Stop the container first + +``` +Unraid → Docker → rackstack-server → Stop +``` + +**Do not skip this.** SQLite keeps recently-committed writes in a separate +write-ahead log. Copying a running database captures the main file without the +log, so the newest progress — potentially hours of it — is silently absent from +your backup. The copy will look valid and open fine. It will just be stale. + +### A2. Copy all three files + +```bash +cd /mnt/user/appdata/rackstack-server +mkdir -p /mnt/user/backups/rackstack/$(date +%F) +cp -av data/rackstack.db /mnt/user/backups/rackstack/$(date +%F)/ +cp -av data/rackstack.db-wal /mnt/user/backups/rackstack/$(date +%F)/ 2>/dev/null || true +cp -av data/rackstack.db-shm /mnt/user/backups/rackstack/$(date +%F)/ 2>/dev/null || true +ls -la /mnt/user/backups/rackstack/$(date +%F)/ +``` + +`-wal` and `-shm` may legitimately be absent if the database was closed cleanly +— that is why those two lines tolerate failure. `rackstack.db` must be there. + +### A3. Verify the backup opens and holds what you expect + +```bash +cd /mnt/user/backups/rackstack/$(date +%F) +sqlite3 rackstack.db "PRAGMA integrity_check;" +sqlite3 rackstack.db "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;" +sqlite3 rackstack.db "SELECT count(*) AS users FROM users;" +sqlite3 rackstack.db "SELECT count(*) AS saves FROM saves;" +sqlite3 rackstack.db "SELECT user_id, length(data) AS bytes, datetime(last_save/1000,'unixepoch') FROM saves;" +``` + +`integrity_check` must print `ok`. Record the user and save counts — they are +what the migration verifies against later. + +### A4. Restart the container + +``` +Unraid → Docker → rackstack-server → Start +``` + +Confirm you can log in and your save is intact before walking away. + +### A5. Send the export for verification + +Copy that dated backup folder somewhere I can read it. It is used to prove the +migration reproduces every save byte-for-byte, and to confirm each stored +`provider_id` matches what SuperTokens will compute in v1.8 — the check that +prevents a player silently landing on a brand-new empty save after the auth +change. + +If you would rather not share save contents, the counts and shapes from A3 plus +the output of + +```bash +sqlite3 rackstack.db "SELECT id, provider, provider_id, username IS NOT NULL FROM users;" +``` + +are enough for the identity-mapping half, though not for the byte-for-byte half. + +--- + +## Part B — Cutover (once Tasks 6–8 land) + +### B1. Stand up Postgres + +Add an official `postgres:16` container in Unraid with its **own** appdata path +(not rackstack's). Create a database and a user for it: + +```sql +CREATE USER rackstack WITH PASSWORD 'choose-something-long'; +CREATE DATABASE rackstack OWNER rackstack; +``` + +### B2. Take a fresh backup + +Repeat Part A. The backup from a week ago is not the one you want to restore +from if something goes wrong tonight. + +### B3. Configure rackstack + +Set on the rackstack container: + +``` +DATABASE_URL=postgresql://rackstack:PASSWORD@192.168.x.x:5432/rackstack +``` + +Three ways this line commonly goes wrong: + +- It must be `postgresql://`. `postgres://` is rejected outright. +- Use the host's LAN IP, not `localhost` or `127.0.0.1` — inside the container + those point at the container itself. +- **Leave the `/app/data` volume mapping in place.** It is the migration source + and your rollback path. Removing it is the one irreversible mistake here. + +### B4. Start and watch + +``` +Unraid → Docker → rackstack-server → Start, then the log +``` + +Expected: a `[migrate]` line per table with a verified row count, then +`committed`, then `listening on :3000`. + +Compare those counts against what you recorded in A3. They must match exactly. + +If migration fails, **the container refuses to start by design.** That is not a +malfunction. Serving an empty game over live save data is worse than being +down: a stopped container gets investigated, an empty leaderboard might not be +noticed until saves have been overwritten on top of it. The log names the table +that failed. + +### B5. Verify before you walk away + +- Log in with **both** Discord and GitHub if you use both. +- Confirm your wafers/racks match what you had. +- Check the admin dashboard loads — that proves `SUPER_ADMIN_IDS` still + resolves, which it should, because `users.id` is deliberately unchanged by + this migration. +- Check the leaderboard renders with real numbers, not blanks. Blank + `rungsClaimed` values would indicate the camelCase-alias problem. + +--- + +## Part C — Rollback + +Four layers, cheapest first. **Read C0 before you need it.** + +### C0. The one-way door + +Rollback is free **until players start making progress on Postgres.** + +Your SQLite file is never modified or deleted by the migration, so reverting to +it always works mechanically. But it is frozen at the moment of cutover. Once +players have been on Postgres for a while, going back to SQLite silently +discards everything earned since — and nobody gets an error, they just find +their progress reverted. + +So: verify immediately after cutover (B5), and decide fast. A rollback ten +minutes after cutover costs nothing. A rollback three days later costs three +days of everyone's progress. + +If you must roll back late, take a Postgres dump first so the interim progress +is at least recoverable: + +```bash +pg_dump -U rackstack -h 192.168.x.x rackstack > rackstack-postgres-$(date +%F).sql +``` + +### C1. Migration failed, container won't start + +Nothing was committed — the migrator runs in a single transaction and rolls the +whole thing back on any verification mismatch. Your SQLite data is untouched. + +``` +Remove DATABASE_URL from the container config → Start +``` + +You are back on SQLite exactly as before. Send me the `[migrate]` log lines. + +### C2. Migration succeeded but the app misbehaves + +``` +Remove DATABASE_URL → Restart +``` + +Back on SQLite. Subject to C0 — fine immediately after cutover, lossy later. + +Leave the Postgres database in place rather than dropping it; it is evidence. + +### C3. SQLite data itself looks wrong + +``` +Stop the container +cd /mnt/user/appdata/rackstack-server/data +mv rackstack.db rackstack.db.suspect +cp -av /mnt/user/backups/rackstack//rackstack.db* . +Start the container +``` + +Restore all three files if all three were backed up. Keep the suspect copy. + +### C4. Full revert to the previous release + +Point the container back at the last known-good image tag: + +``` +Repository: ghcr.io/neverendingcode/rackstack-server:1.6.0 +``` + +then apply C3. v1.6.0 has no knowledge of Postgres or of the `identities` +table, so it needs a pre-migration SQLite file — which is exactly what your +Part A backup is. + +**Do not change `JWT_SECRET` at any point during any of this.** Changing it +logs out every player, which looks alarmingly like data loss and will send you +chasing the wrong problem. + +--- + +## Quick reference + +| Situation | Action | +|---|---| +| Before you touch anything | Part A, container stopped, all three files | +| Migration failed | Remove `DATABASE_URL`, restart — nothing was committed | +| App misbehaving right after cutover | Remove `DATABASE_URL`, restart | +| App misbehaving days after cutover | `pg_dump` first, then remove `DATABASE_URL` — you will lose interim progress | +| Save data looks wrong | Restore backup files (C3) | +| Need the old version back | Pin image to `1.6.0`, then C3 | +| Logins broke | Check `JWT_SECRET` is unchanged before anything else | From 7cc24466be5a8c85419ea5222b572ba821ea316d Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sun, 2 Aug 2026 00:37:46 -0400 Subject: [PATCH 12/14] Fix Task 5 review findings: identities self-heals, FK check rolls back, atomic upsertUser writes Four Important findings from review, all in the migration paths: 1. identities is now created unconditionally on every boot, before the sqlite guard (schema.sqlite.js) - it was previously created only as a side effect of the users rebuild, so any database reaching "users has no provider column" without identities existing would boot cleanly and then throw on first login. Postgres already created it unconditionally. 2. schema.sqlite.js's foreign_key_check and its throw now run INSIDE the rebuild's db.transaction(), before it returns, so a violation aborts the rebuild instead of being discovered after COMMIT - previously the process died post-commit, but the next boot saw no provider column, skipped the rebuild, and never checked again: log-and-continue spread across two boots. The orphan test now also asserts users still has its provider column afterward, pinning the rollback rather than only the rejection. 3. upsertUser's two writes (users, identities) are now atomic on both drivers - db.transaction() on sqlite, a checked-out client with BEGIN/COMMIT/ROLLBACK on pg. Previously a failure on the second write left a users row with no identity, which the identity lookup can never find again - the next login attempt would retry INSERT INTO users on the same primary key and raise a constraint code neither retry guard recognizes, a permanent lockout. Covered by a new test that forces the identities write to fail and asserts no orphaned users row survives. 4. Both base `users` DDLs now ship in their final, post-split shape (no provider/provider_id) instead of declaring columns migrateIdentities immediately removes on every fresh boot. This closes the concrete risk that a future guardedAddColumn addition would exist on users but be absent from the rebuild's hardcoded users_new column list, silently dropping that column's data for any database still upgrading through the pre-split shape - now the rebuild only ever runs against a real upgrade target. users_new carries a comment warning it must stay in sync with the base DDL. Plus the minors: foreign_keys restored in a finally; the pg guard pinned to current_schema(); last_login_at set at insert (not just on return visits); the upgrade-path test asserts save.data byte-for-byte; a case pinning getAllUsersWithSaves returning provider: null for a user with no identity row; migrateIdentities private on both backends; provider_id added to the primary-identity tie-break. npm run test:all: sqlite 472/472, pg 469/472 + 3 pre-existing skips. --- server/db/driver.pg.js | 53 ++++++++++--- server/db/driver.sqlite.js | 28 +++++-- server/db/schema.pg.js | 37 +++++---- server/db/schema.sqlite.js | 152 ++++++++++++++++++++---------------- tests/db.identities.test.js | 101 ++++++++++++++++++++++-- 5 files changed, 258 insertions(+), 113 deletions(-) diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js index f8fc401..3be14a4 100644 --- a/server/db/driver.pg.js +++ b/server/db/driver.pg.js @@ -48,6 +48,40 @@ export async function createPgDriver({ url }) { ).then(Boolean); } + // A brand-new login writes both `users` and `identities`. Run on a single + // checked-out client wrapped in BEGIN/COMMIT so a failure on the second + // write rolls back the first - without this, a users row with no + // matching identity is invisible to the identity lookup at the top of + // upsertUser, so the very next login attempt would retry INSERT INTO + // users with the same primary key, raising SQLSTATE 23505 (the same code + // a username collision raises) and misdiagnosing it as one, burning a + // findAvailableUsername retry that still fails and permanently locking + // the account out. + async function insertUserAndIdentity(user, identityRow) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', + [user.id, user.username, user.avatar_url, user.created_at], + ); + await client.query( + `INSERT INTO identities (provider, provider_id, user_id, created_at, last_login_at) + VALUES ($1, $2, $3, $4, $5)`, + [ + identityRow.provider, identityRow.provider_id, identityRow.user_id, + identityRow.created_at, identityRow.last_login_at, + ], + ); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } + const driver = { __backend: 'pg', __raw: pool, @@ -97,11 +131,11 @@ export async function createPgDriver({ url }) { const user = { id, username, avatar_url: avatarUrl, created_at: now, }; + const identityRow = { + provider, provider_id: providerId, user_id: id, created_at: now, last_login_at: now, + }; try { - await run( - 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', - [user.id, user.username, user.avatar_url, user.created_at], - ); + await insertUserAndIdentity(user, identityRow); } catch (e) { // Two different brand-new OAuth accounts can independently supply the // same (or case-variant) username - the unique functional index on @@ -112,15 +146,8 @@ export async function createPgDriver({ url }) { // the same suffixing convention as dedupeUsernames and retry once. if (e.code !== '23505') throw e; // unique_violation user.username = await findAvailableUsername(username, isUsernameTakenInDb); - await run( - 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', - [user.id, user.username, user.avatar_url, user.created_at], - ); + await insertUserAndIdentity(user, identityRow); } - await run( - 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES ($1, $2, $3, $4)', - [provider, providerId, id, now], - ); return user; }, @@ -141,7 +168,7 @@ export async function createPgDriver({ url }) { s.data, s.last_save, (SELECT i.provider FROM identities i WHERE i.user_id = u.id - ORDER BY i.created_at ASC, i.provider ASC + ORDER BY i.created_at ASC, i.provider ASC, i.provider_id ASC LIMIT 1) AS provider FROM users u LEFT JOIN saves s ON s.user_id = u.id diff --git a/server/db/driver.sqlite.js b/server/db/driver.sqlite.js index 21429ec..1db88e7 100644 --- a/server/db/driver.sqlite.js +++ b/server/db/driver.sqlite.js @@ -37,10 +37,22 @@ export async function createSqliteDriver({ path: dbPath }) { `); const insertIdentityStmt = db.prepare(` - INSERT INTO identities (provider, provider_id, user_id, created_at) - VALUES (@provider, @provider_id, @user_id, @created_at) + INSERT INTO identities (provider, provider_id, user_id, created_at, last_login_at) + VALUES (@provider, @provider_id, @user_id, @created_at, @last_login_at) `); + // A brand-new login writes both `users` and `identities`. Wrapped in one + // transaction so a failure on the second write rolls back the first - + // without this, a users row with no matching identity is invisible to + // the identity lookup at the top of upsertUser, so the very next login + // attempt would retry INSERT INTO users with the same primary key, + // raising SQLITE_CONSTRAINT_PRIMARYKEY (a code the username-collision + // catch below doesn't recognize) and permanently locking the account out. + const insertUserAndIdentity = db.transaction((user, identityRow) => { + insertUserStmt.run(user); + insertIdentityStmt.run(identityRow); + }); + const putEventStmt = db.prepare(` INSERT INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) VALUES (@id, @name, @description, @theme, @modifiers, @ladder, @status, @starts_at, @ends_at, @recurrence, @created_at, @created_by) @@ -120,8 +132,11 @@ export async function createSqliteDriver({ path: dbPath }) { const user = { id, username, avatar_url: avatarUrl, created_at: now, }; + const identityRow = { + provider, provider_id: providerId, user_id: id, created_at: now, last_login_at: now, + }; try { - insertUserStmt.run(user); + insertUserAndIdentity(user, identityRow); } catch (e) { // Two different brand-new OAuth accounts can independently supply the // same (or case-variant) username - the COLLATE NOCASE unique index @@ -132,11 +147,8 @@ export async function createSqliteDriver({ path: dbPath }) { // suffixing convention as dedupeUsernames and retry once. if (e.code !== 'SQLITE_CONSTRAINT_UNIQUE' && e.code !== 'SQLITE_CONSTRAINT') throw e; user.username = await findAvailableUsername(username, isUsernameTakenInDb); - insertUserStmt.run(user); + insertUserAndIdentity(user, identityRow); } - insertIdentityStmt.run({ - provider, provider_id: providerId, user_id: id, created_at: now, - }); return user; }, @@ -157,7 +169,7 @@ export async function createSqliteDriver({ path: dbPath }) { s.data, s.last_save, (SELECT i.provider FROM identities i WHERE i.user_id = u.id - ORDER BY i.created_at ASC, i.provider ASC + ORDER BY i.created_at ASC, i.provider ASC, i.provider_id ASC LIMIT 1) AS provider FROM users u LEFT JOIN saves s ON s.user_id = u.id diff --git a/server/db/schema.pg.js b/server/db/schema.pg.js index ec47ffc..70085e9 100644 --- a/server/db/schema.pg.js +++ b/server/db/schema.pg.js @@ -20,11 +20,12 @@ // Unlike schema.sqlite.js's guarded ALTER history (roles, custom_username, // leaderboard_opt_out, tours_completed all arrived that way across // v1.2-v1.6), every column below ships in its CREATE TABLE from the start - -// this schema didn't exist before those columns did. The one exception is -// v1.7's identities split (see migrateIdentities, below): `provider`/ -// `provider_id` still ship in users' CREATE TABLE and are migrated out on -// every boot, mirroring schema.sqlite.js's evolve-via-guarded-step -// convention now that there's a real migration to run. +// this schema didn't exist before those columns did. `users` ships in its +// final, post-v1.7 shape too: `provider`/`provider_id` are NOT declared +// here. Only a database that predates the identities split (see +// migrateIdentities, below) still has them - this CREATE TABLE is only ever +// a no-op (IF NOT EXISTS) against such a database, never the statement that +// creates its shape. export async function applySchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( @@ -34,16 +35,13 @@ export async function applySchema(pool) { CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - provider_id TEXT NOT NULL, username TEXT, avatar_url TEXT, created_at BIGINT NOT NULL, roles TEXT DEFAULT '[]', custom_username SMALLINT DEFAULT 0, leaderboard_opt_out SMALLINT DEFAULT 0, - tours_completed TEXT DEFAULT '[]', - UNIQUE (provider, provider_id) + tours_completed TEXT DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS saves ( @@ -123,15 +121,13 @@ export async function applySchema(pool) { * never changes here - it's the target of 3 foreign keys and the value * operators put in SUPER_ADMIN_IDS. * - * The CREATE TABLE users statement above is intentionally left untouched - - * it still declares `provider`/`provider_id` and their UNIQUE constraint, - * same as it always has. That means a genuinely fresh database still gets - * those columns for one instant before this function immediately migrates - * them away in the same boot - a deliberate choice: it means every single - * test run in this suite exercises the real migration path, not just a - * dedicated upgrade-path test. Guarded on information_schema so it's a - * no-op on every boot after the first, whether that first boot was against - * a brand-new database or one already holding real player data. + * `identities` is created unconditionally, every boot, *before* the guard + * below - never only as a side effect of the backfill-and-drop. The base + * `CREATE TABLE users` above ships in its final, post-split shape (no + * `provider`/`provider_id`), so the guard below only ever fires for a + * database that predates this migration - a genuinely fresh install never + * has those columns to find. Guarded on information_schema so it's a no-op + * on every boot after the one that actually performs it. */ async function migrateIdentities(pool) { await pool.query(` @@ -151,10 +147,13 @@ async function migrateIdentities(pool) { // no-op on a database that has already been through this - DROP COLUMN // also silently drops the UNIQUE(provider, provider_id) constraint that // depended on them, Postgres handles that automatically (no CASCADE - // needed for a plain table constraint like this one). + // needed for a plain table constraint like this one). table_schema is + // pinned to current_schema() so this can't match a same-named `users` + // table sitting in a different schema on the same search_path. const hasProvider = await pool.query(` SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'provider' + AND table_schema = current_schema() `); if (hasProvider.rowCount > 0) { await pool.query(` diff --git a/server/db/schema.sqlite.js b/server/db/schema.sqlite.js index 0c4d9d6..64b6c80 100644 --- a/server/db/schema.sqlite.js +++ b/server/db/schema.sqlite.js @@ -43,75 +43,94 @@ function guardedAddColumn(db, sql) { * never changes here - it's the target of 3 foreign keys and the value * operators put in SUPER_ADMIN_IDS. * - * The CREATE TABLE users statement above is intentionally left untouched - - * same "keep the original DDL, evolve via guarded steps that run on every - * boot" convention guardedAddColumn uses above. That means a genuinely - * fresh :memory: database still gets a `provider` column for one instant - * before this function immediately migrates it away in the same boot - a - * deliberate choice: it means every single test run in this suite exercises - * the real migration path, not just the dedicated upgrade-path test. + * `identities` is created unconditionally, every boot, *before* the guard + * below - never only as a side effect of the rebuild. A database that + * somehow reaches "users has no provider column" without `identities` + * existing (a partial restore, a manual ALTER, a future base-DDL change) + * must still end up with the table, or driver.sqlite.js's prepared INSERT + * against it throws on first login. + * + * The base `CREATE TABLE users` (in applySchema, below) ships in its final, + * post-split shape - it does NOT declare `provider`/`provider_id`. Only a + * database that predates this migration has those columns, so the rebuild + * below runs against real upgrade targets, never against a fresh install. + * Guarded on PRAGMA table_info so it's a no-op on every boot after the one + * that actually performs it. * * SQLite refuses DROP COLUMN while the column participates in a - * table-level UNIQUE constraint (users has UNIQUE(provider, provider_id)), - * so the columns can't just be ALTERed away - the whole table has to be - * rebuilt. Guarded on PRAGMA table_info so it's a no-op after the first - * boot that runs it, whether that's against a genuinely fresh table or a - * pre-v1.7 database holding real player data. + * table-level UNIQUE constraint (a pre-v1.7 `users` has UNIQUE(provider, + * provider_id)), so the columns can't just be ALTERed away - the whole + * table has to be rebuilt. `users_new` below hardcodes the post-split + * column list; if a future guardedAddColumn call adds a new column to + * `users`, `users_new` MUST be updated to carry it too, or a database still + * on the pre-split shape when it upgrades through that release silently + * loses that column's data in the rebuild's SELECT INTO. */ -export async function migrateIdentities(db) { +function migrateIdentities(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS identities ( + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + supertokens_user_id TEXT UNIQUE, + created_at INTEGER NOT NULL, + last_login_at INTEGER, + PRIMARY KEY (provider, provider_id) + ); + CREATE INDEX IF NOT EXISTS idx_identities_user ON identities (user_id); + `); + const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); - if (!cols.includes('provider')) return; // already migrated + if (!cols.includes('provider')) return; // already migrated (or never had it) db.pragma('foreign_keys = OFF'); - const tx = db.transaction(() => { - db.exec(` - CREATE TABLE IF NOT EXISTS identities ( - provider TEXT NOT NULL, - provider_id TEXT NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - supertokens_user_id TEXT UNIQUE, - created_at INTEGER NOT NULL, - last_login_at INTEGER, - PRIMARY KEY (provider, provider_id) - ); - - INSERT OR IGNORE INTO identities (provider, provider_id, user_id, created_at) - SELECT provider, provider_id, id, created_at FROM users; - - CREATE TABLE users_new ( - id TEXT PRIMARY KEY, - username TEXT, - avatar_url TEXT, - created_at INTEGER NOT NULL, - roles TEXT DEFAULT '[]', - custom_username INTEGER DEFAULT 0, - leaderboard_opt_out INTEGER DEFAULT 0, - tours_completed TEXT DEFAULT '[]' - ); - - INSERT INTO users_new (id, username, avatar_url, created_at, roles, - custom_username, leaderboard_opt_out, tours_completed) - SELECT id, username, avatar_url, created_at, - COALESCE(roles, '[]'), COALESCE(custom_username, 0), - COALESCE(leaderboard_opt_out, 0), COALESCE(tours_completed, '[]') - FROM users; - - DROP TABLE users; - ALTER TABLE users_new RENAME TO users; - `); - // The unique index lived on the dropped table - recreate it. - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_identities_user ON identities (user_id)'); - }); - tx(); - db.pragma('foreign_keys = ON'); - - // saves/minigame_sessions/event_participation declare their foreign keys - // against the *name* `users`, so the rename re-points them. Verify rather - // than assume - a violation here means orphaned saves. - const violations = db.pragma('foreign_key_check'); - if (violations.length > 0) { - throw new Error(`identities migration left ${violations.length} FK violations`); + try { + const tx = db.transaction(() => { + db.exec(` + INSERT OR IGNORE INTO identities (provider, provider_id, user_id, created_at) + SELECT provider, provider_id, id, created_at FROM users; + + CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + username TEXT, + avatar_url TEXT, + created_at INTEGER NOT NULL, + roles TEXT DEFAULT '[]', + custom_username INTEGER DEFAULT 0, + leaderboard_opt_out INTEGER DEFAULT 0, + tours_completed TEXT DEFAULT '[]' + ); + + INSERT INTO users_new (id, username, avatar_url, created_at, roles, + custom_username, leaderboard_opt_out, tours_completed) + SELECT id, username, avatar_url, created_at, + COALESCE(roles, '[]'), COALESCE(custom_username, 0), + COALESCE(leaderboard_opt_out, 0), COALESCE(tours_completed, '[]') + FROM users; + + DROP TABLE users; + ALTER TABLE users_new RENAME TO users; + `); + // The unique index lived on the dropped table - recreate it. + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); + + // saves/minigame_sessions/event_participation declare their foreign + // keys against the *name* `users`, so the rename re-points them. + // Verify rather than assume - a violation here means orphaned saves. + // Checked and thrown INSIDE this transaction, before it returns, so a + // violation aborts the rebuild (better-sqlite3 rolls back on a thrown + // exception) instead of durably committing broken data and only + // failing the boot afterward - which would self-heal into silence: + // the next boot sees no `provider` column, skips the rebuild, and + // never checks again. + const violations = db.pragma('foreign_key_check'); + if (violations.length > 0) { + throw new Error(`identities migration left ${violations.length} FK violations`); + } + }); + tx(); + } finally { + db.pragma('foreign_keys = ON'); } } @@ -126,12 +145,9 @@ export async function applySchema(db) { db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - provider_id TEXT NOT NULL, username TEXT, avatar_url TEXT, - created_at INTEGER NOT NULL, - UNIQUE(provider, provider_id) + created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS saves ( @@ -214,7 +230,7 @@ export async function applySchema(db) { // upgrading straight from a very old pre-index version must be resolved // first, or that CREATE UNIQUE INDEX would fail. await dedupeUsernames(db); - await migrateIdentities(db); + migrateIdentities(db); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username COLLATE NOCASE)'); } diff --git a/tests/db.identities.test.js b/tests/db.identities.test.js index f036b27..4076f27 100644 --- a/tests/db.identities.test.js +++ b/tests/db.identities.test.js @@ -20,8 +20,19 @@ afterAll(async () => { await provisioned.cleanup(); }); +// Renames `identities` away and back, per-backend, so a test can force the +// second write of upsertUser's insert path to fail without touching real +// constraint machinery. +async function renameIdentitiesTable(from, to) { + if (driver.__backend === 'sqlite') { + driver.__raw.exec(`ALTER TABLE ${from} RENAME TO ${to}`); + } else { + await driver.__raw.query(`ALTER TABLE ${from} RENAME TO ${to}`); + } +} + describe('identities split', () => { - it('creates exactly one identity per user on first login', async () => { + it('creates exactly one identity per user on first login, with last_login_at set immediately', async () => { await upsertUser({ provider: 'github', providerId: '37058311', username: 'nec', avatarUrl: null, }); @@ -29,6 +40,11 @@ describe('identities split', () => { expect(ids).toHaveLength(1); expect(ids[0]).toMatchObject({ provider: 'github', provider_id: '37058311' }); expect(ids[0].supertokens_user_id).toBeNull(); + // Previously only set on a *returning* login's UPDATE - a brand-new + // identity is itself a login and should not require a second one before + // last_login_at is populated. + expect(ids[0].last_login_at).toBeTypeOf('number'); + expect(ids[0].last_login_at).toBe(ids[0].created_at); }); it('keeps users.id as provider:providerId', async () => { @@ -40,17 +56,26 @@ describe('identities split', () => { expect(user.id).toBe('discord:536626725380161537'); }); - it('resolves a returning login through identities to the same user', async () => { + it('resolves a returning login through identities to the same user, bumping last_login_at', async () => { await upsertUser({ provider: 'github', providerId: 'ret', username: 'first', avatarUrl: null, }); await putSave('github:ret', { marker: 'keep-me' }, 123); + const [firstLogin] = await listIdentities('github:ret'); + expect(firstLogin.last_login_at).toBeTypeOf('number'); + await upsertUser({ provider: 'github', providerId: 'ret', username: 'renamed', avatarUrl: 'a.png', }); const save = await getSave('github:ret'); expect(JSON.parse(save.data).marker).toBe('keep-me'); - expect(await listIdentities('github:ret')).toHaveLength(1); + const identities = await listIdentities('github:ret'); + expect(identities).toHaveLength(1); + // The returning-login path updates last_login_at on the SAME identity + // row, not >= the first value strictly (two calls in the same test can + // land in the same millisecond), so pin "still populated", not "grew". + expect(identities[0].last_login_at).toBeTypeOf('number'); + expect(identities[0].last_login_at).toBeGreaterThanOrEqual(firstLogin.last_login_at); }); it('getAllUsersWithSaves still exposes provider', async () => { @@ -61,6 +86,59 @@ describe('identities split', () => { expect(rows.find((r) => r.id === 'discord:agg').provider).toBe('discord'); }); + it('getAllUsersWithSaves returns provider: null for a user with no identity row, rather than dropping them', async () => { + // Pins the correlated subquery as a LEFT-style lookup: a future + // refactor to an inner join would silently drop this user from the + // admin list instead of surfacing them with a null provider. + const now = Date.now(); + if (driver.__backend === 'sqlite') { + driver.__raw.prepare( + 'INSERT INTO users (id, username, avatar_url, created_at) VALUES (?, ?, ?, ?)', + ).run('orphan:no-identity', 'orphaned', null, now); + } else { + await driver.__raw.query( + 'INSERT INTO users (id, username, avatar_url, created_at) VALUES ($1, $2, $3, $4)', + ['orphan:no-identity', 'orphaned', null, now], + ); + } + + const rows = await getAllUsersWithSaves(); + const row = rows.find((r) => r.id === 'orphan:no-identity'); + expect(row).toBeDefined(); + expect(row.provider).toBeNull(); + }); + + it('rolls back the whole write if the identities insert fails, leaving no orphaned users row', async () => { + // Forces upsertUser's second write (INSERT INTO identities) to fail by + // renaming the table out from under it, simulating any failure on that + // write. Before the writes were made transactional, this state (a users + // row with no matching identity) was a *permanent* lockout: the next + // login attempt's identity lookup would miss, retry INSERT INTO users + // with the same primary key, and raise a constraint code neither retry + // guard recognizes. + await renameIdentitiesTable('identities', 'identities_moved'); + try { + await expect(upsertUser({ + provider: 'github', providerId: 'atomic-1', username: 'atomicuser', avatarUrl: null, + })).rejects.toThrow(); + + // The users insert must have rolled back along with the failed + // identities insert - not left as an orphan. + expect(await getUserById('github:atomic-1')).toBeUndefined(); + } finally { + await renameIdentitiesTable('identities_moved', 'identities'); + } + + // With identities restored, the exact same login must now succeed + // cleanly - proving the earlier failure didn't leave any partial state + // (e.g. a half-written users row) behind to trip up a retry. + const user = await upsertUser({ + provider: 'github', providerId: 'atomic-1', username: 'atomicuser', avatarUrl: null, + }); + expect(user.id).toBe('github:atomic-1'); + expect(await listIdentities('github:atomic-1')).toHaveLength(1); + }); + it('migrates a pre-split SQLite database without losing saves', async () => { // Build a database in the OLD shape, then let applySchema() upgrade it. // Postgres-only test runs use this too - it's a pure SQLite in-memory @@ -94,7 +172,11 @@ describe('identities split', () => { expect(identity).toMatchObject({ provider: 'github', provider_id: '37058311' }); const save = raw.prepare('SELECT * FROM saves WHERE user_id = ?').get('github:37058311'); - expect(JSON.parse(save.data).wafers).toBe(42); + // Byte-for-byte, not just the parsed field - interface.md states JSON + // columns round-trip exactly as stored, and a parsed-field check alone + // wouldn't catch e.g. whitespace or key-order drift introduced by the + // rebuild's SELECT/INSERT. + expect(save.data).toBe('{"wafers":42}'); expect(save.last_save).toBe(1784859388999); // Idempotency: a second boot over the already-migrated database must be @@ -106,7 +188,7 @@ describe('identities split', () => { expect(identitiesAgain).toHaveLength(1); }); - it('throws rather than silently drop an orphaned save during the rebuild', async () => { + it('throws rather than silently drop an orphaned save during the rebuild, and rolls the rebuild back', async () => { // Proves the post-rebuild foreign_key_check actually catches a real // violation, not just that it stays quiet on clean data. A save row // pointing at a user id that was never inserted is exactly what "an @@ -135,5 +217,14 @@ describe('identities split', () => { const { applySchema } = await import('../server/db/schema.sqlite.js'); await expect(applySchema(raw)).rejects.toThrow(/FK violations/); + + // Pins the rollback, not just the rejection: if the check ran after + // COMMIT (the pre-fix ordering), `users` would already be the rebuilt, + // provider-less table even though applySchema threw - a crashed boot + // that quietly "fixed itself" on the very next boot, at which point the + // guard sees no `provider` column and never checks again. Asserting the + // OLD shape survived is the only way to tell the two apart. + const cols = raw.prepare('PRAGMA table_info(users)').all().map((c) => c.name); + expect(cols).toContain('provider'); }); }); From 0f0ffa9857a0fd43b6c37e68de59690ab9ea6749 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sun, 2 Aug 2026 00:44:01 -0400 Subject: [PATCH 13/14] Fix the atomicity regression test: block the INSERT, not the whole table The test added in 7cc2446 renamed `identities` away entirely to force upsertUser's second write to fail. That also breaks the identity SELECT at the top of upsertUser, so the miss branch's INSERT INTO users never even ran - the test passed against both the fixed driver and the old, non-atomic one, for the wrong reason (nothing to roll back either way). Replaced with a trigger (SQLite: CREATE TRIGGER ... BEFORE INSERT; pg: a BEFORE INSERT trigger function) that only fires for one specific provider_id, so the identity lookup still misses normally, the users insert still runs, and only the identities insert fails - the actual failure mode the fix addresses. Verified directly: reverted driver.sqlite.js and driver.pg.js to their pre-fix (2f3bfe0) content in turn and reran this test - it now fails on both, with the orphaned users row as the visible symptom, exactly as described in the review. Restored the fixed drivers and reran - green on both. Also reverted schema.sqlite.js's FK-check-inside-the-transaction fix locally and confirmed the upgrade test's rollback-pinning assertion (`expect(cols).toContain('provider')`) catches that regression too, before restoring it. npm run test:all: sqlite 472/472, pg 469/472 + 3 pre-existing skips. --- tests/db.identities.test.js | 65 ++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/tests/db.identities.test.js b/tests/db.identities.test.js index 4076f27..4dc6c4f 100644 --- a/tests/db.identities.test.js +++ b/tests/db.identities.test.js @@ -20,14 +20,46 @@ afterAll(async () => { await provisioned.cleanup(); }); -// Renames `identities` away and back, per-backend, so a test can force the -// second write of upsertUser's insert path to fail without touching real -// constraint machinery. -async function renameIdentitiesTable(from, to) { +// Forces INSERT INTO identities to fail for one specific provider_id, while +// leaving SELECT (the identity lookup at the top of upsertUser) completely +// unaffected - a trigger, not renaming the table away. Renaming the table +// breaks the lookup too, so upsertUser throws before ever reaching the +// users insert, and no write of either kind happens - that "passes" a +// naive rollback assertion for the wrong reason (nothing to roll back) and +// does not exercise the atomicity fix at all. This targets only the +// second write, after the first (INSERT INTO users) has already run, which +// is the actual failure mode the fix addresses. +async function blockIdentityInsert(providerId) { if (driver.__backend === 'sqlite') { - driver.__raw.exec(`ALTER TABLE ${from} RENAME TO ${to}`); + driver.__raw.exec(` + CREATE TRIGGER block_test_identity_insert BEFORE INSERT ON identities + WHEN NEW.provider_id = '${providerId}' + BEGIN SELECT RAISE(ABORT, 'forced failure for atomicity test'); END; + `); + } else { + await driver.__raw.query(` + CREATE OR REPLACE FUNCTION block_test_identity_insert() RETURNS trigger AS $$ + BEGIN + IF NEW.provider_id = '${providerId}' THEN + RAISE EXCEPTION 'forced failure for atomicity test'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER block_test_identity_insert_trigger BEFORE INSERT ON identities + FOR EACH ROW EXECUTE FUNCTION block_test_identity_insert(); + `); + } +} + +async function unblockIdentityInsert() { + if (driver.__backend === 'sqlite') { + driver.__raw.exec('DROP TRIGGER IF EXISTS block_test_identity_insert'); } else { - await driver.__raw.query(`ALTER TABLE ${from} RENAME TO ${to}`); + await driver.__raw.query(` + DROP TRIGGER IF EXISTS block_test_identity_insert_trigger ON identities; + DROP FUNCTION IF EXISTS block_test_identity_insert(); + `); } } @@ -109,14 +141,15 @@ describe('identities split', () => { }); it('rolls back the whole write if the identities insert fails, leaving no orphaned users row', async () => { - // Forces upsertUser's second write (INSERT INTO identities) to fail by - // renaming the table out from under it, simulating any failure on that - // write. Before the writes were made transactional, this state (a users - // row with no matching identity) was a *permanent* lockout: the next - // login attempt's identity lookup would miss, retry INSERT INTO users - // with the same primary key, and raise a constraint code neither retry - // guard recognizes. - await renameIdentitiesTable('identities', 'identities_moved'); + // Forces upsertUser's SECOND write (INSERT INTO identities) to fail, + // specifically after the FIRST write (INSERT INTO users) has already + // run - the actual failure mode the fix addresses. Before the writes + // were made transactional, this state (a users row with no matching + // identity) was a *permanent* lockout: the next login attempt's + // identity lookup would miss (the row IS there to find), retry INSERT + // INTO users with the same primary key, and raise a constraint code + // neither retry guard recognizes. + await blockIdentityInsert('atomic-1'); try { await expect(upsertUser({ provider: 'github', providerId: 'atomic-1', username: 'atomicuser', avatarUrl: null, @@ -126,10 +159,10 @@ describe('identities split', () => { // identities insert - not left as an orphan. expect(await getUserById('github:atomic-1')).toBeUndefined(); } finally { - await renameIdentitiesTable('identities_moved', 'identities'); + await unblockIdentityInsert(); } - // With identities restored, the exact same login must now succeed + // With the block lifted, the exact same login must now succeed // cleanly - proving the earlier failure didn't leave any partial state // (e.g. a half-written users row) behind to trip up a retry. const user = await upsertUser({ From 3120def738ba9bda4e9ad1e6fede269acc651104 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sun, 2 Aug 2026 00:54:56 -0400 Subject: [PATCH 14/14] Fix round-2 review findings: restore pg's upgrade-path coverage, fix the vacuous last_login_at assertion Two items from re-review: 1. Important (new, introduced by the prior fix round): moving schema.pg.js's base users DDL to the post-split shape (last round's Important 4) was correct, but it meant migrateIdentities's backfill-and-DROP-COLUMN branch became unreachable by any test that boots a driver normally - no pg test run ever exercised it anymore, unlike sqlite's dedicated old-shape test (which bypasses applySchema's DDL via a raw better-sqlite3 handle and so stayed unaffected). Reinstated as a permanent test: builds a genuinely pre-split users table directly against a fresh Postgres database (not via applySchema), runs applySchema over it, and asserts the columns are dropped, the identity backfilled, users.id unchanged, and the save preserved byte-for-byte - plus idempotency across two more calls. Gated on driver.__backend === 'pg' since no postgres container is even started when running the sqlite suite. 2. Minor 7 (still open): the returning-login test's last_login_at assertion was `>= ` against its own insert-time baseline, which is trivially true even with the UPDATE deleted entirely - the same vacuous-test shape caught in the atomicity test last round. Fixed by faking only `Date` (vi.useFakeTimers({ toFake: ['Date'] })), advancing the clock a real 50s between the two upsertUser calls, and asserting the exact resulting value - only true if the UPDATE actually ran and actually wrote it. Both verified to fail against the un-fixed code before being finalized: - Reverted schema.pg.js's backfill (kept the DROP COLUMN, removed the INSERT) -> new pg upgrade test fails with the identity row missing. - Removed the `UPDATE identities SET last_login_at` statement from both drivers in turn -> the fake-timer assertion fails with the stale insert-time value on both backends. All reverts restored before this commit; git diff against HEAD was empty for every production file both times. npm run test:all: sqlite 472/473 (1 skipped - pg-only test), pg 470/473 (3 skipped - pre-existing sqlite-only tests). --- tests/db.identities.test.js | 143 +++++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 19 deletions(-) diff --git a/tests/db.identities.test.js b/tests/db.identities.test.js index 4dc6c4f..54e7303 100644 --- a/tests/db.identities.test.js +++ b/tests/db.identities.test.js @@ -1,6 +1,8 @@ import { - describe, it, expect, afterAll, + describe, it, expect, afterAll, vi, } from 'vitest'; +import pg from 'pg'; +import { randomUUID } from 'node:crypto'; import { provisionDatabase } from './helpers/backend.js'; // Provision before importing the facade: DATABASE_URL/DB_PATH must be set @@ -89,25 +91,39 @@ describe('identities split', () => { }); it('resolves a returning login through identities to the same user, bumping last_login_at', async () => { - await upsertUser({ - provider: 'github', providerId: 'ret', username: 'first', avatarUrl: null, - }); - await putSave('github:ret', { marker: 'keep-me' }, 123); - const [firstLogin] = await listIdentities('github:ret'); - expect(firstLogin.last_login_at).toBeTypeOf('number'); + // Fake only Date, not timers wholesale - real setTimeout/network I/O + // (the pg client, in particular) must keep working underneath. Without + // controlling the clock, two upsertUser calls a few ms apart can land + // in the same millisecond, and asserting last_login_at >= its own + // insert-time value is trivially true even if the returning-login + // UPDATE never ran at all (delete driver.sqlite.js's/driver.pg.js's + // `UPDATE identities SET last_login_at = ...` entirely and X >= X still + // passes) - the exact vacuous-test shape caught and fixed in the + // atomicity test above. A deliberate, asserted gap makes it real. + vi.useFakeTimers({ toFake: ['Date'] }); + try { + vi.setSystemTime(1_700_000_000_000); + await upsertUser({ + provider: 'github', providerId: 'ret', username: 'first', avatarUrl: null, + }); + await putSave('github:ret', { marker: 'keep-me' }, 123); + const [firstLogin] = await listIdentities('github:ret'); + expect(firstLogin.last_login_at).toBe(1_700_000_000_000); - await upsertUser({ - provider: 'github', providerId: 'ret', username: 'renamed', avatarUrl: 'a.png', - }); - const save = await getSave('github:ret'); - expect(JSON.parse(save.data).marker).toBe('keep-me'); - const identities = await listIdentities('github:ret'); - expect(identities).toHaveLength(1); - // The returning-login path updates last_login_at on the SAME identity - // row, not >= the first value strictly (two calls in the same test can - // land in the same millisecond), so pin "still populated", not "grew". - expect(identities[0].last_login_at).toBeTypeOf('number'); - expect(identities[0].last_login_at).toBeGreaterThanOrEqual(firstLogin.last_login_at); + vi.setSystemTime(1_700_000_050_000); // +50s - a real, asserted gap + await upsertUser({ + provider: 'github', providerId: 'ret', username: 'renamed', avatarUrl: 'a.png', + }); + const save = await getSave('github:ret'); + expect(JSON.parse(save.data).marker).toBe('keep-me'); + const identities = await listIdentities('github:ret'); + expect(identities).toHaveLength(1); + // Exact value, not just >= - only true if the returning-login UPDATE + // actually ran and actually wrote the new clock value. + expect(identities[0].last_login_at).toBe(1_700_000_050_000); + } finally { + vi.useRealTimers(); + } }); it('getAllUsersWithSaves still exposes provider', async () => { @@ -221,6 +237,95 @@ describe('identities split', () => { expect(identitiesAgain).toHaveLength(1); }); + // Postgres counterpart of the sqlite old-shape test above. Since + // schema.pg.js's base CREATE TABLE users now ships in the post-split + // shape (Important 4 of the prior review round), no test that boots a + // driver normally ever exercises migrateIdentities's backfill-and- + // DROP COLUMN branch on Postgres - that branch is unreachable from a + // fresh `applySchema` call. This is the only thing that still exercises + // it: build the pre-split table directly against a real Postgres + // database (bypassing applySchema entirely, the same way the sqlite + // test bypasses it via a raw better-sqlite3 handle), then run + // applySchema over it. Needs a real Postgres server, so it only runs + // when the suite's own backend is pg - unlike the sqlite counterpart, + // there's no in-memory equivalent to fall back to when running under + // TEST_BACKEND=sqlite (no postgres container is even started in that + // run - see tests/setup/pg-global.js). + it.runIf(driver.__backend === 'pg')('migrates a pre-split Postgres database without losing saves', async () => { + const adminUrl = process.env.TEST_DATABASE_URL; + const name = `rackstack_pg_upgrade_${randomUUID().replace(/-/g, '')}`; + const admin = new pg.Client({ connectionString: adminUrl }); + await admin.connect(); + try { + await admin.query(`CREATE DATABASE ${name}`); + } finally { + await admin.end(); + } + + const url = new URL(adminUrl); + url.pathname = `/${name}`; + const pool = new pg.Pool({ connectionString: url.toString() }); + + try { + // Build the OLD shape directly against the pool - deliberately NOT + // via applySchema, which no longer produces this shape at all. + await pool.query(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_id TEXT NOT NULL, + username TEXT, avatar_url TEXT, created_at BIGINT NOT NULL, + UNIQUE(provider, provider_id) + ); + CREATE TABLE saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, last_save BIGINT NOT NULL + ); + INSERT INTO users VALUES ('github:37058311','github','37058311','nec',NULL,1784859388645); + INSERT INTO saves VALUES ('github:37058311','{"wafers":42}',1784859388999); + `); + + const { applySchema } = await import('../server/db/schema.pg.js'); + await applySchema(pool); + + const cols = await pool.query( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'users' ORDER BY column_name", + ); + const colNames = cols.rows.map((r) => r.column_name); + expect(colNames).not.toContain('provider'); + expect(colNames).not.toContain('provider_id'); + + const user = (await pool.query("SELECT * FROM users WHERE id = 'github:37058311'")).rows[0]; + expect(user.id).toBe('github:37058311'); + expect(user.username).toBe('nec'); + + const identity = (await pool.query( + "SELECT * FROM identities WHERE user_id = 'github:37058311'", + )).rows[0]; + expect(identity).toMatchObject({ provider: 'github', provider_id: '37058311' }); + + const save = (await pool.query("SELECT * FROM saves WHERE user_id = 'github:37058311'")).rows[0]; + // Byte-for-byte, same rationale as the sqlite counterpart. + expect(save.data).toBe('{"wafers":42}'); + expect(Number(save.last_save)).toBe(1784859388999); + + // Idempotency against a real upgrade target, not just a fresh db. + await applySchema(pool); + await applySchema(pool); + const identitiesAgain = await pool.query( + "SELECT * FROM identities WHERE user_id = 'github:37058311'", + ); + expect(identitiesAgain.rows).toHaveLength(1); + } finally { + await pool.end(); + const cleanupAdmin = new pg.Client({ connectionString: adminUrl }); + await cleanupAdmin.connect(); + try { + await cleanupAdmin.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + } finally { + await cleanupAdmin.end(); + } + } + }); + it('throws rather than silently drop an orphaned save during the rebuild, and rolls the rebuild back', async () => { // Proves the post-rebuild foreign_key_check actually catches a real // violation, not just that it stays quiet on clean data. A save row