@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.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @bhooai/nexus-data
2
+
3
+ A from-scratch ODM built on the official `mongodb` driver (no mongoose).
4
+
5
+ ## Exports
6
+
7
+ - `connect(uri, options)` / `getConnection()` — single connection + `ConnectionManager`.
8
+ - `model<T>(name, schema, { collection })` — registers a model on the default connection.
9
+ - `NexusSchema` (alias `Schema`) with field options: `{ type, required, default, enum,
10
+ min, max, match, validate, ref, refPath, select, immutable, expires, index, unique, transform }`.
11
+ - `Model` — `find/findOne/findById/create/insertMany/updateOne/updateMany/
12
+ deleteOne/deleteMany/countDocuments/aggregate/bulkWrite/findOneAndUpdate`.
13
+ - `DocumentInstance` — `save/remove/populate/validate` (+ `toObject()`).
14
+ - `Query` — chainable + thenable: `where/gt/sort/limit/skip/select/populate/lean/session`.
15
+ - `transaction(fn)` — driver `withTransaction`.
16
+ - `pre`/`post` hooks for `save/validate/remove/updateOne/deleteOne/find`.
17
+ - Batched multi-level `populate` (with `refPath`/`select`/`match`); auto-index creation at boot.
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { connect, model, Schema } from '@bhooai/nexus-data';
23
+
24
+ connect('mongodb://localhost:27017/app');
25
+ const User = model('User', new Schema({
26
+ email: { type: String, required: true, unique: true },
27
+ roles: { type: [String], default: ['user'] },
28
+ }));
29
+ const u = await User.findOne({ email: 'a@b.com' }).lean();
30
+ ```
31
+
32
+ Tests run against real MongoDB via `.env` (`NEXUS_DB_URI`) — no embedded Mongo.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@bhooai/nexus-data",
3
+ "version": "0.1.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "test": "vitest run"
17
+ },
18
+ "dependencies": {
19
+ "@bhooai/nexus-core": "^0.1.0",
20
+ "mongodb": "^6.9.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.5.0",
24
+ "typescript": "^5.6.2",
25
+ "vitest": "^2.1.1"
26
+ }
27
+ }
@@ -0,0 +1,57 @@
1
+ import { MongoClient, Db, type MongoClientOptions } from 'mongodb';
2
+ import { NexusSchema } from '../schema/Schema.js';
3
+ import { Model } from '../model/Model.js';
4
+ import type { DocumentInstance } from '../model/Document.js';
5
+
6
+ export interface ConnectOptions extends MongoClientOptions {
7
+ /** Database name override (otherwise taken from the URI). */
8
+ name?: string;
9
+ /** Auto-create indexes declared in schemas on connect. */
10
+ autoIndex?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Wraps a `MongoClient` and lazily exposes a `Db`. Models are registered per
15
+ * connection. The default connection is created by the top-level `connect()`.
16
+ */
17
+ export class Connection {
18
+ client: MongoClient;
19
+ dbPromise: Promise<Db>;
20
+ models = new Map<string, Model<any>>();
21
+ private dbName?: string;
22
+ autoIndex: boolean;
23
+
24
+ constructor(uri: string, options: ConnectOptions = {}) {
25
+ this.dbName = options.name;
26
+ this.autoIndex = options.autoIndex ?? true;
27
+ // Strip ODM-only options before handing the rest to the mongo driver.
28
+ const { name: _name, autoIndex: _autoIndex, ...driverOptions } = options;
29
+ this.client = new MongoClient(uri, driverOptions);
30
+ this.dbPromise = this.client.connect().then(() => this.client.db(this.dbName));
31
+ }
32
+
33
+ get db(): Promise<Db> {
34
+ return this.dbPromise;
35
+ }
36
+
37
+ /** Register a model on this connection and (optionally) create indexes. */
38
+ model<T extends DocumentInstance>(name: string, schema: NexusSchema, options: { collection?: string } = {}): Model<T> {
39
+ const existing = this.models.get(name);
40
+ if (existing) return existing as Model<T>;
41
+ const collectionName = options.collection ?? schema.options.collection ?? name.toLowerCase() + 's';
42
+ const model = new Model<T>(name, schema, this, collectionName);
43
+ this.models.set(name, model);
44
+ if (this.autoIndex) void model.createIndexes();
45
+ return model;
46
+ }
47
+
48
+ /** Start a session for transactions. */
49
+ async startSession() {
50
+ await this.dbPromise;
51
+ return this.client.startSession();
52
+ }
53
+
54
+ async close(): Promise<void> {
55
+ await this.client.close();
56
+ }
57
+ }
@@ -0,0 +1,37 @@
1
+ import { Connection, type ConnectOptions } from './Connection.js';
2
+
3
+ /**
4
+ * Manages multiple named connections (e.g. a primary app DB plus an analytics
5
+ * DB). The default connection is registered under the name `'default'` and is
6
+ * what the top-level `connect()`/`model()` helpers use.
7
+ */
8
+ export class ConnectionManager {
9
+ private connections = new Map<string, Connection>();
10
+
11
+ connect(name: string, uri: string, options: ConnectOptions = {}): Connection {
12
+ const existing = this.connections.get(name);
13
+ if (existing) return existing;
14
+ const conn = new Connection(uri, options);
15
+ this.connections.set(name, conn);
16
+ return conn;
17
+ }
18
+
19
+ get(name = 'default'): Connection {
20
+ const conn = this.connections.get(name);
21
+ if (!conn) throw new Error(`Connection '${name}' is not registered.`);
22
+ return conn;
23
+ }
24
+
25
+ has(name: string): boolean {
26
+ return this.connections.has(name);
27
+ }
28
+
29
+ /** Close all managed connections. */
30
+ async closeAll(): Promise<void> {
31
+ await Promise.all([...this.connections.values()].map((c) => c.close()));
32
+ this.connections.clear();
33
+ }
34
+ }
35
+
36
+ /** A process-wide connection manager for multi-DB setups. */
37
+ export const connectionManager = new ConnectionManager();
package/src/errors.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { ValidationError as CoreValidationError } from '../../nexus-core/src/index.js';
2
+
3
+ export { CoreValidationError as ValidationError };
4
+
5
+ export class DocumentNotFoundError extends Error {
6
+ constructor(message = 'No document found') {
7
+ super(message);
8
+ this.name = 'DocumentNotFoundError';
9
+ }
10
+ }
11
+
12
+ export class VersionError extends Error {
13
+ constructor(message = 'Document version mismatch') {
14
+ super(message);
15
+ this.name = 'VersionError';
16
+ }
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { Connection, type ConnectOptions } from './connection/Connection.js';
2
+ import { NexusSchema } from './schema/Schema.js';
3
+ import { Model } from './model/Model.js';
4
+ import type { DocumentInstance } from './model/Document.js';
5
+
6
+ export * from './schema/SchemaType.js';
7
+ export * from './schema/validators.js';
8
+ export { NexusSchema as Schema } from './schema/Schema.js';
9
+ export { Model } from './model/Model.js';
10
+ export { DocumentInstance } from './model/Document.js';
11
+ export { Query } from './query/Query.js';
12
+ export { Connection } from './connection/Connection.js';
13
+ export { ConnectionManager, connectionManager } from './connection/ConnectionManager.js';
14
+ export * from './errors.js';
15
+ export {
16
+ PROJECT_INFO_DB,
17
+ sanitizeDbName,
18
+ resolveProjectInfo,
19
+ connectProjectInfo,
20
+ getProjectInfoCollection,
21
+ upsertProjectInfo,
22
+ listProjectInfo,
23
+ getProjectInfo,
24
+ deleteProjectInfo,
25
+ dropProjectDatabase,
26
+ closeProjectInfo,
27
+ type ProjectInfo,
28
+ type ClusterNodeRecord,
29
+ getClusterNodesCollection,
30
+ upsertClusterNode,
31
+ deleteClusterNode,
32
+ listClusterNodes,
33
+ syncClusterNodes,
34
+ } from './projects.js';
35
+
36
+ let defaultConnection: Connection | undefined;
37
+
38
+ /** Connect to MongoDB and establish the default connection. */
39
+ export function connect(uri: string, options: ConnectOptions = {}): Connection {
40
+ defaultConnection = new Connection(uri, options);
41
+ return defaultConnection;
42
+ }
43
+
44
+ /** The default connection (set by `connect`). */
45
+ export function getConnection(): Connection {
46
+ if (!defaultConnection) throw new Error('Not connected — call connect(uri) first.');
47
+ return defaultConnection;
48
+ }
49
+
50
+ /** Register a model on the default connection. */
51
+ export function model<T extends DocumentInstance>(name: string, schema: NexusSchema, options?: { collection?: string }): Model<T> {
52
+ return getConnection().model<T>(name, schema, options);
53
+ }
54
+
55
+ /** Run a transaction on the default connection. */
56
+ export async function transaction<T>(fn: (session: import('mongodb').ClientSession) => Promise<T>): Promise<T> {
57
+ const conn = getConnection();
58
+ const session = await conn.startSession();
59
+ try {
60
+ return await session.withTransaction(() => fn(session));
61
+ } finally {
62
+ await session.endSession();
63
+ }
64
+ }
65
+
66
+ export { ObjectId, Decimal128 } from 'mongodb';
@@ -0,0 +1,126 @@
1
+ import type { NexusSchema } from '../schema/Schema.js';
2
+ import type { Model } from './Model.js';
3
+ import type { ObjectId } from 'mongodb';
4
+
5
+ /** A hydrated document instance wrapping a plain Mongo object. */
6
+ export class DocumentInstance {
7
+ _doc: Record<string, unknown> = {};
8
+ _schema: NexusSchema;
9
+ _model: Model<this>;
10
+ _isNew = true;
11
+ _modified = new Set<string>();
12
+
13
+ constructor(schema: NexusSchema, model: Model<DocumentInstance>, data: Record<string, unknown>, isNew = true) {
14
+ this._schema = schema;
15
+ this._model = model as Model<this>;
16
+ this._isNew = isNew;
17
+ this._doc = { ...data };
18
+ // New documents consider all initial fields modified (matches mongoose isNew semantics),
19
+ // so pre('save') hooks using isModified() fire for first inserts.
20
+ if (isNew) for (const k of Object.keys(this._doc)) this._modified.add(k);
21
+
22
+ // Proxy property access to _doc so `doc.email` reads/writes the stored value.
23
+ return new Proxy(this, {
24
+ get(target, prop: string) {
25
+ if (prop in target || typeof prop !== 'string') return (target as unknown as Record<string, unknown>)[prop];
26
+ return target._doc[prop];
27
+ },
28
+ set(target, prop: string, value: unknown) {
29
+ if (prop in target && typeof (target as unknown as Record<string, unknown>)[prop] !== 'undefined' && prop.startsWith('_')) {
30
+ (target as unknown as Record<string, unknown>)[prop] = value;
31
+ return true;
32
+ }
33
+ if (prop === '_doc' || prop.startsWith('_')) {
34
+ (target as unknown as Record<string, unknown>)[prop] = value;
35
+ return true;
36
+ }
37
+ target._doc[prop] = value;
38
+ target._modified.add(prop);
39
+ return true;
40
+ },
41
+ });
42
+ }
43
+
44
+ isModified(path?: string): boolean {
45
+ if (path) return this._modified.has(path);
46
+ return this._modified.size > 0;
47
+ }
48
+
49
+ toObject(): Record<string, unknown> {
50
+ const out = { ...this._doc };
51
+ // Apply virtual getters (bound to the instance so they can read other fields).
52
+ for (const [name, virt] of this._schema.virtuals) {
53
+ if (virt.get) {
54
+ try { out[name] = virt.get.call(this); } catch { /* virtual getters must not break serialization */ }
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ toJSON(): Record<string, unknown> {
61
+ return this.toObject();
62
+ }
63
+
64
+ async validate(): Promise<void> {
65
+ await runHooks(this._schema.preHooks, 'validate', this);
66
+ this._schema.validate(this._doc);
67
+ await runHooks(this._schema.postHooks, 'validate', this);
68
+ }
69
+
70
+ async populate(path: string): Promise<this> {
71
+ const { populate } = await import('../populate/populate.js');
72
+ await populate(this._model.connection, [this], path);
73
+ return this;
74
+ }
75
+
76
+ async save(): Promise<this> {
77
+ await runHooks(this._schema.preHooks, 'save', this);
78
+ if (this._schema.options.timestamps) {
79
+ const now = new Date();
80
+ const ts = this._schema.options.timestamps === true ? { createdAt: 'createdAt', updatedAt: 'updatedAt' } : this._schema.options.timestamps;
81
+ if (ts?.createdAt && this._isNew) this._doc[ts.createdAt] = now;
82
+ if (ts?.updatedAt) this._doc[ts.updatedAt] = now;
83
+ }
84
+ if (this._isNew) {
85
+ this._schema.applyDefaults(this._doc);
86
+ await this.validate();
87
+ const result = await this._model.collection.then((c) => c.insertOne(this._doc));
88
+ if (result.acknowledged) this._doc._id = result.insertedId;
89
+ this._isNew = false;
90
+ } else {
91
+ await this.validate();
92
+ const update = this._modified.size ? this._buildUpdate() : { $set: this._doc };
93
+ await this._model.collection.then((c) => c.updateOne({ _id: this._doc._id as ObjectId }, update));
94
+ }
95
+ this._modified.clear();
96
+ await runHooks(this._schema.postHooks, 'save', this);
97
+ return this;
98
+ }
99
+
100
+ async remove(): Promise<void> {
101
+ await runHooks(this._schema.preHooks, 'remove', this);
102
+ await this._model.collection.then((c) => c.deleteOne({ _id: this._doc._id as ObjectId }));
103
+ await runHooks(this._schema.postHooks, 'remove', this);
104
+ }
105
+
106
+ private _buildUpdate(): Record<string, unknown> {
107
+ const $set: Record<string, unknown> = {};
108
+ for (const path of this._modified) {
109
+ // Immutable fields cannot be written after the initial insert.
110
+ const field = this._schema.compiledPaths.get(path);
111
+ if (field?.immutable && !this._isNew) continue;
112
+ $set[path] = this._doc[path];
113
+ }
114
+ return { $set };
115
+ }
116
+ }
117
+
118
+ async function runHooks(
119
+ hooks: Map<string, Array<(ctx: unknown) => unknown | Promise<unknown>>>,
120
+ event: string,
121
+ ctx: unknown,
122
+ ): Promise<void> {
123
+ const list = hooks.get(event);
124
+ if (!list) return;
125
+ for (const fn of list) await fn.call(ctx, ctx);
126
+ }
@@ -0,0 +1,189 @@
1
+ import type { Collection, Db } from 'mongodb';
2
+ import { ObjectId } from 'mongodb';
3
+ import { NexusSchema } from '../schema/Schema.js';
4
+ import { Connection } from '../connection/Connection.js';
5
+ import { DocumentInstance } from './Document.js';
6
+ import { hydrate } from './hydrate.js';
7
+ import { Query } from '../query/Query.js';
8
+
9
+ /** Build a NEW (not-yet-persisted) document instance from input data. */
10
+ export function newInstance<T extends DocumentInstance>(
11
+ schema: NexusSchema,
12
+ model: Model<T>,
13
+ data: Record<string, unknown>,
14
+ ): T {
15
+ return new DocumentInstance(schema, model, data, true) as T;
16
+ }
17
+
18
+ export class Model<T extends DocumentInstance> {
19
+ collection: Promise<Collection>;
20
+ schema: NexusSchema;
21
+ connection: Connection;
22
+ collectionName: string;
23
+ name: string;
24
+
25
+ constructor(name: string, schema: NexusSchema, connection: Connection, collectionName: string) {
26
+ this.name = name;
27
+ this.schema = schema;
28
+ this.connection = connection;
29
+ this.collectionName = collectionName;
30
+ this.collection = connection.db.then((db: Db) => db.collection(collectionName));
31
+
32
+ // Attach static methods declared on the schema.
33
+ for (const [key, fn] of Object.entries(schema.statics)) {
34
+ (this as unknown as Record<string, unknown>)[key] = fn.bind(this);
35
+ }
36
+ }
37
+
38
+ /** Hydrate a raw mongo doc into a Document instance. */
39
+ hydrate(doc: Record<string, unknown>): T {
40
+ return hydrate<T>(this.schema, this, doc) as T;
41
+ }
42
+
43
+ // ── Query builders ──────────────────────────────────────────────────────
44
+ find(filter: Record<string, unknown> = {}): Query<T> {
45
+ return new Query<T>(this, 'find', castFilterId(filter));
46
+ }
47
+
48
+ findOne(filter: Record<string, unknown> = {}): Query<T> {
49
+ return new Query<T>(this, 'findOne', castFilterId(filter));
50
+ }
51
+
52
+ findById(id: unknown): Query<T> {
53
+ return new Query<T>(this, 'findOne', { _id: castId(id) });
54
+ }
55
+
56
+ // ── Writes ──────────────────────────────────────────────────────────────
57
+ async create(docs: Record<string, unknown> | Record<string, unknown>[]): Promise<T[]> {
58
+ const input = Array.isArray(docs) ? docs : [docs];
59
+ const out: T[] = [];
60
+ for (const data of input) {
61
+ const instance = newInstance(this.schema, this, data);
62
+ await instance.save();
63
+ out.push(instance);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ async insertMany(docs: Record<string, unknown>[]): Promise<T[]> {
69
+ const prepared = docs.map((d) => {
70
+ const copy = { ...d };
71
+ this.schema.applyDefaults(copy);
72
+ this.schema.validate(copy);
73
+ return copy;
74
+ });
75
+ const coll = await this.collection;
76
+ const result = await coll.insertMany(prepared);
77
+ return prepared.map((d, i) => {
78
+ d._id = result.insertedIds[i];
79
+ return this.hydrate(d);
80
+ });
81
+ }
82
+
83
+ async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>): Promise<number> {
84
+ await runSchemaHooks(this.schema, 'updateOne', { filter, update, model: this });
85
+ const coll = await this.collection;
86
+ const result = await coll.updateOne(castFilterId(filter), update);
87
+ return result.modifiedCount;
88
+ }
89
+
90
+ async updateMany(filter: Record<string, unknown>, update: Record<string, unknown>): Promise<number> {
91
+ const coll = await this.collection;
92
+ const result = await coll.updateMany(castFilterId(filter), update);
93
+ return result.modifiedCount;
94
+ }
95
+
96
+ async deleteOne(filter: Record<string, unknown>): Promise<number> {
97
+ const coll = await this.collection;
98
+ const result = await coll.deleteOne(castFilterId(filter));
99
+ return result.deletedCount;
100
+ }
101
+
102
+ async deleteMany(filter: Record<string, unknown>): Promise<number> {
103
+ await runSchemaHooks(this.schema, 'deleteMany', { filter, model: this });
104
+ const coll = await this.collection;
105
+ const result = await coll.deleteMany(castFilterId(filter));
106
+ return result.deletedCount;
107
+ }
108
+
109
+ /** Find a doc and apply an update in one round-trip; returns the updated (or original) hydrated doc. */
110
+ async findOneAndUpdate(
111
+ filter: Record<string, unknown>,
112
+ update: Record<string, unknown>,
113
+ opts: { returnDocument?: 'before' | 'after'; upsert?: boolean } = {},
114
+ ): Promise<T | null> {
115
+ const coll = await this.collection;
116
+ const result = await coll.findOneAndUpdate(castFilterId(filter), update, {
117
+ returnDocument: opts.returnDocument === 'before' ? 'before' : 'after',
118
+ upsert: opts.upsert ?? false,
119
+ includeResultMetadata: true,
120
+ });
121
+ if (!result || !result.value) return null;
122
+ return this.hydrate(result.value as Record<string, unknown>);
123
+ }
124
+
125
+ /** Find a doc and remove it; returns the deleted hydrated doc (or null). */
126
+ async findOneAndDelete(filter: Record<string, unknown>): Promise<T | null> {
127
+ const coll = await this.collection;
128
+ const result = await coll.findOneAndDelete(castFilterId(filter), { includeResultMetadata: true });
129
+ if (!result || !result.value) return null;
130
+ return this.hydrate(result.value as Record<string, unknown>);
131
+ }
132
+
133
+ /** Run a bulk write of mixed operations; returns the driver result summary. */
134
+ async bulkWrite(ops: Record<string, unknown>[]): Promise<{ insertedCount: number; modifiedCount: number; deletedCount: number }> {
135
+ const coll = await this.collection;
136
+ const result = await coll.bulkWrite(ops as never);
137
+ return {
138
+ insertedCount: result.insertedCount,
139
+ modifiedCount: result.modifiedCount,
140
+ deletedCount: result.deletedCount,
141
+ };
142
+ }
143
+
144
+ async countDocuments(filter: Record<string, unknown> = {}): Promise<number> {
145
+ const coll = await this.collection;
146
+ return coll.countDocuments(filter);
147
+ }
148
+
149
+ async aggregate(pipeline: Record<string, unknown>[]): Promise<unknown[]> {
150
+ const coll = await this.collection;
151
+ return coll.aggregate(pipeline).toArray();
152
+ }
153
+
154
+ /** Create indexes declared in the schema. Called automatically on connect. */
155
+ async createIndexes(): Promise<void> {
156
+ if (this.schema.indexes.length === 0) return;
157
+ const coll = await this.collection;
158
+ for (const { spec, options } of this.schema.indexes) {
159
+ await coll.createIndex(spec, options);
160
+ }
161
+ }
162
+ }
163
+
164
+ /** Run pre/post hooks for a write event on a schema (best-effort, used by updateOne/deleteMany). */
165
+ async function runSchemaHooks(schema: NexusSchema, event: string, ctx: Record<string, unknown>): Promise<void> {
166
+ const pre = schema.preHooks.get(event);
167
+ if (pre) for (const fn of pre) await fn.call(ctx, ctx);
168
+ const post = schema.postHooks.get(event);
169
+ if (post) for (const fn of post) await fn.call(ctx, ctx);
170
+ }
171
+
172
+ /** Cast a 24-char hex string (or any ObjectId-like) to ObjectId; pass through otherwise. */
173
+ export function castId(id: unknown): unknown {
174
+ if (id instanceof ObjectId) return id;
175
+ if (typeof id === 'string' && /^[0-9a-fA-F]{24}$/.test(id)) return new ObjectId(id);
176
+ return id;
177
+ }
178
+
179
+ /** Cast a top-level scalar `_id` in a filter (Mongoose-style); leaves operator/array filters untouched. */
180
+ function castFilterId(filter: Record<string, unknown>): Record<string, unknown> {
181
+ if (filter && typeof filter === 'object' && '_id' in filter) {
182
+ const v = filter._id;
183
+ if (typeof v === 'string' || v instanceof ObjectId) {
184
+ const cast = castId(v);
185
+ if (cast !== v) filter._id = cast;
186
+ }
187
+ }
188
+ return filter;
189
+ }
@@ -0,0 +1,12 @@
1
+ import type { NexusSchema } from '../schema/Schema.js';
2
+ import type { Model } from './Model.js';
3
+ import { DocumentInstance } from './Document.js';
4
+
5
+ /** Build a Document instance from a raw Mongo doc (not new). */
6
+ export function hydrate<T extends DocumentInstance>(
7
+ schema: NexusSchema,
8
+ model: Model<T>,
9
+ doc: Record<string, unknown>,
10
+ ): T {
11
+ return new DocumentInstance(schema, model, doc, false) as T;
12
+ }
@@ -0,0 +1,114 @@
1
+ import type { Connection } from '../connection/Connection.js';
2
+ import { DocumentInstance } from '../model/Document.js';
3
+ import type { ObjectId } from 'mongodb';
4
+
5
+ interface PopulatePath {
6
+ path: string;
7
+ select?: string;
8
+ match?: Record<string, unknown>;
9
+ }
10
+
11
+ /**
12
+ * Resolve `ref` fields across documents in batched lookups (one query per
13
+ * path, not one per document). Supports nested paths ("a.b"), array refs, and
14
+ * `refPath` (dynamic model per document). Populated docs are hydrated and
15
+ * assigned back onto the parent.
16
+ */
17
+ export async function populate(
18
+ connection: Connection,
19
+ docs: DocumentInstance[],
20
+ paths: string | PopulatePath[],
21
+ ): Promise<void> {
22
+ if (docs.length === 0) return;
23
+ const list = typeof paths === 'string' ? [{ path: paths }] : paths;
24
+ for (const p of list) await populateOne(connection, docs, p);
25
+ }
26
+
27
+ async function populateOne(
28
+ connection: Connection,
29
+ docs: DocumentInstance[],
30
+ p: PopulatePath,
31
+ ): Promise<void> {
32
+ const segments = p.path.split('.');
33
+ const head = segments[0]!;
34
+ const rest = segments.slice(1).join('.');
35
+
36
+ // Determine the ref model for this path. The field may use a static `ref`
37
+ // (found on the schema of any doc) or a dynamic `refPath`.
38
+ const sampleField = docs[0]?._schema.compiledPaths.get(head);
39
+ if (!sampleField || (!sampleField.ref && !sampleField.refPath)) {
40
+ throw new Error(`Path \`${head}\` is not a reference and cannot be populated.`);
41
+ }
42
+
43
+ // Collect ids (supporting arrays of refs).
44
+ const ids = new Set<unknown>();
45
+ for (const doc of docs) {
46
+ const value = doc._doc[head];
47
+ if (Array.isArray(value)) for (const v of value) ids.add(v);
48
+ else if (value !== undefined && value !== null) ids.add(value);
49
+ }
50
+ if (ids.size === 0) return;
51
+
52
+ // Group docs by target model name (for refPath) and resolve each group.
53
+ const byModel = new Map<string, { model: string; ids: Set<unknown> }>();
54
+ for (const doc of docs) {
55
+ const field = doc._schema.compiledPaths.get(head)!;
56
+ const modelName = field.refPath ? String(doc._doc[field.refPath]) : field.ref!;
57
+ const entry = byModel.get(modelName) ?? { model: modelName, ids: new Set() };
58
+ const value = doc._doc[head];
59
+ if (Array.isArray(value)) for (const v of value) entry.ids.add(v);
60
+ else if (value !== undefined && value !== null) entry.ids.add(value);
61
+ byModel.set(modelName, entry);
62
+ }
63
+
64
+ for (const { model: modelName, ids: modelIds } of byModel.values()) {
65
+ const targetModel = connection.models.get(modelName);
66
+ if (!targetModel) throw new Error(`Model \`${modelName}\` is not registered.`);
67
+ const projection = p.select ? buildProjection(p.select) : undefined;
68
+ const found = await targetModel.collection.then((c) =>
69
+ c.find({ _id: { $in: [...modelIds] as ObjectId[] }, ...(p.match ?? {}) }, projection ? { projection } : {}).toArray(),
70
+ );
71
+ const byId = new Map(found.map((d) => [String(d._id), d]));
72
+
73
+ for (const doc of docs) {
74
+ const field = doc._schema.compiledPaths.get(head)!;
75
+ const targetName = field.refPath ? String(doc._doc[field.refPath]) : field.ref!;
76
+ if (targetName !== modelName) continue;
77
+ const value = doc._doc[head];
78
+ if (Array.isArray(value)) {
79
+ doc._doc[head] = value.map((id) => byId.get(String(id)) ?? id).filter((v) => v !== undefined);
80
+ for (const child of doc._doc[head] as unknown[]) {
81
+ if (child && typeof child === 'object' && '_doc' in (child as object)) continue;
82
+ }
83
+ // hydrate populated children
84
+ doc._doc[head] = (doc._doc[head] as unknown[]).map((raw) =>
85
+ raw && typeof raw === 'object' && !('_doc' in raw) ? targetModel.hydrate(raw as Record<string, unknown>) : raw,
86
+ );
87
+ } else {
88
+ const raw = byId.get(String(value));
89
+ if (raw) doc._doc[head] = targetModel.hydrate(raw);
90
+ }
91
+ }
92
+ }
93
+
94
+ // Recurse into nested populate paths.
95
+ if (rest) {
96
+ const children: DocumentInstance[] = [];
97
+ for (const doc of docs) {
98
+ const value = doc._doc[head];
99
+ if (Array.isArray(value)) for (const v of value) if (v instanceof DocumentInstance) children.push(v);
100
+ else if (value instanceof DocumentInstance) children.push(value);
101
+ }
102
+ if (children.length) await populateOne(connection, children, { path: rest, select: p.select, match: p.match });
103
+ }
104
+ }
105
+
106
+ function buildProjection(select: string): Record<string, 0 | 1> {
107
+ const proj: Record<string, 0 | 1> = {};
108
+ for (const part of select.split(' ')) {
109
+ if (!part) continue;
110
+ const exclude = part.startsWith('-');
111
+ proj[exclude ? part.slice(1) : part] = exclude ? 0 : 1;
112
+ }
113
+ return proj;
114
+ }