@bhooai/nexus-core 2.0.13 → 2.0.15
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/package.json +3 -2
- package/src/app/Storage.ts +616 -9
- package/src/app/Upload.ts +114 -0
- package/src/app/adminModule.ts +256 -52
- package/src/app/authModule.ts +56 -30
- package/src/app/createNexusApp.ts +171 -28
- package/src/app/databaseAdminModule.ts +171 -2
- package/src/app/discover.ts +4 -2
- package/src/app/errorPages.ts +21 -5
- package/src/app/fusionEngine.ts +53 -0
- package/src/app/index.ts +3 -0
- package/src/app/preflightModule.ts +41 -4
- package/src/app/userStore.ts +321 -0
- package/src/config/dbAccess.ts +26 -0
- package/src/config/defaults.ts +34 -4
- package/src/config/index.ts +1 -0
- package/src/config/schema.ts +72 -6
- package/src/config/types.ts +94 -1
- package/src/http/Server.ts +27 -3
- package/src/http/static.ts +1 -1
- package/tests/config.test.ts +24 -1
- package/tests/fusion-userstore.test.ts +68 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { FusionDatabase } from '@bhooai/nexus-fusion';
|
|
3
|
+
import { ObjectId } from '@bhooai/nexus-data';
|
|
4
|
+
import { ConflictError } from '../errors.js';
|
|
5
|
+
import { initUserModel, getUserModel, findUserForLogin, type UserInstance } from './userModel.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* UserStore — persistence behind /auth/*, account admin and OAuth linking.
|
|
9
|
+
*
|
|
10
|
+
* Two implementations: MongoUserStore (the original Mongoose-backed logic,
|
|
11
|
+
* default) and FusionUserStore (embedded engine, selected with
|
|
12
|
+
* `db.active: 'fusion'`). Callers see plain lean-shaped records.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface OAuthIdentity {
|
|
16
|
+
provider: string;
|
|
17
|
+
providerUserId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StoredUser {
|
|
21
|
+
_id: unknown;
|
|
22
|
+
email: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
passwordHash?: string;
|
|
25
|
+
roles: string[];
|
|
26
|
+
emailVerified: boolean;
|
|
27
|
+
oauthAccounts: OAuthIdentity[];
|
|
28
|
+
createdAt?: unknown;
|
|
29
|
+
updatedAt?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NewUser {
|
|
33
|
+
email: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
passwordHash?: string;
|
|
36
|
+
roles: string[];
|
|
37
|
+
emailVerified?: boolean;
|
|
38
|
+
oauthAccounts?: OAuthIdentity[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UserStore {
|
|
42
|
+
findByEmail(email: string): Promise<StoredUser | null>;
|
|
43
|
+
findForLogin(email: string): Promise<StoredUser | null>;
|
|
44
|
+
findById(id: string): Promise<StoredUser | null>;
|
|
45
|
+
findPasswordHash(id: string): Promise<string | undefined>;
|
|
46
|
+
create(input: NewUser): Promise<StoredUser>;
|
|
47
|
+
setName(id: string, name: string): Promise<StoredUser | null>;
|
|
48
|
+
setPassword(id: string, passwordHash: string): Promise<void>;
|
|
49
|
+
setRoles(id: string, roles: string[]): Promise<StoredUser | null>;
|
|
50
|
+
count(): Promise<number>;
|
|
51
|
+
countAdmins(): Promise<number>;
|
|
52
|
+
list(limit: number): Promise<StoredUser[]>;
|
|
53
|
+
findOAuthUser(provider: string, providerUserId: string): Promise<StoredUser | null>;
|
|
54
|
+
linkOAuth(userId: string, provider: string, providerUserId: string): Promise<StoredUser | null>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const EMAIL_RE = /.+@.+\..+/;
|
|
58
|
+
|
|
59
|
+
function normalizeEmail(email: string): string {
|
|
60
|
+
return String(email).toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ─── Mongo implementation (original behavior, moved verbatim) ─── */
|
|
64
|
+
|
|
65
|
+
export class MongoUserStore implements UserStore {
|
|
66
|
+
async findByEmail(email: string): Promise<StoredUser | null> {
|
|
67
|
+
const User = getUserModel();
|
|
68
|
+
return (await User.findOne({ email: normalizeEmail(email) }).lean()) as unknown as StoredUser | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async findForLogin(email: string): Promise<StoredUser | null> {
|
|
72
|
+
const user = await findUserForLogin(email);
|
|
73
|
+
return user as unknown as StoredUser | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async findById(id: string): Promise<StoredUser | null> {
|
|
77
|
+
const User = getUserModel();
|
|
78
|
+
return (await User.findById(id).lean()) as unknown as StoredUser | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async findPasswordHash(id: string): Promise<string | undefined> {
|
|
82
|
+
const User = getUserModel();
|
|
83
|
+
const coll = await User.collection;
|
|
84
|
+
const raw = (await coll.findOne({ _id: new ObjectId(id) })) as unknown as Record<string, unknown> | null;
|
|
85
|
+
return raw?.passwordHash as string | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async create(input: NewUser): Promise<StoredUser> {
|
|
89
|
+
const User = getUserModel();
|
|
90
|
+
const [user] = await User.create({ ...input, email: normalizeEmail(input.email) });
|
|
91
|
+
return user as unknown as StoredUser;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async setName(id: string, name: string): Promise<StoredUser | null> {
|
|
95
|
+
const User = getUserModel();
|
|
96
|
+
await User.updateOne({ _id: id }, { $set: { name } });
|
|
97
|
+
return this.findById(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async setPassword(id: string, passwordHash: string): Promise<void> {
|
|
101
|
+
const User = getUserModel();
|
|
102
|
+
await User.updateOne({ _id: id }, { $set: { passwordHash } });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async setRoles(id: string, roles: string[]): Promise<StoredUser | null> {
|
|
106
|
+
const User = getUserModel();
|
|
107
|
+
await User.updateOne({ _id: id }, { $set: { roles } });
|
|
108
|
+
return this.findById(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async count(): Promise<number> {
|
|
112
|
+
return getUserModel().countDocuments();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async countAdmins(): Promise<number> {
|
|
116
|
+
const User = getUserModel();
|
|
117
|
+
const coll = await User.collection;
|
|
118
|
+
return coll.countDocuments({ roles: 'admin' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async list(limit: number): Promise<StoredUser[]> {
|
|
122
|
+
const User = getUserModel();
|
|
123
|
+
const coll = await User.collection;
|
|
124
|
+
const rows = await coll.find({}).sort({ createdAt: -1 }).limit(limit).toArray();
|
|
125
|
+
return rows as unknown as StoredUser[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async findOAuthUser(provider: string, providerUserId: string): Promise<StoredUser | null> {
|
|
129
|
+
const User = getUserModel();
|
|
130
|
+
const coll = await User.collection;
|
|
131
|
+
const existing = (await coll.findOne({
|
|
132
|
+
oauthAccounts: { $elemMatch: { provider, providerUserId } },
|
|
133
|
+
})) as unknown as Record<string, unknown> | null;
|
|
134
|
+
if (!existing) return null;
|
|
135
|
+
return User.hydrate(existing) as unknown as StoredUser;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async linkOAuth(userId: string, provider: string, providerUserId: string): Promise<StoredUser | null> {
|
|
139
|
+
const User = getUserModel();
|
|
140
|
+
await User.updateOne({ _id: userId }, {
|
|
141
|
+
$addToSet: { oauthAccounts: { provider, providerUserId } },
|
|
142
|
+
});
|
|
143
|
+
return this.findById(userId);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ─── Fusion implementation ─── */
|
|
148
|
+
|
|
149
|
+
interface FusionUserData {
|
|
150
|
+
id: string;
|
|
151
|
+
email: string;
|
|
152
|
+
name?: string;
|
|
153
|
+
passwordHash?: string;
|
|
154
|
+
roles: string[];
|
|
155
|
+
emailVerified: boolean;
|
|
156
|
+
oauthAccounts: OAuthIdentity[];
|
|
157
|
+
createdAt: string;
|
|
158
|
+
updatedAt: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const USERS = 'users';
|
|
162
|
+
const OAUTH_LINKS = 'oauth_links';
|
|
163
|
+
|
|
164
|
+
/** doc.get() throws when the collection doesn't exist yet — map that to null. */
|
|
165
|
+
async function getDocOrNull<T>(ref: { get(): Promise<{ id: string; data: T } | null> }): Promise<{ id: string; data: T } | null> {
|
|
166
|
+
try {
|
|
167
|
+
return await ref.get();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof Error && /path not found/i.test(err.message)) return null;
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Query paths throw on missing collections too — treat as empty. */
|
|
175
|
+
function hasCollection(db: FusionDatabase, name: string): boolean {
|
|
176
|
+
try {
|
|
177
|
+
return db.listCollections().includes(name);
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class FusionUserStore implements UserStore {
|
|
184
|
+
constructor(private db: FusionDatabase) {}
|
|
185
|
+
|
|
186
|
+
private toStored(data: FusionUserData): StoredUser {
|
|
187
|
+
return { _id: data.id, ...data };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async findByEmail(email: string): Promise<StoredUser | null> {
|
|
191
|
+
const doc = await getDocOrNull(this.db.collection<FusionUserData>(USERS).doc(normalizeEmail(email)));
|
|
192
|
+
return doc ? this.toStored(doc.data) : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async findForLogin(email: string): Promise<StoredUser | null> {
|
|
196
|
+
return this.findByEmail(email);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async findById(id: string): Promise<StoredUser | null> {
|
|
200
|
+
if (!hasCollection(this.db, USERS)) return null;
|
|
201
|
+
const snap = await this.db.collection<FusionUserData>(USERS).where('id', '==', id).limit(1).get();
|
|
202
|
+
const hit = snap.docs[0];
|
|
203
|
+
return hit ? this.toStored(hit.data) : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async findPasswordHash(id: string): Promise<string | undefined> {
|
|
207
|
+
return (await this.findById(id))?.passwordHash;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async create(input: NewUser): Promise<StoredUser> {
|
|
211
|
+
const email = normalizeEmail(input.email);
|
|
212
|
+
if (!EMAIL_RE.test(email)) throw new ConflictError('Invalid email address');
|
|
213
|
+
const exists = await getDocOrNull(this.db.collection(USERS).doc(email));
|
|
214
|
+
if (exists) throw new ConflictError('A user with that email already exists');
|
|
215
|
+
const now = new Date().toISOString();
|
|
216
|
+
const data: FusionUserData = {
|
|
217
|
+
id: randomUUID(),
|
|
218
|
+
email,
|
|
219
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
220
|
+
...(input.passwordHash !== undefined ? { passwordHash: input.passwordHash } : {}),
|
|
221
|
+
roles: input.roles.length > 0 ? input.roles : ['user'],
|
|
222
|
+
emailVerified: input.emailVerified ?? false,
|
|
223
|
+
oauthAccounts: input.oauthAccounts ?? [],
|
|
224
|
+
createdAt: now,
|
|
225
|
+
updatedAt: now,
|
|
226
|
+
};
|
|
227
|
+
await this.db.collection(USERS).doc(email).set(data as unknown as Record<string, unknown>);
|
|
228
|
+
for (const o of data.oauthAccounts) {
|
|
229
|
+
await this.db.collection(OAUTH_LINKS).doc(`${o.provider}:${o.providerUserId}`).set({ userId: data.id });
|
|
230
|
+
}
|
|
231
|
+
return this.toStored(data);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async updateById(id: string, patch: Partial<FusionUserData>): Promise<StoredUser | null> {
|
|
235
|
+
const current = await this.findById(id);
|
|
236
|
+
if (!current) return null;
|
|
237
|
+
const email = normalizeEmail(String((current as StoredUser & { email: string }).email));
|
|
238
|
+
const updated = await this.db
|
|
239
|
+
.collection<FusionUserData>(USERS)
|
|
240
|
+
.doc(email)
|
|
241
|
+
.update({ ...patch, updatedAt: new Date().toISOString() } as unknown as Record<string, unknown>);
|
|
242
|
+
return this.toStored(updated.data);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async setName(id: string, name: string): Promise<StoredUser | null> {
|
|
246
|
+
return this.updateById(id, { name });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async setPassword(id: string, passwordHash: string): Promise<void> {
|
|
250
|
+
await this.updateById(id, { passwordHash });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async setRoles(id: string, roles: string[]): Promise<StoredUser | null> {
|
|
254
|
+
return this.updateById(id, { roles });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async count(): Promise<number> {
|
|
258
|
+
if (!hasCollection(this.db, USERS)) return 0;
|
|
259
|
+
const snap = await this.db.collection(USERS).get();
|
|
260
|
+
return snap.size;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async countAdmins(): Promise<number> {
|
|
264
|
+
// No array-contains op in the query compiler — filter the (small) users table.
|
|
265
|
+
if (!hasCollection(this.db, USERS)) return 0;
|
|
266
|
+
const snap = await this.db.collection<FusionUserData>(USERS).get();
|
|
267
|
+
return snap.docs.filter((d) => (d.data.roles ?? []).includes('admin')).length;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async list(limit: number): Promise<StoredUser[]> {
|
|
271
|
+
if (!hasCollection(this.db, USERS)) return [];
|
|
272
|
+
const snap = await this.db
|
|
273
|
+
.collection<FusionUserData>(USERS)
|
|
274
|
+
.orderByField('createdAt', 'desc')
|
|
275
|
+
.limit(limit)
|
|
276
|
+
.get();
|
|
277
|
+
return snap.docs.map((d) => this.toStored(d.data));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async findOAuthUser(provider: string, providerUserId: string): Promise<StoredUser | null> {
|
|
281
|
+
const link = await getDocOrNull(
|
|
282
|
+
this.db.collection<{ userId: string }>(OAUTH_LINKS).doc(`${provider}:${providerUserId}`),
|
|
283
|
+
);
|
|
284
|
+
if (!link) return null;
|
|
285
|
+
return this.findById(link.data.userId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async linkOAuth(userId: string, provider: string, providerUserId: string): Promise<StoredUser | null> {
|
|
289
|
+
const current = await this.findById(userId);
|
|
290
|
+
if (!current) return null;
|
|
291
|
+
const seen = new Set(current.oauthAccounts.map((o) => `${o.provider}:${o.providerUserId}`));
|
|
292
|
+
const key = `${provider}:${providerUserId}`;
|
|
293
|
+
if (!seen.has(key)) {
|
|
294
|
+
await this.updateById(userId, {
|
|
295
|
+
oauthAccounts: [...current.oauthAccounts, { provider, providerUserId }],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
await this.db.collection(OAUTH_LINKS).doc(key).set({ userId });
|
|
299
|
+
return this.findById(userId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ─── Singleton (mirrors initUserModel/getUserModel lifecycle) ─── */
|
|
304
|
+
|
|
305
|
+
let store: UserStore | undefined;
|
|
306
|
+
|
|
307
|
+
export function initUserStore(s: UserStore): UserStore {
|
|
308
|
+
store = s;
|
|
309
|
+
return store;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Lazily fall back to the Mongo store so direct registerAuthRoutes() users keep working. */
|
|
313
|
+
export function getUserStore(): UserStore {
|
|
314
|
+
if (!store) {
|
|
315
|
+
initUserModel();
|
|
316
|
+
store = new MongoUserStore();
|
|
317
|
+
}
|
|
318
|
+
return store;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type { UserInstance };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NexusConfig, ActiveDatabase } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Database accessors — read the normalized `db` block (see schema.ts, which
|
|
5
|
+
* maps the legacy flat `{ uri, … }` shape into `mongodb.*` at load).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function activeDatabase(config: NexusConfig): ActiveDatabase {
|
|
9
|
+
return config.db.active;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function mongoEnabled(config: NexusConfig): boolean {
|
|
13
|
+
return config.db.mongodb.enabled;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function mongoUri(config: NexusConfig): string {
|
|
17
|
+
return config.db.mongodb.uri;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fusionEnabled(config: NexusConfig): boolean {
|
|
21
|
+
return config.db.fusion.enabled;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function fusionDir(config: NexusConfig): string {
|
|
25
|
+
return config.db.fusion.dir;
|
|
26
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const defaults: NexusConfig = {
|
|
|
8
8
|
env: 'development',
|
|
9
9
|
server: {
|
|
10
10
|
port: 4000,
|
|
11
|
-
host: '
|
|
11
|
+
host: 'localhost',
|
|
12
12
|
https: false,
|
|
13
13
|
trustProxy: false,
|
|
14
14
|
bodyLimit: 12 * 1024 * 1024, // 12 MiB, allowing a 10 MiB upload plus multipart overhead
|
|
@@ -21,9 +21,21 @@ export const defaults: NexusConfig = {
|
|
|
21
21
|
allowedTypes: [],
|
|
22
22
|
},
|
|
23
23
|
db: {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
active: 'mongodb',
|
|
25
|
+
mongodb: {
|
|
26
|
+
enabled: true,
|
|
27
|
+
uri: 'mongodb://localhost:27017/nexus',
|
|
28
|
+
maxPoolSize: 10,
|
|
29
|
+
autoIndex: true,
|
|
30
|
+
},
|
|
31
|
+
fusion: {
|
|
32
|
+
enabled: false,
|
|
33
|
+
dir: 'storage/fusion',
|
|
34
|
+
persist: true,
|
|
35
|
+
cacheCapacity: 10_000,
|
|
36
|
+
cacheTtlMs: 5_000,
|
|
37
|
+
walCompactThreshold: 1_000,
|
|
38
|
+
},
|
|
27
39
|
},
|
|
28
40
|
redis: {
|
|
29
41
|
url: 'redis://localhost:6379',
|
|
@@ -91,6 +103,7 @@ export const defaults: NexusConfig = {
|
|
|
91
103
|
timeoutMs: 60_000,
|
|
92
104
|
defaultProvider: 'ollama',
|
|
93
105
|
schemaModel: 'llama3.1:8b',
|
|
106
|
+
embedModel: 'qwen3-embedding:4b',
|
|
94
107
|
providers: [
|
|
95
108
|
{ id: 'ollama', label: 'Ollama (local)', baseUrl: 'http://localhost:11434', enabled: true, defaultModel: 'llama3.1:8b' },
|
|
96
109
|
{ id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', enabled: false, defaultModel: 'gpt-4o-mini' },
|
|
@@ -136,4 +149,21 @@ export const defaults: NexusConfig = {
|
|
|
136
149
|
host: 'localhost',
|
|
137
150
|
enabled: true,
|
|
138
151
|
},
|
|
152
|
+
fusion: {
|
|
153
|
+
dir: 'storage/fusion',
|
|
154
|
+
persist: true,
|
|
155
|
+
cacheCapacity: 10_000,
|
|
156
|
+
cacheTtlMs: 5_000,
|
|
157
|
+
walCompactThreshold: 1_000,
|
|
158
|
+
},
|
|
159
|
+
cache: {
|
|
160
|
+
dir: 'storage/fusion/cache',
|
|
161
|
+
persist: true,
|
|
162
|
+
cacheCapacity: 10_000,
|
|
163
|
+
cacheTtlMs: 5_000,
|
|
164
|
+
walCompactThreshold: 1_000,
|
|
165
|
+
db: 'cache',
|
|
166
|
+
branch: 'prod',
|
|
167
|
+
},
|
|
168
|
+
apps: [],
|
|
139
169
|
};
|
package/src/config/index.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -27,12 +27,55 @@ export const nexusConfigSchema = z.object({
|
|
|
27
27
|
maxFiles: z.number().int().positive(),
|
|
28
28
|
allowedTypes: z.array(z.string()),
|
|
29
29
|
}),
|
|
30
|
-
db: z
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
db: z
|
|
31
|
+
.object({
|
|
32
|
+
active: z.enum(['mongodb', 'fusion']).default('mongodb'),
|
|
33
|
+
mongodb: z
|
|
34
|
+
.object({
|
|
35
|
+
enabled: z.boolean().default(true),
|
|
36
|
+
uri: z.string().min(1),
|
|
37
|
+
name: z.string().optional(),
|
|
38
|
+
maxPoolSize: z.number().int().positive(),
|
|
39
|
+
autoIndex: z.boolean(),
|
|
40
|
+
})
|
|
41
|
+
.default({
|
|
42
|
+
enabled: true,
|
|
43
|
+
uri: 'mongodb://localhost:27017/nexus',
|
|
44
|
+
maxPoolSize: 10,
|
|
45
|
+
autoIndex: true,
|
|
46
|
+
}),
|
|
47
|
+
fusion: z
|
|
48
|
+
.object({
|
|
49
|
+
enabled: z.boolean().default(false),
|
|
50
|
+
dir: z.string().min(1),
|
|
51
|
+
persist: z.boolean().default(true),
|
|
52
|
+
cacheCapacity: z.number().int().positive(),
|
|
53
|
+
cacheTtlMs: z.number().int().positive(),
|
|
54
|
+
walCompactThreshold: z.number().int().positive(),
|
|
55
|
+
})
|
|
56
|
+
.default({
|
|
57
|
+
enabled: false,
|
|
58
|
+
dir: 'storage/fusion',
|
|
59
|
+
persist: true,
|
|
60
|
+
cacheCapacity: 10_000,
|
|
61
|
+
cacheTtlMs: 5_000,
|
|
62
|
+
walCompactThreshold: 1_000,
|
|
63
|
+
}),
|
|
64
|
+
// Legacy flat shape — mapped into mongodb.* below.
|
|
65
|
+
uri: z.string().min(1).optional(),
|
|
66
|
+
name: z.string().optional(),
|
|
67
|
+
maxPoolSize: z.number().int().positive().optional(),
|
|
68
|
+
autoIndex: z.boolean().optional(),
|
|
69
|
+
})
|
|
70
|
+
.transform((v) => {
|
|
71
|
+
const mongodb = { ...v.mongodb };
|
|
72
|
+
if (v.uri !== undefined) mongodb.uri = v.uri;
|
|
73
|
+
if (v.name !== undefined) mongodb.name = v.name;
|
|
74
|
+
if (v.maxPoolSize !== undefined) mongodb.maxPoolSize = v.maxPoolSize;
|
|
75
|
+
if (v.autoIndex !== undefined) mongodb.autoIndex = v.autoIndex;
|
|
76
|
+
const { uri: _u, name: _n, maxPoolSize: _m, autoIndex: _a, ...rest } = v;
|
|
77
|
+
return { ...rest, mongodb };
|
|
78
|
+
}),
|
|
36
79
|
redis: z.object({ url: z.string().min(1), keyPrefix: z.string() }),
|
|
37
80
|
graphql: z.object({
|
|
38
81
|
path: z.string().min(1),
|
|
@@ -124,6 +167,7 @@ export const nexusConfigSchema = z.object({
|
|
|
124
167
|
timeoutMs: z.number().int().positive(),
|
|
125
168
|
defaultProvider: z.enum(['openai', 'ollama', 'auto']),
|
|
126
169
|
schemaModel: z.string().min(1).optional(),
|
|
170
|
+
embedModel: z.string().min(1).optional(),
|
|
127
171
|
providers: z.array(z.object({
|
|
128
172
|
id: z.string().min(1),
|
|
129
173
|
label: z.string().min(1),
|
|
@@ -157,6 +201,28 @@ export const nexusConfigSchema = z.object({
|
|
|
157
201
|
host: z.string().min(1),
|
|
158
202
|
enabled: z.boolean(),
|
|
159
203
|
}),
|
|
204
|
+
fusion: z.object({
|
|
205
|
+
dir: z.string().min(1).default('storage/fusion'),
|
|
206
|
+
persist: z.boolean().default(true),
|
|
207
|
+
cacheCapacity: z.number().int().positive().default(10_000),
|
|
208
|
+
cacheTtlMs: z.number().int().positive().default(5_000),
|
|
209
|
+
walCompactThreshold: z.number().int().positive().default(1_000),
|
|
210
|
+
}),
|
|
211
|
+
cache: z.object({
|
|
212
|
+
dir: z.string().min(1).default('storage/fusion/cache'),
|
|
213
|
+
persist: z.boolean().default(true),
|
|
214
|
+
cacheCapacity: z.number().int().positive().default(10_000),
|
|
215
|
+
cacheTtlMs: z.number().int().positive().default(5_000),
|
|
216
|
+
walCompactThreshold: z.number().int().positive().default(1_000),
|
|
217
|
+
db: z.string().min(1).default('cache'),
|
|
218
|
+
branch: z.string().min(1).default('prod'),
|
|
219
|
+
}),
|
|
220
|
+
/** Multi-app port layout (replaces `.nexus-ports.json`). */
|
|
221
|
+
apps: z.array(z.object({
|
|
222
|
+
name: z.string().min(1),
|
|
223
|
+
port: z.number().int().min(1).max(65535),
|
|
224
|
+
enabled: z.boolean().optional(),
|
|
225
|
+
})).default([]),
|
|
160
226
|
});
|
|
161
227
|
|
|
162
228
|
export type ValidatedNexusConfig = z.infer<typeof nexusConfigSchema>;
|
package/src/config/types.ts
CHANGED
|
@@ -35,7 +35,11 @@ export interface UploadsConfig {
|
|
|
35
35
|
allowedTypes: string[];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export
|
|
38
|
+
export type ActiveDatabase = 'mongodb' | 'fusion';
|
|
39
|
+
|
|
40
|
+
export interface MongoDbConfig {
|
|
41
|
+
/** Connect to MongoDB at boot when true. Default true. */
|
|
42
|
+
enabled: boolean;
|
|
39
43
|
uri: string;
|
|
40
44
|
/** Database name override; otherwise taken from the URI. */
|
|
41
45
|
name?: string;
|
|
@@ -45,6 +49,37 @@ export interface DbConfig {
|
|
|
45
49
|
autoIndex: boolean;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
export interface FusionDbConfig {
|
|
53
|
+
/** Open the Fusion engine at boot when true. Default false. */
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
/** Durable storage dir, relative to the project root. Omit/empty for in-memory. */
|
|
56
|
+
dir: string;
|
|
57
|
+
/** Persist the WAL to `dir` (persist=false runs purely in-memory). */
|
|
58
|
+
persist: boolean;
|
|
59
|
+
/** L1 cache capacity (entries). */
|
|
60
|
+
cacheCapacity: number;
|
|
61
|
+
/** L1 cache TTL in ms. */
|
|
62
|
+
cacheTtlMs: number;
|
|
63
|
+
/** WAL compaction threshold (records). */
|
|
64
|
+
walCompactThreshold: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DbConfig {
|
|
68
|
+
/** Which database backs the user store + ODM models. Default 'mongodb'. */
|
|
69
|
+
active: ActiveDatabase;
|
|
70
|
+
mongodb: MongoDbConfig;
|
|
71
|
+
fusion: FusionDbConfig;
|
|
72
|
+
/** @deprecated Legacy flat shape — `{ uri, name?, maxPoolSize, autoIndex }`.
|
|
73
|
+
* Still accepted in nexus.config.ts and mapped into `mongodb.*` at load. */
|
|
74
|
+
uri?: string;
|
|
75
|
+
/** @deprecated See `uri`. Mapped into `mongodb.name`. */
|
|
76
|
+
name?: string;
|
|
77
|
+
/** @deprecated See `uri`. Mapped into `mongodb.maxPoolSize`. */
|
|
78
|
+
maxPoolSize?: number;
|
|
79
|
+
/** @deprecated See `uri`. Mapped into `mongodb.autoIndex`. */
|
|
80
|
+
autoIndex?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
48
83
|
export interface RedisConfig {
|
|
49
84
|
url: string;
|
|
50
85
|
/** Key prefix for namespacing. */
|
|
@@ -197,6 +232,8 @@ export interface AiConfig {
|
|
|
197
232
|
defaultProvider: 'openai' | 'ollama' | 'auto';
|
|
198
233
|
/** Model used by the admin AI schema generator (e.g. "gpt-4o-mini"). */
|
|
199
234
|
schemaModel: string;
|
|
235
|
+
/** Embedding model for semantic search (e.g. "qwen3-embedding:4b"). Optional — defaults in code. */
|
|
236
|
+
embedModel?: string;
|
|
200
237
|
/** Configured AI providers with API keys + enable state. Optional —
|
|
201
238
|
* defaults are provided in code. */
|
|
202
239
|
providers?: AiProviderConfig[];
|
|
@@ -248,6 +285,58 @@ export interface AdminConfig {
|
|
|
248
285
|
enabled: boolean;
|
|
249
286
|
}
|
|
250
287
|
|
|
288
|
+
/**
|
|
289
|
+
* One entry in the multi-app port layout.
|
|
290
|
+
*
|
|
291
|
+
* `apps` is the single source of truth for every dev-service port and fully
|
|
292
|
+
* replaces the legacy `.nexus-ports.json` registry. `name` is the service /
|
|
293
|
+
* app directory name (`backend`, `backend-shop`, `frontend`, `admin`,
|
|
294
|
+
* `ai-server`, `pyserver`, `fusion`, …). `server` / `frontend` / `admin`
|
|
295
|
+
* stay the single-app defaults; `apps` entries override them by name.
|
|
296
|
+
*/
|
|
297
|
+
export interface AppPortConfig {
|
|
298
|
+
/** Service / app name, e.g. 'backend', 'backend-shop', 'frontend', 'admin', 'ai-server', 'pyserver', 'fusion'. */
|
|
299
|
+
name: string;
|
|
300
|
+
/** Canonical configured port for this app. */
|
|
301
|
+
port: number;
|
|
302
|
+
/** Participate in `nexus dev` startup. Defaults to true. */
|
|
303
|
+
enabled?: boolean;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface FusionConfig {
|
|
307
|
+
/** Durable storage dir, resolved against the backend's projectRoot. Empty or
|
|
308
|
+
* persist=false runs the engine purely in-memory. Default 'storage/fusion'. */
|
|
309
|
+
dir: string;
|
|
310
|
+
/** When false, ignore `dir` and run purely in-memory. Default true. */
|
|
311
|
+
persist: boolean;
|
|
312
|
+
/** L1 cache capacity (entries). Default 10_000. */
|
|
313
|
+
cacheCapacity: number;
|
|
314
|
+
/** L1 cache TTL in ms. Default 5_000. */
|
|
315
|
+
cacheTtlMs: number;
|
|
316
|
+
/** WAL compaction threshold (records). Default 1_000. */
|
|
317
|
+
walCompactThreshold: number;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Redis-like FusionCache settings (`@bhooai/nexus-fusion/cache`). */
|
|
321
|
+
export interface CacheConfig {
|
|
322
|
+
/** Durable storage dir, resolved against the backend's projectRoot. Cache
|
|
323
|
+
* lives in its own subdir (default 'storage/fusion/cache') so it never
|
|
324
|
+
* shares a WAL with the main fusion engine. */
|
|
325
|
+
dir: string;
|
|
326
|
+
/** When false, run purely in-memory (volatile, like a non-persistent Redis).
|
|
327
|
+
* Default true. */
|
|
328
|
+
persist: boolean;
|
|
329
|
+
/** Engine L1 cache capacity (entries). Default 10_000. */
|
|
330
|
+
cacheCapacity: number;
|
|
331
|
+
/** Engine L1 cache TTL in ms. Default 5_000. */
|
|
332
|
+
cacheTtlMs: number;
|
|
333
|
+
/** WAL compaction threshold (records). Default 1_000. */
|
|
334
|
+
walCompactThreshold: number;
|
|
335
|
+
/** Database/branch namespaces. Defaults db='cache', branch='prod'. */
|
|
336
|
+
db: string;
|
|
337
|
+
branch: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
251
340
|
export interface NexusConfig {
|
|
252
341
|
env: Env;
|
|
253
342
|
server: ServerConfig;
|
|
@@ -267,6 +356,10 @@ export interface NexusConfig {
|
|
|
267
356
|
plugins: PluginsConfig;
|
|
268
357
|
frontend: FrontendConfig;
|
|
269
358
|
admin: AdminConfig;
|
|
359
|
+
fusion: FusionConfig;
|
|
360
|
+
cache: CacheConfig;
|
|
361
|
+
/** Multi-app port layout. Empty = every service uses its section default. */
|
|
362
|
+
apps: AppPortConfig[];
|
|
270
363
|
}
|
|
271
364
|
|
|
272
365
|
/** Deep partial used for user config files and runtime overrides. */
|