diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4f77c92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: Schema CI + +on: + push: + branches: [main, refactor] + + pull_request: + branches: [main] + +jobs: + check: + name: Structure & Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Run structure check + run: node check-structure.js diff --git a/.gitignore b/.gitignore index 40b878d..3ec544c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules/ +.env \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d7d56fd --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@polestarlabs:registry=https://npm.pkg.github.com diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..3321be5 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,4 @@ +types/** @Flicksie +leanDefaultPlugin.js @Flicksie +*.types.ts @Flicksie +*.schema.ts @bsian03 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..78541a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,375 @@ +# Adding New Schemas + +***Single source of truth for all Pollux database models, front-facing types, and shared constants.*** + +--- + +## Golden Rules + +1. **No TypeScript compilation step.** This package is plain CommonJS JS. Types are provided via hand-written `.d.ts` declaration files. +2. **No runtime dependencies on consumers.** This package must work in both Node.js and Bun without special loaders. +3. **Bump `package.json` version** on every meaningful change (schemas, types, constants). Consumers pin to this version. + +--- + +## Package Layout + +``` +database_schema/ +├── index.js # init(config, extras?) → Schemas object +├── index.d.ts # Mongoose model types, raw doc interfaces, Schemas map +├── schemas.js # Loads all schema modules, returns Schemas +├── virtuals.js # Loader — delegates to per-schema *.virtuals.js +├── utils.js # dbSetter / dbGetter / dbGetterFull helpers +├── leanDefaultPlugin.js # Makes .lean() implicit on all queries +├── redisClient.js # Optional Redis cache layer (.cache() / .noCache()) +├── generate-schema.sh # Scaffolding script for new schemas +│ +├── schemas/ # One folder per MongoDB collection +│ ├── users_core/ +│ │ ├── users_core.js # Mongoose schema + model factory +│ │ ├── users_core.schema.d.ts # Raw doc types (match Mongo 1:1) +│ │ ├── users_core.types.d.ts # Front-facing pretty types +│ │ └── users_core.virtuals.js # Virtual populate paths +│ ├── items/ +│ │ ├── items.js +│ │ ├── items.schema.d.ts +│ │ ├── items.types.d.ts +│ │ └── items.virtuals.js +│ ├── _misc/ # Multi-schema legacy bundle (not yet split) +│ │ ├── _misc.js +│ │ ├── marketplace.schema.d.ts +│ │ ├── marketplace.virtuals.js +│ │ ├── relationships.schema.d.ts +│ │ └── relationships.virtuals.js +│ └── / # pattern for every other collection +│ ├── .js +│ ├── .schema.d.ts +│ ├── .types.d.ts # only if consumer-facing +│ └── .virtuals.js # only if virtual populate needed +│ +├── types/ # Barrel re-export of all front-facing types +│ ├── index.d.ts # export * from generics + each *.types.d.ts +│ └── generics.d.ts # Currency, Rarity, PrimeTier, Profilecard, … +│ +└── constants/ # Runtime constant arrays + ├── index.js # CURRENCY_VALUES, RARITY_VALUES, PRIME_TIERS + └── index.d.ts # Type declarations for the above +``` + +### Three export paths + +| Import path | What it provides | Has JS? | +|---|---|---| +| `@polestarlabs/database_schema` | `init()`, Mongoose models, raw doc types | Yes | +| `@polestarlabs/database_schema/types` | Front-facing types for bot/api/dashboard | No (`.d.ts` only) | +| `@polestarlabs/database_schema/constants` | Constant value arrays | Yes | + +--- + +## Type Layers + +There are **two layers** of types. Understand when to use each: + +### Raw Doc types (`schemas//.schema.d.ts`) + +These mirror the MongoDB document shape 1:1. Used internally within this package and for low-level Mongo operations (aggregations, `findOne`, `updateOne`, etc.). + +```ts +// in schemas/cosmetics/cosmetics.schema.d.ts +export interface Cosmetics { // ← raw doc, matches what Mongo stores + id: string; + name: string; + tags: string; + rarity: string; // raw string in DB + type: string; + // ... +} +export interface CosmeticsSchema extends mongoose.Document, Cosmetics {} +export interface CosmeticsModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} +``` + +> The composite types (Data → Schema → Model) are also kept in `index.d.ts` for backwards compatibility with the main package exports. + +### Front-facing types (`schemas//.types.d.ts`) + +These are the "pretty" types for consumers (bot, api, dashboard). They use proper unions, have cleaner shapes, and reflect what model methods actually return after transformation. + +```ts +// in schemas/cosmetics/cosmetics.types.d.ts +import type { Rarity } from '../../types/generics'; // ← import from generics, NOT from types barrel + +export interface CosmeticBaseItem { // ← clean consumer type + name: string; + tags: string; + rarity: Rarity; // typed union, not raw string + type: CosmeticType; + // ... +} +``` + +> **Important:** Schema `.types.d.ts` files must import generic types from `../../types/generics`, **not** from `../../types`. The barrel (`types/index.d.ts`) re-exports from schema type files — importing back from the barrel creates a circular reference. + +### Generic types (`types/generics.d.ts`) + +Shared primitive unions that multiple schemas reference: `Currency`, `CurrencyLabel`, `Rarity`, `PrimeTier`, `PrimeInfo`, `Profilecard`. When defining a new shared domain type, add it here. + +### Public barrel (`types/index.d.ts`) + +Re-exports everything from `generics.d.ts` and all per-schema `.types.d.ts` files. This is the only file consumers should import from: + +```ts +import type { User, CosmeticItem, InventoryItem, Rarity } from '@polestarlabs/database_schema/types'; +``` + +**Rule of thumb:** +- Consumer code → `@polestarlabs/database_schema/types` → gets `User`, `CosmeticItem`, `InventoryItem`, etc. +- Internal schema code or raw aggregations → raw doc types from `index.d.ts` + +--- + +## How To: Add a New Collection + +### 0. Use the generator (fastest path) + +```bash +# From the database_schema package root: +bash generate-schema.sh my_collection +# With virtuals: +bash generate-schema.sh my_collection --virtuals +``` + +This creates `schemas/my_collection/` with all four boilerplate files. Then fill in the TODOs and wire it up per the steps below. + +--- + +### 1. Create the schema folder + +Create `schemas/my_collection/my_collection.js`: + +```js +'use strict'; +const mongoose = require('mongoose'); + +const MySchema = new mongoose.Schema( + { + id: { type: String, index: { unique: true } }, + name: String, + value: Number, + }, + { collection: 'my_collection' } +); + +// Instance methods (available on full documents via getFull / { lean: false }) +MySchema.methods.doSomething = function () { + return this.constructor.updateOne({ id: this.id }, { $inc: { value: 1 } }); +}; + +MySchema.statics.get = function get(query, projection) { + return this.findOne(query, projection); +}; +MySchema.statics.set = function set(query, update, options = {}) { + return this.findOneAndUpdate(query, update, { upsert: true, new: true, ...options }); +}; + +/** + * @param {import('mongoose').Connection} connection + */ +module.exports = function (connection) { + return connection.model('MyCollection', MySchema); +}; +``` + +### 2. Add raw doc types — `schemas/my_collection/my_collection.schema.d.ts` + +```ts +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface MyCollection { + id: string; + name: string; + value: number; +} +export interface MyCollectionSchema extends mongoose.Document, MyCollection { + doSomething(): Promise; +} +export interface MyCollectionModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} +``` + +### 3. Add front-facing types — `schemas/my_collection/my_collection.types.d.ts` + +Only needed if consumers (bot/api/dashboard) use this collection's data directly. + +```ts +// import type { Rarity } from '../../types/generics'; ← import from generics, not types barrel + +export interface MyThing { + id: string; + name: string; + value: number; +} +``` + +### 4. (If applicable) Create virtuals — `schemas/my_collection/my_collection.virtuals.js` + +```js +'use strict'; +module.exports = function (MySchema) { + MySchema.virtual('relatedData', { + ref: 'OtherCollection', + localField: 'someId', + foreignField: 'id', + justOne: true, + }); +}; +``` + +Then register in `virtuals.js`: +```js +require('./schemas/my_collection/my_collection.virtuals')(Schemas.myCollection.schema); +``` + +### 5. Register in `schemas.js` + +```js +// inside the returned object: +myCollection: require('./schemas/my_collection/my_collection.js')(activeConnection), +``` + +### 6. Wire into the type barrel — `types/index.d.ts` + +```ts +export * from '../schemas/my_collection/my_collection.types'; +``` + +### 7. Add to raw model map — `index.d.ts` + +Add the triple pattern and update `Schemas`: + +```ts +export interface MyCollection { ... } +export interface MyCollectionSchema extends mongoose.Document, MyCollection { ... } +export interface MyCollectionModel extends mongoose.Model { ... } + +export interface Schemas { + // ... + myCollection: MyCollectionModel; +} +``` + +--- + +## How To: Add a New Constant + +### 1. Add the JS value to `constants/index.js` + +```js +const MY_VALUES = Object.freeze(["a", "b", "c"]); + +module.exports = { CURRENCY_VALUES, RARITY_VALUES, PRIME_TIERS, MY_VALUES }; +``` + +### 2. Add the type declaration to `constants/index.d.ts` + +```ts +import type { MyType } from '../types'; + +export declare const MY_VALUES: readonly MyType[]; +``` + +### 3. Add the type union to `types/index.d.ts` + +```ts +export type MyType = "a" | "b" | "c"; +``` + +> The constant array and the type union must always stay in sync. The type is the source of truth; the constant is the runtime representation. + +--- + +## How To: Add a Front-Facing Type + +Add it to the relevant schema's `.types.d.ts` file. No JS file needed — these are pure type declarations. + +```ts +// schemas/my_collection/my_collection.types.d.ts +import type { Rarity } from '../../types/generics'; // ← NOT from '../../types' + +export interface MyThing { ... } +``` + +Then re-export it from the barrel in `types/index.d.ts`: + +```ts +export * from '../schemas/my_collection/my_collection.types'; +``` + +For new **generic** domain types shared across schemas (like a new currency or rarity tier), add them to `types/generics.d.ts` instead. + +Keep these conventions: +- Use **type unions** for finite sets: `type Rarity = "C" | "U" | "R" | "SR" | "UR" | "XR"` +- Use **interfaces** for object shapes: `interface User { ... }` +- Use **intersection types** for variants: `type CosmeticBackground = CosmeticBaseItem & { type: "background"; code: string; }` +- Import generic types from `../../types/generics`, not from `../../types` (avoids circular barrel reference) + +--- + +## Utility Methods Convention + +Every model should expose at minimum: + +| Method | Signature | Returns | Notes | +|---|---|---|---| +| `get` | `(id, project?)` | Lean POJO or `null` | Default projection excludes `_id` | +| `set` | `(id, update, options?)` | Write result | `upsert: true` by default | +| `getFull` | `(id, project?)` | Full Mongoose Document | Has instance methods, `.save()`, etc. | + +These come from `utils.js` (`dbGetter`, `dbSetter`, `dbGetterFull`). Attach them to every new model. + +### Lean by Default + +All queries return lean (plain) objects via `leanDefaultPlugin.js`. To get a full Mongoose document (for `.save()` or instance methods), use: + +```js +Model.findOne(query, project, { lean: false }) +// or +Model.getFull(id) +``` + +--- + +## Instance Methods + +Instance methods go on the **Schema** (not the Model). They are only available on full documents retrieved via `getFull` or `{ lean: false }`. + +```js +MySchema.methods.doThing = function () { + // `this` is the document instance + // Use this.constructor for Model-level operations + return this.constructor.updateOne({ id: this.id }, { ... }); +}; +``` + +Declare them in `index.d.ts` on the Schema interface, and if consumer-facing, also on the pretty type in `types/index.d.ts`. + +--- + +## Checklist for Any Change + +- [ ] Schema folder created: `schemas//` +- [ ] Schema JS created/updated: `schemas//.js` +- [ ] Raw doc types added/updated: `schemas//.schema.d.ts` (Interface + Schema + Model) +- [ ] Registered in `schemas.js` using new path: `require('./schemas//.js')(activeConnection)` +- [ ] Raw model types added to `Schemas` interface in `index.d.ts` +- [ ] Front-facing type file added/updated: `schemas//.types.d.ts` (if consumer-visible) +- [ ] Barrel updated: `export * from '../schemas//.types'` added to `types/index.d.ts` +- [ ] Virtuals file created if needed: `schemas//.virtuals.js`, registered in `virtuals.js` +- [ ] Constants added to `constants/index.js` + `constants/index.d.ts` (if applicable) +- [ ] Version bumped in `package.json` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..62d463d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Dashboard application container +FROM node:24-alpine + +WORKDIR /db + +# copy package files first +COPY package.json package-lock.json* ./ + +# install dependencies (include dev deps for prepare hook, then drop them) +RUN npm ci && npm prune --production + +# copy the rest of the application +COPY . . + +# bind to all interfaces so Docker networking works +ENV NODE_ENV=production + +EXPOSE 4400 + diff --git a/GITHUB_PACKAGE_REGISTRY_SETUP.md b/GITHUB_PACKAGE_REGISTRY_SETUP.md new file mode 100644 index 0000000..1307fad --- /dev/null +++ b/GITHUB_PACKAGE_REGISTRY_SETUP.md @@ -0,0 +1,116 @@ +# Publishing this package to GitHub Package Registry + +This repository (**database_schema**) is the package published as **@polestarlabs/database_schema** on [GitHub Package Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry). + +**Requirement:** The npm scope must match the GitHub org name (lowercase). Our org is **PolestarLabs**, so the scope is **@polestarlabs**. Using `@polestar` causes 403 `permission_denied: create_package`. + +--- + +## What you need to do (tokens & commands) + +- **Step 1:** Create a GitHub Personal Access Token (classic) with `read:packages`, `write:packages` (and `repo` if this repo is private). Do not commit the token. +- **Step 2:** From this repo run: + `npm login --scope=@polestarlabs --auth-type=legacy --registry=https://npm.pkg.github.com` + Use your GitHub username and the token as the password. +- **Step 3:** Run `npm publish` in this repo. +- **Step 4:** In bot, dashboard, and dashboard/api, set **`GITHUB_TOKEN`** to your PAT (or a token with `read:packages`) before running `npm install` or `bun install`. Use an env var or `.env` (gitignored); do not put the token in committed files. + +All file edits (package.json, .npmrc, consuming deps) are already done. + +--- + +## 1. Create a GitHub Personal Access Token (classic) + +1. GitHub → **Settings** → **Developer settings** → **Personal access tokens** → **Tokens (classic)**. +2. **Generate new token (classic)**. +3. Scopes: **`read:packages`**, **`write:packages`** (and **`repo`** if the repo is private and you publish from local). +4. Copy the token (e.g. `ghp_xxxx`). + +--- + +## 2. Configure this repository for publishing + +### 2.1 `package.json` + +Ensure `package.json` in this repo has: + +- **`name`**: `@polestarlabs/database_schema` +- **`repository`**: GitHub URL of this repo, e.g. `https://github.com/PolestarLabs/database_schema` +- **`publishConfig.registry`**: `https://npm.pkg.github.com` + +Example: + +```json +{ + "name": "@polestarlabs/database_schema", + "version": "0.19.0", + "repository": "https://github.com/PolestarLabs/database_schema", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } +} +``` + +### 2.2 Optional: `.npmrc` in this repo + +So `npm publish` always uses GPR: + +``` +@polestarlabs:registry=https://npm.pkg.github.com +``` + +--- + +## 3. Publish from this repository + +```bash +cd /path/to/database_schema + +# Log in to GitHub Package Registry (password = your PAT) +npm login --scope=@polestarlabs --auth-type=legacy --registry=https://npm.pkg.github.com +# Username: YOUR_GITHUB_USERNAME +# Password: ghp_xxxx (your token) + +npm publish +``` + +If the repo is private, the PAT needs **repo** (or equivalent) as well. + +--- + +## 4. Consuming projects (bot, dashboard, dashboard/api) + +After publishing, consuming projects should: + +1. **Use the registry version** in `package.json`: + ```json + "@polestarlabs/database_schema": "^0.19.0" + ``` + (or whatever version you published.) + +2. **Authenticate for install:** + - **Bun:** Set `GITHUB_TOKEN`; those projects already have `bunfig.toml` with `[install.scopes]` for `@polestarlabs`. + - **npm/yarn:** Add `.npmrc` in the project: + ``` + @polestarlabs:registry=https://npm.pkg.github.com + //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} + ``` + Then set `GITHUB_TOKEN` and run `npm install` or `yarn`. + +3. **Optional:** Remove any preinstall/postinstall or `bun-install-github.js` workaround used for the git dependency. + +--- + +## Summary + +| Step | Where | Action | +|------|--------|--------| +| 1 | GitHub | Create PAT with `read:packages`, `write:packages` (and `repo` if private). | +| 2 | **database_schema** repo | Set `repository` and `publishConfig.registry` in `package.json`. | +| 3 | **database_schema** repo | `npm login --scope=@polestarlabs --auth-type=legacy --registry=https://npm.pkg.github.com`, then `npm publish`. | +| 4 | bot, dashboard, api | Dep is `"@polestarlabs/database_schema": "^x.x.x"`. Set `GITHUB_TOKEN`; run `bun install` or npm/yarn. | + +References: + +- [Working with the npm registry (GitHub Docs)](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry) +- [Configure a private registry for a scope (Bun)](https://bun.com/docs/guides/install/registry-scope) diff --git a/README.md b/README.md new file mode 100644 index 0000000..79c9486 --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# Pollux Schemas + +Shared MongoDB schemas, models, types, and constants for the Pollux ecosystem. +Used by the **bot**, **dashboard API**, and **frontend**. + +```bash +npm install @polestarlabs/database_schema +# requires github package repo +``` + +--- + +## Quick Start + +```js +const DBSchema = require("@polestarlabs/database_schema"); + +const DB = await DBSchema({ + hook: webhookDigester, // optional, for connection event alerts + url: "mongodb://localhost/pollux", + options: { useNewUrlParser: true, useUnifiedTopology: true }, +}, { + redis: { host: "127.0.0.1", port: 6379 }, // optional +}); + +// DB is the Schemas object — every collection is accessible from here +const user = await DB.users.get("123456789"); +``` + +--- + +## High-Level Methods + +Every model exposes three standard accessors. **These are the methods you should use 99% of the time.** + +### `Model.get(id, projection?)` + +Returns a **lean POJO** (plain object) or `null`. This is fast and lightweight — no Mongoose overhead. + +```js +const user = await DB.users.get("123456789"); +// user is a plain object: { id, name, currency, profile, ... } +// user.save() ← does NOT exist (lean) +// user.addCurrency ← does NOT exist (lean) +``` + +### `Model.set(id, update, options?)` + +Runs `updateOne` with `upsert: true` by default (except for guilds/server metadata). + +```js +await DB.users.set("123456789", { $inc: { "currency.RBN": 100 } }); +``` + +### `Model.getFull(id, projection?)` + +Returns a **full Mongoose Document** — has instance methods, `.save()`, change tracking, etc. Use this when you need to call instance methods. + +```js +const user = await DB.users.getFull("123456789"); +await user.addCurrency("RBN", 500); // ← works (full document) +await user.addXP(25); // ← works +``` + +### When to use which + +| Scenario | Method | Why | +|---|---|---| +| Read data for display | `get` | Fast, no overhead | +| Update a field | `set` | Direct atomic update | +| Call instance methods | `getFull` | Methods live on Documents | +| Aggregation, bulk ops | Native Mongoose/Mongo | See "Low-Level" section | + +--- + +## Low-Level Methods + +For operations beyond basic CRUD, use Mongoose's native query interface directly on the model. These are **not** wrapped — you get the raw Mongoose API. + +### Restrictions + +- **All queries are lean by default.** A global plugin makes `find()` and `findOne()` return plain objects automatically. If you need a full Document, **opt out explicitly**: + + ```js + // Returns a lean POJO (default): + const doc = await DB.users.findOne({ id: "123" }); + + // Returns a full Mongoose Document: + const fullDoc = await DB.users.findOne({ id: "123" }, null, { lean: false }); + ``` + +- **Don't use `.save()` on lean results.** Lean objects are plain POJOs — they have no `.save()`, no instance methods, no change tracking. If you accidentally try, it will throw. + +- **Use `.exec()` only on hand-offs.** When passing a query to `Promise.all()` or returning it, call `.exec()`. When `await`-ing in the same flow, don't bother — it's implicit. + + ```js + // Awaiting directly — no .exec() needed: + const items = await DB.items.find({ type: "material" }); + + // Handing off — use .exec(): + const [items, cosmetics] = await Promise.all([ + DB.items.find({ type: "material" }).exec(), + DB.cosmetics.find({ type: "sticker" }).exec(), + ]); + ``` + +- **Redis caching is optional.** If Redis was initialized, queries support `.cache(ttl)` and `.noCache()`. If not, these are no-ops. + + ```js + const user = await DB.users.findOne({ id: "123" }).cache(60); // cache 60s + const fresh = await DB.users.findOne({ id: "123" }).noCache(); // bypass cache + ``` + +### Common Low-Level Patterns + +```js +// Aggregation +const topUsers = await DB.users.aggregate([ + { $sort: { "currency.RBN": -1 } }, + { $limit: 10 }, + { $project: { id: 1, name: 1, "currency.RBN": 1 } }, +]); + +// Distinct values +const allRarities = await DB.items.distinct("rarity"); + +// Bulk write +await DB.items.bulkWrite([ + { updateOne: { filter: { id: "sword" }, update: { $set: { price: 500 } } } }, + { updateOne: { filter: { id: "shield" }, update: { $set: { price: 300 } } } }, +]); + +// Count +const totalUsers = await DB.users.countDocuments({ "prime.active": true }); + +// Virtual population (defined in virtuals.js) +const listing = await DB.marketplace.findOne({ id: "abc" }) + .populate("authorData") + .populate("itemData"); +``` + +--- + +## Types + +The package provides **two layers** of types, serving different purposes. + +### Import Paths + +```ts +// Main entry — Mongoose models, raw doc interfaces, init function +import DBSchema from "@polestarlabs/database_schema"; + +// Front-facing types — clean shapes for consumer code +import type { User, CosmeticItem, InventoryItem } from "@polestarlabs/database_schema/types"; + +// Constants — runtime arrays of valid values +import { CURRENCY_VALUES, RARITY_VALUES, PRIME_TIERS } from "@polestarlabs/database_schema/constants"; +``` + +### Layer 1: Raw Doc Types (main entry) + +These live in `index.d.ts` and mirror the MongoDB document shape exactly. Every collection follows a triple pattern: + +```ts +interface Cosmetics { ... } // Plain data shape (what .get() returns) +interface CosmeticsSchema // Mongoose Document (what .getFull() returns) + extends mongoose.Document, Cosmetics { ... } +interface CosmeticsModel // The Model itself (DB.cosmetics) + extends mongoose.Model { ... } +``` + +Use these when: +- Writing low-level Mongo queries or aggregations +- Working with Mongoose-specific features (populate, hooks, etc.) +- You need the exact database field names + +### Layer 2: Front-Facing Types (`/types`) + +These are defined in per-schema `*.types.d.ts` files and re-exported through the `types/index.d.ts` barrel. They are the "pretty" types meant for bot commands, API responses, and dashboard rendering. They use proper type unions instead of raw strings, have cleaner names, and include instance method signatures. + +```ts +import type { User, CosmeticItem, Rarity, Currency } from "@polestarlabs/database_schema/types"; +``` + +| Raw Doc Type (`index.d.ts`) | Front-Facing Type (`types/`) | Difference | +|---|---|---| +| `Cosmetics` | `CosmeticItem` | Union of all subtypes, typed `rarity: Rarity` | +| `Item` | `InventoryItem` | Typed enums for `type`, `series`, `filter` | +| `UserCore` | `User` | Includes instance methods, cleaner shape | +| `rarity: string` | `rarity: Rarity` | `"C" \| "U" \| "R" \| "SR" \| "UR" \| "XR"` | +| `type: string` | `type: CosmeticType` | `"background" \| "medal" \| ...` | + +**Rule of thumb:** Consumer code should always import from `/types`. Only reach for raw doc types when doing direct database operations. + +> **For contributors:** Front-facing types live in `schemas//.types.d.ts`. Generic types (Currency, Rarity, etc.) live in `types/generics.d.ts`. The barrel at `types/index.d.ts` re-exports everything. See [CONTRIBUTING.md](./CONTRIBUTING.md) and `generate-schema.sh` for adding new schemas. + +### Constants (`/constants`) + +Runtime arrays that correspond to the type unions. Use them for validation: + +```ts +import { CURRENCY_VALUES, RARITY_VALUES, PRIME_TIERS } from "@polestarlabs/database_schema/constants"; + +CURRENCY_VALUES // → ["RBN", "JDE", "SPH", "AMY", "EMD", "PSM", "EVT"] +RARITY_VALUES // → ["C", "U", "R", "SR", "UR", "XR"] +PRIME_TIERS // → ["plastic", "aluminium", ..., "neutrino"] + +if (!CURRENCY_VALUES.includes(input)) throw new Error("Invalid currency"); +``` + +--- + +## Available Collections + +### User Collections (split architecture) + +| Accessor | Collection | Description | +|---|---|---| +| `DB.users` | `users` | Core user data: profile, currencies, progression, prime | +| `DB.userInventory` | `user_inventory` | Items, cosmetics, achievements, showcases | +| `DB.userOAuth` | `user_oauth` | Discord/Patreon tokens, identity cache | +| `DB.userGuilds` | `user_guilds` | Guild memberships and permissions | +| `DB.userQuests` | `user_quests` | Quest progress tracking | +| `DB.userAnalytics` | `user_analytics` | Usage stats and legacy global level | +| `DB.userConnections` | `user_connections` | Third-party connections (Twitch, YouTube) | + +### Content Collections + +| Accessor | Collection | Description | +|---|---|---| +| `DB.cosmetics` | `cosmetics` | Backgrounds, medals, stickers, flairs, skins | +| `DB.items` | `items` | Inventory items (materials, consumables, keys, etc.) | +| `DB.collectibles` | `collectibles` | Collectible items | +| `DB.achievements` | `achievements` | Achievement definitions | +| `DB.quests` | `quests` | Quest definitions | + +### Server Collections + +| Accessor | Collection | Description | +|---|---|---| +| `DB.servers` | `guilds` | Server configuration and modules | +| `DB.svMetaDB` | `sv_meta` | Server metadata (channels, roles) | +| `DB.channels` | `channels` | Per-channel settings | +| `DB.localranks` | `localranks` | Per-server user XP/levels | + +### Economy & Social + +| Accessor | Description | +|---|---| +| `DB.marketplace` | Player-to-player item listings | +| `DB.relationships` | Marriages, parent/child bonds | +| `DB.commends` | User commendation system | +| `DB.transactions` | Economy transaction log | +| `DB.gifts` | Gift items in circulation | + +### PascalCase Aliases + +For cleaner code, PascalCase aliases are available on the Schemas object: + +```js +DB.Users // → DB.users (UserCoreModel) +DB.Items // → DB.items (ItemModel) +DB.UserInventory // → DB.userInventory (UserInventoryModel) +``` + +--- + +## Instance Methods (common) + +Available on full Documents returned by `getFull()`: + +### Users +- `user.addCurrency(currency, amount)` — Increment a currency +- `user.addXP(amount)` — Add experience points +- `user.incrementAttr(path, amount)` — Increment any numeric field + +### UserInventory +- `inv.addItem(itemId, amount, crafted?)` — Add item to inventory +- `inv.removeItem(itemId, amount, crafted?)` — Remove item from inventory +- `inv.hasItem(itemId, count?)` — Check if user owns enough of an item +- `inv.amtItem(itemId)` — Get owned quantity +- `inv.modifyItems(items[], debug?)` — Bulk add/remove multiple items + +--- + +## See Also + +- [CONTRIBUTING.md](./CONTRIBUTING.md) — How to add new collections, types, and constants diff --git a/check-structure.js b/check-structure.js new file mode 100644 index 0000000..4590722 --- /dev/null +++ b/check-structure.js @@ -0,0 +1,544 @@ +#!/usr/bin/env node +/** + * check-structure.js + * + * Structural + type-safety integrity check — no test framework needed. + * + * 1. Core loader files exist + * 2. schemas.js require() paths are valid (parsed dynamically) + * 3. virtuals.js require() paths are valid (parsed dynamically) + * 4. schema folders on disk are imported (orphan detection) + * 5. per-schema folder internals are in order + * 6. types/index.d.ts barrel exports resolve + * 7. TypeScript type check on the public API (tsc --noEmit) + * + * Run: node check-structure.js | npm test + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ROOT = __dirname; +const CI = process.env.GITHUB_ACTIONS === 'true'; +let passed = 0; +let failed = 0; +let warns = 0; +let strict = 0; +let noStrict = 0; + +// ── Helpers ────────────────────────────────────────────────────── + +function ok(label) { + console.log(` \x1b[32m✓\x1b[0m ${label}`); + passed++; +} + +// Encode a GitHub Actions annotation property value (title, file, …) +function _encProp(s) { + return String(s) + .replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A') + .replace(/:/g, '%3A').replace(/,/g, '%2C'); +} +// Encode the annotation message +function _encMsg(s) { + return String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} +/** + * Emit a GitHub Actions workflow annotation. + * @param {'error'|'warning'|'notice'} level + * @param {string} file Repo-relative path (may be empty) + * @param {string} title Short title shown in the UI + * @param {string} message Full detail message + */ +function annotate(level, file, title, message) { + if (!CI) return; + const parts = []; + if (file) parts.push(`file=${_encProp(file)}`); + if (title) parts.push(`title=${_encProp(title)}`); + process.stdout.write(`::${level} ${parts.join(',')}::${_encMsg(message)}\n`); +} + +/** + * @param {string} label Human-readable label printed to console + * @param {string} [detail] Extra detail appended with → + * @param {string} [file] Repo-relative file path for the annotation (optional) + */ +function fail(label, detail = '', file = '') { + console.error(` \x1b[31m✗\x1b[0m ${label}${detail ? ` → ${detail}` : ''}`); + failed++; + annotate('error', file, label, detail || label); +} + +function warn(label) { + console.warn(` \x1b[33m⚠\x1b[90m ${label}\x1b[0m`); + warns++; +} + +function section(title) { + console.log(`\n\x1b[1m${title}\x1b[0m`); +} + +function exists(rel) { + const abs = path.join(ROOT, rel); + return fs.existsSync(abs); +} + +/** + * Extract all string arguments passed to require() in a source file + * that start with the given prefix. + */ +function extractRequires(filePath, prefix = '') { + const src = fs.readFileSync(filePath, 'utf8'); + const re = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + const results = []; + let m; + while ((m = re.exec(src)) !== null) { + if (!prefix || m[1].startsWith(prefix)) results.push(m[1]); + } + return results; +} + +/** + * Resolve a require() string (relative to ROOT) to an array of + * candidate absolute paths (with/without .js extension). + */ +function resolveRequire(req) { + const abs = path.resolve(ROOT, req); + return [abs, abs + '.js']; +} + +// ── 1. Core loader files ───────────────────────────────────────── + +section('Core files'); + +const CORE_FILES = [ + 'index.js', + 'schemas.js', + 'virtuals.js', + 'utils.js', + 'index.d.ts', + 'types/index.d.ts', + 'types/generics.d.ts', + 'constants/index.js', + 'constants/index.d.ts', +]; +for (const f of CORE_FILES) { + exists(f) ? ok(f) : fail(f, 'missing core file', f); +} + +// ── 2. schemas.js — dynamic require discovery ──────────────────── + +section('schemas.js — require paths (dynamic)'); + +const schemasJsRequires = extractRequires(path.join(ROOT, 'schemas.js'), './schemas/'); + +function fileStrictMode(absPath) { + try { + const src = fs.readFileSync(absPath, 'utf8'); + if(/strict\s*:\s*false/.test(src)){ + noStrict++ + return "⚠️ "; + } + else if(/strict\s*:\s*true/.test(src)){ + strict++ + return "✔️ "; + }else{ + return "❓"; + } + ; + } catch (_) { + return "❌"; + } +} + +for (const req of schemasJsRequires) { + const candidates = resolveRequire(req); + const found = candidates.find(c => fs.existsSync(c)); + if (found) { + const rel = path.relative(ROOT, found); + const flag = fileStrictMode(found); + ok(`${flag} ${req}`); + } else { + fail(req, 'file not found on disk', 'schemas.js'); + } +} + +// ── 3. virtuals.js — dynamic require discovery ─────────────────── + +section('virtuals.js — virtual file paths (dynamic)'); + +const virtualsJsRequires = extractRequires(path.join(ROOT, 'virtuals.js'), './schemas/'); + +for (const req of virtualsJsRequires) { + const candidates = resolveRequire(req); + const found = candidates.find(c => fs.existsSync(c)); + if (found) { + const rel = path.relative(ROOT, found); + ok(`${req}`); + } else { + fail(req, 'file not found on disk', 'virtuals.js'); + } +} + +// ── 3.5. schema files — verify all require() paths resolve ─────── +// +// In the past we shipped packages where a relative path inside a schema +// file pointed at a non-existent location (e.g. `require("../utils.js")`). +// The bundler would later emit "Could not resolve" errors. To prevent +// that from reaching release we scan every .js file under schemas/ and +// attempt to resolve each require() string using the file's own directory +// as the base. Any failures are treated as errors. + +section('schema files — inline require resolution'); + +/** + * Walk a directory, collecting all .js files (non-recursive for simplicity). + */ +function walkJsFiles(dir, accumulator) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkJsFiles(p, accumulator); + } else if (entry.isFile() && p.endsWith('.js')) { + accumulator.push(p); + } + } +} + +const schemaJsFiles = []; +walkJsFiles(path.join(ROOT, 'schemas'), schemaJsFiles); + +for (const file of schemaJsFiles) { + const relFile = path.relative(ROOT, file); + const reqs = extractRequires(file); + for (const req of reqs) { + // Only validate relative paths; external modules are handled by npm + if (req.startsWith('.')) { + try { + // require.resolve will throw if resolution fails. + require.resolve(req, { paths: [path.dirname(file)] }); + ok(`${relFile} → ${req}`); + } catch (err) { + fail(`${relFile} → ${req}`, 'cannot resolve', relFile); + } + } + } +} + +// ── Build combined referenced-file set ─────────────────────────── +// Used by orphan detection. Normalised to absolute path (no extension). + +const allRefs = new Set( + [...schemasJsRequires, ...virtualsJsRequires].flatMap(resolveRequire) +); + +// ── 4. Orphan detection ─────────────────────────────────────────── +// +// Every DIRECTORY inside schemas/ must have at least one of its files +// referenced in schemas.js or virtuals.js. A folder with zero references +// is an orphan — it won't be loaded at runtime and is invisible to consumers. +// +// Separate check: if a .virtuals.js exists in a folder but is NOT +// registered in virtuals.js, warn (virtual populate would silently not fire). + +section('schema folders — orphan detection'); + +const schemasDir = path.join(ROOT, 'schemas'); + +for (const entry of fs.readdirSync(schemasDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const name = entry.name; + const dir = path.join(schemasDir, name); + const allFiles = fs.readdirSync(dir).map(f => path.join(dir, f)); + + // Is any file in this folder referenced? + const anyReferenced = allFiles.some(f => allRefs.has(f) || allRefs.has(f.replace(/\.js$/, ''))); + + if (anyReferenced) { + ok(`${name}/ — referenced`); + } else { + fail(`${name}/`, 'orphaned — no file in this folder is imported by schemas.js or virtuals.js'); + } + + // Virtuals cross-check: .virtuals.js present but not in virtuals.js? + const virtualsFile = path.join(dir, `${name}.virtuals.js`); + if (fs.existsSync(virtualsFile)) { + const registered = virtualsJsRequires.some(req => { + const abs = path.resolve(ROOT, req); + return abs === virtualsFile || abs + '.js' === virtualsFile; + }); + if (!registered) { + fail( + `${name}/${name}.virtuals.js`, + 'exists but is NOT registered in virtuals.js — virtual populate will not fire', + 'virtuals.js' + ); + } + } +} + +// ── 5. Per-schema folder internals ─────────────────────────────── +// +// Each folder must have .js (required) and .schema.d.ts (required +// except for LEGACY_BUNDLES). .types.d.ts and .virtuals.js are optional. + +section('schema folders — internal structure'); + +// Folders that are multi-schema legacy bundles: skip the .schema.d.ts check +// because their types are split across individual sub-files. +const LEGACY_BUNDLES = new Set(['_misc']); + +for (const entry of fs.readdirSync(schemasDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const name = entry.name; + const dir = path.join(schemasDir, name); + const js = path.join(dir, `${name}.js`); + const schemaD = path.join(dir, `${name}.schema.d.ts`); + const typesD = path.join(dir, `${name}.types.d.ts`); + const virtualsJ = path.join(dir, `${name}.virtuals.js`); + + fs.existsSync(js) ? ok(`${name}/${name}.js`) : fail(`${name}/${name}.js`, 'missing entrypoint', `schemas/${name}/${name}.js`); + + if (LEGACY_BUNDLES.has(name)) { + warn(`${name}/${name}.schema.d.ts (skipped — legacy multi-schema bundle)`); + } else { + fs.existsSync(schemaD) + ? ok(`${name}/${name}.schema.d.ts`) + : fail(`${name}/${name}.schema.d.ts`, 'missing raw doc types', `schemas/${name}/${name}.schema.d.ts`); + } + + if (!fs.existsSync(typesD)) warn(`${name}/${name}.types.d.ts (optional — no front-facing types)`); + //if (!fs.existsSync(virtualsJ)) warn(`${name}/${name}.virtuals.js (optional — no virtuals)`); +} + +// ── 6. types/index.d.ts — barrel exports ───────────────────────── +// +// Dynamically parsed: every `export * from '...'` must resolve on disk. + +section('types/index.d.ts — barrel exports (dynamic)'); + +const barrel = fs.readFileSync(path.join(ROOT, 'types/index.d.ts'), 'utf8'); +const exportRe = /export\s+\*\s+from\s+['"]([^'"]+)['"]/g; +let m; +while ((m = exportRe.exec(barrel)) !== null) { + const from = m[1]; + const abs = path.resolve(ROOT, 'types', from); + if ([abs, abs + '.d.ts', abs + '.ts'].some(c => fs.existsSync(c))) { + ok(`export * from '${from}'`); + } else { + fail(`export * from '${from}'`, `no file found at ${path.relative(ROOT, abs)}`, 'types/index.d.ts'); + } +} + +// ── 7. TypeScript type check ────────────────────────────────────── +// +// Write a tiny consumer .ts that imports the public API surface +// (types barrel + constants), then runs `tsc --noEmit` on it. +// Any broken import, missing type, or re-export collision will surface here. + +section('TypeScript type check (tsc --noEmit)'); + +const tmpFile = path.join(ROOT, '_check_types_tmp.ts'); +const tscBin = path.join(ROOT, 'node_modules', '.bin', 'tsc'); + +// Dynamically collect all export sources from the barrel to verify +// each individual types file is also clean in isolation. +const barrelSources = []; +const barrelRe2 = /export\s+\*\s+from\s+['"]([^'"]+)['"]/g; +let m2; +while ((m2 = barrelRe2.exec(barrel)) !== null) { + const absD = path.resolve(ROOT, 'types', m2[1]); + // Only include paths that actually exist as .d.ts + const candidate = [absD, absD + '.d.ts'].find(c => fs.existsSync(c)); + if (candidate) barrelSources.push(path.relative(ROOT, candidate).replace(/\\/g, '/')); +} + +const tmpContent = [ + '// Auto-generated by check-structure.js — do not commit', + "import type * as PublicTypes from './types/index';", + "import type * as Generics from './types/generics';", + "import { CURRENCY_VALUES, RARITY_VALUES, PRIME_TIERS } from './constants/index';", + '', + '// Spot-check: ensure key types are accessible from the barrel', + 'declare const _u: PublicTypes.User;', + 'declare const _c: PublicTypes.CosmeticItem;', + 'declare const _i: PublicTypes.InventoryItem;', + 'declare const _r: PublicTypes.Rarity;', + 'declare const _cur: PublicTypes.Currency;', + '', + '// Spot-check: constants must be readonly arrays', + 'const _cv: readonly string[] = CURRENCY_VALUES;', + 'const _rv: readonly string[] = RARITY_VALUES;', + 'const _pt: readonly string[] = PRIME_TIERS;', + '', + 'export {};', +].join('\n') + '\n'; + +fs.writeFileSync(tmpFile, tmpContent); + +const tscArgs = [ + '--noEmit', + '--strict', + '--esModuleInterop', + '--moduleResolution', 'node', + '--target', 'ES2020', + '--lib', 'ES2020', + tmpFile, +]; + +// On Windows, the tsc bin is a cmd wrapper; use node to call the JS directly. +const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc'); +const tscResult = spawnSync( + process.execPath, // node + [tscJs, ...tscArgs], + { encoding: 'utf8', cwd: ROOT } +); + +fs.unlinkSync(tmpFile); // always clean up + +const tscOutput = (tscResult.stdout + tscResult.stderr).trim(); + +if (tscResult.status === 0) { + ok('public API types resolve without errors'); + ok('barrel exports (User, CosmeticItem, InventoryItem, Rarity, Currency) are accessible'); + ok('constants import is valid and typed as readonly arrays'); +} else { + // Filter out the tmp filename from output for clarity + const cleaned = tscOutput + .replace(new RegExp(tmpFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '') + .replace(/_check_types_tmp\.ts/g, ''); + fail('TypeScript detected errors in the public type surface', '', 'types/index.d.ts'); + console.error(cleaned.split('\n').map(l => ` ${l}`).join('\n')); + // Emit one annotation per tsc diagnostic line so they appear inline in CI + if (!CI) { + const tscLineRe = /\(\d+,\d+\):\s+error\s+(TS\d+):\s+(.+)$/; + for (const line of tscOutput.split('\n')) { + const m = tscLineRe.exec(line); + if (m) annotate('error', 'types/index.d.ts', m[1], m[2].trim()); + } + } +} + +// ── 8. Database initialization check ───────────────────────────── +// +// Connects to MongoDB using MONGO_TEST (env var first, then .env file +// fallback) and verifies that core Schemas collections are present on +// the resolved object. Runs inside a child process so any crash in +// index.js/schemas.js is isolated and reported cleanly. + +section('Database initialization (live connection)'); + +// Resolve URL: process.env.MONGO_TEST → .env file → skip +let _mongoUrl = process.env.MONGO_TEST; + +if (!_mongoUrl) { + const envFile = path.join(ROOT, '.env'); + if (fs.existsSync(envFile)) { + const envSrc = fs.readFileSync(envFile, 'utf8'); + const match = /^MONGO_TEST\s*=\s*["']?([^"'\r\n]+)["']?/m.exec(envSrc); + if (match) _mongoUrl = match[1].trim(); + } +} + +if (!_mongoUrl) { + warn('MONGO_TEST not set and .env has no MONGO_TEST — skipping live DB check'); + + // ── Summary (sync path) ──────────────────────────────────────── + console.log(`\n${'─'.repeat(50)}`); + if (failed === 0) { + console.log(`\x1b[32m\x1b[1m All ${passed} checks passed.\x1b[0m`); + } else { + console.log(`\x1b[32m ${passed} passed\x1b[0m \x1b[31m${failed} failed\x1b[0m`); + } + console.log(`\x1b[33m ${warns} remarks\x1b[0m (can safely ignore)`); + console.log(`\x1b[90m Strict TRUE: \x1b[0m${strict}\x1b[90m Strict FALSE: \x1b[0m${noStrict}`); + console.log(); + process.exit(failed > 0 ? 1 : 0); + +} else { + // ── Run DB check in a child process ─────────────────────────── + // index.js uses `new Promise(async resolve => {...})` — an async executor + // whose thrown errors become unhandled rejections that crash the process. + // Isolating in a subprocess keeps the test runner alive regardless. + + const EXPECTED_COLLECTIONS = [ + 'users', 'commends', 'userInventory', 'cosmetics', 'items', + 'fanart', 'relationships', 'servers', 'globalDB', + ]; + + const dbCheckScript = ` +'use strict'; +const initSchema = require(${JSON.stringify(path.join(ROOT, 'index.js'))}); +const EXPECTED = ${JSON.stringify(EXPECTED_COLLECTIONS)}; +process.on('unhandledRejection', (err) => { + process.stdout.write(JSON.stringify({ ok: false, error: String(err) }) + '\\n'); + process.exit(1); +}); +initSchema( + { url: process.env.MONGO_URL, options: { useNewUrlParser: true, useUnifiedTopology: true }, hook: undefined }, + null, +).then(Schemas => { + const missing = EXPECTED.filter(col => !Schemas[col]); + const present = EXPECTED.filter(col => !!Schemas[col]); + process.stdout.write(JSON.stringify({ ok: missing.length === 0, present, missing }) + '\\n'); + const conn = Schemas.raw; + const done = () => process.exit(missing.length > 0 ? 1 : 0); + if (conn && typeof conn.close === 'function') conn.close().then(done).catch(done); + else done(); +}).catch(err => { + process.stdout.write(JSON.stringify({ ok: false, error: String(err) }) + '\\n'); + process.exit(1); +}); +`; + + const dbResult = spawnSync( + process.execPath, + ['-e', dbCheckScript], + { + env: { ...process.env, MONGO_URL: _mongoUrl }, + encoding: 'utf8', + timeout: 30_000, + }, + ); + + // Parse last JSON line written by the child script + const outLines = (dbResult.stdout ?? '').split('\n').filter(Boolean); + const jsonLine = outLines.filter(l => l.trim().startsWith('{')).pop(); + let dbPayload = null; + try { if (jsonLine) dbPayload = JSON.parse(jsonLine); } catch (_) {} + + if (dbResult.signal === 'SIGTERM' || dbResult.error?.code === 'ETIMEDOUT') { + fail('DB initialization', 'timed out after 30 s — server unreachable?'); + } else if (!dbPayload) { + const stderr = (dbResult.stderr ?? '').trim(); + fail('DB initialization', stderr || 'child process produced no output'); + } else if (!dbPayload.ok && dbPayload.error) { + fail('DB initialization', dbPayload.error); + } else { + for (const col of (dbPayload.present ?? [])) { + ok(`db.${col} — collection present`); + } + for (const col of (dbPayload.missing ?? [])) { + fail(`db.${col}`, 'collection missing from resolved Schemas'); + } + } + + // ── Summary ──────────────────────────────────────────────────── + + console.log(`\n${'─'.repeat(50)}`); + if (failed === 0) { + console.log(`\x1b[32m\x1b[1m All ${passed} checks passed.\x1b[0m`); + } else { + console.log(`\x1b[32m ${passed} passed\x1b[0m \x1b[31m${failed} failed\x1b[0m`); + } + console.log(`\x1b[33m ${warns} remarks\x1b[0m (can safely ignore)`); + console.log(`\x1b[90m Strict TRUE: \x1b[0m${strict}\x1b[90m Strict FALSE: \x1b[0m${noStrict}`); + console.log(); + process.exit(failed > 0 ? 1 : 0); +} + diff --git a/constants/index.d.ts b/constants/index.d.ts new file mode 100644 index 0000000..01d71ab --- /dev/null +++ b/constants/index.d.ts @@ -0,0 +1,5 @@ +import type { Currency, Rarity, PrimeTier } from '../types'; + +export declare const CURRENCY_VALUES: readonly Currency[]; +export declare const RARITY_VALUES: readonly Rarity[]; +export declare const PRIME_TIERS: readonly PrimeTier[]; diff --git a/constants/index.js b/constants/index.js new file mode 100644 index 0000000..c4606ec --- /dev/null +++ b/constants/index.js @@ -0,0 +1,11 @@ +"use strict"; + +const CURRENCY_VALUES = Object.freeze(["RBN", "JDE", "SPH", "AMY", "EMD", "PSM", "EVT"]); +const RARITY_VALUES = Object.freeze(["C", "U", "R", "SR", "UR", "XR"]); +const PRIME_TIERS = Object.freeze([ + "plastic", "aluminium", "carbon", "iron", + "iridium", "lithium", "palladium", "zircon", + "uranium", "astatine", "antimatter", "neutrino", +]); + +module.exports = { CURRENCY_VALUES, RARITY_VALUES, PRIME_TIERS }; diff --git a/generate-schema.sh b/generate-schema.sh new file mode 100644 index 0000000..56f0869 --- /dev/null +++ b/generate-schema.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────── +# generate-schema.sh +# Scaffold a new per-schema folder with all required boilerplate. +# +# Usage: +# bash generate-schema.sh [--virtuals] +# +# Options: +# --virtuals Also create a .virtuals.js stub. +# +# Examples: +# bash generate-schema.sh user_badges +# bash generate-schema.sh guild_events --virtuals +# ────────────────────────────────────────────────────────────────── +set -e + +NAME="$1" +WITH_VIRTUALS=false + +for arg in "$@"; do + [ "$arg" = "--virtuals" ] && WITH_VIRTUALS=true +done + +if [ -z "$NAME" ]; then + echo "Usage: $0 [--virtuals]" >&2 + echo "Example: $0 user_badges" >&2 + exit 1 +fi + +# Validate name (snake_case or camelCase, no spaces/slashes) +if ! echo "$NAME" | grep -qE '^[a-zA-Z][a-zA-Z0-9_]*$'; then + echo "Error: schema name must be alphanumeric/underscore, no spaces." >&2 + exit 1 +fi + +DIR="schemas/$NAME" + +if [ -d "$DIR" ]; then + echo "Error: $DIR already exists." >&2 + exit 1 +fi + +mkdir -p "$DIR" + +# ── Convert snake_case/camelCase → PascalCase ───────────────────── +PASCAL=$(echo "$NAME" | awk -F_ '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)};print}' OFS='') + +# ── schema.js ───────────────────────────────────────────────────── +cat > "$DIR/${NAME}.js" << ENDJS +'use strict'; +const mongoose = require('mongoose'); + +const _SCHEMA = new mongoose.Schema( + { + // TODO: define your fields here + // id: { type: String, required: true }, + // name: { type: String }, + }, + { collection: '${NAME}' } +); + +_SCHEMA.statics.get = function get(query, projection) { + return this.findOne(query, projection); +}; + +_SCHEMA.statics.set = function set(query, update, options = {}) { + return this.findOneAndUpdate(query, update, { upsert: true, new: true, ...options }); +}; + +/** + * @param {import('mongoose').Connection} connection + * @returns {import('./${NAME}.schema').${PASCAL}Model} + */ +module.exports = function (connection) { + return connection.model('${PASCAL}', _SCHEMA); +}; +ENDJS + +# ── schema.d.ts ─────────────────────────────────────────────────── +cat > "$DIR/${NAME}.schema.d.ts" << ENDTS +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +/** Raw MongoDB document shape for the \`${NAME}\` collection. */ +export interface ${PASCAL} { + // TODO: map raw field types matching your Mongoose schema + // id: string; + // name: string; +} + +export interface ${PASCAL}Schema extends mongoose.Document, ${PASCAL} {} + +export interface ${PASCAL}Model extends mongoose.Model<${PASCAL}Schema> { + set: dbSetter<${PASCAL}Schema>; + get: dbGetter<${PASCAL}Schema, ${PASCAL}>; +} +ENDTS + +# ── types.d.ts ──────────────────────────────────────────────────── +cat > "$DIR/${NAME}.types.d.ts" << ENDTS +// import type { Rarity, Currency } from '../../types/generics'; + +/** Front-facing \`${PASCAL}\` type — clean unions for API/Bot consumption. */ +export interface ${PASCAL}Data { + // TODO: mirror your schema fields but with proper union types + // id: string; + // rarity: Rarity; +} +ENDTS + +# ── virtuals.js (optional) ──────────────────────────────────────── +if [ "$WITH_VIRTUALS" = true ]; then + cat > "$DIR/${NAME}.virtuals.js" << ENDJS +'use strict'; + +/** + * Virtuals for the ${PASCAL} schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} ${PASCAL}Schema + */ +module.exports = function (${PASCAL}Schema) { + // TODO: define virtual populate paths here + // ${PASCAL}Schema.virtual('relatedData', { + // ref: 'OtherCollection', + // localField: 'someId', + // foreignField: 'id', + // justOne: true, + // }); +}; +ENDJS + echo "✓ Created $DIR/${NAME}.virtuals.js" +fi + +echo "" +echo "✓ Created $DIR/${NAME}.js" +echo "✓ Created $DIR/${NAME}.schema.d.ts" +echo "✓ Created $DIR/${NAME}.types.d.ts" +echo "" +echo "Next steps:" +echo " 1. Edit $DIR/${NAME}.js — define Mongoose schema fields and collection name." +echo " 2. Edit $DIR/${NAME}.schema.d.ts — map raw MongoDB field types." +echo " 3. Edit $DIR/${NAME}.types.d.ts — write lean front-facing types." +if [ "$WITH_VIRTUALS" = true ]; then + echo " 4. Edit $DIR/${NAME}.virtuals.js — define virtual populate paths." + echo " 5. Register in virtuals.js: require('./schemas/${NAME}/${NAME}.virtuals')(Schemas.${NAME}.schema);" +fi +echo "" +echo " Then wire it up:" +echo " + schemas.js: ${NAME}: require('./schemas/${NAME}/${NAME}.js')(activeConnection)," +echo " + types/index.d.ts: export * from '../schemas/${NAME}/${NAME}.types';" diff --git a/index.d.ts b/index.d.ts index 76207d6..1469048 100644 --- a/index.d.ts +++ b/index.d.ts @@ -399,7 +399,8 @@ export interface ServerMetadataModel extends mongoose.Model Promise; } -export interface UserModules { +/** @deprecated Use UserModulesLegacy only in legacy shim context */ +export interface UserModulesLegacy { powerups: any; lovepoints: number; PERMS: number; @@ -437,14 +438,270 @@ export interface UserModules { fun: { waifu: any; lovers: any; shiprate: any }; statistics: any; } -export type Donator = 'plastic' | 'aluminium' | 'iron' | 'carbon' | 'lithium' | 'iridium' | 'palladium' | 'zircon' | 'uranium' | 'xastatine' | 'antimatter' | 'neutrino'; + +/** Alias for backwards-compat in consumer type annotations */ +export type UserModules = UserModulesLegacy; + +export type Donator = 'plastic' | 'aluminium' | 'iron' | 'carbon' | 'lithium' | 'iridium' | 'palladium' | 'zircon' | 'uranium' | 'astatine' | 'antimatter' | 'neutrino'; +export type PrimeTier = Donator; + +// ── New split collection types ────────────────────────────────────── + +export interface UserCurrencies { + RBN: number; + SPH: number; + JDE: number; + PSM: number; + EVT: number; +} + +export interface UserProfile { + bgID: string | null; + flairTop: string; + flairDown: string; + sticker: string | null; + favcolor: string; + persotext: string; + tagline: string; + medals: (string | 0)[]; + skins: Record; + featuredMarriage: string | null; +} + +export interface UserProgression { + level: number; + exp: number; + globalLV: number; + globalXP: number; + craftingExp: number; +} + +export interface UserMeta { + createdAt: Date; + lastLogin: Date | null; + lastUpdated: Date; + migrated: boolean; +} + +export interface PrimeData { + tier: PrimeTier | null; + lastClaimed: number; + active: boolean; + maxServers: number; + canReallocate: boolean; + custom_background: boolean; + custom_handle: boolean; + custom_shop: boolean; + servers: string[]; + misc: any; +} + +/** Core user document (new "users" collection) */ +export interface UserCore { + id: string; + name: string; + tag: string; + avatar: string | null; + personalhandle?: string; + currency: UserCurrencies; + profile: UserProfile; + progression: UserProgression; + meta: UserMeta; + prime: PrimeData | null; + blacklisted: string | null; + switches: Record; + counters: Record; + eventData: Record; + /** @deprecated read from prime.tier instead */ + donator?: Donator | null; +} +export interface UserCoreSchema extends mongoose.Document, UserCore { + id: string; + addCurrency: (curr: keyof UserCurrencies, amt?: number) => Promise; + addXP: (amt?: number) => Promise; + incrementAttr: (attr: string, amt?: number) => Promise; +} + +/** Parameter for UsersCore.updateMeta(). Accepts Discord/Eris-like user objects. */ +export interface UserMetaUpdate { + id: string; + username?: string; + global_name?: string | null; + discriminator?: string; + tag?: string; + avatar?: string | null; + displayAvatarURL?: string | null; +} + +export interface UserCoreModel extends mongoose.Model { + updateMeta: (U: UserMetaUpdate) => Promise; + new: (userData: Partial) => Promise; + cat: 'users'; + set: dbSetter; + get: (query: CustomQuery, project?: any) => Promise; + getFull: dbGetterFull; +} + +/** Cosmetics satellite document (new "user_inventory" collection) */ +export interface UserInventoryData { + userId: string; + inventory: Array<{ id: string; count: number; crafted?: number }>; + bgInventory: string[]; + skinInventory: string[]; + flairInventory: string[]; + medalInventory: string[]; + stickerInventory: string[]; + stickerShowcase: string[]; + fishes: any[]; + fishShowcase: any[]; + achievements: any[]; +} +export interface UserInventorySchema extends mongoose.Document, UserInventoryData { + addItem: (item: string, amt?: number, crafted?: boolean) => Promise; + removeItem: (item: string, amt?: number, crafted?: boolean) => Promise; + modifyItems(items: UserItem[], debug: true): Promise<[UserItem[], { userId: string }, { $inc: any }, { arrayFilters: any[] }]>; + modifyItems(items: UserItem[], debug?: boolean): Promise; + hasItem: (itemId: string, count?: number) => boolean; + amtItem: (itemId: string) => number; +} +export interface UserInventoryModel extends mongoose.Model { + get: (userId: IDOrIDObject, project?: any) => Promise; + getFull: (userId: IDOrIDObject) => Promise; + set: (userId: IDOrIDObject, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + getOrCreate: (userId: IDOrIDObject) => Promise; + new: (userId: IDOrIDObject) => Promise; +} + +/** OAuth satellite document (new "user_oauth" collection) */ +export interface UserOAuthData { + userId: string; + discordIdentityCache: { + id: string; + username: string; + avatar: string; + discriminator: string; + global_name: string; + banner: string; + flags: number; + premium_type: number; + } | null; + discord: { + accessToken: string; + refreshToken: string; + expiresAt: Date; + scope: string; + email: string; + locale: string; + verified: boolean; + mfa_enabled: boolean; + premium_type: number; + } | null; + patreon: { + accessToken: string; + refreshToken: string; + expiresAt: Date; + scope: string; + identity: any; + } | null; + geo: any; + fetchedAt: Date; +} +export interface UserOAuthSchema extends mongoose.Document, UserOAuthData {} +export interface UserOAuthModel extends mongoose.Model { + get: (userId: IDOrIDObject, project?: any) => Promise; + getFull: (userId: IDOrIDObject) => Promise; + set: (userId: IDOrIDObject, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + getOrCreate: (userId: IDOrIDObject) => Promise; + new: (userId: IDOrIDObject) => Promise; +} + +/** Guild membership satellite document (new "user_guilds" collection) */ +export interface UserGuildData { + userId: string; + guildId: string; + name: string; + icon: string | null; + banner: string | null; + owner: boolean; + permissions: number; + permissions_new: string | null; + features: string[]; + cachedAt: Date; +} +export interface UserGuildSchema extends mongoose.Document, UserGuildData {} +export interface UserGuildModel extends mongoose.Model { + get: (query: any, project?: any) => Promise; + set: (query: any, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + allForUser: (userId: IDOrIDObject) => Promise; + bulkUpsert: (userId: string, guilds: any[]) => Promise; +} + +/** Quest progress satellite document (new "user_quests" collection) */ +export interface UserQuestData { + userId: string; + questId: number; + target: number; + tracker: string; + progress: number; + completed: boolean; + completedAt: Date | null; +} +export interface UserQuestSchema extends mongoose.Document, UserQuestData {} +export interface UserQuestModel extends mongoose.Model { + get: (query: any, project?: any) => Promise; + set: (query: any, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + allForUser: (userId: IDOrIDObject) => Promise; + incrementProgress: (userId: string, questId: number, amt?: number) => Promise; +} + +/** Analytics satellite document (new "user_analytics" collection) */ +export interface UserAnalyticsData { + userId: string; + legacy: { globalLV: number; globalXP: number }; + dashThemeClicks: number; + statistics: any; +} +export interface UserAnalyticsSchema extends mongoose.Document, UserAnalyticsData {} +export interface UserAnalyticsModel extends mongoose.Model { + get: (userId: IDOrIDObject, project?: any) => Promise; + set: (userId: IDOrIDObject, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + getOrCreate: (userId: IDOrIDObject) => Promise; + new: (userId: IDOrIDObject) => Promise; +} + +/** Connection satellite document (new "user_connections" collection) */ +export interface UserConnectionData { + userId: string; + type: string; + externalId: string; + name: string; + verified: boolean; + visibility: number; + show_activity: boolean; + friend_sync: boolean; + two_way_link: boolean; + metadata_visibility: number; + extra: any; +} +export interface UserConnectionSchema extends mongoose.Document, UserConnectionData {} +export interface UserConnectionModel extends mongoose.Model { + get: (query: any, project?: any) => Promise; + set: (query: any, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + allForUser: (userId: IDOrIDObject) => Promise; + bulkUpsert: (userId: string, connections: any[]) => Promise; +} + +// ── Legacy types (kept for shim/migration period) ─────────────────── + +/** @deprecated Legacy embedded quest shape from monolithic userdb. Use UserQuestData instead. */ export interface Quest { id: number; - tracker: string; // TODO `${quest.action}.${quest.type}${quest.condition?"."+quest.condition:""}` + tracker: string; completed: boolean; progress: number; target: number; } +/** @deprecated Legacy monolithic user shape from "userdb". Use UserCore instead. */ export interface User { id: string; name: string; @@ -938,9 +1195,9 @@ export interface Marketbase { } export interface Schemas { - // TODO missing native: miscDB['global']['db']; serverDB: ServerModel; + /** @deprecated Use DB._legacyUserDB for explicit legacy access. DB.users now points to new collection. */ userDB: UserModel; channelDB: ChannelModel; svMetaDB: ServerMetadataModel; @@ -973,7 +1230,25 @@ export interface Schemas { temproles: TemproleModel; promocodes: PromoCodeModel; airlines: { AIRLINES: AirlineModel; ROUTES: AirlineRouteModel; AIRPORT: AirportsModel; AIRPLANES: AirplaneModel; SLOTS: AirportSlotsModel }; - users: UserModel; + + // ── New split user collections (authoritative) ────────────────── + users: UserCoreModel; + userInventory: UserInventoryModel; + userOAuth: UserOAuthModel; + userGuilds: UserGuildModel; + userQuests: UserQuestModel; + userAnalytics: UserAnalyticsModel; + userConnections: UserConnectionModel; + + // ── Legacy (fallback shim only) ───────────────────────────────── + /** @deprecated Only for _legacy_userdb_shim. Delete when sunset. */ + _legacyUserDB: UserModel; + + // ── PascalCase accessor aliases (preferred) ───────────────────── + Users: UserCoreModel; + Items: ItemModel; + UserInventory: UserInventoryModel; + servers: ServerModel; guilds: ServerModel; channels: ChannelModel; diff --git a/index.js b/index.js index 4f043bc..025d097 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,9 @@ const mongoose = require("mongoose"); +// Default all find/findOne to lean. Opt out with findOne(q, proj, { lean: false }). +mongoose.plugin(require("./plugins/leanDefaultPlugin.js")); + // lightweight color helpers (replacing the `colors` package usage) function red(s){return `\x1b[31m${s}\x1b[0m`;} function green(s){return `\x1b[32m${s}\x1b[0m`;} @@ -8,7 +11,7 @@ function yellow(s){return `\x1b[33m${s}\x1b[0m`;} function blue(s){return `\x1b[34m${s}\x1b[0m`;} //FIXME: REDIS IS MANDATORY, MUST MAKE IT NOT MANDATORY OTHERWISE .cache() and .noCache() will fail -const RedisCache = require("./redisClient.js"); +const RedisCache = require("./plugins/redisClient.js"); module.exports = async function ({hook, url, options},extras) { @@ -34,18 +37,32 @@ module.exports = async function ({hook, url, options},extras) { console.info(blue("• "), "Connecting to Database..."); - const db = mongoose.createConnection(url, options, (err) => { - if (err) return console.error(err, `${red("• ")}Failed to connect to Database!`); + const mongooseMajor = parseInt(mongoose.version.split('.')[0], 10); + const cleanedOptions = { ...(options || {}) }; + if (mongooseMajor >= 6) { + // Mongoose 6+ removed these flags entirely; passing them throws. + ["useNewUrlParser", "useUnifiedTopology", "useFindAndModify", "useCreateIndex"].forEach( + (k) => delete cleanedOptions[k] + ); + } else { + // Mongoose 5.x: these flags suppress deprecation warnings from the + // legacy MongoDB driver and must be set explicitly. + cleanedOptions.useNewUrlParser = true; + cleanedOptions.useUnifiedTopology = true; + cleanedOptions.useCreateIndex = true; + cleanedOptions.useFindAndModify = false; + mongoose.set('useCreateIndex', true); + mongoose.set('useFindAndModify', false); + } + + const db = mongoose.createConnection(url, cleanedOptions, (err) => { + if (err) return console.error(err, `${red("• ")}Failed to connect to Database ${url}!`); return console.log(green("• "), "Connection OK"); }); const Schemas = require('./schemas.js')(db); const Virtuals = require('./virtuals.js')(Schemas); - - mongoose.set("useFindAndModify", false); - mongoose.set("useCreateIndex", true); - db.on("error", console.error.bind(console, red("• ") + red("DB connection error:"))); db.once("open", async () => { diff --git a/package-lock.json b/package-lock.json index 2af22a8..de1214b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@polestar/database_schema", - "version": "0.16.4", + "name": "@polestarlabs/database_schema", + "version": "0.20.10", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@polestar/database_schema", - "version": "0.16.4", + "name": "@polestarlabs/database_schema", + "version": "0.20.10", "license": "UNLICENSED", "dependencies": { "bluebird": "^3.5.1", @@ -16,6 +16,7 @@ "devDependencies": { "@types/redis": "^2.8.28", "eris": "^0.15.0", + "husky": "^8.0.0", "typescript": "^4.2.4" } }, @@ -101,6 +102,22 @@ "tweetnacl": "^1.0.1" } }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -484,6 +501,12 @@ "ws": "^7.2.1" } }, + "husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true + }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", diff --git a/package.json b/package.json index 97aeb13..9bd9bf4 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,47 @@ { - "name": "@polestar/database_schema", - "version": "0.18.6", + "name": "@polestarlabs/database_schema", + "version": "0.20.12", "description": "Database Schemas & Functions", "main": "index.js", "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./types": { + "types": "./types/index.d.ts" + }, + "./constants": { + "types": "./constants/index.d.ts", + "default": "./constants/index.js" + } + }, + "typesVersions": { + "*": { + "types": [ + "types/index.d.ts" + ], + "constants": [ + "constants/index.d.ts" + ] + } + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node check-structure.js", + "new": "./generate-schema.sh", + "prepare": "husky install" }, - "repository": { - "type": "git", - "url": "git+https://gitlab.com/PolestarLabs/database_schema.git" + "repository": "https://github.com/PolestarLabs/database_schema", + "publishConfig": { + "registry": "https://npm.pkg.github.com" }, "author": "", "license": "UNLICENSED", "bugs": { - "url": "https://gitlab.com/PolestarLabs/database_schema/issues" + "url": "https://github.com/PolestarLabs/database_schema/issues" }, - "homepage": "https://gitlab.com/PolestarLabs/database_schema#README", + "homepage": "https://github.com/PolestarLabs/database_schema#readme", "dependencies": { "bluebird": "^3.5.1", "mongoose": "^5.9.20", @@ -25,6 +50,12 @@ "devDependencies": { "@types/redis": "^2.8.28", "eris": "^0.15.0", - "typescript": "^4.2.4" + "typescript": "^4.2.4", + "husky": "^8.0.0" + }, + "husky": { + "hooks": { + "pre-push": "npm test" + } } } diff --git a/plugins/leanDefaultPlugin.js b/plugins/leanDefaultPlugin.js new file mode 100644 index 0000000..d89033c --- /dev/null +++ b/plugins/leanDefaultPlugin.js @@ -0,0 +1,24 @@ +/** + * Makes .lean() the default for find() and findOne() on all schemas. + * Opt out when you need a full Mongoose document (e.g. .save(), instance methods) + * by passing { lean: false } as options: + * + * Model.findOne(query, projection, { lean: false }) + * Model.find(query, projection, { lean: false }) + * + * getFull() and any code that needs a Document should use that opt-out. + */ +function leanDefaultPlugin(schema) { + schema.pre("find", function setLean() { + const opts = this.getOptions ? this.getOptions() : this.options; + if (opts && opts.lean === false) return; + this.lean(); + }); + schema.pre("findOne", function setLean() { + const opts = this.getOptions ? this.getOptions() : this.options; + if (opts && opts.lean === false) return; + this.lean(); + }); +} + +module.exports = leanDefaultPlugin; diff --git a/redisClient.js b/plugins/redisClient.js similarity index 93% rename from redisClient.js rename to plugins/redisClient.js index 26d5c72..2c24154 100644 --- a/redisClient.js +++ b/plugins/redisClient.js @@ -187,15 +187,16 @@ const init = (host, port, options = { time: 600 }) => { const result = await _origExec.apply(this, arguments); if (redisClient.verbose) console.log("\x1b[31m•\x1b[0m", "Uncached", queryKey.slice(0, 60)); - // ── Cache WRITE — always warm regardless of .cache()/.noCache() ── - if (result === null || result === undefined) { - // Cache null results with a short TTL to prevent repeated DB misses - safeSet(queryKey, "__null__", Math.min(ttl, 30)); - } else { - try { - safeSet(queryKey, JSON.stringify(result), ttl); - } catch (serErr) { - if (redisClient.verbose) console.warn("[Cache] Serialize error:", serErr.message); + // ── Cache WRITE — only when .cache() was used (opt-in). Default and .noCache() do not populate cache. + if (this.ignoreCache === false) { + if (result === null || result === undefined) { + safeSet(queryKey, "__null__", Math.min(ttl, 30)); + } else { + try { + safeSet(queryKey, JSON.stringify(result), ttl); + } catch (serErr) { + if (redisClient.verbose) console.warn("[Cache] Serialize error:", serErr.message); + } } } diff --git a/schemas.js b/schemas.js index 844c731..0e5b822 100644 --- a/schemas.js +++ b/schemas.js @@ -1,29 +1,43 @@ +/** Mongoose query conventions (.lean(), .exec()): see ./utils.js JSDoc. */ module.exports = function SCHEMAS(activeConnection){ +const collections = { + miscDB: require("./schemas/_misc/_misc.js")(activeConnection), + serverDB: require("./schemas/servers/servers.js")(activeConnection), + channelDB: require("./schemas/channels/channels.js")(activeConnection), + svMetaDB: require("./schemas/serverMeta/serverMeta.js")(activeConnection), + marketplace: require("./schemas/marketplace/marketplace.js")(activeConnection), + relationships: require("./schemas/relationships/relationships.js")(activeConnection), + // ── New split user collections ──────────────────────────────── + usersCore: require("./schemas/users_core/users_core.js")(activeConnection), + userInventory: require("./schemas/user_inventory/user_inventory.js")(activeConnection), + userOAuth: require("./schemas/user_oauth/user_oauth.js")(activeConnection), + userGuilds: require("./schemas/user_guilds/user_guilds.js")(activeConnection), + userQuests: require("./schemas/user_quests/user_quests.js")(activeConnection), + userAnalytics: require("./schemas/user_analytics/user_analytics.js")(activeConnection), + userConnections: require("./schemas/user_connections/user_connections.js")(activeConnection), +} + // ── Legacy monolithic userdb (fallback only) ────────────────── + /** @deprecated Only used by _legacy_userdb_shim files. Will be removed. */ + const _legacyUserDB = require("./schemas/_legacy_users.js")(activeConnection); - const miscDB = require("./schemas/_misc.js")(activeConnection); - const serverDB = require("./schemas/servers.js")(activeConnection); - const userDB = require("./schemas/users.js")(activeConnection); - const channelDB = require("./schemas/channels.js")(activeConnection); - const svMetaDB = require("./schemas/serverMeta.js")(activeConnection); + const {channelDB,serverDB,miscDB} = collections; return { version: require('./package.json').version, native: miscDB.global.db, - serverDB, - userDB, - channelDB, - svMetaDB, - localranks: require("./schemas/localranks.js")(activeConnection), - rankings: require("./schemas/rankings.js")(activeConnection), - responses: require("./schemas/responses.js")(activeConnection), - audits: require("./schemas/audits.js")(activeConnection), - miscDB, + /** @deprecated Use DB._legacyUserDB for explicit legacy access. DB.users now points to new collection. */ + userDB: _legacyUserDB, + localranks: require("./schemas/localranks/localranks.js")(activeConnection), + rankings: require("./schemas/rankings/rankings.js")(activeConnection), + responses: require("./schemas/responses/responses.js")(activeConnection), + audits: require("./schemas/audits/audits.js")(activeConnection), + miscDB, buyables: miscDB.buyables, fanart: miscDB.fanart, globalDB: miscDB.global, - commends: miscDB.commends, + commends: miscDB.commends, control: miscDB.control, marketplace: miscDB.marketplace, reactRoles: miscDB.reactRoles, @@ -34,22 +48,36 @@ module.exports = function SCHEMAS(activeConnection){ usercols: miscDB.usercols, gifts: miscDB.gift, - cosmetics: require("./schemas/cosmetics.js")(activeConnection), - collectibles: require("./schemas/collectibles.js")(activeConnection), - items: require("./schemas/items.js")(activeConnection), - achievements: require("./schemas/achievements.js")(activeConnection).ACHIEVEMENTS, - quests: require("./schemas/achievements.js")(activeConnection).QUESTS, - advLocations: (require("./schemas/adventure.js"))(activeConnection).LOCATIONS, - advJourneys: (require("./schemas/adventure.js"))(activeConnection).JOURNEYS, - mutes: require("./schemas/mutes.js")(activeConnection), - temproles: require("./schemas/temproles.js")(activeConnection), - promocodes: require("./schemas/promocodes.js")(activeConnection), - airlines: require("./schemas/airlines.js")(activeConnection), - users: userDB, + cosmetics: require("./schemas/cosmetics/cosmetics.js")(activeConnection), + collectibles: require("./schemas/collectibles/collectibles.js")(activeConnection), + items: require("./schemas/items/items.js")(activeConnection), + achievements: require("./schemas/achievements/achievements.js")(activeConnection).ACHIEVEMENTS, + quests: require("./schemas/achievements/achievements.js")(activeConnection).QUESTS, + advLocations: (require("./schemas/adventure/adventure.js"))(activeConnection).LOCATIONS, + advJourneys: (require("./schemas/adventure/adventure.js"))(activeConnection).JOURNEYS, + mutes: require("./schemas/mutes/mutes.js")(activeConnection), + temproles: require("./schemas/temproles/temproles.js")(activeConnection), + promocodes: require("./schemas/promocodes/promocodes.js")(activeConnection), + airlines: require("./schemas/airlines/airlines.js")(activeConnection), + + // ── New collections (authoritative) ─────────────────────── + /*users: usersCore, + userInventory, + userOAuth, + userGuilds, + userQuests, + userAnalytics, + userConnections,*/ + + // ── Legacy (fallback shim only) ─────────────────────────── + /** @deprecated Only for _legacy_userdb_shim. Delete when sunset. */ + _legacyUserDB, + servers: serverDB, guilds: serverDB, channels: channelDB, globals: miscDB.global, + users: collections.usersCore, // alias marketbase: async function refreshBases(projection) { let [bgBase, mdBase, stBase, itBase] = await Promise.all([ this.cosmetics.find({ @@ -144,6 +172,6 @@ module.exports = function SCHEMAS(activeConnection){ fullbase, }; }, - + ...collections }; } \ No newline at end of file diff --git a/schemas/users.js b/schemas/_legacy_users.js similarity index 90% rename from schemas/users.js rename to schemas/_legacy_users.js index 1a3004e..ba536ef 100644 --- a/schemas/users.js +++ b/schemas/_legacy_users.js @@ -1,9 +1,28 @@ +/** + * @deprecated LEGACY USER SCHEMA — monolithic "userdb" collection. + * + * This schema targets the OLD "userdb" collection and is retained ONLY + * as a fallback for the migration period. All new code MUST use: + * - schemas/users_core.js (core user data) + * - schemas/user_inventory.js (inventories, achievements) + * - schemas/user_oauth.js (OAuth tokens, identity cache) + * - schemas/user_guilds.js (cached guild memberships) + * - schemas/user_quests.js (quest progress) + * - schemas/user_analytics.js (statistics, legacy metrics) + * - schemas/user_connections.js (third-party connections) + * + * When sunsetting: delete this file and remove the `_legacyUserDB` + * export from schemas.js. + * + * @module _legacy_users + */ const mongoose = require("mongoose"); const utils = require("../utils.js"); const { Mixed } = mongoose.Schema.Types; -module.exports = function USER_DB(activeConnection){ +/** @deprecated Use schemas/users_core.js and split collections. Scheduled for removal. */ +module.exports = function LEGACY_USER_DB(activeConnection){ const UserSchema = new mongoose.Schema({ @@ -248,7 +267,7 @@ module.exports = function USER_DB(activeConnection){ } UserSchema.methods.upCommend = function upCommend(USER, amt = 1) { - const miscDB = require("./_misc.js")(activeConnection); + const miscDB = require("./_misc/_misc.js")(activeConnection); return new Promise(async (resolve) => { await miscDB.commends.add(this.id, USER.id, amt); const res = await miscDB.commends.parseFull(this.id); diff --git a/schemas/_misc.js b/schemas/_misc/_misc.js similarity index 76% rename from schemas/_misc.js rename to schemas/_misc/_misc.js index 16b2897..b21d24c 100644 --- a/schemas/_misc.js +++ b/schemas/_misc/_misc.js @@ -3,7 +3,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; const { Mixed } = Schema.Types; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); module.exports = function MISC_DB(activeConnection){ @@ -82,33 +82,6 @@ module.exports = function MISC_DB(activeConnection){ extras: Mixed, }, { strict: false }); - const MarketplaceModel = new Schema({ - id: String, - item_id: String, - item_type: String, - price: Number, - currency: String, - author: String, - timestamp: Number, - type: String, - lock: Boolean, - completed: Boolean, - }, { strict: false }); - - - - - const RelationShipModel = new Schema({ - id: String, - users: [{type:String }], - ring: {type:String }, - ringCollection: [{type:String }], - initiative: {type:String }, - since: Number, - lovepoints: Number, - type: String, // MARRIAGE / PARENTS / CHILDREN - - }, { strict: false }); @@ -201,35 +174,6 @@ module.exports = function MISC_DB(activeConnection){ } }; - const MARKETPLACE = activeConnection.model("marketplace", MarketplaceModel, "marketplace"); - MARKETPLACE.set = utils.dbSetter; - MARKETPLACE.get = utils.dbGetter; - MARKETPLACE.new = (payload) => { - const aud = new MARKETPLACE(payload); - aud.save((err) => { - if (err) return console.error(err); - console.log("[NEW MARKETPLACE ENTRY]".blue, payload); - }); - return aud; - }; - - const relationships = activeConnection.model("Relationship", RelationShipModel, "relationships"); - relationships.set = utils.dbSetter; - relationships.get = utils.dbGetter; - relationships.create = function (type, users, initiative, ring, date) { - return new Promise(async (resolve, reject) => { - const rel = await relationships.find({ type, users: { $all: users } }); - if (rel.length > 0) return reject(`Duplicate Relationship: \n${JSON.stringify(rel, null, 2)}`); - - relationship = new relationships({ - type, users, initiative, ring, ringCollection: [ring], since: date || Date.now(), - }); - relationship.save((err, item) => { - resolve(item); - }); - }); - }; - const fanart = activeConnection.model("fanart", FanartModel, "fanart"); fanart.set = utils.dbSetter; fanart.get = utils.dbGetter; @@ -271,9 +215,7 @@ module.exports = function MISC_DB(activeConnection){ } } - - return { - gift, paidroles, usercols, global, fanart, buyables, commends, reactRoles, marketplace: MARKETPLACE, relationships, alert, feed, control, + gift, paidroles, usercols, global, fanart, buyables, commends, reactRoles, alert, feed, control, }; } \ No newline at end of file diff --git a/schemas/achievements.js b/schemas/achievements/achievements.js similarity index 81% rename from schemas/achievements.js rename to schemas/achievements/achievements.js index e81fa90..e014918 100644 --- a/schemas/achievements.js +++ b/schemas/achievements/achievements.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; @@ -44,10 +44,10 @@ module.exports = function MISC_DB(activeConnection){ const QUESTS = activeConnection.model("Quests", Quests, "Quests"); ACHIEVEMENTS.award = (user, achiev) => { - const userDB = require("./users.js")(activeConnection); + const userInventory = require("../user_inventory/user_inventory.js")(activeConnection); return new Promise(async (resolve) => { - await userDB - .updateOne({ id: user.id || user }, { $push: { "modules.achievements": { id: achiev, unlocked: Date.now() } } }).then((res) => resolve(res)); + await userInventory + .updateOne({ id: user.id || user }, { $push: { "achievements": { id: achiev, unlocked: Date.now() } } }).then((res) => resolve(res)); }); }; diff --git a/schemas/achievements/achievements.schema.d.ts b/schemas/achievements/achievements.schema.d.ts new file mode 100644 index 0000000..ec9d83a --- /dev/null +++ b/schemas/achievements/achievements.schema.d.ts @@ -0,0 +1,42 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Achievement { + name: string; + icon: string; + exp: number; + reveal_level: number; + reveal_requisites: unknown[]; + flavor_text_id: string; + condition: string; + advanced_conditions: string[]; + id: string; +} +export interface AchievementSchema extends mongoose.Document, Achievement { id: string; } +export interface AchievementModel extends mongoose.Model { + award: (user: any, achiev: string) => Promise; + set: dbSetter; + get: dbGetter; +} + +export interface Quest { + id: number; + name: string; + flavor_text: string; + instruction: string; + reveal_level: number; + action: string; + type: string; + condition: string; + target: number; + tier: string; + icon: string; + reveal_requisites: string; + advanced_conditions: string; +} +export interface QuestSchema extends mongoose.Document, Quest { id: number; } +export interface QuestModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/adventure.js b/schemas/adventure/adventure.js similarity index 98% rename from schemas/adventure.js rename to schemas/adventure/adventure.js index e0a123c..b5fde3b 100644 --- a/schemas/adventure.js +++ b/schemas/adventure/adventure.js @@ -2,7 +2,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; // future use diff --git a/schemas/adventure/adventure.schema.d.ts b/schemas/adventure/adventure.schema.d.ts new file mode 100644 index 0000000..dcb87d6 --- /dev/null +++ b/schemas/adventure/adventure.schema.d.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter, dbGetterFull } from '../../index'; + +export interface AdventureLocationTraceRoute { + _id: string; name: string; type: string; distance: number; +} +export interface AdventureLocationTraceRouteOptions { + relocating?: boolean; soft?: boolean; exploring?: boolean; +} +export interface AdventureLocation { + id: string; type: string; name: string; description: string; landmark: string; + connects: string[]; drops: unknown[]; canSettle: boolean; + coordinates: { x: number; y: number }; +} +export interface AdventureLocationSchema extends mongoose.Document, AdventureLocation { + id: string; + isAdjacent: (locationID: string) => boolean; +} +export interface AdventureLocationModel extends mongoose.Model { + traceRoutes: (start: string, depth: number, options?: AdventureLocationTraceRouteOptions) => Promise; + set: dbSetter; + get: dbGetterFull; + read: dbGetter; +} + +export interface JourneyEvent { time: number; id: number; trueTime: number; interaction: any; } +export interface Journey { + user: string; start: number; end: number; location: string; insurance: number; events: JourneyEvent[]; +} +export interface JourneySchema extends mongoose.Document, Journey {} +export interface JourneyModel extends mongoose.Model { + new: (user: string, journey: Omit, events: JourneyEvent[]) => Promise; + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/airlines.js b/schemas/airlines/airlines.js similarity index 99% rename from schemas/airlines.js rename to schemas/airlines/airlines.js index 537aab8..35f6c53 100644 --- a/schemas/airlines.js +++ b/schemas/airlines/airlines.js @@ -2,7 +2,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; const Mixed = mongoose.Schema.Types.Mixed; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); module.exports = function AIRLINES_DB(activeConnection){ diff --git a/schemas/airlines/airlines.schema.d.ts b/schemas/airlines/airlines.schema.d.ts new file mode 100644 index 0000000..2707517 --- /dev/null +++ b/schemas/airlines/airlines.schema.d.ts @@ -0,0 +1,52 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { dbSetter, dbGetter, dbGetterFull } from '../../index'; + +export interface Airports { + id: string; name: string; tier: number; passengers: number; + slotAmount: number; slotPrice: number; location: { type: string; coordinates: [number, number] }; +} +export interface AirportsSchema extends mongoose.Document, Airports { + id: string; + withinRange: (km: number) => mongoose.QueryWithHelpers; +} +export interface AirportsModel extends mongoose.Model { + set: dbSetter; get: dbGetter; getFull: dbGetterFull; +} + +export interface Airline { + id: string; acquiredAirplanes: { id: string; assigned: boolean }[]; user: string; airlineName: string; +} +export interface AirlineSchema extends mongoose.Document, Airline { id: string; } +export interface AirlineModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (user: string, id: string, airlineName: string) => Promise; +} + +export interface AirportSlots { airline: string; airport: string; expiresIn: number; } +export interface AirportSlotsSchema extends mongoose.Document, AirportSlots {} +export interface AirportSlotsModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (id: string, airport: string, time: number) => Promise; +} + +export interface AirlineRoute { + startAirport: string; endAirport: string; airline: string; airplane: string; ticketPrice: number; +} +export interface AirlineRouteSchema extends mongoose.Document, AirlineRoute {} +export interface AirlineRouteModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (sa: string, ea: string, airline: string, airplane: string, price: number) => Promise; + check: (a: AirlineRoute) => Promise; + shutdown: (options: { _id: string; airplane: string; airline: string }) => Promise; +} + +export interface Airplane { + id: string; humanName: string; price: number; passengerCap: number; + maintenanceCost: number; make: string; tier: number; range: number; +} +export interface AirplaneSchema extends mongoose.Document, Airplane { id: string; } +export interface AirplaneModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + buy: (airline: string, id: string) => Promise; +} diff --git a/schemas/audits.js b/schemas/audits/audits.js similarity index 98% rename from schemas/audits.js rename to schemas/audits/audits.js index dafc4d2..8b8b62f 100644 --- a/schemas/audits.js +++ b/schemas/audits/audits.js @@ -2,7 +2,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; const { Mixed } = Schema.Types; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const crypto = require('crypto') module.exports = function MISC_DB(activeConnection){ diff --git a/schemas/audits/audits.schema.d.ts b/schemas/audits/audits.schema.d.ts new file mode 100644 index 0000000..e63d57d --- /dev/null +++ b/schemas/audits/audits.schema.d.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Audit { + from: string; to: string; type: string; currency: string; + transaction: string; amt: number; timestamp: number; transactionId: string; details: any; +} +export interface AuditSchema extends mongoose.Document, Audit {} +export interface AuditModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; + new: (payload: Partial) => Promise; + receive: (user: string, type: string, currency?: string, amt?: number) => Promise; + forfeit: (user: string, type: string, currency?: string, amt?: number) => Promise; +} diff --git a/schemas/channels.js b/schemas/channels/channels.js similarity index 98% rename from schemas/channels.js rename to schemas/channels/channels.js index b03b227..34dface 100644 --- a/schemas/channels.js +++ b/schemas/channels/channels.js @@ -1,5 +1,5 @@ const mongoose = require("mongoose"); -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = mongoose.Schema.Types; diff --git a/schemas/channels/channels.schema.d.ts b/schemas/channels/channels.schema.d.ts new file mode 100644 index 0000000..111b411 --- /dev/null +++ b/schemas/channels/channels.schema.d.ts @@ -0,0 +1,14 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Channel { + meta: any; snipe: any; name: string; server: string; guild: string; + slowmode: boolean; ignored: boolean; settings: any; slowmodeTimer: number; + LANGUAGE: string; id: string; modules: any; +} +export interface ChannelSchema extends mongoose.Document, Channel { id: string; } +export interface ChannelModel extends mongoose.Model { + updateMeta(C: { name: string; topic: string; position: number; nsfw: boolean }): Promise; + new: (chanData: any) => void; + set: dbSetter; get: dbGetter; +} diff --git a/schemas/collectibles.js b/schemas/collectibles/collectibles.js similarity index 92% rename from schemas/collectibles.js rename to schemas/collectibles/collectibles.js index 29bebab..9d00d0d 100644 --- a/schemas/collectibles.js +++ b/schemas/collectibles/collectibles.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; diff --git a/schemas/collectibles/collectibles.schema.d.ts b/schemas/collectibles/collectibles.schema.d.ts new file mode 100644 index 0000000..948c76d --- /dev/null +++ b/schemas/collectibles/collectibles.schema.d.ts @@ -0,0 +1,9 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Collectibles { name: string; id: string; rarity: string; icon: string; emoji: string; attribs: any; } +export interface CollectiblesSchema extends mongoose.Document, Collectibles { id: string; } +export interface CollectiblesModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/cosmetics.js b/schemas/cosmetics/cosmetics.js similarity index 96% rename from schemas/cosmetics.js rename to schemas/cosmetics/cosmetics.js index 04dc6ac..9cc5ac8 100644 --- a/schemas/cosmetics.js +++ b/schemas/cosmetics/cosmetics.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; diff --git a/schemas/cosmetics/cosmetics.schema.d.ts b/schemas/cosmetics/cosmetics.schema.d.ts new file mode 100644 index 0000000..1fbff00 --- /dev/null +++ b/schemas/cosmetics/cosmetics.schema.d.ts @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Cosmetics { + id: string; + name: string; + tags: string; + series: string; + series_id: string; + type: string; + icon: string; + code: string; + rarity: string; + price: number; + event: string; + droppable: boolean; + buyable: boolean; + howto: string; + category: string; + items: string[]; + color: string; + for: string; + localizer: string; + exclusive: string; + public: boolean; + filter: string; + expires: number; +} + +export interface CosmeticsSchema extends mongoose.Document, Cosmetics { + id: string; +} + +export interface CosmeticsModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; + bgs: (filter?: mongoose.FilterQuery) => mongoose.QueryWithHelpers; + medals: (filter?: mongoose.FilterQuery) => mongoose.QueryWithHelpers; + stickers: (filter?: mongoose.FilterQuery) => mongoose.QueryWithHelpers; +} diff --git a/schemas/cosmetics/cosmetics.types.d.ts b/schemas/cosmetics/cosmetics.types.d.ts new file mode 100644 index 0000000..34cabf0 --- /dev/null +++ b/schemas/cosmetics/cosmetics.types.d.ts @@ -0,0 +1,88 @@ +import type { Rarity } from '../../types/generics'; + +export type CosmeticType = 'background' | 'medal' | 'sticker' | 'flair' | 'skin'; +type SkinCompatible = 'casino' | 'tarot'; +type SkinSubtype = 'deck'; + +export interface CosmeticBaseItem { + _id: { toString(): string }; + id?: string; + name: string; + tags: string; + rarity: Rarity; + type: CosmeticType; + event?: string; + meta: Record; + price?: number; + BUNDLE?: string; + exclusive: string; + public: boolean; + destroyable: boolean; + tradeable: boolean; + droppable: boolean; + buyable: boolean; +} + +export type CosmeticBackground = CosmeticBaseItem & { + id: string; + type: 'background'; + artistName?: string; + artistLink?: string; + code: string; +}; + +export type CosmeticMedal = CosmeticBaseItem & { + type: 'medal'; + category: string; + howto: string; + icon: string; +}; + +export type CosmeticSticker = CosmeticBaseItem & { + id: string; + type: 'sticker'; + release_number?: number; + series_id?: string; + series?: string; + GROUP?: string; +}; + +export type CosmeticFlair = CosmeticBaseItem & { + type: 'flair'; + id: string; +}; + +export type CosmeticSkin = CosmeticBaseItem & { + type: 'skin'; + localizer: string; + for: SkinCompatible; + subtype: SkinSubtype; + author?: string; + author_link?: string; + id: string; +}; + +export type CosmeticBoosterpack = CosmeticBaseItem & { + id: string; + type: 'boosterpack'; + color: string; + GROUP: string; + price: number; + items: Array<{ id: string; type: string }>; +}; + +export type CosmeticBundle = CosmeticBaseItem & { + type: 'bundle'; + GROUP: string; + price: number; + items: Array<{ id: string; type: string }>; +}; + +export type CosmeticItem = + | CosmeticBackground + | CosmeticMedal + | CosmeticSticker + | CosmeticFlair + | CosmeticSkin + | CosmeticBoosterpack + | CosmeticBundle; diff --git a/schemas/cosmetics/cosmetics.virtuals.js b/schemas/cosmetics/cosmetics.virtuals.js new file mode 100644 index 0000000..97e7805 --- /dev/null +++ b/schemas/cosmetics/cosmetics.virtuals.js @@ -0,0 +1,16 @@ +'use strict'; + +/** + * Virtuals for the Cosmetics schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} CosmeticsSchema + */ +module.exports = function (CosmeticsSchema) { + CosmeticsSchema.virtual('packData', { + ref: 'Item', + localField: 'series_id', + foreignField: 'icon', + justOne: true, + }); +}; diff --git a/schemas/items.js b/schemas/items/items.js similarity index 93% rename from schemas/items.js rename to schemas/items/items.js index ee652cb..a9cc9c2 100644 --- a/schemas/items.js +++ b/schemas/items/items.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; @@ -51,10 +51,10 @@ module.exports = function ITEM_DB(activeConnection){ }; const itemOperation = (user, itemId, field, amt=1) => { - return this.model("UserDB").updateOne( + return this.model("UsersCore").updateOne( { id: user.id||user }, {$inc:{ - ["modules.inventory.$[item]."+field]: amt + ["profile.inventory.$[item]."+field]: amt }}, {arrayFilters: [ {"item.id":itemId} diff --git a/schemas/items/items.schema.d.ts b/schemas/items/items.schema.d.ts new file mode 100644 index 0000000..2c68ebf --- /dev/null +++ b/schemas/items/items.schema.d.ts @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { CustomQuery, dbSetter, dbGetter, IDOrIDObject } from '../../index'; + +export interface Item { + name: string; + id: string; + rarity: string; + icon: string; + emoji: string; + price: number; + altEmoji: string; + event: string; + event_id: number; + type: string; + tradeable: boolean; + buyable: boolean; + destroyable: boolean; + usefile: string; + code: string; + misc: any; + subtype: string; + series: string; + filter: string; + crafted: boolean; + color: string; + exclusive: string; + public: boolean; + materials: { id: string; count: number }[]; + typeCraft: { type: string; count: number }[]; + gemcraft: { RBN: number; JDE: number; SPH: number }; +} + +export interface ItemSchema extends mongoose.Document, Item { + id: string; +} + +export interface ItemModel extends mongoose.Model { + getAll: () => Promise; + cat: (cat: string) => Promise; + consume: (user: IDOrIDObject, itemID: string, amt?: number) => mongoose.QueryWithHelpers; + destroy: ItemModel['consume']; + receive: (user: IDOrIDObject, itemID: string, amt?: number) => mongoose.QueryWithHelpers; + add: ItemModel['receive']; + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/items/items.types.d.ts b/schemas/items/items.types.d.ts new file mode 100644 index 0000000..9322f4f --- /dev/null +++ b/schemas/items/items.types.d.ts @@ -0,0 +1,48 @@ +import type { Rarity, Currency } from '../../types/generics'; + +export type ItemType = + | 'boosterpack' | 'box' | 'consumable' + | 'key' | 'material' | 'junk' | 'other'; + +export type ItemSeries = + | 'artifact' | 'booster' | 'consumables' | 'crafting' + | 'fishing' | 'gem' | 'event' | 'ring' | 'wtf' | 'other'; + +export type ItemFilter = + | 'FLW' | 'SFK' | 'neutral' | 'chibi' | 'epic' | 'event' | 'plx_collection'; + +/** Slim inventory entry — what a user owns. */ +export interface UserItem { + id: string; + count: number; + crafted?: number; +} + +/** Full item definition — what the items collection stores. */ +export interface InventoryItem { + id: string; + name: string; + rarity: Rarity; + type: ItemType; + icon: string; + emoji: string; + price: number; + misc: Record; + public: boolean; + tradeable: boolean; + buyable: boolean; + destroyable: boolean; + crafted: boolean; + event?: string; + code?: string; + features?: string; + maxBulkCraft?: number; + materials: UserItem[]; + typeCraft?: Array<{ type: string; count: number }>; + rewards?: Array<{ type: string; id: string }>; + gemcraft?: { [K in Currency]?: number }; + series?: ItemSeries; + filter?: ItemFilter; + subtype?: string; + exclusive?: string; +} diff --git a/schemas/items/items.virtuals.js b/schemas/items/items.virtuals.js new file mode 100644 index 0000000..e657fe4 --- /dev/null +++ b/schemas/items/items.virtuals.js @@ -0,0 +1,16 @@ +'use strict'; + +/** + * Virtuals for the Items schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} ItemsSchema + */ +module.exports = function (ItemsSchema) { + ItemsSchema.virtual('stickers', { + ref: 'Cosmetic', + localField: 'icon', + foreignField: 'id', + justOne: false, + }); +}; diff --git a/schemas/localranks.js b/schemas/localranks/localranks.js similarity index 96% rename from schemas/localranks.js rename to schemas/localranks/localranks.js index 53966f7..8f985b6 100644 --- a/schemas/localranks.js +++ b/schemas/localranks/localranks.js @@ -1,5 +1,5 @@ const mongoose = require("mongoose"); -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = mongoose.Schema.Types; diff --git a/schemas/localranks/localranks.schema.d.ts b/schemas/localranks/localranks.schema.d.ts new file mode 100644 index 0000000..57c7464 --- /dev/null +++ b/schemas/localranks/localranks.schema.d.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface LocalRanks { server: string; user: string; level: number; exp: number; thx: number; lastUpdated: Date; } +export interface LocalRanksSchema extends mongoose.Document, LocalRanks {} +export interface LocalRanksModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (US: { U: any; S: any }) => void; + incrementExp: (US: { U: any; S: any }, X?: number) => mongoose.Query; + incrementLv: (US: { U: any; S: any }, X?: number) => mongoose.Query; +} diff --git a/schemas/lootboxes.js b/schemas/lootboxes.js deleted file mode 100644 index e69de29..0000000 diff --git a/schemas/marketplace/marketplace.js b/schemas/marketplace/marketplace.js new file mode 100644 index 0000000..4de17e3 --- /dev/null +++ b/schemas/marketplace/marketplace.js @@ -0,0 +1,56 @@ +'use strict'; +const mongoose = require('mongoose'); +const utils = require("../../utils.js"); +const { Schema } = mongoose; + +const MarketplaceModel = new Schema({ + id: { type: String, required: true }, + item_id: { type: String, required: true }, + item_type: { type: String, required: true }, + price: { type: Number, required: true }, + currency: { type: String, required: true }, + author: { type: String, required: true }, + timestamp: Number, + type: String, + lock: Boolean, + completed: Boolean, +}, { strict: false }); + +MarketplaceModel.statics.get = function get(query, projection) { + return this.findOne(query, projection); +}; + +MarketplaceModel.statics.set = function set(query, update, options = {}) { + return this.findOneAndUpdate(query, update, { upsert: true, new: true, ...options }); +}; + +module.exports = function (connection) { + + const MARKETPLACE = connection.model("marketplace", MarketplaceModel, "marketplace"); + MARKETPLACE.set = utils.dbSetter; + MARKETPLACE.get = utils.dbGetter; + MARKETPLACE.new = (payload) => { + const aud = new MARKETPLACE(payload); + aud.save((err) => { + if (err) return console.error(err); + console.log("[NEW MARKETPLACE ENTRY]".blue, payload); + }); + return aud; + }; + + + + + /** + * @param {import('mongoose').Connection} connection + * @returns {import('./marketplace.schema').MarketplaceModel} + */ + + return MARKETPLACE; +}; + + + + + + diff --git a/schemas/marketplace/marketplace.schema.d.ts b/schemas/marketplace/marketplace.schema.d.ts new file mode 100644 index 0000000..fe85a30 --- /dev/null +++ b/schemas/marketplace/marketplace.schema.d.ts @@ -0,0 +1,28 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Marketplace { + id: string; + item_id: string; + item_type: string; + price: number; + currency: string; + author: string; + timestamp: number; +} +export interface MarketplaceSchema extends mongoose.Document, Marketplace { + id: string; +} +export interface MarketplaceModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; + new: (payload: Marketplace) => void; +} + +export interface MarketbaseProjection { + bgBase?: boolean; + mdBase?: boolean; + stBase?: boolean; + itBase?: boolean; + fullbase?: boolean; +} diff --git a/schemas/marketplace/marketplace.types.d.ts b/schemas/marketplace/marketplace.types.d.ts new file mode 100644 index 0000000..f638540 --- /dev/null +++ b/schemas/marketplace/marketplace.types.d.ts @@ -0,0 +1,8 @@ +// import type { Rarity, Currency } from '../../types/generics'; + +/** Front-facing `Marketplace` type — clean unions for API/Bot consumption. */ +export interface MarketplaceData { + // TODO: mirror your schema fields but with proper union types + // id: string; + // rarity: Rarity; +} diff --git a/schemas/marketplace/marketplace.virtuals.js b/schemas/marketplace/marketplace.virtuals.js new file mode 100644 index 0000000..660a9dc --- /dev/null +++ b/schemas/marketplace/marketplace.virtuals.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Virtuals for the Marketplace schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} MarketplaceSchema + */ +module.exports = function (MarketplaceSchema) { + MarketplaceSchema.virtual('authorData', { + ref: 'UsersCore', // Was "UserDB" (legacy userdb collection) + localField: 'author', + foreignField: 'id', + justOne: true, + }); + + MarketplaceSchema.virtual('moreFromAuthor', { + ref: 'marketplace', + localField: 'author', + foreignField: 'author', + justOne: false, + }); + + MarketplaceSchema.virtual('moreLikeThis', { + ref: 'marketplace', + localField: 'item_id', + foreignField: 'item_id', + justOne: false, + }); + + MarketplaceSchema.virtual('itemData', { + ref: function () { + return ['background', 'medal', 'flair', 'sticker', 'shade'].includes(this.item_type) + ? 'Cosmetic' + : 'Item'; + }, + localField: 'item_id', + foreignField: '_id', + justOne: true, + }); +}; diff --git a/schemas/mutes.js b/schemas/mutes/mutes.js similarity index 97% rename from schemas/mutes.js rename to schemas/mutes/mutes.js index 71eb1a2..10b91ae 100644 --- a/schemas/mutes.js +++ b/schemas/mutes/mutes.js @@ -1,5 +1,5 @@ const mongoose = require("mongoose"); -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = mongoose.Schema.Types; diff --git a/schemas/mutes/mutes.schema.d.ts b/schemas/mutes/mutes.schema.d.ts new file mode 100644 index 0000000..6eb246c --- /dev/null +++ b/schemas/mutes/mutes.schema.d.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { dbSetter, dbGetter, US, USE } from '../../index'; + +export interface Mute { server: string; user: string; expires: number; } +export interface MuteSchema extends mongoose.Document, Mute {} +export interface MuteModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (US: USE) => void; add: (US: USE) => void; + expire(US: US): mongoose.QueryWithHelpers; + expire(US: number): mongoose.QueryWithHelpers; +} diff --git a/schemas/promocodes.js b/schemas/promocodes/promocodes.js similarity index 93% rename from schemas/promocodes.js rename to schemas/promocodes/promocodes.js index 76eed21..892d79d 100644 --- a/schemas/promocodes.js +++ b/schemas/promocodes/promocodes.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; diff --git a/schemas/promocodes/promocodes.schema.d.ts b/schemas/promocodes/promocodes.schema.d.ts new file mode 100644 index 0000000..4d0b64a --- /dev/null +++ b/schemas/promocodes/promocodes.schema.d.ts @@ -0,0 +1,9 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface PromoCode { code: string; locked: boolean; consumed: boolean; redeemedBy: any; maxUses: number; uses: number; prize: any; } +export interface PromoCodeSchema extends mongoose.Document, PromoCode {} +export interface PromoCodeModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/rankings.js b/schemas/rankings/rankings.js similarity index 93% rename from schemas/rankings.js rename to schemas/rankings/rankings.js index 182c3b9..31c974d 100644 --- a/schemas/rankings.js +++ b/schemas/rankings/rankings.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; diff --git a/schemas/rankings/rankings.schema.d.ts b/schemas/rankings/rankings.schema.d.ts new file mode 100644 index 0000000..904a8cc --- /dev/null +++ b/schemas/rankings/rankings.schema.d.ts @@ -0,0 +1,9 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Ranking { id: string; type: string; points: number; timestamp: number; data: any; } +export interface RankingSchema extends mongoose.Document, Ranking { id: string; } +export interface RankingModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/relationships/relationships.js b/schemas/relationships/relationships.js new file mode 100644 index 0000000..4323279 --- /dev/null +++ b/schemas/relationships/relationships.js @@ -0,0 +1,51 @@ +'use strict'; +const mongoose = require('mongoose'); +const utils = require("../../utils.js"); +const { Schema } = mongoose; + +const RelationShipModel = new Schema({ + id: String, + users: [{ type: String }], + ring: { type: String }, + ringCollection: [{ type: String }], + initiative: { type: String }, + since: Number, + lovepoints: Number, + type: String, // MARRIAGE / PARENTS / CHILDREN + +}, { strict: false }); + + +RelationShipModel.statics.get = function get(query, projection) { + return this.findOne(query, projection); +}; + +RelationShipModel.statics.set = function set(query, update, options = {}) { + return this.findOneAndUpdate(query, update, { upsert: true, new: true, ...options }); +}; + +/** + * @param {import('mongoose').Connection} connection + * @returns {import('./relationships.schema').RelationshipsModel} + */ +module.exports = function (connection) { + const relationships = connection.model("Relationships", RelationShipModel, "relationships"); + relationships.set = utils.dbSetter; + relationships.get = utils.dbGetter; + relationships.create = function (type, users, initiative, ring, date) { + return new Promise(async (resolve, reject) => { + const rel = await relationships.find({ type, users: { $all: users } }); + if (rel.length > 0) return reject(`Duplicate Relationship: \n${JSON.stringify(rel, null, 2)}`); + + relationship = new relationships({ + type, users, initiative, ring, ringCollection: [ring], since: date || Date.now(), + }); + relationship.save((err, item) => { + resolve(item); + }); + }); + }; + + + return relationships; +}; diff --git a/schemas/relationships/relationships.schema.d.ts b/schemas/relationships/relationships.schema.d.ts new file mode 100644 index 0000000..d58666e --- /dev/null +++ b/schemas/relationships/relationships.schema.d.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Relationship { + id: string; + users: [string, string]; + ring: 'jade' | 'sapphire' | 'stardust' | 'rubine'; + ringCollection: string[]; + initiative: string; + since: number; + lovepoints: number; + type: 'marriage' | 'parents' | 'children'; +} +export interface RelationshipSchema extends mongoose.Document, Relationship { + id: string; +} +// @ts-ignore +export interface RelationshipModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; + create: (type: 'marriage' | 'parents' | 'children', users: [string, string], initiative: string, ring: 'jade' | 'sapphire' | 'stardust' | 'rubine', date?: number) => Promise; +} diff --git a/schemas/relationships/relationships.types.d.ts b/schemas/relationships/relationships.types.d.ts new file mode 100644 index 0000000..6da9d39 --- /dev/null +++ b/schemas/relationships/relationships.types.d.ts @@ -0,0 +1,8 @@ +// import type { Rarity, Currency } from '../../types/generics'; + +/** Front-facing `Relationships` type — clean unions for API/Bot consumption. */ +export interface RelationshipsData { + // TODO: mirror your schema fields but with proper union types + // id: string; + // rarity: Rarity; +} diff --git a/schemas/relationships/relationships.virtuals.js b/schemas/relationships/relationships.virtuals.js new file mode 100644 index 0000000..3b69174 --- /dev/null +++ b/schemas/relationships/relationships.virtuals.js @@ -0,0 +1,16 @@ +'use strict'; + +/** + * Virtuals for the Relationships schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} RelationshipsSchema + */ +module.exports = function (RelationshipsSchema) { + RelationshipsSchema.virtual('usersData', { + ref: 'UsersCore', // Was "UserDB" (legacy userdb collection) + localField: 'users', + foreignField: 'id', + justOne: false, + }); +}; diff --git a/schemas/responses.js b/schemas/responses/responses.js similarity index 93% rename from schemas/responses.js rename to schemas/responses/responses.js index b75ea41..69059ab 100644 --- a/schemas/responses.js +++ b/schemas/responses/responses.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const { Schema } = mongoose; -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = Schema.Types; diff --git a/schemas/responses/responses.schema.d.ts b/schemas/responses/responses.schema.d.ts new file mode 100644 index 0000000..ceff613 --- /dev/null +++ b/schemas/responses/responses.schema.d.ts @@ -0,0 +1,9 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export interface Responses { trigger: string; response: string; server: string; id: string; embed: any; type: 'EMBED' | 'STRING' | 'FILE'; } +export interface ResponsesSchema extends mongoose.Document, Responses { id: string; } +export interface ResponsesModel extends mongoose.Model { + set: dbSetter; + get: dbGetter; +} diff --git a/schemas/serverMeta.js b/schemas/serverMeta/serverMeta.js similarity index 93% rename from schemas/serverMeta.js rename to schemas/serverMeta/serverMeta.js index bcb8f40..c226f90 100644 --- a/schemas/serverMeta.js +++ b/schemas/serverMeta/serverMeta.js @@ -1,5 +1,5 @@ const mongoose = require("mongoose"); -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = mongoose.Schema.Types; module.exports = function SVMETA_DB(activeConnection){ diff --git a/schemas/serverMeta/serverMeta.schema.d.ts b/schemas/serverMeta/serverMeta.schema.d.ts new file mode 100644 index 0000000..233f942 --- /dev/null +++ b/schemas/serverMeta/serverMeta.schema.d.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +export type ChannelType = number; + +export interface ServerMetadataChannel { name: string; pos: number; id: string; cat: string; type: ChannelType; nsfw: boolean; } +export interface ServerMetadata { + id: string; name: string; number: string; roles: [string, string][]; + adms: string[]; channels: ServerMetadataChannel[]; icon: string; +} +export interface ServerMetadataSchema extends mongoose.Document, ServerMetadata { id: string; } +export interface ServerMetadataModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + cat: 'sv_meta'; + updateMeta: (S: ServerMetadata) => Promise; +} diff --git a/schemas/servers.js b/schemas/servers/servers.js similarity index 98% rename from schemas/servers.js rename to schemas/servers/servers.js index 658c54e..e2d1a9b 100644 --- a/schemas/servers.js +++ b/schemas/servers/servers.js @@ -1,7 +1,7 @@ const SERVER_QUEUE = []; const mongoose = require("mongoose"); -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = mongoose.Schema.Types; @@ -117,7 +117,7 @@ module.exports = function MISC_DB(activeConnection){ const MODEL = activeConnection.model("ServerDB", ServerSchema, "serverdb"); - const META = require("./serverMeta.js")(activeConnection); + const META = require("../serverMeta/serverMeta.js")(activeConnection); META.updateMeta = function (S) { return new Promise(async (resolve) => { diff --git a/schemas/servers/servers.schema.d.ts b/schemas/servers/servers.schema.d.ts new file mode 100644 index 0000000..fe46c43 --- /dev/null +++ b/schemas/servers/servers.schema.d.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter } from '../../index'; + +// For full server/channel types see index.d.ts (Server, ServerModule, etc.) +export interface Server { + id: string; name: string; globalhandle: string; globalPrefix: boolean; + respondDisabled: boolean; event: any; eventReg: string; partner: boolean; + progression: any; partnerDetails: any; utilityChannels: any; logging: boolean; + imgwelcome: boolean; splitLogs: boolean; switches: any; modules: any; logs: any; + channels: any; lastUpdated: Date; +} +export interface ServerSchema extends mongoose.Document, Server { id: string; } +export interface ServerModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (svData: Server) => void; +} diff --git a/schemas/temproles.js b/schemas/temproles/temproles.js similarity index 97% rename from schemas/temproles.js rename to schemas/temproles/temproles.js index a15475b..4a29b3f 100644 --- a/schemas/temproles.js +++ b/schemas/temproles/temproles.js @@ -1,5 +1,5 @@ const mongoose = require("mongoose"); -const utils = require("../utils.js"); +const utils = require("../../utils.js"); const { Mixed } = mongoose.Schema.Types; diff --git a/schemas/temproles/temproles.schema.d.ts b/schemas/temproles/temproles.schema.d.ts new file mode 100644 index 0000000..f7c85b6 --- /dev/null +++ b/schemas/temproles/temproles.schema.d.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { dbSetter, dbGetter, US, USER } from '../../index'; + +export interface Temprole { server: string; user: string; role: string; expires: number; } +export interface TemproleSchema extends mongoose.Document, Temprole {} +export interface TemproleModel extends mongoose.Model { + set: dbSetter; get: dbGetter; + new: (US: USER) => void; add: (US: USER) => void; + expire(US: US): mongoose.QueryWithHelpers; + expire(US: number): mongoose.QueryWithHelpers; +} diff --git a/schemas/user_analytics/user_analytics.js b/schemas/user_analytics/user_analytics.js new file mode 100644 index 0000000..123ae58 --- /dev/null +++ b/schemas/user_analytics/user_analytics.js @@ -0,0 +1,65 @@ +/** + * User Analytics — statistics and legacy metrics. + * Collection: "user_analytics" + * + * Extracted from: + * userdb.progression.globalLV/globalXP → user_analytics.legacy.* + * userdb.counters.dashThemeClicks → user_analytics.dashThemeClicks + * userdb.modules.statistics → user_analytics.statistics + * + * @module user_analytics + */ +const mongoose = require("mongoose"); + +const { Mixed } = mongoose.Schema.Types; + +module.exports = function USER_ANALYTICS(activeConnection) { + + const UserAnalyticsSchema = new mongoose.Schema({ + userId: { type: String, required: true, index: { unique: true } }, + + legacy: { + globalLV: { type: Number, default: 0 }, + globalXP: { type: Number, default: 0 }, + }, + + dashThemeClicks: { type: Number, default: 0 }, + statistics: { type: Mixed, default: {} }, + + }, { + strict: true, + collection: "user_analytics", + timestamps: false, + }); + + const MODEL = activeConnection.model("UserAnalytics", UserAnalyticsSchema, "user_analytics"); + + MODEL.get = function (userId, project) { + if (typeof userId === "object" && userId.id) userId = userId.id; + if (!project) project = { _id: 0 }; + return this.findOne({ userId: userId.toString() }, project).lean(); + }; + + MODEL.set = function (userId, alter, options = {}) { + if (typeof userId === "object" && userId.id) userId = userId.id; + if (!options.upsert) options.upsert = true; + return this.updateOne({ userId: userId.toString() }, alter, options).lean().exec(); + }; + + MODEL.getOrCreate = async function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + userId = userId.toString(); + let doc = await this.findOne({ userId }); + if (!doc) { + doc = new MODEL({ userId }); + await doc.save(); + } + return doc; + }; + + MODEL.new = function (userId) { + return MODEL.getOrCreate(userId); + }; + + return MODEL; +}; diff --git a/schemas/user_analytics/user_analytics.schema.d.ts b/schemas/user_analytics/user_analytics.schema.d.ts new file mode 100644 index 0000000..391260e --- /dev/null +++ b/schemas/user_analytics/user_analytics.schema.d.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; +import { IDOrIDObject } from '../../index'; + +export interface UserAnalyticsData { + userId: string; + legacy: { globalLV: number; globalXP: number }; + dashThemeClicks: number; + statistics: any; +} +export interface UserAnalyticsSchema extends mongoose.Document, UserAnalyticsData {} +export interface UserAnalyticsModel extends mongoose.Model { + get: (userId: IDOrIDObject, project?: any) => Promise; + set: (userId: IDOrIDObject, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + getOrCreate: (userId: IDOrIDObject) => Promise; + new: (userId: IDOrIDObject) => Promise; +} diff --git a/schemas/user_connections/user_connections.js b/schemas/user_connections/user_connections.js new file mode 100644 index 0000000..efd2d65 --- /dev/null +++ b/schemas/user_connections/user_connections.js @@ -0,0 +1,94 @@ +/** + * User Connections — third-party linked accounts. + * Collection: "user_connections" + * + * Extracted from: + * userdb.discordData.connections[] (Discord-linked accounts) + * userdb.connections.lastfm/twitter/spotify/twitch + * + * One document per (userId, type) pair. + * + * @module user_connections + */ +const mongoose = require("mongoose"); + +const { Mixed } = mongoose.Schema.Types; + +module.exports = function USER_CONNECTIONS(activeConnection) { + + const UserConnectionsSchema = new mongoose.Schema({ + userId: { type: String, required: true }, + type: { type: String, required: true }, + externalId: { type: String, default: "" }, + name: { type: String, default: "" }, + verified: { type: Boolean, default: false }, + visibility: { type: Number, default: 0 }, + show_activity: { type: Boolean, default: false }, + friend_sync: { type: Boolean, default: false }, + two_way_link: { type: Boolean, default: false }, + metadata_visibility: { type: Number, default: 0 }, + extra: { type: Mixed, default: null }, + }, { + strict: true, + collection: "user_connections", + timestamps: false, + }); + + // Compound unique index matching mongoscript + UserConnectionsSchema.index({ userId: 1, type: 1 }, { unique: true }); + + const MODEL = activeConnection.model("UserConnection", UserConnectionsSchema, "user_connections"); + + MODEL.get = function (query, project) { + if (typeof query === "string") query = { userId: query }; + if (!project) project = { _id: 0 }; + return this.findOne(query, project).lean(); + }; + + MODEL.set = function (query, alter, options = {}) { + if (typeof query === "string") query = { userId: query }; + if (!options.upsert) options.upsert = true; + return this.updateOne(query, alter, options).lean().exec(); + }; + + /** + * Get all connections for a user. + * @param {string} userId + * @returns {Promise} + */ + MODEL.allForUser = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return this.find({ userId: userId.toString() }, { _id: 0 }).lean(); + }; + + /** + * Bulk upsert connections from Discord OAuth response. + * @param {string} userId + * @param {object[]} connections - Array from Discord connections endpoint + */ + MODEL.bulkUpsert = function (userId, connections) { + if (!connections || !connections.length) return Promise.resolve(); + const ops = connections.map((c) => ({ + updateOne: { + filter: { userId, type: c.type }, + update: { + $set: { + externalId: c.id || c.external_id || "", + name: c.name || "", + verified: c.verified || false, + visibility: c.visibility || 0, + show_activity: c.show_activity || false, + friend_sync: c.friend_sync || false, + two_way_link: c.two_way_link || false, + metadata_visibility: c.metadata_visibility || 0, + extra: c, + }, + }, + upsert: true, + }, + })); + return this.bulkWrite(ops); + }; + + return MODEL; +}; diff --git a/schemas/user_connections/user_connections.schema.d.ts b/schemas/user_connections/user_connections.schema.d.ts new file mode 100644 index 0000000..8d66462 --- /dev/null +++ b/schemas/user_connections/user_connections.schema.d.ts @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; +import { IDOrIDObject } from '../../index'; + +export interface UserConnectionData { + userId: string; + type: string; + externalId: string; + name: string; + verified: boolean; + visibility: number; + show_activity: boolean; + friend_sync: boolean; + two_way_link: boolean; + metadata_visibility: number; + extra: any; +} +export interface UserConnectionSchema extends mongoose.Document, UserConnectionData {} +export interface UserConnectionModel extends mongoose.Model { + get: (query: any, project?: any) => Promise; + set: (query: any, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + allForUser: (userId: IDOrIDObject) => Promise; + bulkUpsert: (userId: string, connections: any[]) => Promise; +} diff --git a/schemas/user_guilds/user_guilds.js b/schemas/user_guilds/user_guilds.js new file mode 100644 index 0000000..04f3591 --- /dev/null +++ b/schemas/user_guilds/user_guilds.js @@ -0,0 +1,89 @@ +/** + * User Guilds — cached Discord guild memberships. + * Collection: "user_guilds" + * + * Extracted from userdb.discordData.guilds (unwound per guild). + * One document per (userId, guildId) pair. + * + * @module user_guilds + */ +const mongoose = require("mongoose"); + +const { Mixed } = mongoose.Schema.Types; + +module.exports = function USER_GUILDS(activeConnection) { + + const UserGuildsSchema = new mongoose.Schema({ + userId: { type: String, required: true, index: true }, + guildId: { type: String, required: true, index: true }, + name: { type: String, default: "" }, + icon: { type: String, default: null }, + banner: { type: String, default: null }, + owner: { type: Boolean, default: false }, + permissions: { type: Number, default: 0 }, + permissions_new: { type: String, default: null }, + features: { type: [String], default: [] }, + cachedAt: { type: Date, default: Date.now }, + }, { + strict: true, + collection: "user_guilds", + timestamps: false, + }); + + // Compound unique index matching mongoscript + UserGuildsSchema.index({ userId: 1, guildId: 1 }, { unique: true }); + + const MODEL = activeConnection.model("UserGuild", UserGuildsSchema, "user_guilds"); + + MODEL.get = function (query, project) { + if (typeof query === "string") query = { userId: query }; + if (!project) project = { _id: 0 }; + return this.findOne(query, project).lean(); + }; + + MODEL.set = function (query, alter, options = {}) { + if (typeof query === "string") query = { userId: query }; + if (!options.upsert) options.upsert = true; + return this.updateOne(query, alter, options).lean().exec(); + }; + + /** + * Get all guilds for a user. + * @param {string} userId + * @returns {Promise} + */ + MODEL.allForUser = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return this.find({ userId: userId.toString() }, { _id: 0 }).lean(); + }; + + /** + * Upsert multiple guilds for a user at once. + * @param {string} userId + * @param {Array} guilds + */ + MODEL.bulkUpsert = function (userId, guilds) { + if (!guilds || !guilds.length) return Promise.resolve(); + const ops = guilds.map((g) => ({ + updateOne: { + filter: { userId, guildId: g.id }, + update: { + $set: { + name: g.name, + icon: g.icon, + banner: g.banner, + owner: g.owner, + permissions: g.permissions, + permissions_new: g.permissions_new, + features: g.features, + cachedAt: new Date(), + }, + }, + upsert: true, + }, + })); + return this.bulkWrite(ops); + }; + + return MODEL; +}; diff --git a/schemas/user_guilds/user_guilds.schema.d.ts b/schemas/user_guilds/user_guilds.schema.d.ts new file mode 100644 index 0000000..0f49a5e --- /dev/null +++ b/schemas/user_guilds/user_guilds.schema.d.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; +import { dbSetter, IDOrIDObject } from '../../index'; + +export interface UserGuildData { + userId: string; + guildId: string; + name: string; + icon: string | null; + banner: string | null; + owner: boolean; + permissions: number; + permissions_new: string | null; + features: string[]; + cachedAt: Date; +} +export interface UserGuildSchema extends mongoose.Document, UserGuildData {} +export interface UserGuildModel extends mongoose.Model { + get: (query: any, project?: any) => Promise; + set: (query: any, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + allForUser: (userId: IDOrIDObject) => Promise; + bulkUpsert: (userId: string, guilds: any[]) => Promise; +} diff --git a/schemas/user_inventory/user_inventory.js b/schemas/user_inventory/user_inventory.js new file mode 100644 index 0000000..ffb0b04 --- /dev/null +++ b/schemas/user_inventory/user_inventory.js @@ -0,0 +1,221 @@ +/** + * User Cosmetics — inventory and collectible satellite collection. + * Collection: "user_inventory" + * + * Extracted from the monolithic userdb.modules.* fields. + * One document per user, keyed by userId. + * + * Old paths: + * modules.inventory → user_inventory.inventory + * modules.bgInventory → user_inventory.bgInventory + * modules.skinInventory → user_inventory.skinInventory + * modules.flairsInventory → user_inventory.flairInventory + * modules.medalInventory → user_inventory.medalInventory + * modules.stickerInventory→ user_inventory.stickerInventory + * modules.stickerCollection→user_inventory.stickerShowcase + * modules.fishes → user_inventory.fishes + * modules.fishCollection → user_inventory.fishShowcase + * modules.achievements → user_inventory.achievements + * + * @module user_inventory + */ +const mongoose = require("mongoose"); +const utils = require("../../utils.js"); + +const { Mixed } = mongoose.Schema.Types; + +module.exports = function USER_COSMETICS(activeConnection) { + + const UserInventorySchema = new mongoose.Schema({ + userId: { type: String, required: true, index: { unique: true } }, + + inventory: { + type: [{ + id: { type: String, required: true }, + count: { type: Number, default: 0 }, + crafted: { type: Number, default: 0 }, + }], + default: [{ id: "lootbox_dev", count: 1, crafted: 0 }], + }, + + bgInventory: { type: [String], default: ["5zhr3HWlQB4OmyCBFyHbFuoIhxrZY6l6"] }, + skinInventory: { type: [String], default: [] }, + flairInventory: { type: [String], default: [] }, + medalInventory: { type: [String], default: [] }, + stickerInventory: { type: [String], default: [] }, + stickerShowcase: { type: [String], default: [] }, + fishes: { type: [Mixed], default: [] }, + fishShowcase: { type: [Mixed], default: [] }, + achievements: { type: [Mixed], default: [] }, + + }, { + strict: true, + collection: "user_inventory", + timestamps: false, + }); + + // ── Instance methods (ported from legacy UserSchema) ──────────── + + /** + * Add an item to inventory. + * @param {string} itemId + * @param {number} [amt=1] + * @param {boolean} [crafted=false] + */ + UserInventorySchema.methods.addItem = function (itemId, amt = 1, crafted = false) { + const existing = this.inventory.find((itm) => itm.id === itemId); + if (!existing) { + return this.constructor.updateOne( + { userId: this.userId }, + { $addToSet: { inventory: { id: itemId, count: amt, crafted: crafted ? amt : 0 } } } + ); + } + return this.constructor.updateOne( + { userId: this.userId }, + { + $inc: { + "inventory.$[item].count": amt, + "inventory.$[item].crafted": crafted ? amt : 0, + }, + }, + { arrayFilters: [{ "item.id": itemId }] } + ); + }; + + /** + * Remove an item from inventory. + * @param {string} itemId + * @param {number} [amt=1] + * @param {boolean} [crafted=false] + */ + UserInventorySchema.methods.removeItem = function (itemId, amt = 1, crafted = false) { + return this.addItem(itemId, -amt, crafted); + }; + + /** + * Batch-modify multiple inventory items at once. + * @param {Array<{id:string, count:number}>} items + * @param {boolean} [debug=false] + */ + UserInventorySchema.methods.modifyItems = async function (items, debug) { + const arrayFilters = []; + const increments = {}; + + for (let i = 0; i < items.length; i++) { + arrayFilters.push({ [`i${i}.id`]: items[i].id }); + Object.keys(items[i]).forEach((key) => { + if (key !== "id") { + increments[`inventory.$[i${i}].${key}`] = items[i][key]; + } + }); + } + + const unowned = items.filter( + (itm) => !this.inventory.some((inv) => inv.id === itm.id) + ); + if (unowned.length) { + await this.constructor.updateOne( + { userId: this.userId }, + { + $addToSet: { + inventory: { + $each: unowned.map((ni) => ({ id: ni.id, count: 0, crafted: 0 })), + }, + }, + } + ); + } + + if (debug) return [unowned, { userId: this.userId }, { $inc: increments }, { arrayFilters }]; + + return this.constructor.updateOne( + { userId: this.userId }, + { $inc: increments }, + { arrayFilters } + ); + }; + + /** + * Check if user owns at least `count` of an item. + * @param {string} itemId + * @param {number} [count=1] + * @returns {boolean} + */ + UserInventorySchema.methods.hasItem = function (itemId, count = 1) { + return (this.inventory.find((itm) => itm.id === itemId)?.count || 0) >= count; + }; + + /** + * Get the quantity of an item in inventory. + * @param {string} itemId + * @returns {number} + */ + UserInventorySchema.methods.amtItem = function (itemId) { + return this.inventory.find((itm) => itm.id === itemId)?.count || 0; + }; + + // ── Model & statics ──────────────────────────────────────────── + + const MODEL = activeConnection.model("UserInventory", UserInventorySchema, "user_inventory"); + + /** + * Get cosmetics doc by userId (lean). + * @param {string} userId + * @param {object} [project] + * @returns {Promise} + */ + MODEL.get = function (userId, project) { + if (typeof userId === "object" && userId.id) userId = userId.id; + if (!project) project = { _id: 0 }; + return this.findOne({ userId: userId.toString() }, project).lean(); + }; + + /** + * Get full Mongoose document (with instance methods). + * @param {string} userId + * @returns {Promise} + */ + MODEL.getFull = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return this.findOne({ userId: userId.toString() }); + }; + + /** + * Update cosmetics doc. + * @param {string} userId + * @param {object} alter + * @param {object} [options] + */ + MODEL.set = function (userId, alter, options = {}) { + if (typeof userId === "object" && userId.id) userId = userId.id; + if (!options.upsert) options.upsert = true; + return this.updateOne({ userId: userId.toString() }, alter, options).lean().exec(); + }; + + /** + * Get or create a cosmetics document for a user. + * @param {string} userId + * @returns {Promise} + */ + MODEL.getOrCreate = async function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + userId = userId.toString(); + let doc = await this.findOne({ userId }); + if (!doc) { + doc = new MODEL({ userId }); + await doc.save(); + } + return doc; + }; + + /** + * Create a new cosmetics document. + * @param {string} userId + */ + MODEL.new = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return MODEL.getOrCreate(userId); + }; + + return MODEL; +}; diff --git a/schemas/user_inventory/user_inventory.schema.d.ts b/schemas/user_inventory/user_inventory.schema.d.ts new file mode 100644 index 0000000..6beba7c --- /dev/null +++ b/schemas/user_inventory/user_inventory.schema.d.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { dbSetter, dbGetter, IDOrIDObject } from '../../index'; +import { UserItem } from '../items/items.types'; + +export interface UserInventoryData { + userId: string; + inventory: Array<{ id: string; count: number; crafted?: number }>; + bgInventory: string[]; + skinInventory: string[]; + flairInventory: string[]; + medalInventory: string[]; + stickerInventory: string[]; + stickerShowcase: string[]; + fishes: any[]; + fishShowcase: any[]; + achievements: any[]; +} + +export interface UserInventorySchema extends mongoose.Document, UserInventoryData { + addItem: (item: string, amt?: number, crafted?: boolean) => Promise; + removeItem: (item: string, amt?: number, crafted?: boolean) => Promise; + modifyItems(items: UserItem[], debug: true): Promise<[UserItem[], { userId: string }, { $inc: any }, { arrayFilters: any[] }]>; + modifyItems(items: UserItem[], debug?: boolean): Promise; + hasItem: (itemId: string, count?: number) => boolean; + amtItem: (itemId: string) => number; +} + +export interface UserInventoryModel extends mongoose.Model { + get: (userId: IDOrIDObject, project?: any) => Promise; + getFull: (userId: IDOrIDObject) => Promise; + set: (userId: IDOrIDObject, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + getOrCreate: (userId: IDOrIDObject) => Promise; + new: (userId: IDOrIDObject) => Promise; +} diff --git a/schemas/user_inventory/user_inventory.types.d.ts b/schemas/user_inventory/user_inventory.types.d.ts new file mode 100644 index 0000000..63eec13 --- /dev/null +++ b/schemas/user_inventory/user_inventory.types.d.ts @@ -0,0 +1,24 @@ +import type { UserItem } from '../items/items.types'; + +export type { UserItem }; + +/** Full inventory snapshot for a user. */ +export interface UserInventoryData { + userId: string; + inventory: UserItem[]; + bgInventory: string[]; + skinInventory: string[]; + flairInventory: string[]; + medalInventory: string[]; + stickerInventory: string[]; + stickerShowcase: string[]; + fishes: any[]; + fishShowcase: any[]; + achievements: any[]; + + // Instance methods (only on full documents via getFull) + addItem(itemId: string, amount?: number, crafted?: boolean): Promise; + removeItem(itemId: string, amount?: number, crafted?: boolean): Promise; + hasItem(itemId: string, count?: number): boolean; + amtItem(itemId: string): number; +} diff --git a/schemas/user_inventory/user_inventory.virtuals.js b/schemas/user_inventory/user_inventory.virtuals.js new file mode 100644 index 0000000..76b916c --- /dev/null +++ b/schemas/user_inventory/user_inventory.virtuals.js @@ -0,0 +1,16 @@ +'use strict'; + +/** + * Virtuals for the UserInventory schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} UserInventorySchema + */ +module.exports = function (UserInventorySchema) { + UserInventorySchema.virtual('itemsData', { + ref: 'Item', + localField: 'inventory.id', + foreignField: 'id', + justOne: false, + }); +}; diff --git a/schemas/user_oauth/user_oauth.js b/schemas/user_oauth/user_oauth.js new file mode 100644 index 0000000..02398e5 --- /dev/null +++ b/schemas/user_oauth/user_oauth.js @@ -0,0 +1,104 @@ +/** + * User OAuth — authentication tokens and identity cache. + * Collection: "user_oauth" + * + * Extracted from: + * userdb.discordData.* → user_oauth.discord.* / discordIdentityCache + * userdb.connections.patreon → user_oauth.patreon + * userdb.personal → user_oauth.geo + * + * @module user_oauth + */ +const mongoose = require("mongoose"); + +const { Mixed } = mongoose.Schema.Types; + +module.exports = function USER_OAUTH(activeConnection) { + + const UserOAuthSchema = new mongoose.Schema({ + userId: { type: String, required: true, index: { unique: true } }, + + // Cached Discord profile (non-sensitive identity fields) + discordIdentityCache: { + id: String, + username: String, + avatar: String, + discriminator: String, + global_name: String, + banner: String, + flags: Number, + premium_type: Number, + }, + + // Discord OAuth tokens (sensitive) + discord: { + accessToken: String, + refreshToken: String, + expiresAt: Date, + scope: String, + email: String, + locale: String, + verified: Boolean, + mfa_enabled: Boolean, + premium_type: Number, + }, + + // Patreon OAuth (sensitive) + patreon: { + accessToken: String, + refreshToken: String, + expiresAt: Date, + scope: String, + identity: Mixed, + }, + + // Geolocation / personal info + geo: { type: Mixed, default: null }, + + fetchedAt: { type: Date, default: Date.now }, + + }, { + strict: true, + collection: "user_oauth", + timestamps: false, + }); + + // ── Model & statics ──────────────────────────────────────────── + + const MODEL = activeConnection.model("UserOAuth", UserOAuthSchema, "user_oauth"); + + MODEL.get = function (userId, project) { + if (typeof userId === "object" && userId.id) userId = userId.id; + if (!project) project = { _id: 0 }; + return this.findOne({ userId: userId.toString() }, project).lean(); + }; + + MODEL.getFull = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return this.findOne({ userId: userId.toString() }); + }; + + MODEL.set = function (userId, alter, options = {}) { + if (typeof userId === "object" && userId.id) userId = userId.id; + if (!options.upsert) options.upsert = true; + return this.updateOne({ userId: userId.toString() }, alter, options).lean().exec(); + }; + + MODEL.getOrCreate = async function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + userId = userId.toString(); + let doc = await this.findOne({ userId }); + if (!doc) { + doc = new MODEL({ userId }); + await doc.save(); + } + return doc; + }; + + MODEL.new = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return MODEL.getOrCreate(userId); + }; + + return MODEL; +}; diff --git a/schemas/user_oauth/user_oauth.schema.d.ts b/schemas/user_oauth/user_oauth.schema.d.ts new file mode 100644 index 0000000..3f7de19 --- /dev/null +++ b/schemas/user_oauth/user_oauth.schema.d.ts @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; +import { dbSetter, dbGetter, IDOrIDObject } from '../../index'; + +export interface UserOAuthData { + userId: string; + discordIdentityCache: { + id: string; + username: string; + avatar: string; + discriminator: string; + global_name: string; + banner: string; + flags: number; + premium_type: number; + } | null; + discord: { + accessToken: string; + refreshToken: string; + expiresAt: Date; + scope: string; + email: string; + locale: string; + verified: boolean; + mfa_enabled: boolean; + premium_type: number; + } | null; + patreon: { + accessToken: string; + refreshToken: string; + expiresAt: Date; + scope: string; + identity: any; + } | null; + geo: any; + fetchedAt: Date; +} +export interface UserOAuthSchema extends mongoose.Document, UserOAuthData {} +export interface UserOAuthModel extends mongoose.Model { + get: (userId: IDOrIDObject, project?: any) => Promise; + getFull: (userId: IDOrIDObject) => Promise; + set: (userId: IDOrIDObject, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + getOrCreate: (userId: IDOrIDObject) => Promise; + new: (userId: IDOrIDObject) => Promise; +} diff --git a/schemas/user_quests/user_quests.js b/schemas/user_quests/user_quests.js new file mode 100644 index 0000000..3e5906f --- /dev/null +++ b/schemas/user_quests/user_quests.js @@ -0,0 +1,77 @@ +/** + * User Quests — per-quest progress tracking. + * Collection: "user_quests" + * + * Extracted from userdb.quests[] (unwound per quest). + * One document per (userId, questId) pair. + * + * @module user_quests + */ +const mongoose = require("mongoose"); + +module.exports = function USER_QUESTS(activeConnection) { + + const UserQuestsSchema = new mongoose.Schema({ + userId: { type: String, required: true }, + questId: { type: Number, required: true }, + target: { type: Number, default: 0 }, + tracker: { type: String, default: "" }, + progress: { type: Number, default: 0 }, + completed: { type: Boolean, default: false }, + completedAt: { type: Date, default: null }, + }, { + strict: true, + collection: "user_quests", + timestamps: false, + }); + + // Compound unique index matching mongoscript + UserQuestsSchema.index({ userId: 1, questId: 1 }, { unique: true }); + + const MODEL = activeConnection.model("UserQuest", UserQuestsSchema, "user_quests"); + + MODEL.get = function (query, project) { + if (typeof query === "string") query = { userId: query }; + if (!project) project = { _id: 0 }; + return this.findOne(query, project).lean(); + }; + + MODEL.set = function (query, alter, options = {}) { + if (typeof query === "string") query = { userId: query }; + if (!options.upsert) options.upsert = true; + return this.updateOne(query, alter, options).lean().exec(); + }; + + /** + * Get all quests for a user. + * @param {string} userId + * @returns {Promise} + */ + MODEL.allForUser = function (userId) { + if (typeof userId === "object" && userId.id) userId = userId.id; + return this.find({ userId: userId.toString() }, { _id: 0 }).lean(); + }; + + /** + * Increment quest progress. Marks as completed if target is reached. + * @param {string} userId + * @param {number} questId + * @param {number} [amt=1] + */ + MODEL.incrementProgress = async function (userId, questId, amt = 1) { + const result = await this.findOneAndUpdate( + { userId, questId, completed: false }, + { $inc: { progress: amt } }, + { new: true, upsert: false } + ); + if (result && result.progress >= result.target && !result.completed) { + await this.updateOne( + { userId, questId }, + { $set: { completed: true, completedAt: new Date() } } + ); + } + return result; + }; + + return MODEL; +}; diff --git a/schemas/user_quests/user_quests.schema.d.ts b/schemas/user_quests/user_quests.schema.d.ts new file mode 100644 index 0000000..ad5d766 --- /dev/null +++ b/schemas/user_quests/user_quests.schema.d.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +export interface UserQuestData { + userId: string; + questId: number; + target: number; + tracker: string; + progress: number; + completed: boolean; + completedAt: Date | null; +} +export interface UserQuestSchema extends mongoose.Document, UserQuestData {} +export interface UserQuestModel extends mongoose.Model { + get: (query: any, project?: any) => Promise; + set: (query: any, alter: mongoose.UpdateQuery, options?: mongoose.QueryOptions) => Promise; + allForUser: (userId: string) => Promise; + incrementProgress: (userId: string, questId: number, amt?: number) => Promise; +} diff --git a/schemas/users_core/users_core.js b/schemas/users_core/users_core.js new file mode 100644 index 0000000..881c767 --- /dev/null +++ b/schemas/users_core/users_core.js @@ -0,0 +1,230 @@ +/** + * Users (core) — the hot-path user document. + * Collection: "users" + * + * Only data needed for profile rendering, command execution, and + * economy transactions lives here. Everything else is split into + * satellite collections (user_inventory, user_oauth, etc.). + * + * Field layout mirrors the canonical mongoscript.js migration output. + * + * @module users_core + */ +const mongoose = require("mongoose"); +const utils = require("../../utils.js"); + +const { Mixed } = mongoose.Schema.Types; + +module.exports = function USERS_CORE(activeConnection) { + + const UsersCoreSchema = new mongoose.Schema({ + id: { type: String, required: true, index: { unique: true } }, + name: { type: String, default: "" }, + tag: { type: String, default: "" }, + avatar: { type: String, default: null }, + personalhandle: { + type: String, trim: true, index: true, unique: true, sparse: true, + }, + + // ── Currencies ──────────────────────────────────────────────── + // Nested under currency.* per mongoscript requirement. + // Old path: modules.RBN → New path: currency.RBN + currency: { + RBN: { type: Number, default: 500, index: true }, + SPH: { type: Number, default: 0, index: true }, + JDE: { type: Number, default: 2500, index: true }, + PSM: { type: Number, default: 0 }, + EVT: { type: Number, default: 0, index: true }, + }, + + // ── Profile (equipped cosmetic state) ───────────────────────── + // Old path: modules.bgID → New path: profile.bgID + profile: { + background: { type: String, default: null }, + flair: { type: String, default: "default" }, + flairDown: { type: String, default: "default" }, + sticker: { type: String, default: null }, + color: { type: String, default: "#eb497b" }, + about: { type: String, default: "I have no bio because I'm too lazy to set one." }, + tagline: { type: String, default: "A fellow Pollux user" }, + medals: { type: [Mixed], default: [0, 0, 0, 0, 0, 0, 0, 0, 0] }, + skins: { type: Mixed, default: {} }, + featuredMarriage: { type: String, default: null }, + }, + + // ── Progression ─────────────────────────────────────────────── + // Old path: modules.level → New path: progression.level + progression: { + level: { type: Number, default: 0, index: true }, + exp: { type: Number, default: 0, min: 0, index: true }, + craftingExp: { type: Number, default: 0 }, + }, + + // ── Meta ────────────────────────────────────────────────────── + meta: { + createdAt: { type: Date, default: Date.now }, + lastLogin: { type: Date, default: null }, + lastUpdated: { type: Date, default: Date.now }, + migrated: { type: Boolean, default: false }, + apiKey: { type: String, default: null, sparse: true }, + apiPerms: { type: String, default: "basic" }, + }, + + // ── Subscription / Prime ────────────────────────────────────── + // Old path: donator / prime → Unified under prime + prime: { + type: Object, + default: null, + tier: { type: String, index: true }, + lastClaimed: Number, + active: Boolean, + maxServers: Number, + canReallocate: Boolean, + custom_background: Boolean, + custom_handle: Boolean, + custom_shop: Boolean, + servers: [String], + misc: Mixed, + }, + + blacklisted: { type: String, default: null }, + + // Preferences / feature toggles + switches: { type: Mixed, default: {} }, + + // Time-sensitive counters (dailies, event timers, etc.) + counters: { type: Mixed, default: {} }, + + // Ephemeral event data + eventData: { type: Mixed, default: {} }, + + // ── Legacy bridge fields (read-only, kept for transition) ───── + // These are NOT authoritative. The shim layer populates them + // when adapting legacy docs. New code should NEVER write to these. + /** @deprecated read from prime.tier instead */ + donator: { type: String, default: null }, + + // Misc fields carried over from old schema + spdaily: Mixed, + rewardsMonth: Number, + rewardsClaimed: Boolean, + hidden: Boolean, + cherries: Number, + cherrySet: Mixed, + married: Array, + limits: Mixed, + + }, { + strict: true, // only persist declared paths; typos in $set no longer create ghost fields + collection: "users", + timestamps: true, + }); + + // ── Pre-save hook: update meta.lastUpdated ────────────────────── + UsersCoreSchema.pre(/^update/, function () { + this.update({}, { $set: { "meta.lastUpdated": new Date() } }); + }); + + // ── Instance methods ──────────────────────────────────────────── + + /** + * Increment a currency by amount. + * @param {"RBN"|"SPH"|"JDE"|"PSM"|"EVT"} curr + * @param {number} amt + */ + UsersCoreSchema.methods.addCurrency = function (curr, amt = 1) { + return this.constructor.updateOne( + { id: this.id }, + { $inc: { [`currency.${curr}`]: amt } } + ); + }; + + /** + * Add XP to user. + * @param {number} amt + */ + UsersCoreSchema.methods.addXP = function (amt = 1) { + return this.constructor.updateOne( + { id: this.id }, + { $inc: { "progression.exp": amt } } + ); + }; + + /** + * Generic attribute incrementer. + * @param {string} attr Dot-path relative to document root + * @param {number} amt + */ + UsersCoreSchema.methods.incrementAttr = function (attr, amt = 1) { + return this.constructor.updateOne( + { id: this.id }, + { $inc: { [attr]: amt } } + ); + }; + + // ── Model ─────────────────────────────────────────────────────── + + const MODEL = activeConnection.model("UsersCore", UsersCoreSchema, "users"); + + MODEL.updateMeta = (U) => + MODEL.updateOne( + { id: U.id }, + { + $set: { + name: U.username || U.global_name || "", + tag: U.tag || `${U.username}#${U.discriminator || "0"}`, + avatar: U.displayAvatarURL || U.avatar || null, + "meta.lastUpdated": new Date(), + }, + }, + { upsert: false } + ); + + MODEL.new = (userDATA) => { + if (!userDATA) return; + return new Promise((resolve) => { + MODEL.findOne({ id: userDATA.id }, (err, existing) => { + if (err) console.error(err); + if (existing) return resolve(existing); + + const user = new MODEL({ + id: userDATA.id, + name: userDATA.username || userDATA.global_name || "", + tag: userDATA.tag || "", + avatar: userDATA.displayAvatarURL || userDATA.avatar || null, + "meta.createdAt": new Date(), + "meta.migrated": true, + }); + user.save((err) => { + if (err) console.error("[UsersCore] save error:", err); + return resolve(user); + }); + }); + }); + }; + + MODEL.cat = "users"; + MODEL.check = utils.dbChecker; + MODEL.set = utils.dbSetter; + + /** + * Get user by id or query. Always bypasses Redis cache so user data is fresh. + * Callers that want to cache must use findOne({ id }).cache(ttl) explicitly. + */ + MODEL.get = function (query, project, avoidNew) { + return new Promise(async (resolve) => { + if (["string", "number"].includes(typeof query)) { + query = { id: query.toString() }; + } + if (!typeof project) project = { _id: 0 }; + const data = await this.findOne(query, project).lean().noCache(); + if (data === null && (query.id || typeof query === "string")) + return resolve(this.new(await PLX.resolveUser?.(query.id || query))); + return resolve(data); + }); + }; + + MODEL.getFull = utils.dbGetterFull; + + return MODEL; +}; diff --git a/schemas/users_core/users_core.schema.d.ts b/schemas/users_core/users_core.schema.d.ts new file mode 100644 index 0000000..3cc4c71 --- /dev/null +++ b/schemas/users_core/users_core.schema.d.ts @@ -0,0 +1,101 @@ +import mongoose from 'mongoose'; +import mongodb from 'mongodb'; +import { CustomQuery, dbSetter, dbGetter, dbGetterFull, IDOrIDObject } from '../../index'; + +export interface UserCurrencies { + RBN: number; + SPH: number; + JDE: number; + PSM: number; + EVT: number; +} + +export interface UserProfile { + background: string | null; + flair: string; + flairDown: string; + sticker: string | null; + color: string; + about: string; + tagline: string; + medals: (string | 0)[]; + skins: Record; + featuredMarriage: string | null; +} + +export interface UserProgression { + level: number; + exp: number; + craftingExp: number; +} + +export interface UserMeta { + createdAt: Date; + lastLogin: Date | null; + lastUpdated: Date; + migrated: boolean; + apiKey: string | null; + apiPerms: string; +} + +export interface PrimeData { + tier: string | null; + lastClaimed: number; + active: boolean; + maxServers: number; + canReallocate: boolean; + custom_background: boolean; + custom_handle: boolean; + custom_shop: boolean; + servers: string[]; + misc: any; +} + +export interface UserCore { + id: string; + name: string; + tag: string; + avatar: string | null; + personalhandle?: string; + currency: UserCurrencies; + profile: UserProfile; + progression: UserProgression; + meta: UserMeta; + prime: PrimeData | null; + blacklisted: string | null; + switches: Record; + counters: Record; + eventData: Record; + /** @deprecated read from prime.tier instead */ + donator?: string | null; +} + +export interface UserCoreSchema extends mongoose.Document, UserCore { + id: string; + addCurrency: (curr: keyof UserCurrencies, amt?: number) => Promise; + addXP: (amt?: number) => Promise; + incrementAttr: (attr: string, amt?: number) => Promise; +} + +/** + * Parameter for UsersCore.updateMeta(). Accepts Discord/Eris-like user objects. + * Implementation sets: name, tag, avatar, meta.lastUpdated. + */ +export interface UserMetaUpdate { + id: string; + username?: string; + global_name?: string | null; + discriminator?: string; + tag?: string; + avatar?: string | null; + displayAvatarURL?: string | null; +} + +export interface UserCoreModel extends mongoose.Model { + updateMeta: (U: UserMetaUpdate) => Promise; + new: (userData: Partial) => Promise; + cat: 'users'; + set: dbSetter; + get: (query: CustomQuery, project?: any) => Promise; + getFull: dbGetterFull; +} diff --git a/schemas/users_core/users_core.types.d.ts b/schemas/users_core/users_core.types.d.ts new file mode 100644 index 0000000..8473814 --- /dev/null +++ b/schemas/users_core/users_core.types.d.ts @@ -0,0 +1,31 @@ +import type { Currency, PrimeTier, PrimeInfo, Profilecard } from '../../types/generics'; + +/** Front-facing user type returned by DB.Users.get() and DB.users.get(). */ +export interface User { + id: string; + name: string; + tag: string; + avatar: string | null; + personalHandle?: string; + currency: { [K in Currency]: number }; + progression: { + level: number; + exp: number; + }; + profile: Profilecard; + prime: PrimeInfo | null; + blacklisted: string | null; + switches: Record; + counters: Record; + eventData: Record; + meta: { + createdAt: Date; + lastLogin: Date | null; + lastUpdated: Date; + }; + + // Instance methods (only available on full documents via getFull) + addCurrency(currency: Currency, amount?: number): Promise; + addXP(amount?: number): Promise; + incrementAttr(attr: string, amount?: number): Promise; +} diff --git a/schemas/users_core/users_core.virtuals.js b/schemas/users_core/users_core.virtuals.js new file mode 100644 index 0000000..9bd0b58 --- /dev/null +++ b/schemas/users_core/users_core.virtuals.js @@ -0,0 +1,31 @@ +'use strict'; + +/** + * Virtuals for the UsersCore schema. + * Registered by virtuals.js at app init. + * + * @param {import('mongoose').Schema} UsersSchema + */ +module.exports = function (UsersSchema) { + UsersSchema.virtual('fanarts', { + ref: 'fanart', + localField: 'id', + foreignField: 'author_ID', + justOne: false, + }); + + UsersSchema.virtual('collections', { + ref: 'UserCollection', + localField: 'id', + foreignField: 'id', + select: 'collections', + justOne: true, + }); + + UsersSchema.virtual('marriageData', { + ref: 'Relationship', + localField: 'profile.featuredMarriage', // Was "featuredMarriage" (top-level in legacy) + foreignField: '_id', + justOne: true, + }); +}; diff --git a/types/generics.d.ts b/types/generics.d.ts new file mode 100644 index 0000000..5bef43f --- /dev/null +++ b/types/generics.d.ts @@ -0,0 +1,68 @@ +// ────────────────────────────────────────────────────────────────── +// @polestarlabs/database_schema — generic domain types +// +// Schema-specific type files import directly from this file to avoid +// circular references with the barrel at types/index.d.ts. +// External consumers should import from '@polestarlabs/database_schema/types' +// which re-exports everything from here. +// ────────────────────────────────────────────────────────────────── + +// ── Currency ────────────────────────────────────────────────────── + +export type Currency = "RBN" | "JDE" | "SPH" | "AMY" | "EMD" | "PSM" | "COS" | "EVT"; + +export type CurrencyLabel = { + RBN: "Rubine"; + JDE: "Jade"; + SPH: "Sapphire"; + AMY: "Amethyst"; + EMD: "Emerald"; + PSM: "Prism"; + COS: "Cosmic Fragment"; + EVT: "Event Token"; +}; + +// ── Rarity ──────────────────────────────────────────────────────── + +export type Rarity = "C" | "U" | "R" | "SR" | "UR" | "XR"; + +// ── Prime ───────────────────────────────────────────────────────── + +export type PrimeTier = + | "plastic" + | "aluminium" + | "carbon" + | "iron" + | "iridium" + | "lithium" + | "palladium" + | "zircon" + | "uranium" + | "astatine" + | "antimatter" + | "neutrino"; + +export interface PrimeInfo { + tier: PrimeTier; + lastClaimed: number; + active: boolean; + maxServers: number; + canReallocate: boolean; + custom_background: boolean; + custom_handle: boolean; + custom_shop: boolean; + servers: string[]; + misc?: unknown; +} + +// ── Profilecard ─────────────────────────────────────────────────── + +export interface Profilecard { + background: string; + sticker?: string; + color: string; + flair: string; + about: string; + tagline: string; + medals: string[]; +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..c517b89 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,24 @@ +// ────────────────────────────────────────────────────────────────── +// @polestarlabs/database_schema/types +// +// Barrel re-export of all front-facing ("pretty") types. +// Import from here in Bot, Dashboard, and API code. +// +// import type { User, InventoryItem, CosmeticItem } from +// '@polestarlabs/database_schema/types'; +// +// For raw Mongoose document types, import from the main package entry. +// ────────────────────────────────────────────────────────────────── + +// Generic domain types (Currency, Rarity, PrimeTier, Profilecard, …) +export * from './generics'; + +// Schema-specific pretty types +export * from '../schemas/items/items.types'; +export * from '../schemas/cosmetics/cosmetics.types'; +export * from '../schemas/users_core/users_core.types'; +export * from '../schemas/user_inventory/user_inventory.types'; + +// Schema model helpers (updateMeta parameter, etc.) +export type { UserMetaUpdate } from '../schemas/users_core/users_core.schema'; + diff --git a/utils.js b/utils.js index 60cf86f..475603f 100644 --- a/utils.js +++ b/utils.js @@ -1,3 +1,19 @@ +/** + * Mongoose query conventions (bot + dashboard + database_schema): + * + * LEAN IS IMPLICIT: + * - find() and findOne() default to .lean() (plain objects). Prefer model.get(id) when it exists. + * - To get a full Document (e.g. for .save() or instance methods), opt out: findOne(q, proj, { lean: false }) or use model.getFull(id). + * + * Caveats of NOT using lean (i.e. full Mongoose documents): + * - Heavier: more memory and CPU (change tracking, getters/setters). Lean docs are ~3x smaller. + * - Risk of accidental persistence: if code calls .save() or modifies and saves, changes are written. With lean you get POJOs and cannot .save(). + * - Virtuals and document instance methods only exist on full documents; lean results are plain objects. + * + * .exec(): + * - Use .exec() only on hand-offs: when the promise is passed elsewhere (e.g. .then(cb), Promise.all([ ... ])). + * - When we await the result in the same flow, do not use .exec(): use await Model.find() (lean is already default). + */ module.exports = { dbSetter(query, alter, options = {}) { return new Promise((resolve) => { @@ -26,7 +42,7 @@ module.exports = { query = { id: query.toString() }; } if (!project) project = { _id: 0 }; - return this.findOne(query, project).lean().then((data) => { + return this.findOne(query, project).exec().then((data) => { // lean implicit via plugin; .exec() for hand-off try{ if (!data && !!this.cat && PLX[this.cat].size) return this.new(PLX[this.cat].find((u) => u.id === query.id)).then(resolve); if (data === null) return resolve(null);// return resolve( this.new(PLX.users.find(u=>u.id === query.id)) ); @@ -45,7 +61,7 @@ module.exports = { } if (!project) project = { _id: 0 }; - const data = await this.findOne(query, project); + const data = await this.findOne(query, project, { lean: false }); if (!avoidNew){ if (!data && !!this.cat) return resolve( await this.new(PLX[this.cat].find((u) => u.id === query.id))); diff --git a/virtuals.js b/virtuals.js index 2815144..1599f90 100644 --- a/virtuals.js +++ b/virtuals.js @@ -1,98 +1,13 @@ +/** + * Central virtual registration. + * Each schema has its own *.virtuals.js that receives only its own schema + * and defines its virtual populate paths. + */ module.exports = function VIRTUALS(Schemas) { - - const Marketplace = Schemas.marketplace.schema; - const Users = Schemas.users.schema; - const Items = Schemas.items.schema; - const Cosmetics = Schemas.cosmetics.schema; - const Relationships = Schemas.relationships.schema; - - Marketplace.virtual("authorData", { - ref: "UserDB", - localField: "author", - foreignField: "id", - justOne: true, - }); - - Marketplace.virtual("moreFromAuthor", { - ref: "marketplace", - localField: "author", - foreignField: "author", - justOne: false, - }); - - Marketplace.virtual("moreLikeThis", { - ref: "marketplace", - localField: "item_id", - foreignField: "item_id", - justOne: false, - }); - - Marketplace.virtual("itemData", { - ref: function () { - return ["background", "medal", "flair", "sticker", "shade"].includes( - this.item_type - ) - ? "Cosmetic" - : "Item"; - }, - localField: "item_id", - foreignField: "_id", - justOne: true, - }); - - // USER - - Users.virtual("itemsData", { - ref: "Item", - localField: "modules.inventory.id", - foreignField: "id", - justOne: false, - }); - - Users.virtual("fanarts", { - ref: "fanart", - localField: "id", - foreignField: "author_ID", - justOne: false, - }); - - Users.virtual("collections", { - ref: "UserCollection", - localField: "id", - foreignField: "id", - select: "collections", - justOne: true, - }); - - Users.virtual("marriageData", { - ref: "Relationship", - localField: "featuredMarriage", - foreignField: "_id", - justOne: true, - }); - - // RELATIONSHIPS - - Relationships.virtual("usersData", { - ref: "UserDB", - localField: "users", - foreignField: "id", - justOne: false, - }); - - // ITEM - - Items.virtual("stickers", { - ref: "Cosmetic", - localField: "icon", - foreignField: "id", - justOne: false, - }); - - Cosmetics.virtual("packData", { - ref: "Item", - localField: "series_id", - foreignField: "icon", - justOne: true, - }); + require('./schemas/items/items.virtuals')(Schemas.items.schema); + require('./schemas/cosmetics/cosmetics.virtuals')(Schemas.cosmetics.schema); + require('./schemas/user_inventory/user_inventory.virtuals')(Schemas.userInventory.schema); + require('./schemas/users_core/users_core.virtuals')(Schemas.users.schema); + require('./schemas/marketplace/marketplace.virtuals')(Schemas.marketplace.schema); + require('./schemas/relationships/relationships.virtuals')(Schemas.relationships.schema); };