@bhooai/nexus-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { Collection } from 'mongodb';
4
+ import { connectionManager } from './connection/ConnectionManager.js';
5
+
6
+ /** Shared database that stores one record per registered project. */
7
+ export const PROJECT_INFO_DB = 'nexus_projects';
8
+
9
+ const CONNECTION_NAME = 'project-info';
10
+ const PROJECTS_COLLECTION = 'projects';
11
+
12
+ /** A registered project's identity + runtime snapshot. */
13
+ export interface ProjectInfo {
14
+ /** Canonical project name (package.json `name`). */
15
+ name: string;
16
+ /** Absolute path to the project root. */
17
+ path: string;
18
+ /** Per-project MongoDB database name (sanitized from `name`). */
19
+ dbName: string;
20
+ /** Redacted runtime snapshot (env, ports, paths, db). */
21
+ settings?: Record<string, unknown>;
22
+ /** Whether a backend instance is currently running. */
23
+ status?: 'running' | 'stopped';
24
+ /** App version when last seen running. */
25
+ version?: string;
26
+ startedAt?: string;
27
+ updatedAt?: string;
28
+ }
29
+
30
+ /**
31
+ * Derive a MongoDB-safe database name from a project name. Every project owns
32
+ * its own database (e.g. `sample-project` → `sample_project`).
33
+ */
34
+ export function sanitizeDbName(name: string): string {
35
+ const clean = name
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9_]+/g, '_')
38
+ .replace(/_{2,}/g, '_')
39
+ .replace(/^_+|_+$/g, '')
40
+ .slice(0, 63);
41
+ return clean || 'project';
42
+ }
43
+
44
+ /** Resolve the canonical project identity from a project root. */
45
+ export async function resolveProjectInfo(root: string): Promise<ProjectInfo> {
46
+ let name = '';
47
+ try {
48
+ const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')) as { name?: string };
49
+ name = pkg.name ?? '';
50
+ } catch { /* fall through to basename */ }
51
+ if (!name) name = root.split(/[\\/]/).filter(Boolean).pop() ?? 'project';
52
+ return { name, path: root, dbName: sanitizeDbName(name) };
53
+ }
54
+
55
+ /**
56
+ * Open the shared project-info database on a second connection. The main
57
+ * (default) connection keeps pointing at the per-project database.
58
+ */
59
+ export function connectProjectInfo(uri: string, options: { autoIndex?: boolean } = {}): void {
60
+ connectionManager.connect(CONNECTION_NAME, uri, { name: PROJECT_INFO_DB, autoIndex: options.autoIndex });
61
+ }
62
+
63
+ /** The project-info `projects` collection (call `connectProjectInfo` first). */
64
+ export function getProjectInfoCollection(): Promise<Collection<ProjectInfo>> {
65
+ return connectionManager.get(CONNECTION_NAME).db.then((db) => db.collection<ProjectInfo>(PROJECTS_COLLECTION));
66
+ }
67
+
68
+ let indexEnsured = false;
69
+ async function ensureIndexes(): Promise<void> {
70
+ if (indexEnsured) return;
71
+ const coll = await getProjectInfoCollection();
72
+ await coll.createIndex({ name: 1 }, { unique: true });
73
+ indexEnsured = true;
74
+ }
75
+
76
+ /** Insert or update a project record in the shared project-info database. */
77
+ export async function upsertProjectInfo(project: ProjectInfo): Promise<ProjectInfo> {
78
+ const coll = await getProjectInfoCollection();
79
+ await ensureIndexes();
80
+ const doc = { ...project, updatedAt: new Date().toISOString() };
81
+ await coll.updateOne({ name: project.name }, { $set: doc }, { upsert: true });
82
+ return doc;
83
+ }
84
+
85
+ /** List every registered project, alphabetical by name. */
86
+ export async function listProjectInfo(): Promise<ProjectInfo[]> {
87
+ const coll = await getProjectInfoCollection();
88
+ await ensureIndexes();
89
+ return coll.find({}).sort({ name: 1 }).toArray();
90
+ }
91
+
92
+ /** Fetch a single project record by canonical name. */
93
+ export async function getProjectInfo(name: string): Promise<ProjectInfo | null> {
94
+ const coll = await getProjectInfoCollection();
95
+ await ensureIndexes();
96
+ return coll.findOne({ name });
97
+ }
98
+
99
+ /** Delete a project record from the shared project-info database. */
100
+ export async function deleteProjectInfo(name: string): Promise<boolean> {
101
+ const coll = await getProjectInfoCollection();
102
+ await ensureIndexes();
103
+ const r = await coll.deleteOne({ name });
104
+ return r.deletedCount > 0;
105
+ }
106
+
107
+ /** Drop a project's own database (the per-project one, NOT nexus_projects).
108
+ * Uses the project-info connection's MongoClient to drop another db on the
109
+ * same server — no second connection needed. */
110
+ export async function dropProjectDatabase(dbName: string): Promise<boolean> {
111
+ const conn = connectionManager.get(CONNECTION_NAME);
112
+ await conn.db; // ensure the client has connected
113
+ return conn.client.db(dbName).dropDatabase();
114
+ }
115
+
116
+ /** Close the project-info connection (call during shutdown). */
117
+ export async function closeProjectInfo(): Promise<void> {
118
+ if (connectionManager.has(CONNECTION_NAME)) await connectionManager.get(CONNECTION_NAME).close();
119
+ }
120
+
121
+ // ── cluster node registry (mirror of cluster.runtime.json in Mongo) ───────
122
+
123
+ const CLUSTER_NODES_COLLECTION = 'cluster_nodes';
124
+
125
+ /** One linked cluster node's registry record (mirrors cluster.runtime.json). */
126
+ export interface ClusterNodeRecord {
127
+ /** Stable node id (e.g. `<host>-<role>`). */
128
+ id: string;
129
+ role: string;
130
+ tier: string;
131
+ version?: string;
132
+ baseUrl: string;
133
+ services?: Record<string, string>;
134
+ status: string;
135
+ enabled: boolean;
136
+ registeredAt: string;
137
+ lastSeenAt: string;
138
+ lastHealth?: unknown;
139
+ lastMetrics?: unknown;
140
+ updatedAt: string;
141
+ }
142
+
143
+ /** The cluster_nodes collection (call `connectProjectInfo` first). */
144
+ export function getClusterNodesCollection(): Promise<Collection<ClusterNodeRecord>> {
145
+ return connectionManager.get(CONNECTION_NAME).db.then((db) => db.collection<ClusterNodeRecord>(CLUSTER_NODES_COLLECTION));
146
+ }
147
+
148
+ let clusterNodesIndexEnsured = false;
149
+
150
+ /** Upsert a cluster node record by id (mirrors registry.save for one node). */
151
+ export async function upsertClusterNode(node: ClusterNodeRecord): Promise<void> {
152
+ const coll = await getClusterNodesCollection();
153
+ if (!clusterNodesIndexEnsured) {
154
+ await coll.createIndex({ id: 1 }, { unique: true });
155
+ clusterNodesIndexEnsured = true;
156
+ }
157
+ await coll.updateOne({ id: node.id }, { $set: node }, { upsert: true });
158
+ }
159
+
160
+ /** Delete a cluster node record by id (mirrors registry.remove). */
161
+ export async function deleteClusterNode(id: string): Promise<boolean> {
162
+ const coll = await getClusterNodesCollection();
163
+ const r = await coll.deleteOne({ id });
164
+ return r.deletedCount > 0;
165
+ }
166
+
167
+ /** List all cluster node records (mirrors registry.list). */
168
+ export async function listClusterNodes(): Promise<ClusterNodeRecord[]> {
169
+ const coll = await getClusterNodesCollection();
170
+ return coll.find({}).sort({ role: 1, id: 1 }).toArray();
171
+ }
172
+
173
+ /** Replace all cluster node records (bulk sync — used after a full reload). */
174
+ export async function syncClusterNodes(nodes: ClusterNodeRecord[]): Promise<void> {
175
+ const coll = await getClusterNodesCollection();
176
+ if (!clusterNodesIndexEnsured) {
177
+ await coll.createIndex({ id: 1 }, { unique: true });
178
+ clusterNodesIndexEnsured = true;
179
+ }
180
+ const ops = nodes.map((n) => ({ updateOne: { filter: { id: n.id }, update: { $set: n }, upsert: true } }));
181
+ if (ops.length) await coll.bulkWrite(ops as never);
182
+ // Remove records no longer in the registry.
183
+ const ids = nodes.map((n) => n.id);
184
+ await coll.deleteMany({ id: { $nin: ids } });
185
+ }
@@ -0,0 +1,158 @@
1
+ import type { DocumentInstance } from '../model/Document.js';
2
+ import type { Model } from '../model/Model.js';
3
+
4
+ type Op = 'find' | 'findOne';
5
+
6
+ interface PopulatePath {
7
+ path: string;
8
+ select?: string;
9
+ match?: Record<string, unknown>;
10
+ }
11
+
12
+ /**
13
+ * Fluent, thenable query builder. Accumulates filter/projection/sort/limit/skip/
14
+ * populate options and compiles them to a mongo driver call on `then()` or
15
+ * `exec()`. Hydrates results into Document instances unless `lean()` is used.
16
+ */
17
+ export class Query<T extends DocumentInstance> {
18
+ private filter: Record<string, unknown>;
19
+ private projection: Record<string, 0 | 1> | undefined;
20
+ private sortSpec: Record<string, 1 | -1> | undefined;
21
+ private limitVal = 0;
22
+ private skipVal = 0;
23
+ private populatePaths: PopulatePath[] = [];
24
+ private leanFlag = false;
25
+ private sessionRef?: { session: unknown };
26
+
27
+ constructor(
28
+ private model: Model<T>,
29
+ private op: Op,
30
+ filter: Record<string, unknown>,
31
+ ) {
32
+ this.filter = { ...filter };
33
+ }
34
+
35
+ // ── Filter helpers ─────────────────────────────────────────────────────
36
+ where(path: string, value: unknown): this { this.filter[path] = value; return this; }
37
+ gt(path: string, value: unknown): this { return this.op2(path, '$gt', value); }
38
+ gte(path: string, value: unknown): this { return this.op2(path, '$gte', value); }
39
+ lt(path: string, value: unknown): this { return this.op2(path, '$lt', value); }
40
+ lte(path: string, value: unknown): this { return this.op2(path, '$lte', value); }
41
+ in(path: string, values: unknown[]): this { return this.op2(path, '$in', values); }
42
+ nin(path: string, values: unknown[]): this { return this.op2(path, '$nin', values); }
43
+ ne(path: string, value: unknown): this { return this.op2(path, '$ne', value); }
44
+ exists(path: string, yes = true): this { return this.op2(path, '$exists', yes); }
45
+
46
+ private op2(path: string, op: string, value: unknown): this {
47
+ const current = this.filter[path];
48
+ if (current && typeof current === 'object' && !Array.isArray(current) && !(current instanceof Date)) {
49
+ (current as Record<string, unknown>)[op] = value;
50
+ } else {
51
+ this.filter[path] = { [op]: value };
52
+ }
53
+ return this;
54
+ }
55
+
56
+ // ── Shaping ────────────────────────────────────────────────────────────
57
+ sort(spec: string | Record<string, 1 | -1>): this {
58
+ if (typeof spec === 'string') {
59
+ this.sortSpec = {};
60
+ for (const part of spec.split(' ')) {
61
+ if (!part) continue;
62
+ const desc = part.startsWith('-');
63
+ this.sortSpec[desc ? part.slice(1) : part] = desc ? -1 : 1;
64
+ }
65
+ } else {
66
+ this.sortSpec = spec;
67
+ }
68
+ return this;
69
+ }
70
+
71
+ limit(n: number): this { this.limitVal = n; return this; }
72
+ skip(n: number): this { this.skipVal = n; return this; }
73
+
74
+ select(fields: string): this {
75
+ this.projection = {};
76
+ for (const part of fields.split(' ')) {
77
+ if (!part) continue;
78
+ const exclude = part.startsWith('-');
79
+ this.projection[exclude ? part.slice(1) : part] = exclude ? 0 : 1;
80
+ }
81
+ return this;
82
+ }
83
+
84
+ populate(path: string, opts: { select?: string; match?: Record<string, unknown> } = {}): this {
85
+ this.populatePaths.push({ path, select: opts.select, match: opts.match });
86
+ return this;
87
+ }
88
+
89
+ lean(): this { this.leanFlag = true; return this; }
90
+ session(session: unknown): this { this.sessionRef = { session }; return this; }
91
+
92
+ // ── Execution ──────────────────────────────────────────────────────────
93
+ async exec(): Promise<T[] | T | null> {
94
+ const schema = this.model.schema;
95
+ // pre('find') / pre('findOne') hooks receive the query's filter for mutation.
96
+ const hookCtx = { filter: this.filter, model: this.model, op: this.op };
97
+ await runFindHooks(schema.preHooks, this.op, hookCtx);
98
+ this.filter = hookCtx.filter;
99
+
100
+ const coll = await this.model.collection;
101
+ const options: Record<string, unknown> = {};
102
+ // Default projection: exclude fields flagged select:false unless caller set an explicit projection.
103
+ if (this.projection) {
104
+ options.projection = this.projection;
105
+ } else {
106
+ const excluded: Record<string, 0> = {};
107
+ let hasExcluded = false;
108
+ for (const [path, field] of schema.compiledPaths) {
109
+ if (field.select === false) { excluded[path] = 0; hasExcluded = true; }
110
+ }
111
+ if (hasExcluded) options.projection = excluded;
112
+ }
113
+ if (this.sessionRef) options.session = this.sessionRef.session;
114
+ if (this.sortSpec) options.sort = this.sortSpec;
115
+ if (this.limitVal) options.limit = this.limitVal;
116
+ if (this.skipVal) options.skip = this.skipVal;
117
+
118
+ if (this.op === 'find') {
119
+ const cursor = coll.find(this.filter, options);
120
+ const docs = await cursor.toArray();
121
+ let result: unknown[] = this.leanFlag ? docs : docs.map((d) => this.model.hydrate(d));
122
+ if (this.populatePaths.length && !this.leanFlag) {
123
+ const { populate } = await import('../populate/populate.js');
124
+ await populate(this.model.connection, result as DocumentInstance[], this.populatePaths);
125
+ }
126
+ await runFindHooks(schema.postHooks, 'find', { docs: result, ...hookCtx });
127
+ return result as T[];
128
+ } else {
129
+ const doc = await coll.findOne(this.filter, options);
130
+ if (!doc) return null;
131
+ const result = this.leanFlag ? doc : this.model.hydrate(doc);
132
+ if (this.populatePaths.length && !this.leanFlag) {
133
+ const { populate } = await import('../populate/populate.js');
134
+ await populate(this.model.connection, [result as DocumentInstance], this.populatePaths);
135
+ }
136
+ await runFindHooks(schema.postHooks, 'findOne', { doc: result, ...hookCtx });
137
+ return result as T;
138
+ }
139
+ }
140
+
141
+ // ── Thenable ───────────────────────────────────────────────────────────
142
+ then<TResult1 = T[] | T | null, TResult2 = never>(
143
+ onFulfilled?: (value: T[] | T | null) => TResult1 | PromiseLike<TResult1>,
144
+ onRejected?: (reason: unknown) => TResult2 | PromiseLike<TResult2>,
145
+ ): Promise<TResult1 | TResult2> {
146
+ return this.exec().then(onFulfilled, onRejected);
147
+ }
148
+ }
149
+
150
+ async function runFindHooks(
151
+ hooks: Map<string, Array<(ctx: unknown) => unknown | Promise<unknown>>>,
152
+ op: string,
153
+ ctx: unknown,
154
+ ): Promise<void> {
155
+ const list = hooks.get(op);
156
+ if (!list) return;
157
+ for (const fn of list) await fn.call(ctx, ctx);
158
+ }
@@ -0,0 +1,173 @@
1
+ import { coerce, typeOf, type SchemaTypeToken } from './SchemaType.js';
2
+ import { ValidationError } from '../../../nexus-core/src/index.js';
3
+ import {
4
+ type CompiledField,
5
+ type FieldDefinition,
6
+ type IndexSpec,
7
+ type SchemaOptions,
8
+ type SchemaDefinition,
9
+ } from './validators.js';
10
+
11
+ export class NexusSchema<T = unknown> {
12
+ compiledPaths = new Map<string, CompiledField>();
13
+ indexes: IndexSpec[] = [];
14
+ options: SchemaOptions;
15
+ virtuals = new Map<string, { get?: () => unknown; set?: (v: unknown) => void }>();
16
+ preHooks = new Map<string, Array<(ctx: unknown) => unknown | Promise<unknown>>>();
17
+ postHooks = new Map<string, Array<(ctx: unknown) => unknown | Promise<unknown>>>();
18
+ methods: Record<string, (...args: unknown[]) => unknown> = {};
19
+ statics: Record<string, (...args: unknown[]) => unknown> = {};
20
+
21
+ constructor(definition: SchemaDefinition = {}, options: SchemaOptions = {}) {
22
+ this.options = { timestamps: false, versionKey: '__v', strict: true, ...options };
23
+ for (const [path, raw] of Object.entries(definition)) {
24
+ this.define(path, normalizeField(raw));
25
+ }
26
+ if (this.options.timestamps) {
27
+ const ts = this.options.timestamps === true ? { createdAt: 'createdAt', updatedAt: 'updatedAt' } : this.options.timestamps;
28
+ if (ts.createdAt && !this.compiledPaths.has(ts.createdAt)) {
29
+ this.define(ts.createdAt, { type: Date });
30
+ }
31
+ if (ts.updatedAt && !this.compiledPaths.has(ts.updatedAt)) {
32
+ this.define(ts.updatedAt, { type: Date });
33
+ }
34
+ }
35
+ }
36
+
37
+ define(path: string, def: FieldDefinition): void {
38
+ const token = resolveToken(def.type);
39
+ const validators = def.validate ? (Array.isArray(def.validate) ? def.validate : [def.validate]) : [];
40
+ const isRef = !!def.ref || !!def.refPath;
41
+ this.compiledPaths.set(path, {
42
+ token,
43
+ isRef,
44
+ ref: def.ref,
45
+ refPath: def.refPath,
46
+ required: def.required ?? false,
47
+ hasDefault: def.default !== undefined,
48
+ default: def.default,
49
+ enum: def.enum,
50
+ min: def.min,
51
+ max: def.max,
52
+ match: def.match,
53
+ validators,
54
+ select: def.select ?? true,
55
+ immutable: def.immutable ?? false,
56
+ transform: def.transform,
57
+ isEmbedded: false,
58
+ });
59
+ if (def.unique) {
60
+ this.indexes.push({ spec: { [path]: 1 }, options: { unique: true, sparse: def.sparse ?? false } });
61
+ } else if (def.index) {
62
+ const opts = typeof def.index === 'object' ? def.index : {};
63
+ this.indexes.push({ spec: { [path]: 1 }, options: opts });
64
+ }
65
+ if (def.expires) {
66
+ this.indexes.push({ spec: { [path]: 1 }, options: { expireAfterSeconds: def.expires } });
67
+ }
68
+ }
69
+
70
+ virtual(path: string, opts: { get?: () => unknown; set?: (v: unknown) => void } = {}): void {
71
+ this.virtuals.set(path, opts);
72
+ }
73
+
74
+ pre(event: string, fn: (ctx: unknown) => unknown | Promise<unknown>): void {
75
+ const list = this.preHooks.get(event) ?? [];
76
+ list.push(fn);
77
+ this.preHooks.set(event, list);
78
+ }
79
+
80
+ post(event: string, fn: (ctx: unknown) => unknown | Promise<unknown>): void {
81
+ const list = this.postHooks.get(event) ?? [];
82
+ list.push(fn);
83
+ this.postHooks.set(event, list);
84
+ }
85
+
86
+ method(name: string, fn: (...args: unknown[]) => unknown): void {
87
+ this.methods[name] = fn;
88
+ }
89
+
90
+ staticMethod(name: string, fn: (...args: unknown[]) => unknown): void {
91
+ this.statics[name] = fn;
92
+ }
93
+
94
+ /** Apply defaults for missing fields to a plain object. */
95
+ applyDefaults(doc: Record<string, unknown>): Record<string, unknown> {
96
+ for (const [path, field] of this.compiledPaths) {
97
+ if (doc[path] === undefined && field.hasDefault) {
98
+ doc[path] = typeof field.default === 'function' ? (field.default as () => unknown)() : field.default;
99
+ }
100
+ }
101
+ return doc;
102
+ }
103
+
104
+ /** Validate + coerce a document object; throws CoreValidationError on failure. */
105
+ validate(doc: Record<string, unknown>): void {
106
+ const errors: Array<{ path: string; message: string }> = [];
107
+ const strict = this.options.strict;
108
+
109
+ for (const [path, field] of this.compiledPaths) {
110
+ const value = doc[path];
111
+ const required = typeof field.required === 'function' ? field.required() : field.required;
112
+ if (value === undefined || value === null) {
113
+ if (required) errors.push({ path, message: `Path \`${path}\` is required.` });
114
+ continue;
115
+ }
116
+ let coerced: unknown;
117
+ try {
118
+ coerced = coerce(field.token, value);
119
+ } catch (e) {
120
+ errors.push({ path, message: (e as Error).message });
121
+ continue;
122
+ }
123
+ if (field.transform) coerced = field.transform(coerced);
124
+ if (field.enum && !field.enum.includes(coerced)) {
125
+ errors.push({ path, message: `\`${path}\` must be one of the enum values ${JSON.stringify(field.enum)}.` });
126
+ }
127
+ if (field.token === 'Number' && typeof coerced === 'number') {
128
+ if (field.min !== undefined && coerced < field.min) errors.push({ path, message: `\`${path}\` must be >= ${field.min}.` });
129
+ if (field.max !== undefined && coerced > field.max) errors.push({ path, message: `\`${path}\` must be <= ${field.max}.` });
130
+ }
131
+ if (field.token === 'String' && typeof coerced === 'string' && field.match && !field.match.test(coerced)) {
132
+ errors.push({ path, message: `\`${path}\` format is invalid.` });
133
+ }
134
+ for (const v of field.validators) {
135
+ if (!v.validator(coerced)) errors.push({ path, message: v.message });
136
+ }
137
+ doc[path] = coerced;
138
+ }
139
+
140
+ if (strict === true) {
141
+ for (const key of Object.keys(doc)) {
142
+ if (!this.compiledPaths.has(key) && !this.virtuals.has(key) && key !== '_id') {
143
+ delete doc[key];
144
+ }
145
+ }
146
+ } else if (strict === 'throw') {
147
+ for (const key of Object.keys(doc)) {
148
+ if (!this.compiledPaths.has(key) && !this.virtuals.has(key) && key !== '_id') {
149
+ errors.push({ path: key, message: `Path \`${key}\` is not in schema.` });
150
+ }
151
+ }
152
+ }
153
+
154
+ if (errors.length) {
155
+ throw new ValidationError(`Validation failed: ${errors.map((e) => e.message).join('; ')}`, errors);
156
+ }
157
+ }
158
+ }
159
+
160
+ function normalizeField(raw: unknown): FieldDefinition {
161
+ if (raw && typeof raw === 'object' && 'type' in (raw as Record<string, unknown>)) {
162
+ return raw as FieldDefinition;
163
+ }
164
+ return { type: raw };
165
+ }
166
+
167
+ function resolveToken(type: unknown): SchemaTypeToken {
168
+ if (Array.isArray(type)) return 'Array';
169
+ if (type instanceof NexusSchema) return 'Mixed';
170
+ if (typeof type === 'string') return type as SchemaTypeToken;
171
+ if (typeof type === 'function') return typeOf(type);
172
+ return 'Mixed';
173
+ }
@@ -0,0 +1,80 @@
1
+ import { ObjectId, Decimal128, Binary } from 'mongodb';
2
+
3
+ /** Primitive type tokens understood by the ODM. */
4
+ export type SchemaTypeToken =
5
+ | 'String'
6
+ | 'Number'
7
+ | 'Boolean'
8
+ | 'Date'
9
+ | 'Buffer'
10
+ | 'ObjectId'
11
+ | 'Decimal128'
12
+ | 'Mixed'
13
+ | 'Array'
14
+ | 'Map';
15
+
16
+ /** Map a JS constructor to a type token. */
17
+ export function typeOf(ctor: unknown): SchemaTypeToken {
18
+ switch (ctor) {
19
+ case String: return 'String';
20
+ case Number: return 'Number';
21
+ case Boolean: return 'Boolean';
22
+ case Date: return 'Date';
23
+ case Buffer: return 'Buffer';
24
+ case ObjectId: return 'ObjectId';
25
+ case Decimal128: return 'Decimal128';
26
+ case Binary: return 'Buffer';
27
+ case Object: return 'Mixed';
28
+ case Array: return 'Array';
29
+ case Map: return 'Map';
30
+ default: return 'Mixed';
31
+ }
32
+ }
33
+
34
+ /** Coerce a value to the target type, throwing on impossible coercion. */
35
+ export function coerce(token: SchemaTypeToken, value: unknown): unknown {
36
+ if (value === undefined || value === null) return value;
37
+ switch (token) {
38
+ case 'String':
39
+ if (typeof value === 'string') return value;
40
+ if (typeof value === 'number' || typeof value === 'boolean' || value instanceof Date) return String(value);
41
+ if (value instanceof ObjectId) return value.toHexString();
42
+ throw new TypeError(`Cannot coerce ${typeof value} to String`);
43
+ case 'Number': {
44
+ const n = Number(value);
45
+ if (Number.isNaN(n)) throw new TypeError(`Cannot coerce ${JSON.stringify(value)} to Number`);
46
+ return n;
47
+ }
48
+ case 'Boolean':
49
+ if (typeof value === 'boolean') return value;
50
+ if (value === 'true' || value === 1 || value === '1') return true;
51
+ if (value === 'false' || value === 0 || value === '0') return false;
52
+ throw new TypeError(`Cannot coerce ${JSON.stringify(value)} to Boolean`);
53
+ case 'Date': {
54
+ if (value instanceof Date) return value;
55
+ const d = new Date(value as string);
56
+ if (Number.isNaN(d.getTime())) throw new TypeError(`Cannot coerce ${JSON.stringify(value)} to Date`);
57
+ return d;
58
+ }
59
+ case 'Buffer':
60
+ if (Buffer.isBuffer(value)) return value;
61
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
62
+ throw new TypeError('Cannot coerce value to Buffer');
63
+ case 'ObjectId':
64
+ if (value instanceof ObjectId) return value;
65
+ if (typeof value === 'string' && /^[0-9a-fA-F]{24}$/.test(value)) return new ObjectId(value);
66
+ throw new TypeError(`Cannot coerce ${JSON.stringify(value)} to ObjectId`);
67
+ case 'Decimal128':
68
+ if (value instanceof Decimal128) return value;
69
+ if (typeof value === 'string' || typeof value === 'number') return Decimal128.fromString(String(value));
70
+ throw new TypeError('Cannot coerce value to Decimal128');
71
+ case 'Mixed':
72
+ case 'Array':
73
+ case 'Map':
74
+ return value;
75
+ default:
76
+ return value;
77
+ }
78
+ }
79
+
80
+ export { ObjectId, Decimal128 };
@@ -0,0 +1,88 @@
1
+ import { coerce, typeOf, type SchemaTypeToken } from './SchemaType.js';
2
+
3
+ export interface FieldValidator {
4
+ validator: (value: unknown) => boolean;
5
+ message: string;
6
+ }
7
+
8
+ export interface FieldDefinition {
9
+ /** A JS constructor, an array `[Constructor]`, a nested Schema, or a type token. */
10
+ type: unknown;
11
+ required?: boolean | (() => boolean);
12
+ default?: unknown | (() => unknown);
13
+ enum?: unknown[];
14
+ min?: number;
15
+ max?: number;
16
+ match?: RegExp;
17
+ validate?: FieldValidator | FieldValidator[];
18
+ ref?: string; // model name for population
19
+ refPath?: string; // dynamic ref field
20
+ select?: boolean; // exclude from queries by default when false
21
+ immutable?: boolean;
22
+ expires?: number; // TTL index seconds
23
+ index?: boolean | Record<string, unknown>;
24
+ unique?: boolean;
25
+ sparse?: boolean;
26
+ transform?: (value: unknown) => unknown;
27
+ }
28
+
29
+ export interface CompiledField {
30
+ token: SchemaTypeToken;
31
+ isRef: boolean;
32
+ ref?: string;
33
+ refPath?: string;
34
+ required: boolean | (() => boolean);
35
+ hasDefault: boolean;
36
+ default: unknown | (() => unknown);
37
+ enum?: unknown[];
38
+ min?: number;
39
+ max?: number;
40
+ match?: RegExp;
41
+ validators: FieldValidator[];
42
+ select: boolean;
43
+ immutable: boolean;
44
+ transform?: (value: unknown) => unknown;
45
+ isEmbedded: boolean;
46
+ embeddedSchema?: Schema;
47
+ }
48
+
49
+ export interface IndexSpec {
50
+ spec: Record<string, 1 | -1>;
51
+ options: Record<string, unknown>;
52
+ }
53
+
54
+ /** A field-validation error with a path. */
55
+ export interface FieldError {
56
+ path: string;
57
+ message: string;
58
+ }
59
+
60
+ /** Self-referencing type for embedded schemas. */
61
+ export interface Schema {
62
+ compiledPaths: Map<string, CompiledField>;
63
+ indexes: IndexSpec[];
64
+ options: SchemaOptions;
65
+ virtuals: Map<string, { get?: () => unknown; set?: (v: unknown) => void }>;
66
+ preHooks: Map<string, Array<(ctx: unknown) => unknown | Promise<unknown>>>;
67
+ postHooks: Map<string, Array<(ctx: unknown) => unknown | Promise<unknown>>>;
68
+ methods: Record<string, (...args: unknown[]) => unknown>;
69
+ statics: Record<string, (...args: unknown[]) => unknown>;
70
+ define(path: string, def: FieldDefinition): void;
71
+ virtual(path: string, opts?: { get?: () => unknown; set?: (v: unknown) => void }): void;
72
+ pre(event: string, fn: (ctx: unknown) => unknown | Promise<unknown>): void;
73
+ post(event: string, fn: (ctx: unknown) => unknown | Promise<unknown>): void;
74
+ method(name: string, fn: (...args: unknown[]) => unknown): void;
75
+ staticMethod(name: string, fn: (...args: unknown[]) => unknown): void;
76
+ }
77
+
78
+ export interface SchemaOptions {
79
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string };
80
+ collection?: string;
81
+ discriminatorKey?: string;
82
+ versionKey?: string | false;
83
+ strict?: boolean | 'throw';
84
+ }
85
+
86
+ export interface SchemaDefinition {
87
+ [path: string]: FieldDefinition | unknown;
88
+ }