Rebuild atomcms-next foundation on Prisma (align to habbo-next reference)
Replace the superseded Drizzle monorepo scaffold with a single-app Prisma 7 data layer, mirroring the proven habbo-next approach to the shared Arcturus emulator DB (introspect/conform only, idempotent SQL migrations via a custom runner; never migrate-diff). - prisma/schema.prisma: User, UsersCurrency (composite PK), Ban, WebsiteSetting mapped to AtomCMS's default.sql columns; client generated to src/generated/prisma - src/lib/prisma.ts: PrismaMariaDb adapter singleton; src/env.ts zod env - src/lib/services: siteSettings (cached setting() equivalent) + CurrencyType enum - prisma/migrations + scripts/apply-migrations.ts: idempotent CMS-table runner Verified: prisma generate OK, tsc --noEmit exit 0, vitest 6/6 pass.
This commit is contained in:
1 parent
7bd4f2b28e
commit
b5bc7f6daf
44 files changed
+1279
-511
No files matched your search
+11
-3
@@ -1,3 +1,11 @@
|
|||||||
# Connection to the LIVE/COPY emulator MySQL database (introspect-only).
|
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
||||||
# Format: mysql://user:password@host:3306/database
|
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
||||||
DATABASE_URL=
|
# it does NOT own or migrate the emulator tables. Format:
|
||||||
|
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||||
|
|
||||||
|
# Optional pool tuning (defaults shown)
|
||||||
|
DATABASE_POOL_SIZE=40
|
||||||
|
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||||
|
|
||||||
|
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||||
|
HOTEL_NAME=Atom
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
.turbo/
|
.turbo/
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
# Prisma client is generated by `prisma generate`
|
||||||
|
src/generated/
|
||||||
+16
-8
@@ -5,18 +5,26 @@
|
|||||||
"engines": { "node": ">=22" },
|
"engines": { "node": ">=22" },
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"prisma:generate": "prisma generate",
|
||||||
"test": "turbo run test",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck": "turbo run typecheck"
|
"test": "vitest run",
|
||||||
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.3.0",
|
"@types/node": "^22.10.0",
|
||||||
"typescript": "^5.7.0",
|
"dotenv": "^16.4.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"vitest": "^2.1.0",
|
"typescript": "^5.7.0",
|
||||||
"@types/node": "^22.10.0"
|
"vitest": "^2.1.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": ["esbuild"]
|
"onlyBuiltDependencies": ["esbuild", "prisma", "@prisma/client", "@prisma/engines"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# @atomcms/db
|
|
||||||
|
|
||||||
Typed data-access layer over the **emulator-owned** MySQL database.
|
|
||||||
|
|
||||||
## Hard rule: introspect-only
|
|
||||||
|
|
||||||
The database schema is owned and written live by the Habbo game emulator.
|
|
||||||
**Never** run `drizzle-kit push`, `generate`, or `migrate` against it. The only
|
|
||||||
allowed schema command is introspection:
|
|
||||||
|
|
||||||
DATABASE_URL=mysql://user:pass@host:3306/db pnpm --filter @atomcms/db db:pull
|
|
||||||
|
|
||||||
`db:pull` reads the live schema and writes a snapshot to `./drizzle`. The
|
|
||||||
hand-authored files in `src/schema/` cover only the tables the web tier needs
|
|
||||||
first (users, users_currency, bans, website_settings); regenerate the full
|
|
||||||
122-table set from the live DB with `db:pull` when you need more tables.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
import { createDb, makeUsersRepo } from "@atomcms/db";
|
|
||||||
const { db, pool } = createDb(process.env.DATABASE_URL!);
|
|
||||||
const users = makeUsersRepo(db);
|
|
||||||
const sysacc = await users.findByUsername("Systemaccount");
|
|
||||||
await pool.end();
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { defineConfig } from "drizzle-kit";
|
|
||||||
|
|
||||||
// INTROSPECT-ONLY. We never generate or push migrations against this database —
|
|
||||||
// it is owned and written live by the game emulator. Use only `drizzle-kit pull`.
|
|
||||||
export default defineConfig({
|
|
||||||
dialect: "mysql",
|
|
||||||
schema: "./src/schema/index.ts",
|
|
||||||
out: "./drizzle", // where `pull` writes the introspected schema snapshot
|
|
||||||
dbCredentials: {
|
|
||||||
url: process.env.DATABASE_URL ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@atomcms/db",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"db:pull": "drizzle-kit pull"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@atomcms/settings": "workspace:*",
|
|
||||||
"drizzle-orm": "^0.38.0",
|
|
||||||
"mysql2": "^3.11.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"drizzle-kit": "^0.30.0",
|
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"vitest": "^2.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { isBanActive } from "./is-ban-active.js";
|
|
||||||
|
|
||||||
describe("isBanActive", () => {
|
|
||||||
const now = 1_700_000_000; // fixed unix seconds
|
|
||||||
|
|
||||||
it("is active when ban_expire is in the future", () => {
|
|
||||||
expect(isBanActive({ banExpire: now + 100 }, now)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is inactive when ban_expire is in the past", () => {
|
|
||||||
expect(isBanActive({ banExpire: now - 100 }, now)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is inactive when ban_expire equals now (Laravel uses strict >)", () => {
|
|
||||||
expect(isBanActive({ banExpire: now }, now)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is inactive for the default 0 expiry", () => {
|
|
||||||
expect(isBanActive({ banExpire: 0 }, now)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Mirrors Laravel's active-ban query: where('ban_expire', '>', time()).
|
|
||||||
// ban_expire and now are both unix timestamps in seconds.
|
|
||||||
export function isBanActive(ban: { banExpire: number }, nowUnixSeconds: number): boolean {
|
|
||||||
return ban.banExpire > nowUnixSeconds;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
|
||||||
import mysql from "mysql2/promise";
|
|
||||||
import * as schema from "./schema/index.js";
|
|
||||||
|
|
||||||
export type Database = MySql2Database<typeof schema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Drizzle client backed by a mysql2 pool.
|
|
||||||
* The connection targets the LIVE emulator-owned database; this layer
|
|
||||||
* only ever reads/writes data — it never runs migrations.
|
|
||||||
*/
|
|
||||||
export function createDb(databaseUrl: string): { db: Database; pool: mysql.Pool } {
|
|
||||||
const pool = mysql.createPool(databaseUrl);
|
|
||||||
const db = drizzle(pool, { schema, mode: "default" });
|
|
||||||
return { db, pool };
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { createDb } from "./client.js";
|
|
||||||
export type { Database } from "./client.js";
|
|
||||||
export * as schema from "./schema/index.js";
|
|
||||||
export { isBanActive } from "./bans/is-ban-active.js";
|
|
||||||
export { makeUsersRepo } from "./repositories/users-repo.js";
|
|
||||||
export { makeCurrencyRepo } from "./repositories/currency-repo.js";
|
|
||||||
export { makeBansRepo } from "./repositories/bans-repo.js";
|
|
||||||
export { makeSettingsRepo } from "./repositories/settings-repo.js";
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
||||||
import type { Pool } from "mysql2/promise";
|
|
||||||
import { createDb, type Database } from "../client.js";
|
|
||||||
import { makeUsersRepo } from "../repositories/users-repo.js";
|
|
||||||
import { makeCurrencyRepo } from "../repositories/currency-repo.js";
|
|
||||||
import { makeSettingsRepo } from "../repositories/settings-repo.js";
|
|
||||||
import { SettingsService } from "@atomcms/settings";
|
|
||||||
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
|
|
||||||
// Skips cleanly when no real database is configured (e.g. local dev without DB).
|
|
||||||
// The connection is built in beforeAll so it is NOT created when the suite is
|
|
||||||
// skipped (a skipped describe still executes its body during collection).
|
|
||||||
describe.skipIf(!url)("DB reads against the real schema", () => {
|
|
||||||
let db: Database;
|
|
||||||
let pool: Pool;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
const created = createDb(url as string);
|
|
||||||
db = created.db;
|
|
||||||
pool = created.pool;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await pool.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads a user by username", async () => {
|
|
||||||
const user = await makeUsersRepo(db).findByUsername("Systemaccount");
|
|
||||||
expect(user).not.toBeNull();
|
|
||||||
expect(user?.username).toBe("Systemaccount");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads composite-key currency rows for a user", async () => {
|
|
||||||
const user = await makeUsersRepo(db).findByUsername("Systemaccount");
|
|
||||||
const balances = await makeCurrencyRepo(db).forUser(user!.id);
|
|
||||||
// Map<type, amount>; presence is enough to prove composite-key reads work.
|
|
||||||
expect(balances).toBeInstanceOf(Map);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads settings through SettingsService", async () => {
|
|
||||||
const repo = makeSettingsRepo(db);
|
|
||||||
const svc = new SettingsService(() => repo.all());
|
|
||||||
const name = await svc.get("hotel_name", "unknown");
|
|
||||||
expect(typeof name).toBe("string");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Database } from "../client.js";
|
|
||||||
import { bans } from "../schema/bans.js";
|
|
||||||
import { isBanActive } from "../bans/is-ban-active.js";
|
|
||||||
|
|
||||||
export function makeBansRepo(db: Database) {
|
|
||||||
return {
|
|
||||||
activeForUser: async (userId: number, nowUnixSeconds: number) => {
|
|
||||||
const rows = await db.select().from(bans).where(eq(bans.userId, userId));
|
|
||||||
return rows.filter((b) => isBanActive(b, nowUnixSeconds));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Database } from "../client.js";
|
|
||||||
import { usersCurrency } from "../schema/users-currency.js";
|
|
||||||
|
|
||||||
export function makeCurrencyRepo(db: Database) {
|
|
||||||
return {
|
|
||||||
// Returns a map of currency type -> amount for one user.
|
|
||||||
forUser: async (userId: number): Promise<Map<number, number>> => {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(usersCurrency)
|
|
||||||
.where(eq(usersCurrency.userId, userId));
|
|
||||||
return new Map(rows.map((r) => [r.type, r.amount]));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { Database } from "../client.js";
|
|
||||||
import { websiteSettings } from "../schema/website-settings.js";
|
|
||||||
import type { SettingRow } from "@atomcms/settings";
|
|
||||||
|
|
||||||
export function makeSettingsRepo(db: Database) {
|
|
||||||
return {
|
|
||||||
// Shaped to match @atomcms/settings SettingsFetcher.
|
|
||||||
all: async (): Promise<SettingRow[]> => {
|
|
||||||
const rows = await db
|
|
||||||
.select({ key: websiteSettings.key, value: websiteSettings.value })
|
|
||||||
.from(websiteSettings);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Database } from "../client.js";
|
|
||||||
import { users } from "../schema/users.js";
|
|
||||||
|
|
||||||
export function makeUsersRepo(db: Database) {
|
|
||||||
return {
|
|
||||||
findById: async (id: number) => {
|
|
||||||
const rows = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
},
|
|
||||||
findByUsername: async (username: string) => {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.username, username))
|
|
||||||
.limit(1);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { mysqlTable, int, varchar, mysqlEnum } from "drizzle-orm/mysql-core";
|
|
||||||
|
|
||||||
export const bans = mysqlTable("bans", {
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
userId: int("user_id").notNull(),
|
|
||||||
ip: varchar("ip", { length: 50 }).notNull().default(""),
|
|
||||||
machineId: varchar("machine_id", { length: 255 }).notNull().default(""),
|
|
||||||
userStaffId: int("user_staff_id").notNull(),
|
|
||||||
timestamp: int("timestamp").notNull(),
|
|
||||||
banExpire: int("ban_expire").notNull().default(0),
|
|
||||||
banReason: varchar("ban_reason", { length: 200 }).notNull().default(""),
|
|
||||||
type: mysqlEnum("type", ["account", "ip", "machine", "super"])
|
|
||||||
.notNull()
|
|
||||||
.default("account"),
|
|
||||||
cfhTopic: int("cfh_topic").notNull().default(-1),
|
|
||||||
});
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { users } from "./users.js";
|
|
||||||
export { usersCurrency } from "./users-currency.js";
|
|
||||||
export { bans } from "./bans.js";
|
|
||||||
export { websiteSettings } from "./website-settings.js";
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { mysqlTable, int, primaryKey } from "drizzle-orm/mysql-core";
|
|
||||||
|
|
||||||
// MyISAM table with a composite primary key and no surrogate id column.
|
|
||||||
// This is the canonical test of the introspect-only / composite-key design.
|
|
||||||
export const usersCurrency = mysqlTable(
|
|
||||||
"users_currency",
|
|
||||||
{
|
|
||||||
userId: int("user_id").notNull(),
|
|
||||||
type: int("type").notNull(),
|
|
||||||
amount: int("amount").notNull().default(0),
|
|
||||||
},
|
|
||||||
(t) => [primaryKey({ columns: [t.userId, t.type] })],
|
|
||||||
);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { mysqlTable, int, varchar, mysqlEnum } from "drizzle-orm/mysql-core";
|
|
||||||
|
|
||||||
// Subset of the real `users` table — the columns the web tier needs first.
|
|
||||||
// The complete column set will be regenerated via `db:pull` against the live DB.
|
|
||||||
export const users = mysqlTable("users", {
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
username: varchar("username", { length: 25 }).notNull(),
|
|
||||||
realName: varchar("real_name", { length: 25 }).notNull(),
|
|
||||||
password: varchar("password", { length: 64 }).notNull(),
|
|
||||||
mail: varchar("mail", { length: 500 }),
|
|
||||||
mailVerified: mysqlEnum("mail_verified", ["0", "1"]).notNull().default("0"),
|
|
||||||
accountCreated: int("account_created").notNull(),
|
|
||||||
lastLogin: int("last_login").notNull().default(0),
|
|
||||||
motto: varchar("motto", { length: 127 }).notNull().default(""),
|
|
||||||
look: varchar("look", { length: 256 }).notNull(),
|
|
||||||
gender: mysqlEnum("gender", ["M", "F"]).notNull().default("M"),
|
|
||||||
rank: int("rank").notNull().default(1),
|
|
||||||
credits: int("credits").notNull().default(2500),
|
|
||||||
pixels: int("pixels").notNull().default(500),
|
|
||||||
points: int("points").notNull().default(10),
|
|
||||||
online: mysqlEnum("online", ["0", "1", "2"]).notNull().default("0"),
|
|
||||||
authTicket: varchar("auth_ticket", { length: 256 }).notNull().default(""),
|
|
||||||
ipRegister: varchar("ip_register", { length: 45 }).notNull(),
|
|
||||||
ipCurrent: varchar("ip_current", { length: 45 }).notNull(),
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { mysqlTable, bigint, varchar, text } from "drizzle-orm/mysql-core";
|
|
||||||
|
|
||||||
// CMS-owned table (created by Laravel migration, not in default.sql).
|
|
||||||
// `value` was widened from varchar(255) to TEXT by a later migration.
|
|
||||||
export const websiteSettings = mysqlTable("website_settings", {
|
|
||||||
id: bigint("id", { mode: "number", unsigned: true }).autoincrement().primaryKey(),
|
|
||||||
key: varchar("key", { length: 255 }).notNull(),
|
|
||||||
value: text("value").notNull(),
|
|
||||||
comment: varchar("comment", { length: 255 }).notNull(),
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
// Local config so Vitest does not walk up into the parent Laravel repo's
|
|
||||||
// vite.config.js (this monorepo is nested inside the Laravel project).
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@atomcms/settings",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"vitest": "^2.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { CurrencyType, currencyLabel } from "./currency-type.js";
|
|
||||||
export { SettingsService } from "./settings-service.js";
|
|
||||||
export type { SettingRow, SettingsFetcher } from "./settings-service.js";
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { SettingsService } from "./settings-service.js";
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
{ key: "hotel_name", value: "AtomHotel" },
|
|
||||||
{ key: "maintenance_enabled", value: "1" },
|
|
||||||
{ key: "radio_enabled", value: "0" },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("SettingsService", () => {
|
|
||||||
it("returns a value by key", async () => {
|
|
||||||
const svc = new SettingsService(async () => rows);
|
|
||||||
expect(await svc.get("hotel_name")).toBe("AtomHotel");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the default when key is missing", async () => {
|
|
||||||
const svc = new SettingsService(async () => rows);
|
|
||||||
expect(await svc.get("missing", "fallback")).toBe("fallback");
|
|
||||||
expect(await svc.get("missing")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coerces '1'/'0' string booleans", async () => {
|
|
||||||
const svc = new SettingsService(async () => rows);
|
|
||||||
expect(await svc.getBool("maintenance_enabled")).toBe(true);
|
|
||||||
expect(await svc.getBool("radio_enabled")).toBe(false);
|
|
||||||
expect(await svc.getBool("missing", true)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetches only once and caches until cleared", async () => {
|
|
||||||
const fetcher = vi.fn(async () => rows);
|
|
||||||
const svc = new SettingsService(fetcher);
|
|
||||||
await svc.get("hotel_name");
|
|
||||||
await svc.get("radio_enabled");
|
|
||||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
||||||
svc.clear();
|
|
||||||
await svc.get("hotel_name");
|
|
||||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
export interface SettingRow {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SettingsFetcher = () => Promise<SettingRow[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors Laravel's App\Services\SettingsService: loads the whole
|
|
||||||
* website_settings table into a key->value map, caches it in-process,
|
|
||||||
* and reads with a default fallback. Booleans are stored as '1'/'0'.
|
|
||||||
*/
|
|
||||||
export class SettingsService {
|
|
||||||
private cache: Map<string, string> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly fetcher: SettingsFetcher) {}
|
|
||||||
|
|
||||||
private async load(): Promise<Map<string, string>> {
|
|
||||||
if (this.cache === null) {
|
|
||||||
const rows = await this.fetcher();
|
|
||||||
this.cache = new Map(rows.map((r) => [r.key, r.value]));
|
|
||||||
}
|
|
||||||
return this.cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(key: string, def: string | null = null): Promise<string | null> {
|
|
||||||
const map = await this.load();
|
|
||||||
return map.has(key) ? (map.get(key) as string) : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBool(key: string, def = false): Promise<boolean> {
|
|
||||||
const map = await this.load();
|
|
||||||
if (!map.has(key)) return def;
|
|
||||||
const v = (map.get(key) as string).toLowerCase();
|
|
||||||
return v === "1" || v === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.cache = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
// Local config so Vitest does not walk up into the parent Laravel repo's
|
|
||||||
// vite.config.js (this monorepo is nested inside the Laravel project).
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Generated
+812
-62
File diff suppressed because it is too large.
Load diff
@@ -1,3 +0,0 @@
|
|||||||
packages:
|
|
||||||
- "packages/*"
|
|
||||||
- "apps/*"
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
// Prisma 7 config. The datasource URL lives here (not in schema.prisma).
|
||||||
|
// We NEVER run `prisma migrate`/`db push` against this database — it is shared
|
||||||
|
// live with the Arcturus emulator. CMS-only schema changes go in
|
||||||
|
// prisma/migrations/*.sql (idempotent) applied via `pnpm db:migrate`.
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CMS-owned settings table. Idempotent: safe to re-run.
|
||||||
|
-- Mirrors AtomCMS's Laravel `website_settings` (key UNIQUE, value TEXT).
|
||||||
|
CREATE TABLE IF NOT EXISTS website_settings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`key` VARCHAR(255) NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
comment VARCHAR(255) NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY website_settings_key_unique (`key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// AtomCMS → Next.js conversion data layer.
|
||||||
|
//
|
||||||
|
// The database schema is OWNED by the Arcturus Morningstar emulator and shared
|
||||||
|
// live with the game server. This app conforms to that schema — it never
|
||||||
|
// generates or migrates the emulator tables. CMS-only tables (website_*) are
|
||||||
|
// created via idempotent SQL in prisma/migrations/*.sql (see apply-migrations).
|
||||||
|
//
|
||||||
|
// This is the first slice (users / users_currency / bans / website_settings);
|
||||||
|
// the full table set will be regenerated via `prisma db pull` once a live DB
|
||||||
|
// connection is available.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum users_gender {
|
||||||
|
M
|
||||||
|
F
|
||||||
|
}
|
||||||
|
|
||||||
|
enum bans_type {
|
||||||
|
account
|
||||||
|
ip
|
||||||
|
machine
|
||||||
|
super
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emulator-owned core account table (users). Column names mirror default.sql.
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique(map: "username") @db.VarChar(25)
|
||||||
|
realName String @default("KREWS DEV") @map("real_name") @db.VarChar(25)
|
||||||
|
password String @db.VarChar(64)
|
||||||
|
mail String? @db.VarChar(500)
|
||||||
|
mailVerified String @default("0") @map("mail_verified")
|
||||||
|
accountCreated Int @map("account_created")
|
||||||
|
accountDayOfBirth Int @default(0) @map("account_day_of_birth")
|
||||||
|
lastLogin Int @default(0) @map("last_login")
|
||||||
|
lastOnline Int @default(0) @map("last_online")
|
||||||
|
motto String @default("") @db.VarChar(127)
|
||||||
|
look String @default("") @db.VarChar(256)
|
||||||
|
gender users_gender @default(M)
|
||||||
|
rank Int @default(1)
|
||||||
|
credits Int @default(2500)
|
||||||
|
pixels Int @default(500)
|
||||||
|
points Int @default(10)
|
||||||
|
online String @default("0")
|
||||||
|
// Ephemeral SSO ticket rotated on each emulator handshake ({HOTEL_NAME}-{uuid}).
|
||||||
|
// The emulator validates it byte-for-byte when the Nitro/Flash client connects.
|
||||||
|
authTicket String @default("") @map("auth_ticket") @db.VarChar(256)
|
||||||
|
ipRegister String @map("ip_register") @db.VarChar(45)
|
||||||
|
ipCurrent String @map("ip_current") @db.VarChar(45)
|
||||||
|
machineId String @default("") @map("machine_id") @db.VarChar(64)
|
||||||
|
homeRoom Int @default(0) @map("home_room")
|
||||||
|
secretKey String? @map("secret_key") @db.VarChar(40)
|
||||||
|
pincode String? @db.VarChar(11)
|
||||||
|
extraRank Int? @map("extra_rank")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emulator-owned multi-currency ledger. Composite PK (user_id, type), no id.
|
||||||
|
/// Currency types are fixed: Credits=-1, Duckets=0, Diamonds=5, Points=101.
|
||||||
|
model UsersCurrency {
|
||||||
|
userId Int @map("user_id")
|
||||||
|
type Int
|
||||||
|
amount Int @default(0)
|
||||||
|
|
||||||
|
@@id([userId, type])
|
||||||
|
@@map("users_currency")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emulator-owned bans table. Active bans are those with ban_expire > now().
|
||||||
|
model Ban {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int @map("user_id")
|
||||||
|
ip String @default("") @db.VarChar(50)
|
||||||
|
machineId String @default("") @map("machine_id") @db.VarChar(255)
|
||||||
|
userStaffId Int @map("user_staff_id")
|
||||||
|
timestamp Int
|
||||||
|
banExpire Int @default(0) @map("ban_expire")
|
||||||
|
banReason String @default("") @map("ban_reason") @db.VarChar(200)
|
||||||
|
type bans_type @default(account)
|
||||||
|
cfhTopic Int @default(-1) @map("cfh_topic")
|
||||||
|
|
||||||
|
@@map("bans")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CMS-owned key/value settings (created by prisma/migrations, not the emulator).
|
||||||
|
model WebsiteSetting {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
key String @unique(map: "website_settings_key_unique") @db.VarChar(255)
|
||||||
|
value String @db.Text
|
||||||
|
comment String? @db.VarChar(255)
|
||||||
|
|
||||||
|
@@map("website_settings")
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* DB migration runner for the manual SQL files in prisma/migrations/.
|
||||||
|
*
|
||||||
|
* Why a custom runner: the database is shared with the Arcturus emulator, so we
|
||||||
|
* apply CMS-only schema changes via hand-written idempotent SQL (every file is
|
||||||
|
* `IF NOT EXISTS`-guarded) instead of `prisma migrate dev`, whose auto-diff
|
||||||
|
* would try to DROP emulator-owned tables. An `_applied_migrations` audit table
|
||||||
|
* tracks what ran so the workflow doesn't rely on memory.
|
||||||
|
*
|
||||||
|
* pnpm db:migrate run pending migrations
|
||||||
|
* pnpm db:migrate:status print pending vs applied
|
||||||
|
* pnpm db:migrate --mark <f> mark an already-applied file without running it
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = join(process.cwd(), "prisma", "migrations");
|
||||||
|
|
||||||
|
async function ensureAuditTable(): Promise<void> {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _applied_migrations (
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
sha256 CHAR(64) NULL,
|
||||||
|
PRIMARY KEY (filename)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appliedSet(): Promise<Set<string>> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ filename: string }[]>(
|
||||||
|
"SELECT filename FROM _applied_migrations",
|
||||||
|
);
|
||||||
|
return new Set(rows.map((r) => r.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMigrations(): Promise<string[]> {
|
||||||
|
const files = await readdir(MIGRATIONS_DIR);
|
||||||
|
return files.filter((f) => f.endsWith(".sql")).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(filename: string): Promise<string> {
|
||||||
|
const content = await readFile(join(MIGRATIONS_DIR, filename));
|
||||||
|
return createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPrismaDbExecute(filename: string): boolean {
|
||||||
|
const file = join(MIGRATIONS_DIR, filename);
|
||||||
|
// shell:true so `npx` resolves to npx.cmd on Windows (direct .cmd spawn is
|
||||||
|
// blocked there since CVE-2024-27980). Quote the path for spaces.
|
||||||
|
const result = spawnSync("npx", ["prisma", "db", "execute", "--file", `"${file}"`], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordApplied(filename: string): Promise<void> {
|
||||||
|
const hash = await sha256(filename);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
"INSERT INTO _applied_migrations (filename, sha256) VALUES (?, ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE applied_at = CURRENT_TIMESTAMP(3), sha256 = VALUES(sha256)",
|
||||||
|
filename,
|
||||||
|
hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusOnly(): Promise<void> {
|
||||||
|
await ensureAuditTable();
|
||||||
|
const applied = await appliedSet();
|
||||||
|
const files = await listMigrations();
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No migrations found in prisma/migrations/.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Status:");
|
||||||
|
for (const f of files) {
|
||||||
|
const isApplied = applied.has(f);
|
||||||
|
console.log(` ${isApplied ? "OK" : " ."} ${f}${isApplied ? "" : " (PENDING)"}`);
|
||||||
|
}
|
||||||
|
const pending = files.filter((f) => !applied.has(f));
|
||||||
|
console.log(`\n${pending.length} pending / ${files.length} total`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markApplied(filename: string): Promise<void> {
|
||||||
|
await ensureAuditTable();
|
||||||
|
const files = await listMigrations();
|
||||||
|
if (!files.includes(filename)) {
|
||||||
|
console.error(`File not found in prisma/migrations/: ${filename}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await recordApplied(filename);
|
||||||
|
console.log(`Marked ${filename} as applied (no SQL was run).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPending(): Promise<void> {
|
||||||
|
await ensureAuditTable();
|
||||||
|
const applied = await appliedSet();
|
||||||
|
const files = await listMigrations();
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No migrations found in prisma/migrations/.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ran = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
if (applied.has(f)) {
|
||||||
|
console.log(`OK ${f} (already applied)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`-> applying ${f} ...`);
|
||||||
|
if (!runPrismaDbExecute(f)) {
|
||||||
|
console.error(`FAILED ${f}; aborting`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await recordApplied(f);
|
||||||
|
ran++;
|
||||||
|
}
|
||||||
|
console.log(ran === 0 ? "\nNothing to do — DB already in sync." : `\nApplied ${ran} migration(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
try {
|
||||||
|
if (args[0] === "--status") {
|
||||||
|
await statusOnly();
|
||||||
|
} else if (args[0] === "--mark") {
|
||||||
|
const filename = args[1];
|
||||||
|
if (!filename) {
|
||||||
|
console.error("Usage: pnpm db:migrate --mark <filename>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await markApplied(filename);
|
||||||
|
} else {
|
||||||
|
await applyPending();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("FAILED:", e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Minimal validated env for the foundation. When the Next.js app is added this
|
||||||
|
// will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer
|
||||||
|
// only needs the DB connection + a couple of values today.
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(40),
|
||||||
|
DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
|
||||||
|
HOTEL_NAME: z.string().default("Atom"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Env = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
// SKIP_ENV_VALIDATION lets tooling (typecheck, tests that don't touch the DB)
|
||||||
|
// import modules transitively without a populated .env.
|
||||||
|
export const env: Env = process.env.SKIP_ENV_VALIDATION
|
||||||
|
? (process.env as unknown as Env)
|
||||||
|
: schema.parse(process.env);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||||
|
|
||||||
|
function createPrismaClient(): PrismaClient {
|
||||||
|
const url = new URL(env.DATABASE_URL);
|
||||||
|
const adapter = new PrismaMariaDb({
|
||||||
|
host: url.hostname,
|
||||||
|
port: Number(url.port) || 3306,
|
||||||
|
user: decodeURIComponent(url.username),
|
||||||
|
password: decodeURIComponent(url.password),
|
||||||
|
database: url.pathname.replace(/^\//, ""),
|
||||||
|
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||||
|
connectTimeout: 10_000,
|
||||||
|
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CurrencyType, currencyLabel } from "./currency-type.js";
|
import { CurrencyType, currencyLabel } from "./currency";
|
||||||
|
|
||||||
describe("CurrencyType", () => {
|
describe("CurrencyType", () => {
|
||||||
it("uses the emulator-fixed numeric values", () => {
|
it("uses the emulator-fixed numeric values", () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Numeric values are dictated by the game emulator and MUST NOT change.
|
// Numeric values are dictated by the Arcturus emulator and MUST NOT change.
|
||||||
export enum CurrencyType {
|
export enum CurrencyType {
|
||||||
Credits = -1,
|
Credits = -1,
|
||||||
Duckets = 0,
|
Duckets = 0,
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { websiteSetting: { findMany } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { siteSettings } from "./site-settings";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
findMany.mockReset();
|
||||||
|
siteSettings.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("siteSettings", () => {
|
||||||
|
it("returns a value by key", async () => {
|
||||||
|
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||||
|
expect(await siteSettings.get("hotel_name")).toBe("AtomHotel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the fallback when key is missing", async () => {
|
||||||
|
findMany.mockResolvedValue([]);
|
||||||
|
expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
|
||||||
|
expect(await siteSettings.get("missing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces '1'/'0' string booleans", async () => {
|
||||||
|
findMany.mockResolvedValue([
|
||||||
|
{ key: "maintenance_enabled", value: "1" },
|
||||||
|
{ key: "radio_enabled", value: "0" },
|
||||||
|
]);
|
||||||
|
expect(await siteSettings.getBool("maintenance_enabled")).toBe(true);
|
||||||
|
expect(await siteSettings.getBool("radio_enabled")).toBe(false);
|
||||||
|
expect(await siteSettings.getBool("missing", true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches once and caches until reload", async () => {
|
||||||
|
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||||
|
await siteSettings.get("hotel_name");
|
||||||
|
await siteSettings.getBool("hotel_name");
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(1);
|
||||||
|
siteSettings.reload();
|
||||||
|
await siteSettings.get("hotel_name");
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB-driven CMS config, mirroring AtomCMS's `setting()` / habbo-next's
|
||||||
|
* `siteSettings`. Loads the whole website_settings table into a key→value map,
|
||||||
|
* cached in-process. Booleans are stored as the strings '1' / '0'.
|
||||||
|
*/
|
||||||
|
class SiteSettings {
|
||||||
|
private cache: Map<string, string> | null = null;
|
||||||
|
|
||||||
|
private async load(): Promise<Map<string, string>> {
|
||||||
|
if (this.cache === null) {
|
||||||
|
const rows = await prisma.websiteSetting.findMany({
|
||||||
|
select: { key: true, value: true },
|
||||||
|
});
|
||||||
|
this.cache = new Map(rows.map((r) => [r.key, r.value]));
|
||||||
|
}
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
||||||
|
const map = await this.load();
|
||||||
|
return map.has(key) ? (map.get(key) as string) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBool(key: string, fallback = false): Promise<boolean> {
|
||||||
|
const map = await this.load();
|
||||||
|
if (!map.has(key)) return fallback;
|
||||||
|
const v = (map.get(key) as string).toLowerCase();
|
||||||
|
return v === "1" || v === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate the in-process cache after a settings write. */
|
||||||
|
reload(): void {
|
||||||
|
this.cache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const siteSettings = new SiteSettings();
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ES2022"],
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
|
||||||
"declaration": true,
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": true,
|
"isolatedModules": true,
|
||||||
"types": ["node"]
|
"skipLibCheck": true,
|
||||||
}
|
"types": ["node"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "scripts", "prisma.config.ts", "vitest.config.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://turbo.build/schema.json",
|
|
||||||
"tasks": {
|
|
||||||
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
|
||||||
"test": { "dependsOn": ["^build"] },
|
|
||||||
"typecheck": { "dependsOn": ["^build"] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// Local config so Vitest does not walk up into the parent Laravel repo's
|
||||||
|
// vite.config.js (this app lives inside the Laravel project tree).
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1 +0,0 @@
|
|||||||
export default ["packages/*"];
|
|
||||||
Reference in new issue
Block a user