rebel shell

This commit is contained in:
zk
2026-05-08 16:54:34 +00:00
parent 03ad9a7dfd
commit efcd7652ae
148 changed files with 1317 additions and 514 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
Dockerfile
# Env files — mirror repo root `.dockerignore` when this package is build context alone.
# Env files - mirror repo root `.dockerignore` when this package is build context alone.
.env
.env.*
**/.env
-1
View File
@@ -13,7 +13,6 @@ MIGRATIONS_DIR=/tachi/db/migrations
LOG_LEVEL=debug
TACHI_EMAIL_FROM=dev@localhost
TACHI_EMAIL_AUTH_POSTMARK=false
TACHI_EMAIL_HOST=tachi-mailpit
TACHI_EMAIL_PORT=1025
TACHI_EMAIL_SECURE=false
+4 -5
View File
@@ -53,7 +53,6 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=
# Email (required)
TACHI_EMAIL_FROM=
TACHI_EMAIL_AUTH_POSTMARK=
TACHI_EMAIL_HOST=
TACHI_EMAIL_PORT=
TACHI_EMAIL_SECURE=
@@ -69,7 +68,7 @@ TACHI_MYT_API_HOST=
TACHI_ARC_AUTH_TOKEN=
TACHI_MYT_AUTH_TOKEN=
# OAuth2 — set all 3 for a provider or none
# OAuth2 - set all 3 for a provider or none
TACHI_FLO_OAUTH2_CLIENT_ID=
TACHI_FLO_OAUTH2_CLIENT_SECRET=
TACHI_FLO_OAUTH2_REDIRECT_URI=
@@ -80,7 +79,7 @@ TACHI_MIN_OAUTH2_CLIENT_ID=
TACHI_MIN_OAUTH2_CLIENT_SECRET=
TACHI_MIN_OAUTH2_REDIRECT_URI=
# CG — set both for a suffix or neither
# CG - set both for a suffix or neither
TACHI_CG_DEV_API_KEY=
TACHI_CG_DEV_URL=
TACHI_CG_NAG_API_KEY=
@@ -88,7 +87,7 @@ TACHI_CG_NAG_URL=
TACHI_CG_GAN_API_KEY=
TACHI_CG_GAN_URL=
# Seeds (optional) — set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable
# Seeds (optional) - set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable
TACHI_SEEDS_TYPE=
TACHI_SEEDS_PATH=
TACHI_SEEDS_REPO_URL=
@@ -96,7 +95,7 @@ TACHI_SEEDS_USER_NAME=
TACHI_SEEDS_USER_EMAIL=
TACHI_SEEDS_BRANCH=
# Invite codes — set all 3 or none
# Invite codes - set all 3 or none
TACHI_INVITE_CODE_BATCH_SIZE=
TACHI_INVITE_CODE_INVITE_CAP=
TACHI_INVITE_CODE_BETA_USER_BONUS=
-1
View File
@@ -42,7 +42,6 @@ TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
TACHI_EMAIL_FROM=test@example.com
TACHI_EMAIL_AUTH_POSTMARK=false
TACHI_EMAIL_HOST=127.0.0.1
TACHI_EMAIL_PORT=1025
TACHI_EMAIL_SECURE=false
-18
View File
@@ -1,18 +0,0 @@
files: "src/test-utils/single-process-tap.ts"
test-env:
- NODE_ENV=test
- PORT=8080
- LOG_LEVEL=info
# On a local machine, these tests run in under 2-3 minutes.
# On github actions, these tests can take an inordinate amount of time.
# The timeout is set to 10 minutes, which is ridiculous.
# Doesn't matter for real machines.
timeout: 600
ts: true
before: "./src/test-utils/hack-setup.js"
check-coverage: true
branches: 30
functions: 80
lines: 80
statements: 80
reporter: terse
@@ -31,6 +31,6 @@ export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async
const { text, html } = EmailFormatVerifyEmail(taker.acct.username, newToken);
void SendEmail(verifyInfo.email, "Email Verification", html, text);
await SendEmail(verifyInfo.email, "Email Verification", html, text);
return {};
});
@@ -1,6 +1,7 @@
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { seedInvite, seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ANON_ACTION_Register } from "./register";
@@ -217,6 +218,52 @@ describe("ANON_ACTION_Register", () => {
expect(user).toBeUndefined();
});
// ── Sequence gaps ─────────────────────────────────────────────────────────
it("does not burn sequence values on failed registrations with bad invite codes", async () => {
const first = await ANON_ACTION_Register(taker, {
username: "firstuser",
"!password": "securepassword",
email: "first@example.com",
captcha: "test",
inviteCode,
});
// Need a fresh invite for the next successful signup.
const { id: seedId } = await seedUser({
username: "inviter2",
email: "inviter2@example.com",
});
const secondInvite = await seedInvite(seedId, "SECOND_INVITE");
// 10 failed registrations — bad invite code but unique username/email,
// so they enter the transaction and reach AddNewUser before failing.
for (let i = 0; i < 10; i++) {
await expect(
ANON_ACTION_Register(taker, {
username: `baduser${i}`,
"!password": "securepassword",
email: `bad${i}@example.com`,
captcha: "test",
inviteCode: "BOGUS_CODE",
}),
).rejects.toThrow();
}
const second = await ANON_ACTION_Register(taker, {
username: "seconduser",
"!password": "securepassword",
email: "second@example.com",
captcha: "test",
inviteCode: secondInvite,
});
// If sequences are gap-free, the second real user should be first.id + 1
// (skipping only the seedUser helper we inserted, which also burns one).
// With BIGSERIAL, this FAILS: the 10 rolled-back txns each burned a value.
expect(second.userID).toBe(first.userID + 2);
});
// ── Audit log ──────────────────────────────────────────────────────────────
it("writes a BAD action row when the username is already taken", async () => {
@@ -276,3 +323,94 @@ describe("ANON_ACTION_Register", () => {
expect(input).not.toHaveProperty("!password");
});
});
// ─── Bootstrap invite (INVITE_ADMIN_INITIAL_INVITE_CODE) ──────────────────────
describe("ANON_ACTION_Register - bootstrap invite", () => {
const taker = { ip: "127.0.0.1" };
const BOOTSTRAP_CODE = "BOOTSTRAP_SECRET_FOR_TESTS";
let originalBootstrap: typeof ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE;
beforeEach(() => {
originalBootstrap = ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE;
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = BOOTSTRAP_CODE;
});
afterEach(() => {
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = originalBootstrap;
});
it("creates an admin user when the instance has no accounts", async () => {
const result = await ANON_ACTION_Register(taker, {
username: "firstadmin",
"!password": "securepassword",
email: "admin@example.com",
captcha: "test",
inviteCode: BOOTSTRAP_CODE,
});
const account = await DB.selectFrom("account")
.select(["account.id", "account.auth_level"])
.where("account.id", "=", result.userID)
.executeTakeFirstOrThrow();
expect(account.auth_level).toBe("admin");
});
it("does not consume any priv_invite row", async () => {
await ANON_ACTION_Register(taker, {
username: "firstadmin",
"!password": "securepassword",
email: "admin@example.com",
captcha: "test",
inviteCode: BOOTSTRAP_CODE,
});
const invites = await DB.selectFrom("priv_invite")
.select(DB.fn.countAll().as("count"))
.executeTakeFirstOrThrow();
expect(Number(invites.count)).toBe(0);
});
it("rejects the bootstrap code when an account already exists", async () => {
await seedUser();
await expect(
ANON_ACTION_Register(taker, {
username: "seconduser",
"!password": "securepassword",
email: "second@example.com",
captcha: "test",
inviteCode: BOOTSTRAP_CODE,
}),
).rejects.toMatchObject({ code: 400 });
});
it("rejects a wrong code even on an empty instance", async () => {
await expect(
ANON_ACTION_Register(taker, {
username: "firstadmin",
"!password": "securepassword",
email: "admin@example.com",
captcha: "test",
inviteCode: "WRONG_CODE",
}),
).rejects.toThrow();
});
it("falls through to normal invite validation when env is unset", async () => {
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = undefined;
await expect(
ANON_ACTION_Register(taker, {
username: "firstadmin",
"!password": "securepassword",
email: "admin@example.com",
captcha: "test",
inviteCode: "NONEXISTENT",
}),
).rejects.toThrow();
});
});
+63 -9
View File
@@ -7,8 +7,25 @@ import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user";
import { ExpectedErr, log } from "bliss";
import crypto from "crypto";
import { sql } from "kysely";
import { type UserDocument } from "tachi-common";
/** Namespace for the advisory lock that serialises bootstrap-invite registrations. */
const BOOTSTRAP_INVITE_ADVISORY_KEY1 = 0x42_6f_6f_74; // "Boot"
const BOOTSTRAP_INVITE_ADVISORY_KEY2 = 0x49_6e_76_74; // "Invt"
function bootstrapCodeMatches(input: string, expected: string): boolean {
const a = Buffer.from(input, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
export const ANON_ACTION_Register = MakeAnonAction(
"REGISTER",
async (taker, { email, "!password": password, inviteCode, username, captcha }) => {
@@ -44,10 +61,6 @@ export const ANON_ACTION_Register = MakeAnonAction(
}
const newUser = await DB.transaction().execute(async (txn): Promise<UserDocument> => {
// if we get to this point, We're good to create the user.
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
if (ServerConfig.INVITE_CODE_CONFIG) {
if (!inviteCode) {
throw new ExpectedErr(
@@ -55,20 +68,61 @@ export const ANON_ACTION_Register = MakeAnonAction(
"No invite code given, yet the server uses invites.",
);
}
}
const isBootstrapInvite =
ServerConfig.INVITE_CODE_CONFIG &&
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE &&
inviteCode !== null &&
bootstrapCodeMatches(inviteCode, ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE);
if (isBootstrapInvite) {
await sql`SELECT pg_advisory_xact_lock(${BOOTSTRAP_INVITE_ADVISORY_KEY1}, ${BOOTSTRAP_INVITE_ADVISORY_KEY2})`.execute(
txn,
);
const { count } = await txn
.selectFrom("account")
.select(txn.fn.countAll().as("count"))
.executeTakeFirstOrThrow();
if (Number(count) > 0) {
log.info("Bootstrap invite used but instance already has users.");
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
}
} else if (ServerConfig.INVITE_CODE_CONFIG) {
// Validate the invite code BEFORE creating the user so a bad code
// never burns a sequence value on `account.id`.
// FOR UPDATE locks the row so a concurrent signup can't consume
// the same code between our check and the update below.
const inviteCodeDoc = await txn
.selectFrom("priv_invite")
.select("code")
.where("code", "=", inviteCode)
.where("consumed", "=", false)
.select("priv_invite.code")
.where("priv_invite.code", "=", inviteCode!)
.where("priv_invite.consumed", "=", false)
.forUpdate()
.executeTakeFirst();
if (!inviteCodeDoc) {
log.info(`Invalid invite code given: ${inviteCode}.`);
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
}
}
log.info(`Consumed invite ${inviteCodeDoc.code}.`);
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
if (isBootstrapInvite) {
await txn
.updateTable("account")
.set({ auth_level: "admin" })
.where("account.id", "=", newUser.id)
.execute();
log.info(
`Bootstrap invite consumed — user ${newUser.username} (${newUser.id}) is now admin.`,
);
} else if (ServerConfig.INVITE_CODE_CONFIG) {
log.info(`Consumed invite ${inviteCode}.`);
await txn
.updateTable("priv_invite")
@@ -77,7 +131,7 @@ export const ANON_ACTION_Register = MakeAnonAction(
consumed_at: new Date().toISOString(),
consumed_by: newUser.id,
})
.where("code", "=", inviteCode)
.where("priv_invite.code", "=", inviteCode!)
.execute();
}
+8 -5
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-await-in-loop */
import { loadServerEnvFile } from "#lib/setup/load-server-env";
loadServerEnvFile(process.env.NODE_ENV === "test" ? ".env.test" : ".env");
@@ -5,8 +6,12 @@ import { runCronTickOnce } from "#lib/jobs/cron/cron-service";
import { log } from "#lib/log/log";
import { Env } from "#lib/setup/config";
import { ClosePgConnection } from "#services/pg/db";
import { Sleep } from "#utils/misc";
import { writeFileSync } from "fs";
import { applyMigrations } from "tachi-db-migration-engine";
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
const TICK_MS = 5_000;
process.on("uncaughtException", (err, origin) => {
@@ -23,6 +28,7 @@ void bootstrap();
async function bootstrap() {
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
log.info({ bootInfo: true }, "tachi cron worker starting.");
let stopping = false;
const shutdown = () => {
stopping = true;
@@ -32,8 +38,8 @@ async function bootstrap() {
// eslint-disable-next-line no-unmodified-loop-condition
while (!stopping) {
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
try {
// eslint-disable-next-line no-await-in-loop
await runCronTickOnce();
} catch (e) {
log.error(e, "Cron tick error.");
@@ -41,10 +47,7 @@ async function bootstrap() {
if (stopping) {
break;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, TICK_MS);
});
await Sleep(TICK_MS);
}
log.info("Cron worker stopped.");
await ClosePgConnection();
@@ -150,7 +150,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
it("counts at most 30 PBs per flare category (global flare order, per-category index)", async () => {
const { id: userId } = await seedUser();
// 31 CLASSIC rows, flareSkill 1031..1001 — sorted desc; index 30 is dropped (top < 30 keeps 0..29).
// 31 CLASSIC rows, flareSkill 1031..1001 - sorted desc; index 30 is dropped (top < 30 keeps 0..29).
for (let i = 0; i < 31; i++) {
await seedDdrPbRow(userId, {
flareSkill: 1031 - i,
@@ -236,7 +236,7 @@ describe("IIDX_IMPL (unit)", () => {
});
});
describe("scoreCalcs ktLampRatingNC / ktLampRatingHC / ktLampRatingEXHC", () => {
describe("scoreCalcs ktLampRatingHC / ktLampRatingEXHC", () => {
it("IIDX SP", () => {
const run = (
scoreData: Partial<ScoreData<"iidx-sp">>,
@@ -258,37 +258,30 @@ describe("IIDX_IMPL (unit)", () => {
const tiered = { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) };
expect(run({ lamp: "FAILED" }, {})).toMatchObject({
ktLampRatingNC: 0,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "EASY CLEAR" }, {})).toMatchObject({
ktLampRatingNC: 0,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "CLEAR" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "HARD CLEAR" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 16,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "EX HARD CLEAR" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 16,
ktLampRatingEXHC: 17,
});
expect(run({ lamp: "FULL COMBO" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 16,
ktLampRatingEXHC: 17,
});
expect(run({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15,
ktLampRatingEXHC: 0,
});
@@ -313,27 +306,22 @@ describe("IIDX_IMPL (unit)", () => {
}
expect(run({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 0,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15,
ktLampRatingEXHC: 0,
});
expect(run({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15,
ktLampRatingEXHC: 15,
});
expect(run({ lamp: "FULL COMBO" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15,
ktLampRatingEXHC: 15,
});
@@ -29,23 +29,19 @@ type IIDXGames = "iidx-dp" | "iidx-sp";
const IIDX_SESSION_CALCS: GPTSessionCalcs<IIDXGames> = (arr) => ({
BPI: SessionAvgBest10For("BPI")(arr),
ktLampRating: SessionAvgBest10For("ktLampRating")(arr),
ktLampRatingNC: SessionAvgBest10For("ktLampRatingNC")(arr),
ktLampRatingHC: SessionAvgBest10For("ktLampRatingHC")(arr),
ktLampRatingEXHC: SessionAvgBest10For("ktLampRatingEXHC")(arr),
});
const IIDX_PROFILE_CALCS: GPTProfileCalcs<IIDXGames> = async (game, userID) => {
const [BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all(
[
ProfileAvgBestN("BPI", 20, true)(game, userID),
ProfileAvgBestN("ktLampRating", 20)(game, userID),
ProfileAvgBestN("ktLampRatingNC", 20)(game, userID),
ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID),
],
);
const [BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all([
ProfileAvgBestN("BPI", 20, true)(game, userID),
ProfileAvgBestN("ktLampRating", 20)(game, userID),
ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID),
]);
return { BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC };
return { BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC };
};
const IIDX_MERGERS: Array<PBMergeFunction<IIDXGames>> = [
@@ -156,11 +152,6 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
ktLampRating = 0;
}
const atLeastNcClear =
scoreData.lamp === "CLEAR" ||
scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" ||
scoreData.lamp === "FULL COMBO";
const atLeastHcClear =
scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" ||
@@ -168,14 +159,12 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
const atLeastExhcClear =
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
const ktLampRatingNC = atLeastNcClear ? ncValue : 0;
const ktLampRatingHC = atLeastHcClear ? hcValue : 0;
const ktLampRatingEXHC = atLeastExhcClear ? exhcValue : 0;
return {
BPI: bpi,
ktLampRating,
ktLampRatingNC,
ktLampRatingHC,
ktLampRatingEXHC,
};
@@ -235,11 +224,6 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
ktLampRating = 0;
}
const atLeastNcClear =
scoreData.lamp === "CLEAR" ||
scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" ||
scoreData.lamp === "FULL COMBO";
const atLeastHcClear =
scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" ||
@@ -247,14 +231,12 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
const atLeastExhcClear =
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
const ktLampRatingNC = atLeastNcClear ? ecValue : 0;
const ktLampRatingHC = atLeastHcClear ? ecValue : 0;
const ktLampRatingEXHC = atLeastExhcClear ? ecValue : 0;
return {
BPI: bpi,
ktLampRating,
ktLampRatingNC,
ktLampRatingHC,
ktLampRatingEXHC,
};
@@ -12,7 +12,7 @@ import { GoalFmtPercent, GradeGoalFormatter } from "./_common";
export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
// *important*: don't check survivedPercent === 100 — floating point can
// *important*: don't check survivedPercent === 100 - floating point can
// produce a 100% survived-percent on a fail for very long charts.
finalPercent:
scoreData.lamp === "FAILED" ? scoreData.survivedPercent : 100 + scoreData.scorePercent,
@@ -60,7 +60,6 @@ describe("CreatePBMergeFor (Postgres)", () => {
judgements: s.mongo.judgements ?? {},
});
// eslint-disable-next-line no-await-in-loop
await DB.insertInto("score")
.values({
id: scoreId,
+13 -7
View File
@@ -10,8 +10,12 @@ import { processScoreImportJobFromPayload } from "#lib/score-import/worker/score
import { Env } from "#lib/setup/config";
import { ClosePgConnection } from "#services/pg/db";
import { CloseRedisConnection } from "#services/redis/redis";
import { Sleep } from "#utils/misc";
import { writeFileSync } from "fs";
import { applyMigrations } from "tachi-db-migration-engine";
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
const POLL_MS = 250;
process.on("uncaughtException", (err, origin) => {
@@ -21,31 +25,32 @@ process.on("uncaughtException", (err, origin) => {
void bootstrap();
/**
* Often run by `just server` (one or more via `TACHI_SERVER_JOB_WORKER_COUNT`); each process claims
* with `FOR UPDATE SKIP LOCKED`.
*/
async function bootstrap() {
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
log.info({ bootInfo: true }, "tachi job-queue worker starting (Postgres job_queue).");
let stopping = false;
const shutdown = () => {
stopping = true;
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// eslint-disable-next-line no-unmodified-loop-condition
while (!stopping) {
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
const job = await ClaimNextJob();
if (!job) {
if (stopping) {
break;
}
await new Promise((r) => {
setTimeout(r, POLL_MS);
});
await Sleep(POLL_MS);
continue;
}
try {
switch (job.job_kind) {
case JOB_KIND_SCORE_IMPORT:
@@ -61,6 +66,7 @@ async function bootstrap() {
await MarkJobFailed(job.row_id);
}
}
log.info("Job worker loop stopped, closing resources.");
await CloseScoreImportQueue();
await CloseRedisConnection();
@@ -38,7 +38,7 @@ export type ActivityConstraint = {
userID?: integer | { $in: Array<integer> };
};
/** Kysely dynamic column refs — same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
/** Kysely dynamic column refs - same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OperandExpressionFactory varies by query (session / join / class_achievement).
type ActivityWhereEb = any;
+3 -3
View File
@@ -92,13 +92,13 @@ export const SELECT_PB_DOCUMENT_WITH_LEADERBOARD = [
export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise<PBScoreDocument> {
const composedRows = await DB.selectFrom("pb_composed_from")
.where("pb_id", "=", row.row_id)
.select("score_id")
.select(["score_id", "merge_name"])
.execute();
const composedFrom: [PBReference, ...PBReference[]] =
composedRows.length > 0
? (composedRows.map((c, i) => ({
name: i === 0 ? "Primary" : `Merge${i}`,
? (composedRows.map((c) => ({
name: c.merge_name,
scoreID: c.score_id,
})) as [PBReference, ...PBReference[]])
: [{ name: "Primary", scoreID: "unknown" }];
+3 -2
View File
@@ -23,7 +23,7 @@ try {
} else {
transporter.verify((err) => {
if (err) {
// Do NOT throw here — this is an async callback and the throw would
// Do NOT throw here - this is an async callback and the throw would
// become an uncaught exception, crashing the process. Log and exit instead.
log.fatal({ err }, `Could not connect to email server.`);
process.exit(1);
@@ -65,7 +65,7 @@ export function SendEmail(
headers: transporter.options.headers,
})
.catch((err: unknown) => {
log.info(
log.error(
{
err,
subject,
@@ -74,5 +74,6 @@ export function SendEmail(
},
`Failed to send email to ${to}.`,
);
throw err;
});
}
@@ -5,7 +5,7 @@ import { type Kysely, sql } from "kysely";
/**
* Folder membership SQL (see tests in `folders.test.ts`).
* Does not import the global `DB` module — safe for scripts that only set `POSTGRES_URL`.
* Does not import the global `DB` module - safe for scripts that only set `POSTGRES_URL`.
*/
export async function BuildFolderQuery(folderID: string, db: Kysely<Database>) {
const folder = await db
@@ -75,7 +75,7 @@ export async function GetFolderChartIDs(folderID: string, db: Kysely<Database>)
/**
* Evaluates `folder.where` SQL (same as a full rebuild). Used by
* {@link rebuildFolderChartLookup}; do not call for normal reads — use {@link GetFolderChartIDs}.
* {@link rebuildFolderChartLookup}; do not call for normal reads - use {@link GetFolderChartIDs}.
*/
export async function computeFolderChartIdsFromFolderSql(folderID: string, db: Kysely<Database>) {
const { folderQuery } = await BuildFolderQuery(folderID, db);
@@ -46,7 +46,7 @@ function buildList(): Array<CronTaskDef> {
},
{
id: "drain_stats_queues",
schedule: "* * * * *",
schedule: "* * * * *", // TODO(zk): really? this is so lazy
description:
"Drain score_rederive, pb_dirty, session_dirty, game_profile_dirty (ordered)",
run: drainStatsQueuesInOrder,
@@ -291,7 +291,7 @@ export async function drainStatsQueuesInOrder(): Promise<void> {
/**
* Drain `score_rederive`, then `pb_dirty`, `session_dirty`, and `game_profile_dirty`,
* repeating until a full pass moves nothing. No per-tick row cap (unlike the cron
* drain) — intended for admin synchronous recalc.
* drain) - intended for admin synchronous recalc.
*/
export async function drainStatsQueuesFully(): Promise<void> {
for (;;) {
@@ -345,7 +345,7 @@ export async function drainStatsQueuesFully(): Promise<void> {
/**
* Drain `pb_dirty` then `session_dirty` and `game_profile_dirty`, repeating until
* idle. No per-tick row cap — intended for admin synchronous PB recalc.
* idle. No per-tick row cap - intended for admin synchronous PB recalc.
*/
export async function drainPbDirtyAndDownstream(): Promise<void> {
for (;;) {
@@ -192,7 +192,7 @@ export class TypedRouter<S extends AnyRouterSpec> {
if (!outputParseResult.success) {
log.error(
{ route, issues: outputParseResult.error.issues },
"TypedRouter: output validation failed — handler returned body not matching spec output schema",
"TypedRouter: output validation failed - handler returned body not matching spec output schema",
);
}
@@ -155,7 +155,7 @@ export async function deleteOrphanScoreForUser(
/**
* Lists orphan_score rows for a user, newest first, with keyset pagination on `row_id` + `time_inserted`.
* @param afterRowID — `row_id` of the last item from the previous page (omit on first page).
* @param afterRowID - `row_id` of the last item from the previous page (omit on first page).
*/
export async function listOrphanScoresForUser(opts: {
afterRowID?: string;
@@ -103,6 +103,7 @@ export async function upsertPbFromMongoDoc(
pbDoc.composedFrom.map((ref) => ({
pb_id: pbId,
score_id: ref.scoreID,
merge_name: ref.name,
})),
)
.execute();
@@ -99,6 +99,7 @@ export async function finalizeImportToPostgres(
await db
.insertInto("import_game")
.values(games.map((game) => ({ id: importID, game })))
.onConflict((oc) => oc.columns(["id", "game"]).doNothing())
.execute();
}
@@ -113,6 +114,7 @@ export async function finalizeImportToPostgres(
message: err.message,
})),
)
.onConflict((oc) => oc.column("row_id").doNothing())
.execute();
}
@@ -130,6 +132,7 @@ export async function finalizeImportToPostgres(
new: d.new,
})),
)
.onConflict((oc) => oc.column("row_id").doNothing())
.execute();
}
@@ -145,6 +148,7 @@ export async function finalizeImportToPostgres(
type: s.type.toLowerCase() as "appended" | "created",
})),
)
.onConflict((oc) => oc.columns(["import_id", "session_id"]).doNothing())
.execute();
}
@@ -169,6 +173,7 @@ export async function finalizeImportToPostgres(
new_progress_human: g.new.progressHuman,
})),
)
.onConflict((oc) => oc.column("row_id").doNothing())
.execute();
}
@@ -187,6 +192,7 @@ export async function finalizeImportToPostgres(
new_progress: q.new.progress,
})),
)
.onConflict((oc) => oc.column("row_id").doNothing())
.execute();
}
@@ -211,23 +217,6 @@ export async function finalizeImportToPostgres(
quest_secs: timing.questMs,
total_secs: timing.totalMs,
})
.onConflict((oc) =>
oc.column("id").doUpdateSet({
timestamp: tsNow,
import_secs_avg: timing.importMs / n,
import_parse_secs_avg: timing.importParseMs / n,
pb_secs_avg: timing.pbMs / n,
session_secs_avg: timing.sessionMs / n,
parse_secs: timing.parseMs,
import_secs: timing.importMs,
import_parse_secs: timing.importParseMs,
session_secs: timing.sessionMs,
pb_secs: timing.pbMs,
ugs_secs: timing.ugsMs,
goal_secs: timing.goalMs,
quest_secs: timing.questMs,
total_secs: timing.totalMs,
}),
)
.onConflict((oc) => oc.column("id").doNothing())
.execute();
}
@@ -2,8 +2,14 @@ import { seedUser } from "#actions/test-utils/api-tokens";
import { CDNRetrieve } from "#lib/cdn/cdn";
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
import DB from "#services/pg/db";
import { FakeSmallBatchManual, Testing511Song, Testing511SPA } from "#test-utils/test-data";
import {
FakeSmallBatchManual,
Testing511Song,
Testing511SPA,
TestingJubeatSong,
} from "#test-utils/test-data";
import { Sleep } from "#utils/misc";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { beforeEach, describe, expect, it } from "vitest";
import { MakeScoreImport } from "./score-import";
@@ -110,3 +116,273 @@ describe("MakeScoreImport (ported from score-import.oldtest.ts)", () => {
},
);
});
const JUBEAT_ARRAY_IG_FIRST = 80000037;
function mkBatchManualMulterFile(body: object): Express.Multer.File {
return {
buffer: Buffer.from(JSON.stringify(body), "utf-8"),
} as Express.Multer.File;
}
function jubeatScoreLine(opts: {
identifier: number | string;
musicRate?: number;
score?: number;
timeAchieved: number;
}) {
return {
difficulty: "ADV" as const,
identifier: String(opts.identifier),
judgements: {
good: 0,
great: 0,
miss: 0,
perfect: 100,
poor: 0,
},
lamp: "CLEAR" as const,
matchType: "inGameID" as const,
musicRate: opts.musicRate ?? 96.5,
score: opts.score ?? 920_000,
timeAchieved: opts.timeAchieved,
};
}
async function seedJubeatChartArrayInGameID(opts: { chartId: string; legacyId: number }) {
await DB.insertInto("song")
.values({
id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`,
legacy_id: opts.legacyId,
game_group: "jubeat",
title: TestingJubeatSong.title,
artist: TestingJubeatSong.artist,
search_terms: TestingJubeatSong.searchTerms,
alt_titles: TestingJubeatSong.altTitles,
data: TestingJubeatSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: opts.chartId,
legacy_id: String(opts.legacyId),
game: "jubeat",
song_id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`,
difficulty: "ADV",
level: "6",
level_num: 6,
is_primary: true,
versions: ["festo"],
data: JSON.stringify({
inGameID: [JUBEAT_ARRAY_IG_FIRST, 50_000_020],
noteCount: 100,
musicBar: [0, 1, 2, 3],
}),
})
.execute();
}
async function seedJubeatChartScalarInGameID(opts: {
chartId: string;
inGameID: number;
legacyId: number;
songIdSuffix: string;
}) {
await DB.insertInto("song")
.values({
id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`,
legacy_id: opts.legacyId,
game_group: "jubeat",
title: TestingJubeatSong.title,
artist: TestingJubeatSong.artist,
search_terms: TestingJubeatSong.searchTerms,
alt_titles: TestingJubeatSong.altTitles,
data: TestingJubeatSong.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: opts.chartId,
legacy_id: String(opts.legacyId),
game: "jubeat",
song_id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`,
difficulty: "ADV",
level: "6",
level_num: 6,
is_primary: true,
versions: ["festo"],
data: JSON.stringify({
inGameID: opts.inGameID,
noteCount: 100,
musicBar: [0, 1, 2, 3],
}),
})
.execute();
}
describe("batch-manual score import (smoke)", () => {
beforeEach(async () => {
await seedUser({
username: "test_batch_manual_smoke",
email: "batch-manual-smoke@example.com",
withCredential: true,
withSettings: true,
});
});
it("file/batch-manual: jubeat inGameID matches chart with array inGameID and commits scores", async () => {
const chartId = "chart-smoke-jubeat-array-ingameid";
await seedJubeatChartArrayInGameID({ chartId, legacyId: 9_001 });
const baseMs = Date.UTC(2024, 5, 1, 12, 0, 0, 0);
const batch = {
meta: { game: "jubeat", playtype: "Single", service: "smoke-test" },
scores: [jubeatScoreLine({ identifier: JUBEAT_ARRAY_IG_FIRST, timeAchieved: baseMs })],
};
const importID = "import-smoke-jubeat-array";
const doc = await MakeScoreImport({
importID,
importType: "file/batch-manual",
parserArguments: [mkBatchManualMulterFile(batch), {}],
userID: 1,
userIntent: true,
});
expect(doc.scoreIDs).toHaveLength(1);
expect(doc.errors).toHaveLength(0);
const nCommitted = await DB.selectFrom("score")
.select((eb) => eb.fn.countAll<number>().as("c"))
.where("import_id", "=", importID)
.where("committed", "=", true)
.executeTakeFirst();
expect(Number(nCommitted?.c)).toBe(1);
});
it("second import of identical data reports no sessions", async () => {
await seedJubeatChartScalarInGameID({
chartId: "chart-dedup-sessions",
inGameID: 20_000_001,
legacyId: 9_010,
songIdSuffix: "dedup-sessions-song",
});
const baseMs = Date.UTC(2024, 3, 1, 14, 0, 0, 0);
const batch = {
meta: { game: "jubeat", playtype: "Single", service: "dedup-test" },
scores: [jubeatScoreLine({ identifier: 20_000_001, timeAchieved: baseMs })],
};
const doc1 = await MakeScoreImport({
importID: "import-dedup-sessions-1",
importType: "file/batch-manual",
parserArguments: [mkBatchManualMulterFile(batch), {}],
userID: 1,
userIntent: true,
});
// Sanity check: first import should have created exactly one session
expect(doc1.createdSessions).toHaveLength(1);
expect(doc1.createdSessions[0]?.type).toBe("Created");
const doc2 = await MakeScoreImport({
importID: "import-dedup-sessions-2",
importType: "file/batch-manual",
parserArguments: [mkBatchManualMulterFile(batch), {}],
userID: 1,
userIntent: true,
});
// Second import of identical data should claim no sessions — no new scores
// were actually committed, so no session was touched.
expect(doc2.createdSessions).toHaveLength(0);
});
it("file/batch-manual: two session groups appending the same nearby session finalizes import_session without error", async () => {
await seedJubeatChartScalarInGameID({
chartId: "chart-smoke-jubeat-dup-a",
inGameID: 10_000_001,
legacyId: 9_002,
songIdSuffix: "smoke2-song-a",
});
await seedJubeatChartScalarInGameID({
chartId: "chart-smoke-jubeat-dup-b",
inGameID: 10_000_002,
legacyId: 9_003,
songIdSuffix: "smoke2-song-b",
});
const baseMs = Date.UTC(2024, 5, 10, 8, 0, 0, 0);
const sessionId = `Q${"a".repeat(40)}`;
const sixHoursMs = 6 * 60 * 60 * 1000;
const oneHourMs = 60 * 60 * 1000;
await DB.insertInto("session")
.values({
id: sessionId,
user_id: 1,
game: "jubeat",
name: "preseed-smoke",
description: null,
time_inserted: UnixMillisecondsToISO8601(Date.now()),
time_started: UnixMillisecondsToISO8601(baseMs),
time_ended: UnixMillisecondsToISO8601(baseMs + sixHoursMs),
calculated_data: JSON.stringify({ jubility: null }),
highlight: false,
})
.execute();
const batch = {
meta: { game: "jubeat", playtype: "Single", service: "smoke-test" },
scores: [
jubeatScoreLine({
identifier: 10_000_001,
musicRate: 95.0,
score: 910_000,
timeAchieved: baseMs + oneHourMs,
}),
jubeatScoreLine({
identifier: 10_000_002,
musicRate: 97.0,
score: 925_000,
timeAchieved: baseMs + 5 * oneHourMs,
}),
],
};
const importID = "import-smoke-jubeat-session-meta";
const doc = await MakeScoreImport({
importID,
importType: "file/batch-manual",
parserArguments: [mkBatchManualMulterFile(batch), {}],
userID: 1,
userIntent: true,
});
expect(doc.scoreIDs).toHaveLength(2);
expect(doc.errors).toHaveLength(0);
const nCommitted = await DB.selectFrom("score")
.select((eb) => eb.fn.countAll<number>().as("c"))
.where("import_id", "=", importID)
.where("committed", "=", true)
.executeTakeFirst();
expect(Number(nCommitted?.c)).toBe(2);
const importSessionRows = await DB.selectFrom("import_session")
.select("session_id")
.where("import_id", "=", importID)
.execute();
expect(importSessionRows).toHaveLength(1);
expect(importSessionRows[0]?.session_id).toBe(sessionId);
});
});
@@ -13,7 +13,7 @@ import ScoreImportFatalError from "./score-importing/score-import-error";
* Makes a score import given ScoreImportJobData (same process as the API).
*
* When `USE_EXTERNAL_SCORE_IMPORT_WORKER` is true, HTTP routes call
* {@link EnqueueScoreImportJob} instead — this function is only used for the
* {@link EnqueueScoreImportJob} instead - this function is only used for the
* inline (non-queued) path.
*/
export async function MakeScoreImport<I extends ImportTypes>(
@@ -155,7 +155,7 @@ describe("CreateScoreID", () => {
);
});
it("is deterministic (canary — changing the algorithm is a breaking change)", () => {
it("is deterministic (canary - changing the algorithm is a breaking change)", () => {
const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID);
expect(scoreID).toBe("T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81");
@@ -303,7 +303,7 @@ export async function HandlePostImportSteps(
// best score).
const pbTimeStart = process.hrtime.bigint();
// processing PBs is a playtype-specific action. As such, we need to split chartIDs
// processing PBs is a game-specific action. As such, we need to split chartIDs
// accordingly
const chartIDsSeparatedByGame: ChartIDGameMap = {};
@@ -407,7 +407,7 @@ async function HydrateCheckAndInsertScore(
}
} else {
if (importId === null && !directCommit) {
log.debug(`Skipped score — missing import id.`);
log.debug(`Skipped score - missing import id.`);
return null;
}
@@ -29,6 +29,8 @@ const TWO_HOURS = ONE_HOUR * 2;
export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap, log: KtLogger) {
const allSessionInfo = [];
console.dir(scoreGameMap);
for (const [game, scores] of Object.entries(scoreGameMap)) {
// eslint-disable-next-line no-await-in-loop
const sessionInfo = await LoadScoresIntoSessions(userID, scores, game as V3Game, log);
@@ -36,6 +38,8 @@ export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap
allSessionInfo.push(...sessionInfo);
}
console.dir(allSessionInfo);
return allSessionInfo;
}
@@ -126,7 +126,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts
const ratings = typeof gp.ratings === "string" ? JSON.parse(gp.ratings) : gp.ratings;
expect(ratings).toMatchObject({
ktLampRating: expect.any(Number),
ktLampRatingNC: expect.any(Number),
ktLampRatingHC: expect.any(Number),
ktLampRatingEXHC: expect.any(Number),
});
@@ -175,7 +174,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts
calculatedData: {
BPI: 10.1,
ktLampRating: e,
ktLampRatingNC: e,
ktLampRatingHC: e,
ktLampRatingEXHC: e,
},
@@ -23,7 +23,7 @@ async function* streamPlaylog(apiId: string, log: KtLogger): AsyncIterable<MytWa
try {
for await (const item of client.getPlaylog(request)) {
if (!item.info) {
log.warn(`Received WACCA playlog stream item with no info — skipping.`);
log.warn(`Received WACCA playlog stream item with no info - skipping.`);
continue;
}
@@ -96,7 +96,7 @@ describe("#ParserFn", () => {
);
});
it("No Playtype — game group without playtype is not a V3 game", () => {
it("No Playtype - game group without playtype is not a V3 game", () => {
expectThrowsFatal(
() =>
ParserFn(
@@ -47,7 +47,7 @@ async function finalizeHits(
/**
* Search a user's sessions for one GPT: `websearch_to_tsquery` on `session.textsearch`, optional
* short-query exact match, then pg_trgm / ILIKE — same strategy as
* short-query exact match, then pg_trgm / ILIKE - same strategy as
* {@link SearchSongsForGameFtsAndTrgm} / {@link SearchFoldersForGameFtsAndTrgm}.
*
* Returns full {@link SessionDocument}s (no follow-up `WHERE id IN (...)` on `session`).
@@ -31,23 +31,23 @@ describe("Postgres song search (full seeds)", () => {
await importSeeds(DB, SEEDS_DIR);
}, 600_000);
bench("iidx FTS — gradius (title)", async () => {
bench("iidx FTS - gradius (title)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "gradius", 50);
});
bench("iidx FTS — taka (artist)", async () => {
bench("iidx FTS - taka (artist)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "taka", 50);
});
bench("iidx short query — ab (FTS + trgm)", async () => {
bench("iidx short query - ab (FTS + trgm)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "ab", 50);
});
bench("iidx sparse — xyzunlikely (mostly trgm / empty FTS)", async () => {
bench("iidx sparse - xyzunlikely (mostly trgm / empty FTS)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "xyzunlikely", 50);
});
bench("bms FTS — fezike (artist)", async () => {
bench("bms FTS - fezike (artist)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_BMS, "fezike", 50);
});
+1 -1
View File
@@ -51,7 +51,7 @@ export type SongSearchRow = {
/**
* Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback
* (Zenith-style — no full-table load, no huge IN lists).
* (Zenith-style - no full-table load, no huge IN lists).
*
* Queries with length ≤ {@link SHORT_QUERY_STRICT_MAX_LEN} also run an **exact** match pass
* (title, artist, `search_terms`, `alt_titles`) with a boosted rank; substring
@@ -7,10 +7,12 @@ describe("buildEmailConfig", () => {
expect(() => buildEmailConfig({})).toThrow(/TACHI_EMAIL_FROM is required/u);
});
it("builds Postmark transport when TACHI_EMAIL_AUTH_POSTMARK is true", () => {
it("builds Postmark transport when host is smtp.postmarkapp.com", () => {
const cfg = buildEmailConfig({
TACHI_EMAIL_FROM: "from@example.com",
TACHI_EMAIL_AUTH_POSTMARK: "true",
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
TACHI_EMAIL_PORT: "587",
TACHI_EMAIL_SECURE: "false",
TACHI_EMAIL_AUTH_PASS: "pm-token",
});
expect(cfg.TRANSPORT_OPS).toMatchObject({
@@ -21,10 +23,11 @@ describe("buildEmailConfig", () => {
});
});
it("uses TACHI_EMAIL_AUTH_USER when POSTMARK and only user is set", () => {
it("uses TACHI_EMAIL_AUTH_USER for Postmark when only user is set", () => {
const cfg = buildEmailConfig({
TACHI_EMAIL_FROM: "from@example.com",
TACHI_EMAIL_AUTH_POSTMARK: "1",
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
TACHI_EMAIL_PORT: "587",
TACHI_EMAIL_AUTH_USER: "only-user-token",
});
expect(cfg.TRANSPORT_OPS).toMatchObject({
@@ -32,19 +35,32 @@ describe("buildEmailConfig", () => {
});
});
it("throws when POSTMARK is true but no token is given", () => {
it("matches Postmark host case-insensitively for auth token rules", () => {
const cfg = buildEmailConfig({
TACHI_EMAIL_FROM: "from@example.com",
TACHI_EMAIL_HOST: "SMTP.POSTMARKAPP.COM",
TACHI_EMAIL_PORT: "587",
TACHI_EMAIL_AUTH_PASS: "pm-token",
});
expect(cfg.TRANSPORT_OPS).toMatchObject({
host: "SMTP.POSTMARKAPP.COM",
auth: { user: "pm-token", pass: "pm-token" },
});
});
it("throws when Postmark host is set but no token is given", () => {
expect(() =>
buildEmailConfig({
TACHI_EMAIL_FROM: "a@b.com",
TACHI_EMAIL_AUTH_POSTMARK: "true",
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
TACHI_EMAIL_PORT: "587",
}),
).toThrow(/TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER/u);
});
it("builds generic SMTP without auth when POSTMARK is false", () => {
it("builds generic SMTP without auth", () => {
const cfg = buildEmailConfig({
TACHI_EMAIL_FROM: "dev@localhost",
TACHI_EMAIL_AUTH_POSTMARK: "false",
TACHI_EMAIL_HOST: "tachi-mailpit",
TACHI_EMAIL_PORT: "1025",
TACHI_EMAIL_SECURE: "false",
@@ -59,7 +75,6 @@ describe("buildEmailConfig", () => {
it("includes auth when user and/or pass are set for generic SMTP", () => {
const cfg = buildEmailConfig({
TACHI_EMAIL_FROM: "a@b.com",
TACHI_EMAIL_AUTH_POSTMARK: "false",
TACHI_EMAIL_HOST: "smtp.example",
TACHI_EMAIL_PORT: "587",
TACHI_EMAIL_SECURE: "false",
@@ -77,7 +92,6 @@ describe("buildEmailConfig", () => {
it("defaults TACHI_EMAIL_SECURE to false", () => {
const cfg = buildEmailConfig({
TACHI_EMAIL_FROM: "a@b.com",
TACHI_EMAIL_AUTH_POSTMARK: "false",
TACHI_EMAIL_HOST: "h",
TACHI_EMAIL_PORT: "25",
});
@@ -88,7 +102,6 @@ describe("buildEmailConfig", () => {
expect(() =>
buildEmailConfig({
TACHI_EMAIL_FROM: "a@b.com",
TACHI_EMAIL_AUTH_POSTMARK: "false",
TACHI_EMAIL_PORT: "1025",
}),
).toThrow(/TACHI_EMAIL_HOST is required/u);
@@ -98,7 +111,6 @@ describe("buildEmailConfig", () => {
expect(() =>
buildEmailConfig({
TACHI_EMAIL_FROM: "a@b.com",
TACHI_EMAIL_AUTH_POSTMARK: "false",
TACHI_EMAIL_HOST: "localhost",
}),
).toThrow(/TACHI_EMAIL_PORT is required/u);
@@ -108,7 +120,6 @@ describe("buildEmailConfig", () => {
expect(() =>
buildEmailConfig({
TACHI_EMAIL_FROM: "a@b.com",
TACHI_EMAIL_AUTH_POSTMARK: "false",
TACHI_EMAIL_HOST: "localhost",
TACHI_EMAIL_PORT: "nope",
}),
+28 -36
View File
@@ -82,6 +82,7 @@ const configSchema = z.object({
BETA_USER_BONUS: z.number().int().nonnegative(),
})
.optional(),
INVITE_ADMIN_INITIAL_INVITE_CODE: z.string().optional(),
TACHI_CONFIG: z.object({
NAME: z.string(),
TYPE: z.enum(["kamai", "boku", "omni"]),
@@ -263,14 +264,17 @@ function envBoolFrom(env: NodeJS.ProcessEnv, key: string, defaultVal: boolean):
return defaultVal;
}
const POSTMARK_SMTP_HOST = "smtp.postmarkapp.com";
/**
* SMTP settings from env. Required for every deployment.
*
* - `TACHI_EMAIL_FROM` — `From` header (must match Postmark sender when using Postmark).
* - `TACHI_EMAIL_AUTH_POSTMARK` — if `true`, uses Postmark SMTP; set `TACHI_EMAIL_AUTH_USER` /
* `TACHI_EMAIL_AUTH_PASS` to your server token (both are the token for Postmark).
* - Otherwise: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`, and optionally
* `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` (e.g. Mailpit: no auth).
* - `TACHI_EMAIL_FROM` - `From` header (must match a verified sender when using Postmark).
* - `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` (`true` / `false`).
* - Optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` when the server needs SMTP auth
* (Mailpit locally typically needs none).
* - For Postmark, set host to `smtp.postmarkapp.com` (commonly port `587`, `TACHI_EMAIL_SECURE=false`).
* Either auth field may hold the server token; the other is filled with the same value.
*
* @internal Exported for unit tests.
*/
@@ -280,39 +284,14 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA
throw new Error(`TACHI_EMAIL_FROM is required.`);
}
if (envBoolFrom(env, "TACHI_EMAIL_AUTH_POSTMARK", false)) {
const pass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
const user = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
const token = pass ?? user;
if (token === undefined) {
throw new Error(
`TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_AUTH_POSTMARK is true.`,
);
}
const authUser = user ?? token;
const authPass = pass ?? token;
return {
FROM: from,
TRANSPORT_OPS: {
host: "smtp.postmarkapp.com",
port: 587,
secure: false,
auth: {
user: authUser,
pass: authPass,
},
},
};
}
const host = envOptFrom(env, "TACHI_EMAIL_HOST");
if (host === undefined) {
throw new Error(`TACHI_EMAIL_HOST is required when TACHI_EMAIL_AUTH_POSTMARK is false.`);
throw new Error(`TACHI_EMAIL_HOST is required.`);
}
const portRaw = envOptFrom(env, "TACHI_EMAIL_PORT");
if (portRaw === undefined) {
throw new Error(`TACHI_EMAIL_PORT is required when TACHI_EMAIL_AUTH_POSTMARK is false.`);
throw new Error(`TACHI_EMAIL_PORT is required.`);
}
const port = Number.parseInt(portRaw, 10);
if (Number.isNaN(port)) {
@@ -320,8 +299,19 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA
}
const secure = envBoolFrom(env, "TACHI_EMAIL_SECURE", false);
const authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
const authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
let authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
let authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
if (host.toLowerCase() === POSTMARK_SMTP_HOST) {
const token = authPass ?? authUser;
if (token === undefined) {
throw new Error(
`TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_HOST is ${POSTMARK_SMTP_HOST}.`,
);
}
authUser = authUser ?? token;
authPass = authPass ?? token;
}
const transportOps: Record<string, unknown> = {
host,
@@ -393,6 +383,7 @@ const eagOauth = oauth2Optional("EAG");
const minOauth = oauth2Optional("MIN");
const emailCfg = emailConfig();
const inviteCfg = inviteCodeConfig();
const bootstrapInvite = opt("TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE")?.trim() || undefined;
const seedsCfg = seedsConfig();
const clientDev = clientDevServer();
const extWorkerConc = opt("TACHI_EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY");
@@ -437,6 +428,7 @@ const configFromEnv: unknown = {
MAX_RIVALS: parseIntEnv("TACHI_MAX_RIVALS", 5),
OUR_URL: req("TACHI_OUR_URL"),
...(inviteCfg !== undefined ? { INVITE_CODE_CONFIG: inviteCfg } : {}),
...(bootstrapInvite !== undefined ? { INVITE_ADMIN_INITIAL_INVITE_CODE: bootstrapInvite } : {}),
TACHI_CONFIG: {
NAME: req("TACHI_NAME"),
TYPE: req("TACHI_TYPE"),
@@ -526,14 +518,14 @@ if (!MIGRATIONS_DIR) {
let version = process.env.VERSION;
if (!version) {
log.error(`No VERSION specified in environment. defaulting to 0.0.0.`);
log.warn(`No VERSION specified in environment. defaulting to 0.0.0.`);
version = "0.0.0";
}
let commitHash = process.env.COMMIT_HASH;
if (!commitHash) {
log.error(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`);
log.warn(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`);
commitHash = "unknown";
}
+3 -3
View File
@@ -6,7 +6,7 @@ Stress **`POST /api/v1/import/file`** against a **running** Tachi instance using
- Server reachable (e.g. `http://127.0.0.1:8080` when using local `PORT`).
- Accounts with **`submit_score`**:
- **Session:** login cookie (see below) — **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits.
- **Session:** login cookie (see below) - **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits.
- **API tokens:** one token per parallel slot. Generate many tokens with the seeder (below).
**Rate limiting:** In dev, score imports are limited to **5 per minute per IP** unless you set:
@@ -96,5 +96,5 @@ just load-test-score-import -- \
## Files
- `score-import-load-cli.ts` — multipart client.
- `seed-stress-api-tokens.ts` — bulk token seeder for dev DBs.
- `score-import-load-cli.ts` - multipart client.
- `seed-stress-api-tokens.ts` - bulk token seeder for dev DBs.
@@ -1,7 +1,7 @@
/**
* HTTP stress harness: multipart POST /api/v1/import/file against a live Tachi instance.
*
* Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) — not MER.
* Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) - not MER.
*
* One in-flight import per user (409 if you exceed). Use a token pool sized ≥ concurrency.
*
@@ -155,7 +155,7 @@ function maybeMutateFileBody(
return buf;
}
}
// XML / others: suffix a harmless byte that most XML parsers ignore after root — skip
// XML / others: suffix a harmless byte that most XML parsers ignore after root - skip
return buf;
}
@@ -215,7 +215,7 @@ function tsReq(ms: number): string {
// ──────────────────────────────────────────────────────────────────────────────
// API permission helpers
// (Old MongoDB data uses dash-separated permission names, not the underscore
// form in the current APIPermissions type — kept as plain string lookups.)
// form in the current APIPermissions type - kept as plain string lookups.)
// ──────────────────────────────────────────────────────────────────────────────
/** Extract one Postgres pm_* column from a MongoDB requestedPermissions array. */
@@ -371,7 +371,7 @@ async function main(): Promise<void> {
const chartIdMap = buildChartIdMap(SEEDS_DIR);
// ══════════════════════════════════════════════════════════════════════════
// LEVEL 0 — No FK dependencies
// LEVEL 0 - No FK dependencies
// ══════════════════════════════════════════════════════════════════════════
console.log("\n── Level 0 ──────────────────────────────────────────────────────");
@@ -489,7 +489,7 @@ async function main(): Promise<void> {
}
// ══════════════════════════════════════════════════════════════════════════
// LEVEL 1 — Depend on account and/or level-0 tables
// LEVEL 1 - Depend on account and/or level-0 tables
// ══════════════════════════════════════════════════════════════════════════
console.log("\n── Level 1 ──────────────────────────────────────────────────────");
@@ -894,7 +894,7 @@ async function main(): Promise<void> {
game: mongoGameToPg(a.game, (a as { playtype?: string }).playtype),
user_id: a.userID,
class_set: a.classSet as string,
// classOldValue can be null in Mongo; Postgres requires a string — use empty string.
// classOldValue can be null in Mongo; Postgres requires a string - use empty string.
class_prev_value: a.classOldValue ?? "",
class_value: a.classValue,
timestamp: tsReq(a.timeAchieved),
@@ -1049,18 +1049,18 @@ async function main(): Promise<void> {
if (skipped > 0) {
console.warn(
` [import_session] Skipped ${skipped} row(s) — session not found`,
` [import_session] Skipped ${skipped} row(s) - session not found`,
);
}
}
// Base rows first — children have FK references to import(id).
// Base rows first - children have FK references to import(id).
await batchInsert("import", importRows);
await batchInsert("import_game", importGameRows);
await batchInsert("import_error", importErrorRows);
await batchInsert("import_class", importClassRows);
await batchInsert("import_session", importSessionRows);
// import_goal and import_quest are intentionally skipped — the
// import_goal and import_quest are intentionally skipped - the
// historical data doesn't align with reality.
},
"import + children",
@@ -1088,7 +1088,7 @@ async function main(): Promise<void> {
for (const gs of goalSubs) {
if (!existingGoalIds.has(gs.goalID)) {
console.warn(` [goal_sub] Skipping — goal ${gs.goalID} not found in DB`);
console.warn(` [goal_sub] Skipping - goal ${gs.goalID} not found in DB`);
continue;
}
@@ -1170,7 +1170,7 @@ async function main(): Promise<void> {
const skipped = folderViews.length - viewRows.length;
if (skipped > 0) {
console.warn(` [folder_view] Skipping ${skipped} row(s) — folder not found in DB`);
console.warn(` [folder_view] Skipping ${skipped} row(s) - folder not found in DB`);
}
for (let i = 0; i < viewRows.length; i = i + INSERT_CHUNK) {
@@ -1207,7 +1207,7 @@ async function main(): Promise<void> {
}
// ══════════════════════════════════════════════════════════════════════════
// LEVEL 2 — Depend on level 1 tables
// LEVEL 2 - Depend on level 1 tables
// ══════════════════════════════════════════════════════════════════════════
console.log("\n── Level 2 ──────────────────────────────────────────────────────");
@@ -1244,7 +1244,7 @@ async function main(): Promise<void> {
console.warn(
` ${
apiTokens.length - validTokens.length
} API tokens skipped — null token/userID or deleted oauth2 client`,
} API tokens skipped - null token/userID or deleted oauth2 client`,
);
}
@@ -1332,7 +1332,7 @@ async function main(): Promise<void> {
}
if (skipped > 0) {
console.warn(` [import_timing] Skipped ${skipped} row(s) — import not found`);
console.warn(` [import_timing] Skipped ${skipped} row(s) - import not found`);
}
await batchInsert("import_timing", rows);
@@ -1341,7 +1341,7 @@ async function main(): Promise<void> {
);
// ══════════════════════════════════════════════════════════════════════════
// LEVEL 3 — Large collections: scores and PBs (cursor-streamed)
// LEVEL 3 - Large collections: scores and PBs (cursor-streamed)
// ══════════════════════════════════════════════════════════════════════════
console.log("\n── Level 3 (streaming) ──────────────────────────────────────────");
@@ -1397,7 +1397,7 @@ async function main(): Promise<void> {
if (chartSid === undefined) {
throw new Error(
` [score] Skipping score ${s.scoreID} — no sid for chartID ${s.chartID}`,
` [score] Skipping score ${s.scoreID} - no sid for chartID ${s.chartID}`,
);
}
+2 -2
View File
@@ -7,10 +7,10 @@ import { collectDefaultMetrics, Counter, Histogram, Registry } from "prom-client
/** Dedicated listener for `GET /metrics` (see `main.ts`). */
export const METRICS_PORT = 9779;
/** Seconds — aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */
/** Seconds - aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */
const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
/** Seconds — score imports can run from sub-second to tens of minutes. */
/** Seconds - score imports can run from sub-second to tens of minutes. */
const SCORE_IMPORT_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600, 1800];
let scoreImportDurationSeconds: Histogram | null = null;
@@ -13,7 +13,7 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => {
});
}
// Strip the client secret — this middleware is used for public lookups.
// Strip the client secret - this middleware is used for public lookups.
const { clientSecret: _secret, ...publicClient } = client;
REQ_AssignToReqTachiData(req, { apiClientDoc: publicClient });
@@ -244,7 +244,7 @@ describe("GET /api/v1/clients/:clientID", () => {
// ─── PATCH /api/v1/clients/:clientID ─────────────────────────────────────────
describe("PATCH /api/v1/clients/:clientID", () => {
/** Prudence `?string` fields — omit vs null is not optional; send null when not updating. */
/** Prudence `?string` fields - omit vs null is not optional; send null when not updating. */
const nullTemplateAndUris = {
apiKeyTemplate: null,
redirectUri: null,
@@ -91,5 +91,5 @@ describe("POST /api/v1/games/:game/charts/resolve", () => {
});
describe("GET /api/v1/games/:game/charts (popular & search)", () => {
it.todo("requires large IIDX chart/PB corpus — port from router.oldtest.ts");
it.todo("requires large IIDX chart/PB corpus - port from router.oldtest.ts");
});
@@ -77,7 +77,7 @@ describe("GET /api/v1/games/:game", () => {
});
it.todo(
"GET /api/v1/games/:v3Game stats (chart/player/score): blocked — Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group",
"GET /api/v1/games/:v3Game stats (chart/player/score): blocked - Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group",
);
it("returns 400 for an unsupported game slug", async () => {
@@ -141,7 +141,7 @@ describe("GET /api/v1/games/iidx-sp/songs/:songID", () => {
});
it("only returns charts for the requested playtype", async () => {
// Insert a DP chart for the same song — should not appear in the SP response.
// Insert a DP chart for the same song - should not appear in the SP response.
await DB.insertInto("chart")
.values({
id: "C_TEST_IIDX_DP_CHART",
@@ -167,6 +167,66 @@ describe("GET /api/v1/sessions/:sessionID/folder-raises", () => {
});
});
describe("GET /api/v1/sessions/:sessionID/adjacent", () => {
async function seedThreeSessions() {
const { id: userId } = await seedUser({ username: "adjacent_user" });
const sessionIds = ["adj_session_oldest", "adj_session_middle", "adj_session_newest"];
for (const [i, id] of sessionIds.entries()) {
const t = new Date(2024, 0, 1 + i).toISOString();
await DB.insertInto("session")
.values({
id,
user_id: userId,
game: "iidx-sp",
name: `Session ${i}`,
description: null,
time_inserted: t,
time_started: t,
time_ended: t,
calculated_data: JSON.stringify({}),
highlight: false,
})
.execute();
}
return { userId, sessionIds };
}
it("returns both neighbors for the middle session", async () => {
const { sessionIds } = await seedThreeSessions();
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[1]}/adjacent`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body.prev?.sessionID).toBe(sessionIds[0]);
expect(res.body.body.next?.sessionID).toBe(sessionIds[2]);
});
it("returns null next for the newest session", async () => {
const { sessionIds } = await seedThreeSessions();
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[2]}/adjacent`);
expect(res.status).toBe(200);
expect(res.body.body.next).toBeNull();
expect(res.body.body.prev?.sessionID).toBe(sessionIds[1]);
});
it("returns null prev for the oldest session", async () => {
const { sessionIds } = await seedThreeSessions();
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[0]}/adjacent`);
expect(res.status).toBe(200);
expect(res.body.body.prev).toBeNull();
expect(res.body.body.next?.sessionID).toBe(sessionIds[1]);
});
});
describe("PATCH /api/v1/sessions/:sessionID", () => {
it("updates the session name when authorised", async () => {
const { sessionId, userId } = await seedSessionFixture();
@@ -4,7 +4,7 @@ import { GetSessionFolderRaises } from "#lib/folders/get-session-folder-raises";
import { withSession, withSessionOwner } from "#lib/router/middleware";
import { success } from "#lib/router/typed-router";
import { API_V1_ROUTER } from "#server/router/api/v1/router";
import { GetSessionData } from "#utils/queries/sessions";
import { GetAdjacentSessions, GetSessionData } from "#utils/queries/sessions";
import { GetUserWithID } from "#utils/user";
import { ExpectedErr } from "bliss";
@@ -44,6 +44,18 @@ API_V1_ROUTER.add("GET /sessions/:sessionID/folder-raises", withSession, async (
return success("Retrieved folder raises.", raises);
});
/**
* Returns the chronologically adjacent sessions (prev = older, next = newer)
* for the same user and game.
*
* @name GET /api/v1/sessions/:sessionID/adjacent
*/
API_V1_ROUTER.add("GET /sessions/:sessionID/adjacent", withSession, async ({ ctx }) => {
const { prev, next } = await GetAdjacentSessions(ctx.sessionDoc);
return success("Retrieved adjacent sessions.", { next, prev });
});
/**
* Modifies a session.
*
@@ -61,7 +61,7 @@ type AdminJobQueueFilters = { job_kind?: string; scope?: string; status?: number
type AdminActionFilters = { kind?: string; username?: string };
// ─── Output schema helpers ────────────────────────────────────────────────────
// Opaque MongoDB document — validates the value is a record but does not
// Opaque MongoDB document - validates the value is a record but does not
// constrain individual fields. Pass `T` so `z.infer` and handler output types
// match the API document type (runtime validation stays shallow).
function doc<T = Record<string, unknown>>(): z.ZodType<T> {
@@ -72,7 +72,7 @@ function docArray<T = Record<string, unknown>>(): z.ZodType<T[]> {
return z.array(z.record(z.string(), z.unknown())) as z.ZodType<T[]>;
}
// Empty success body — mutation endpoints that carry no return payload.
// Empty success body - mutation endpoints that carry no return payload.
const empty = z.object({});
export const API_V1_SPEC = {
@@ -624,7 +624,7 @@ export const API_V1_SPEC = {
// Users /:userID/games/:game (UGPT)
// ────────────────────────────────────────────────
// User games — literal gameGroup segments (game-specific)
// User games - literal gameGroup segments (game-specific)
"GET /users/:userID/games/:game/custom-tables/:tableUrlName": {
description:
"HTML stub for a user-specific custom BMS table; things like their rivals, etc.",
@@ -1259,7 +1259,7 @@ export const API_V1_SPEC = {
}),
},
// Games — BMS / IIDX literal paths (game-specific)
// Games - BMS / IIDX literal paths (game-specific)
"GET /games/:game/custom-tables": {
description: "List custom BMS tables available.",
input: z.object({}),
@@ -1622,6 +1622,15 @@ export const API_V1_SPEC = {
output: empty,
},
"GET /sessions/:sessionID/adjacent": {
description: "Retrieve the chronologically adjacent sessions (prev/next) for this session.",
input: z.object({}),
output: z.strictObject({
prev: doc<SessionDocument>().nullable(),
next: doc<SessionDocument>().nullable(),
}),
},
"GET /sessions/:sessionID/folder-raises": {
description: "Folder raise summary for a session.",
input: z.object({}),
@@ -154,7 +154,7 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", ()
const baseSd = TestingIIDXSPScore.scoreData;
// Chart A: CLEAR lamp, null time — sorts first in final array (null → 0).
// Chart A: CLEAR lamp, null time - sorts first in final array (null → 0).
await insertIidxSpScore({
id: "tl_s_a",
userId,
@@ -178,7 +178,7 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", ()
scoreData: baseSd,
timeAchievedMs: 100,
});
// EASY CLEAR (index 3) — does not satisfy CLEAR floor (index 4).
// EASY CLEAR (index 3) - does not satisfy CLEAR floor (index 4).
await insertIidxSpScore({
id: "tl_s_b_easy",
userId,
@@ -114,7 +114,7 @@ async function seedIidxChartPb(opts: { userId: number; withComposition?: boolean
if (opts.withComposition) {
await DB.insertInto("pb_composed_from")
.values({ pb_id: pbIns.row_id, score_id: scoreId })
.values({ pb_id: pbIns.row_id, score_id: scoreId, name: "Default" })
.execute();
}
@@ -146,7 +146,7 @@ describe("GET /api/v1/users/:userID/games/:game/scores/:chartID", () => {
expect(res.body.success).toBe(true);
expect(res.body.body).toEqual([]);
// Other user may have scores on the same chart — still empty for target.
// Other user may have scores on the same chart - still empty for target.
await DB.insertInto("score")
.values({
id: `sc-ugpt-empty-other-${seedCounter}`,
@@ -74,7 +74,7 @@ describe("PATCH /api/v1/users/:userID/games/:game/settings", () => {
});
});
describe("PATCH UGPT settings — extended cases", () => {
describe("PATCH UGPT settings - extended cases", () => {
it.todo(
"port remaining router.oldtest.ts cases (BPI bounds, defaultTable validation, cross-user 403, …)",
);
@@ -92,7 +92,7 @@ describe("GET /api/v1/users?search=", () => {
it("treats regex special characters as literals", async () => {
// '.*' matches everything as a regex, but is treated as a literal ILIKE
// pattern — no usernames contain that substring, so results are empty.
// pattern - no usernames contain that substring, so results are empty.
const res = await mockApi.get("/api/v1/users?search=.*");
expect(res.status).toBe(200);
@@ -433,6 +433,6 @@ describe("POST /ir/fervidex/score/submit (Postgres)", () => {
});
});
describe.skip("POST /ir/fervidex/profile/submit — deferred KT bulk seed to Postgres", () => {
describe.skip("POST /ir/fervidex/profile/submit - deferred KT bulk seed to Postgres", () => {
it.skip("imports fervidex-static profile", () => {});
});
+10
View File
@@ -14,6 +14,16 @@ pg.types.setTypeParser(pg.types.builtins.INT8, (val) => Number(val));
const pool = new Pool({ connectionString: Env.POSTGRES_URL });
if (process.env.NODE_ENV === "test") {
// Swallow 57P01 (admin_shutdown) errors that arrive on idle pool connections
// during test teardown. Prevents some flakiness in CI.
pool.on("error", (err: { code?: string } & Error) => {
if (err.code !== "57P01") {
throw err;
}
});
}
const DB = new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
});
+1 -1
View File
@@ -594,7 +594,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
};
});
// Goals are never updated once created — only new ones are inserted.
// Goals are never updated once created - only new ones are inserted.
await batchIgnorePg(pg, "goal", goalRows);
console.log(` ${goals.length} goals\n`);
}
@@ -61,8 +61,8 @@ interface SeedUserOpts {
* Insert an `account` row (and optionally `priv_account_credential` /
* `account_settings`) and return the resulting data.
*
* - `withCredential: true` — also inserts `priv_account_credential`
* - `withSettings: true` — also inserts `account_settings`
* - `withCredential: true` - also inserts `priv_account_credential`
* - `withSettings: true` - also inserts `account_settings`
*/
export async function seedUser(opts?: SeedUserOpts) {
const username = opts?.username ?? "test_user";
@@ -4,7 +4,7 @@ import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Default `db/seeds` (collections JSON) relative to the server package — same as `load-seeds-pg.ts`. */
/** Default `db/seeds` (collections JSON) relative to the server package - same as `load-seeds-pg.ts`. */
export const DEFAULT_SEEDS_DIR = path.resolve(__dirname, "../../../../db/seeds");
export function resolveSeedsDir(): string {
@@ -72,7 +72,6 @@ export const TestingIIDXSPScorePB: PBScoreDocument<"iidx-sp"> = {
calculatedData: {
BPI: 10.1,
ktLampRating: 0,
ktLampRatingNC: 0,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
},
@@ -202,7 +201,6 @@ export const TestingIIDXSPScore: ScoreDocument<"iidx-sp"> = {
calculatedData: {
BPI: null,
ktLampRating: 10,
ktLampRatingNC: 10,
ktLampRatingHC: 0,
ktLampRatingEXHC: 0,
},
+1 -1
View File
@@ -147,7 +147,7 @@ function whereUserIdOnQuestSub(userID: unknown) {
}
/**
* Next numeric `song.legacy_id` for BMS/PMS — `max(existing) + 1`, or `1` if none.
* Next numeric `song.legacy_id` for BMS/PMS - `max(existing) + 1`, or `1` if none.
* Replaces Mongo `counters` `*-song-id` documents.
*
* This shit sucks and should be dropped asap: TODO(zk)
+2 -2
View File
@@ -56,8 +56,8 @@ describe("IsValidURL", () => {
expect(IsValidURL("ftp://example.com")).toBeFalsy();
});
// expect(IsValidURL("http://example")).toBe(false) — lol this is valid???? insane.
// expect(IsValidURL("http:/example.com")).toBe(false) — this is also valid, the JS URL parser is ridiculously lenient. Whatever.
// expect(IsValidURL("http://example")).toBe(false) - lol this is valid???? insane.
// expect(IsValidURL("http:/example.com")).toBe(false) - this is also valid, the JS URL parser is ridiculously lenient. Whatever.
});
describe("RoundToNDecimalPlaces", () => {
+21 -9
View File
@@ -221,7 +221,7 @@ export async function FindITGChartsByHashGSv3(hash: string): Promise<Array<Chart
}
/**
* Beatoraja IR: chart by `data.hashSHA256` — BMS first, then PMS (SHA256 only; not MD5).
* Beatoraja IR: chart by `data.hashSHA256` - BMS first, then PMS (SHA256 only; not MD5).
*/
export async function FindBeatorajaChartOnHashSHA256(
hash: string,
@@ -283,6 +283,18 @@ export async function SongHasAnyChart(game: GameGroup, songID: string): Promise<
return row !== undefined;
}
/**
* In Game IDs are sometimes arrays of inGameIDs. I don't personally like this - makes the sql very
* complex, but whatever.
*/
function sqlChartDataInGameIDEquals(inGameID: number) {
return sql<boolean>`(
(jsonb_typeof(chart.data::jsonb->'inGameID') = 'number' AND (chart.data::jsonb->>'inGameID')::int = ${inGameID})
OR
(jsonb_typeof(chart.data::jsonb->'inGameID') = 'array' AND (chart.data::jsonb->'inGameID') @> to_jsonb(${inGameID}::int))
)`;
}
/**
* Find a chart on its in-game-ID, playtype and difficulty.
*/
@@ -295,7 +307,7 @@ export async function FindChartOnInGameID(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where("chart.difficulty", "=", difficulty as string)
.executeTakeFirst();
@@ -318,7 +330,7 @@ export async function FindChartOnInGameIDPrimary(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where("chart.difficulty", "=", difficulty as string)
.where("chart.is_primary", "=", true)
.executeTakeFirst();
@@ -342,7 +354,7 @@ export async function FindIIDXChartOnInGameID(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", "iidx-sp")
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
.where("chart.is_primary", "=", true)
.where("chart.difficulty", "=", difficulty as string)
@@ -369,7 +381,7 @@ export async function FindIIDXChartOnInGameIDVersion(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
.where("chart.difficulty", "=", difficulty as string)
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`)
@@ -395,7 +407,7 @@ export async function FindChartOnInGameIDVersion<TGame extends V3Game = V3Game>(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where("chart.difficulty", "=", difficulty as string)
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`)
.executeTakeFirst();
@@ -490,7 +502,7 @@ export async function FindSDVXChartOnInGameID(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", "sdvx")
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where("chart.is_primary", "=", true);
q =
@@ -516,7 +528,7 @@ export async function FindSDVXChartOnInGameIDVersion(
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", "sdvx")
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sqlChartDataInGameIDEquals(inGameID))
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`);
q =
@@ -640,7 +652,7 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise<Array<Chart
* Returns the N most popular charts for this game + playtype.
* Popularity is determined by how many rows exist in Postgres `score` for each chart.
*
* @param _scoreCollection — ignored; kept for API compatibility with the old Mongo implementation.
* @param _scoreCollection - ignored; kept for API compatibility with the old Mongo implementation.
*/
export async function FindChartsOnPopularity(
game: V3Game,
@@ -42,7 +42,6 @@ describe("GetAdjacentAbove / GetAdjacentBelow (Postgres)", () => {
.execute();
for (const r of rows) {
// eslint-disable-next-line no-await-in-loop
await DB.insertInto("pb")
.values({
calculated_data: JSON.stringify({}),
@@ -1,8 +1,10 @@
import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart";
import { SELECT_SCORE_DOCUMENT, ToScoreDocument } from "#lib/db-formats/score";
import { SELECT_SESSION_DOCUMENT, ToSessionDocument } from "#lib/db-formats/session";
import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song";
import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions";
import DB from "#services/pg/db";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetUserWithIDGuaranteed } from "#utils/user";
import _ from "lodash";
import {
@@ -43,6 +45,58 @@ export async function GetScoreIdsGroupedBySessionId(
return map;
}
/**
* Returns the chronologically adjacent sessions (prev = older, next = newer)
* for the same user and game, using (time_ended, id) as a deterministic sort key.
*/
export async function GetAdjacentSessions(
session: SessionDocument,
): Promise<{ next: SessionDocument | null; prev: SessionDocument | null }> {
const timeEnded = UnixMillisecondsToISO8601(session.timeEnded);
const [newerRow, olderRow] = await Promise.all([
DB.selectFrom("session")
.select(SELECT_SESSION_DOCUMENT)
.where("session.user_id", "=", session.userID)
.where("session.game", "=", session.game)
.where((eb) =>
eb.or([
eb("session.time_ended", ">", timeEnded),
eb.and([
eb("session.time_ended", "=", timeEnded),
eb("session.id", ">", session.sessionID),
]),
]),
)
.orderBy("session.time_ended", "asc")
.orderBy("session.id", "asc")
.limit(1)
.executeTakeFirst(),
DB.selectFrom("session")
.select(SELECT_SESSION_DOCUMENT)
.where("session.user_id", "=", session.userID)
.where("session.game", "=", session.game)
.where((eb) =>
eb.or([
eb("session.time_ended", "<", timeEnded),
eb.and([
eb("session.time_ended", "=", timeEnded),
eb("session.id", "<", session.sessionID),
]),
]),
)
.orderBy("session.time_ended", "desc")
.orderBy("session.id", "desc")
.limit(1)
.executeTakeFirst(),
]);
return {
next: newerRow ? ToSessionDocument(newerRow, []) : null,
prev: olderRow ? ToSessionDocument(olderRow, []) : null,
};
}
export async function GetSessionData(session: SessionDocument): Promise<{
charts: Array<ChartDocument>;
scoreInfo: Array<SessionScoreInfo>;
+1 -1
View File
@@ -86,7 +86,7 @@ describe("GetUsersRankingAndOutOf", () => {
expect(result).toEqual({ ranking: 3, outOf: 5 });
});
it("handles tied ratings — only strictly greater counts", async () => {
it("handles tied ratings - only strictly greater counts", async () => {
const user1 = await seedUser({ username: "tied_a" });
const user2 = await seedUser({ username: "tied_b" });
const user3 = await seedUser({ username: "tied_c" });
+1 -1
View File
@@ -41,7 +41,7 @@ export default defineConfig({
COMMIT_HASH: "test",
},
// Parallel test execution — each worker gets its own isolated Postgres database.
// Parallel test execution - each worker gets its own isolated Postgres database.
fileParallelism: true,
globalSetup: "./vitest.globalSetup.ts",
setupFiles: "./vitest.setup.ts",
+1 -1
View File
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
import { ensureTestCdnBucket } from "./src/test-utils/ensure-test-cdn-bucket";
/**
* Global vitest setup — runs ONCE before any workers start.
* Global vitest setup - runs ONCE before any workers start.
*
* Creates a fully-migrated template database. Workers clone from it
* instead of running migrations themselves, which is much faster.
+2 -2
View File
@@ -18,7 +18,7 @@ const POSTGRES_HOST = "tachi-postgres";
const POSTGRES_USER = "tachi";
const POSTGRES_PASS = "tachi";
// Set POSTGRES_URL before any app code is imported — config.ts reads it at load time.
// Set POSTGRES_URL before any app code is imported - config.ts reads it at load time.
process.env.POSTGRES_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@${POSTGRES_HOST}/${WORKER_DB_NAME}`;
// Now that env vars are set, we can safely import external packages.
@@ -92,7 +92,7 @@ async function resetDatabase() {
const { clearGameStatsCacheForTests } = await import("#server/router/api/v1/games/router");
clearGameStatsCacheForTests();
} catch {
// ignore — router not loaded in edge test contexts
// ignore - router not loaded in edge test contexts
}
}