db/odm.ts
7.5 KB · sha256:dbd4f3a0d8d8ed9aa5ab4fb2b9be9447c55466a7fac4860dc754f5ad67bb8f96
import mongoose, {
Schema,
SchemaOptions,
Model,
HydratedDocument,
} from "mongoose";
import { env } from "../config/env";
const METADATA_SYMBOL: unique symbol = Symbol.for("Symbol.metadata") as never;
(Symbol as { metadata?: symbol }).metadata ??= METADATA_SYMBOL;
const META = {
MODEL: Symbol("odm:model"),
FIELDS: Symbol("odm:fields"),
INDEXES: Symbol("odm:indexes"),
HOOKS: Symbol("odm:hooks"),
} as const;
type FieldOptions = {
type?: unknown;
required?: boolean;
default?: unknown;
enum?: readonly unknown[];
ref?: string;
unique?: boolean;
index?: boolean | 1 | -1 | "text" | "hashed";
sparse?: boolean;
trim?: boolean;
lowercase?: boolean;
uppercase?: boolean;
array?: boolean;
select?: boolean;
min?: number;
max?: number;
match?: RegExp;
validate?: mongoose.SchemaTypeOptions<unknown>["validate"];
};
type FieldDef = FieldOptions;
type HookKind = "pre" | "post";
type HookOp =
| "save"
| "validate"
| "remove"
| "init"
| "updateOne"
| "deleteOne"
| "find"
| "findOne"
| "findOneAndUpdate"
| "insertMany";
type HookDef = { kind: HookKind; op: HookOp; handler: (...args: any[]) => any };
type ModelDef = { name: string; options?: SchemaOptions };
type IndexDef =
| {
keys: Record<string, 1 | -1 | "text" | "hashed">;
options?: mongoose.IndexOptions;
}
| { prop: string; options?: mongoose.IndexOptions; unique?: boolean };
function ownFields(
metadata: DecoratorMetadataObject,
): Record<string, FieldDef> {
const own = Object.getOwnPropertyDescriptor(metadata, META.FIELDS);
if (own) return own.value as Record<string, FieldDef>;
const inherited = metadata[META.FIELDS] as
| Record<string, FieldDef>
| undefined;
const fresh: Record<string, FieldDef> = inherited ? { ...inherited } : {};
metadata[META.FIELDS] = fresh;
return fresh;
}
function ownIndexes(metadata: DecoratorMetadataObject): IndexDef[] {
const own = Object.getOwnPropertyDescriptor(metadata, META.INDEXES);
if (own) return own.value as IndexDef[];
const inherited = metadata[META.INDEXES] as IndexDef[] | undefined;
const fresh: IndexDef[] = inherited ? [...inherited] : [];
metadata[META.INDEXES] = fresh;
return fresh;
}
function ownHooks(metadata: DecoratorMetadataObject): HookDef[] {
const own = Object.getOwnPropertyDescriptor(metadata, META.HOOKS);
if (own) return own.value as HookDef[];
const inherited = metadata[META.HOOKS] as HookDef[] | undefined;
const fresh: HookDef[] = inherited ? [...inherited] : [];
metadata[META.HOOKS] = fresh;
return fresh;
}
function readMetadata(ctor: object): {
model?: ModelDef;
fields: Record<string, FieldDef>;
indexes: IndexDef[];
hooks: HookDef[];
} {
const metadata = (ctor as { [METADATA_SYMBOL]?: DecoratorMetadataObject })[
METADATA_SYMBOL
];
return {
model: metadata?.[META.MODEL] as ModelDef | undefined,
fields:
(metadata?.[META.FIELDS] as Record<string, FieldDef> | undefined) ?? {},
indexes: (metadata?.[META.INDEXES] as IndexDef[] | undefined) ?? [],
hooks: (metadata?.[META.HOOKS] as HookDef[] | undefined) ?? [],
};
}
export function model(name: string, options?: SchemaOptions) {
return function (
_value: new (...args: any[]) => object,
context: ClassDecoratorContext,
): void {
context.metadata![META.MODEL] = { name, options } satisfies ModelDef;
};
}
export function field(opts: FieldOptions = {}) {
return function (
_value: undefined,
context: ClassFieldDecoratorContext,
): void {
if (typeof context.name === "symbol") return;
const fields = ownFields(context.metadata!);
const key = context.name;
fields[key] = { ...fields[key], ...opts };
};
}
export function index(options?: mongoose.IndexOptions) {
return function (
_value: undefined,
context: ClassFieldDecoratorContext,
): void {
if (typeof context.name === "symbol") return;
ownIndexes(context.metadata!).push({ prop: context.name, options });
};
}
export function unique(options?: mongoose.IndexOptions) {
return function (
_value: undefined,
context: ClassFieldDecoratorContext,
): void {
if (typeof context.name === "symbol") return;
ownIndexes(context.metadata!).push({
prop: context.name,
options,
unique: true,
});
};
}
export function compoundIndex(
keys: Record<string, 1 | -1 | "text" | "hashed">,
options?: mongoose.IndexOptions,
) {
return function (
_value: new (...args: any[]) => object,
context: ClassDecoratorContext,
): void {
ownIndexes(context.metadata!).push({ keys, options });
};
}
export function hook(
kind: HookKind,
op: HookOp,
handler: (...args: any[]) => any,
) {
return function (
_value: new (...args: any[]) => object,
context: ClassDecoratorContext,
): void {
ownHooks(context.metadata!).push({ kind, op, handler });
};
}
function toSchemaField(def: FieldDef): Record<string, unknown> {
const baseType = def.type ?? Schema.Types.Mixed;
const asArray = def.array;
const core: Record<string, unknown> = {
type: asArray ? [baseType] : baseType,
required: def.required,
default: def.default,
enum: def.enum,
ref: def.ref,
unique: def.unique,
index: def.index,
sparse: def.sparse,
trim: def.trim,
lowercase: def.lowercase,
uppercase: def.uppercase,
select: def.select,
min: def.min,
max: def.max,
match: def.match,
validate: def.validate,
};
for (const k of Object.keys(core)) {
if (core[k] === undefined) delete core[k];
}
return core;
}
function buildSchemaFromClass(ctor: object): Schema {
const meta = readMetadata(ctor);
if (!meta.model) {
throw new Error(
`Class ${(ctor as { name: string }).name} is not decorated with @model`,
);
}
const schemaShape: Record<string, unknown> = {};
for (const [key, f] of Object.entries(meta.fields)) {
schemaShape[key] = toSchemaField(f);
}
const schema = new Schema(schemaShape, {
timestamps: true,
...meta.model.options,
});
for (const d of meta.indexes) {
if ("keys" in d) {
schema.index(d.keys, d.options);
} else {
schema.index({ [d.prop]: 1 }, { unique: d.unique, ...(d.options || {}) });
}
}
for (const h of meta.hooks) {
if (h.kind === "pre") {
schema.pre(h.op as any, h.handler as any);
} else {
schema.post(h.op as any, h.handler as any);
}
}
return schema;
}
// ---------- Model registry ----------
type Timestamps = { createdAt: Date; updatedAt: Date };
const modelCache = new Map<unknown, Model<any>>();
export function getModel<T>(
ctor: new (...args: any[]) => T,
): Model<T & Timestamps> {
const cached = modelCache.get(ctor);
if (cached) return cached as Model<T & Timestamps>;
const meta = readMetadata(ctor);
if (!meta.model) {
throw new Error(`Class ${ctor.name} is not decorated with @model`);
}
const mdl =
(mongoose.models[meta.model.name] as Model<T & Timestamps>) ??
mongoose.model<T & Timestamps>(meta.model.name, buildSchemaFromClass(ctor));
modelCache.set(ctor, mdl);
return mdl;
}
export type Doc<T> = HydratedDocument<T & Timestamps>;
// ---------- Connection ----------
let isConnected = false;
export async function connectDB(uri?: string): Promise<void> {
if (isConnected) return;
const mongoUri = uri || env.DATABASE_URI;
if (!mongoUri) {
throw new Error("DATABASE_URI environment variable is required");
}
await mongoose.connect(mongoUri);
console.info("Database Connected");
isConnected = true;
}
export async function disconnectDB(): Promise<void> {
isConnected = false;
await mongoose.disconnect();
}