@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 +32 -0
- package/package.json +27 -0
- package/src/connection/Connection.ts +57 -0
- package/src/connection/ConnectionManager.ts +37 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +66 -0
- package/src/model/Document.ts +126 -0
- package/src/model/Model.ts +189 -0
- package/src/model/hydrate.ts +12 -0
- package/src/populate/populate.ts +114 -0
- package/src/projects.ts +185 -0
- package/src/query/Query.ts +158 -0
- package/src/schema/Schema.ts +173 -0
- package/src/schema/SchemaType.ts +80 -0
- package/src/schema/validators.ts +88 -0
- package/tests/odm.test.ts +202 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +10 -0
- package/vitest.config.ts.timestamp-1786094491228-e7af26515712d.mjs +14 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
2
|
+
import { connect, Schema, model, transaction, ObjectId, type DocumentInstance, type Model } from '../src/index.js';
|
|
3
|
+
import { MongoClient } from 'mongodb';
|
|
4
|
+
|
|
5
|
+
const URI = process.env.NEXUS_DB_URI?.replace(/\/[^/?]*$/, '/nexus_odm_test') ?? 'mongodb://localhost:27017/nexus_odm_test';
|
|
6
|
+
const TEST_DB = new URL(URI.replace(/^mongodb:/, 'http:')).pathname.slice(1) || 'nexus_odm_test';
|
|
7
|
+
const RAW_URI = URI.replace(/\/[^/?]*$/, '/') + TEST_DB;
|
|
8
|
+
|
|
9
|
+
interface OrgDoc { _id?: ObjectId; name: string; members: number }
|
|
10
|
+
interface UserDoc { _id?: ObjectId; email: string; name: string; age?: number; role?: string; orgId?: ObjectId | null }
|
|
11
|
+
|
|
12
|
+
let Org: Model<DocumentInstance & OrgDoc>;
|
|
13
|
+
let User: Model<DocumentInstance & UserDoc>;
|
|
14
|
+
let supportsTransactions = false;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
connect(URI, { autoIndex: true });
|
|
18
|
+
// wait for connection + drop the test db for a clean slate
|
|
19
|
+
const conn = (await import('../src/index.js')).getConnection();
|
|
20
|
+
await conn.db;
|
|
21
|
+
const mongo = new MongoClient(RAW_URI);
|
|
22
|
+
await mongo.connect();
|
|
23
|
+
await mongo.db(TEST_DB).dropDatabase();
|
|
24
|
+
await mongo.close();
|
|
25
|
+
|
|
26
|
+
// Transactions require a replica set / mongos; detect and skip if standalone.
|
|
27
|
+
try {
|
|
28
|
+
const admin = conn.client.db().admin();
|
|
29
|
+
const status = await admin.command({ replSetGetStatus: 1 } as never);
|
|
30
|
+
supportsTransactions = !!status?.ok || !!status?.setName;
|
|
31
|
+
} catch {
|
|
32
|
+
supportsTransactions = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const orgSchema = new Schema<OrgDoc>({ name: { type: String, required: true }, members: { type: Number, default: 0 } });
|
|
36
|
+
Org = model<OrgDoc & DocumentInstance>('Org', orgSchema as unknown as Schema);
|
|
37
|
+
|
|
38
|
+
const userSchema = new Schema<UserDoc>({
|
|
39
|
+
email: { type: String, required: true, unique: true, match: /.+@.+\..+/ },
|
|
40
|
+
name: { type: String, required: true },
|
|
41
|
+
age: { type: Number, min: 0, max: 150 },
|
|
42
|
+
role: { type: String, enum: ['admin', 'user'], default: 'user' },
|
|
43
|
+
orgId: { type: ObjectId, ref: 'Org' },
|
|
44
|
+
});
|
|
45
|
+
userSchema.pre('save', function (this: DocumentInstance & UserDoc) {
|
|
46
|
+
if (this.isModified('email') && this.email) this.email = this.email.toLowerCase();
|
|
47
|
+
});
|
|
48
|
+
User = model<UserDoc & DocumentInstance>('User', userSchema as unknown as Schema);
|
|
49
|
+
// indexes are created async on connect; ensure they're ready
|
|
50
|
+
await User.createIndexes();
|
|
51
|
+
await Org.createIndexes();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
const conn = (await import('../src/index.js')).getConnection();
|
|
56
|
+
await conn.close();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
await User.deleteMany({});
|
|
61
|
+
await Org.deleteMany({});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('ODM: create / find / update / delete', () => {
|
|
65
|
+
it('creates a document and reads it back', async () => {
|
|
66
|
+
const [u] = await User.create({ email: 'Alice@Example.com', name: 'Alice', age: 30 });
|
|
67
|
+
expect(u._id).toBeDefined();
|
|
68
|
+
expect(u.email).toBe('alice@example.com'); // pre('save') hook lowercased
|
|
69
|
+
const found = await User.findById(u._id);
|
|
70
|
+
expect(found?.name).toBe('Alice');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('chains query helpers: sort, limit, skip, select, lean', async () => {
|
|
74
|
+
for (const n of ['b', 'a', 'c']) await User.create({ email: `${n}@x.com`, name: n });
|
|
75
|
+
const rows = await User.find().sort('name').limit(2).lean();
|
|
76
|
+
expect(rows.map((r) => r.name)).toEqual(['a', 'b']);
|
|
77
|
+
const one = await User.findOne({ name: 'a' }).select('name');
|
|
78
|
+
expect(one?.name).toBe('a');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('updates and deletes documents', async () => {
|
|
82
|
+
const [u] = await User.create({ email: 'z@x.com', name: 'Z' });
|
|
83
|
+
await User.updateOne({ _id: u._id }, { $set: { name: 'Zed' } });
|
|
84
|
+
expect((await User.findById(u._id))?.name).toBe('Zed');
|
|
85
|
+
await User.deleteOne({ _id: u._id });
|
|
86
|
+
expect(await User.countDocuments()).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('ODM: validation', () => {
|
|
91
|
+
it('rejects missing required fields', async () => {
|
|
92
|
+
await expect(User.create({ name: 'NoEmail' })).rejects.toThrow(/required/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects out-of-range numbers and bad enum values', async () => {
|
|
96
|
+
await expect(User.create({ email: 'a@b.com', name: 'A', age: 999 })).rejects.toThrow(/age/);
|
|
97
|
+
await expect(User.create({ email: 'a@b.com', name: 'A', role: 'wizard' })).rejects.toThrow(/enum/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects malformed emails (match)', async () => {
|
|
101
|
+
await expect(User.create({ email: 'not-an-email', name: 'A' })).rejects.toThrow(/format/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('enforces unique indexes', async () => {
|
|
105
|
+
await User.create({ email: 'dup@x.com', name: 'A' });
|
|
106
|
+
await expect(User.create({ email: 'dup@x.com', name: 'B' })).rejects.toThrow();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('ODM: populate', () => {
|
|
111
|
+
it('resolves a single ref', async () => {
|
|
112
|
+
const [org] = await Org.create({ name: 'Acme' });
|
|
113
|
+
const [u] = await User.create({ email: 'p@x.com', name: 'P', orgId: org._id });
|
|
114
|
+
const found = await User.findById(u._id).populate('orgId');
|
|
115
|
+
expect((found?.orgId as unknown as DocumentInstance)?.toObject?.()?.name ?? (found?.orgId as any)?.name).toBe('Acme');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('ODM: advanced model API', () => {
|
|
120
|
+
interface SecretDoc { _id?: ObjectId; label: string; token: string; origin: string; visits: number }
|
|
121
|
+
let Secret: Model<DocumentInstance & SecretDoc>;
|
|
122
|
+
|
|
123
|
+
beforeAll(() => {
|
|
124
|
+
const schema = new Schema<SecretDoc>({
|
|
125
|
+
label: { type: String, required: true },
|
|
126
|
+
token: { type: String, select: false },
|
|
127
|
+
origin: { type: String, immutable: true, default: 'web' },
|
|
128
|
+
visits: { type: Number, default: 0 },
|
|
129
|
+
});
|
|
130
|
+
schema.virtual('summary', { get(this: DocumentInstance & SecretDoc) { return `${this.label}:${this.visits}`; } });
|
|
131
|
+
Secret = model<SecretDoc & DocumentInstance>('Secret', schema as unknown as Schema);
|
|
132
|
+
void Secret.createIndexes();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
beforeEach(async () => {
|
|
136
|
+
await Secret.deleteMany({});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('hides select:false fields from default queries but includes them when selected', async () => {
|
|
140
|
+
await Secret.create({ label: 'L', token: 'secret-value' });
|
|
141
|
+
const def = await Secret.findOne({ label: 'L' }).lean() as Record<string, unknown>;
|
|
142
|
+
expect(def.token).toBeUndefined();
|
|
143
|
+
const explicit = await Secret.findOne({ label: 'L' }).select('token').lean() as Record<string, unknown>;
|
|
144
|
+
expect(explicit.token).toBe('secret-value');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('exposes virtual getters via toObject', async () => {
|
|
148
|
+
const [s] = await Secret.create({ label: 'L', token: 't', visits: 3 });
|
|
149
|
+
expect(s.toObject().summary).toBe('L:3');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('enforces immutable on update but allows it on insert', async () => {
|
|
153
|
+
const [s] = await Secret.create({ label: 'L', token: 't', origin: 'mobile' });
|
|
154
|
+
expect(s.origin).toBe('mobile');
|
|
155
|
+
s.visits = 5;
|
|
156
|
+
(s as unknown as SecretDoc).origin = 'changed'; // attempt mutation
|
|
157
|
+
await s.save();
|
|
158
|
+
const reloaded = await Secret.findById(s._id).lean() as Record<string, unknown>;
|
|
159
|
+
expect(reloaded.visits).toBe(5);
|
|
160
|
+
expect(reloaded.origin).toBe('mobile'); // immutable blocked the change
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('findOneAndUpdate returns the updated document', async () => {
|
|
164
|
+
const [s] = await Secret.create({ label: 'L', token: 't' });
|
|
165
|
+
const updated = await Secret.findOneAndUpdate({ _id: s._id }, { $inc: { visits: 2 } });
|
|
166
|
+
expect(updated?.visits).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('bulkWrite performs mixed operations', async () => {
|
|
170
|
+
await Secret.bulkWrite([
|
|
171
|
+
{ insertOne: { document: { label: 'A', token: 'a' } } },
|
|
172
|
+
{ insertOne: { document: { label: 'B', token: 'b' } } },
|
|
173
|
+
{ updateOne: { filter: { label: 'A' }, update: { $set: { visits: 9 } } } },
|
|
174
|
+
]);
|
|
175
|
+
expect(await Secret.countDocuments()).toBe(2);
|
|
176
|
+
expect((await Secret.findOne({ label: 'A' }).lean() as Record<string, unknown>).visits).toBe(9);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('ODM: transactions', () => {
|
|
181
|
+
it('commits when fn succeeds', async (ctx) => {
|
|
182
|
+
if (!supportsTransactions) ctx.skip();
|
|
183
|
+
const [org] = await Org.create({ name: 'T', members: 0 });
|
|
184
|
+
await transaction(async (session) => {
|
|
185
|
+
await User.collection.then((c) => c.insertOne({ email: 't@x.com', name: 'T', orgId: org._id }, { session }));
|
|
186
|
+
await Org.updateOne({ _id: org._id }, { $inc: { members: 1 } });
|
|
187
|
+
});
|
|
188
|
+
expect((await Org.findById(org._id))?.members).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rolls back when fn throws', async (ctx) => {
|
|
192
|
+
if (!supportsTransactions) ctx.skip();
|
|
193
|
+
const [org] = await Org.create({ name: 'R', members: 5 });
|
|
194
|
+
await expect(
|
|
195
|
+
transaction(async () => {
|
|
196
|
+
await Org.updateOne({ _id: org._id }, { $inc: { members: 100 } });
|
|
197
|
+
throw new Error('boom');
|
|
198
|
+
}),
|
|
199
|
+
).rejects.toThrow('boom');
|
|
200
|
+
expect((await Org.findById(org._id))?.members).toBe(5);
|
|
201
|
+
});
|
|
202
|
+
});
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// packages/nexus-data/vitest.config.ts
|
|
2
|
+
import { defineProject } from "file:///C:/server/BhooAI/BhooAI-Nexus/BhooAI-Nexus/bhooai-nexus/node_modules/vitest/dist/config.js";
|
|
3
|
+
var vitest_config_default = defineProject({
|
|
4
|
+
test: {
|
|
5
|
+
environment: "node",
|
|
6
|
+
include: ["tests/**/*.test.ts"],
|
|
7
|
+
globals: false,
|
|
8
|
+
testTimeout: 3e4
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
export {
|
|
12
|
+
vitest_config_default as default
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGFja2FnZXMvbmV4dXMtZGF0YS92aXRlc3QuY29uZmlnLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcc2VydmVyXFxcXEJob29BSVxcXFxCaG9vQUktTmV4dXNcXFxcQmhvb0FJLU5leHVzXFxcXGJob29haS1uZXh1c1xcXFxwYWNrYWdlc1xcXFxuZXh1cy1kYXRhXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJDOlxcXFxzZXJ2ZXJcXFxcQmhvb0FJXFxcXEJob29BSS1OZXh1c1xcXFxCaG9vQUktTmV4dXNcXFxcYmhvb2FpLW5leHVzXFxcXHBhY2thZ2VzXFxcXG5leHVzLWRhdGFcXFxcdml0ZXN0LmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovc2VydmVyL0Job29BSS9CaG9vQUktTmV4dXMvQmhvb0FJLU5leHVzL2Job29haS1uZXh1cy9wYWNrYWdlcy9uZXh1cy1kYXRhL3ZpdGVzdC5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVQcm9qZWN0IH0gZnJvbSAndml0ZXN0L2NvbmZpZyc7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZVByb2plY3Qoe1xuICB0ZXN0OiB7XG4gICAgZW52aXJvbm1lbnQ6ICdub2RlJyxcbiAgICBpbmNsdWRlOiBbJ3Rlc3RzLyoqLyoudGVzdC50cyddLFxuICAgIGdsb2JhbHM6IGZhbHNlLFxuICAgIHRlc3RUaW1lb3V0OiAzMF8wMDAsXG4gIH0sXG59KTsiXSwKICAibWFwcGluZ3MiOiAiO0FBQXVhLFNBQVMscUJBQXFCO0FBRXJjLElBQU8sd0JBQVEsY0FBYztBQUFBLEVBQzNCLE1BQU07QUFBQSxJQUNKLGFBQWE7QUFBQSxJQUNiLFNBQVMsQ0FBQyxvQkFBb0I7QUFBQSxJQUM5QixTQUFTO0FBQUEsSUFDVCxhQUFhO0FBQUEsRUFDZjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|