@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.
@@ -1,7 +1,19 @@
1
1
  import type { Router } from '../index.js';
2
+ import type { ActiveDatabase } from '../config/types.js';
3
+ import { getAppFusion } from './fusionEngine.js';
4
+ import type { FusionDatabase } from '@bhooai/nexus-fusion';
2
5
 
3
6
  /**
4
- * Admin routes for MongoDB database / collection administration.
7
+ * Admin routes for database / collection administration.
8
+ *
9
+ * MongoDB backend (default): full database/collection management with
10
+ * `$jsonSchema` validators (used by the AI schema generator).
11
+ *
12
+ * Fusion backend (`db.active: 'fusion'`): branches stand in for databases
13
+ * (routes keep the same paths); collections are implicit. Rename is
14
+ * copy-then-delete (the engine has no rename op); `$jsonSchema` validators
15
+ * are Mongo-only and rejected with a 400 explaining the alternative
16
+ * (`@bhooai/nexus-fusion/schemas` validation in code).
5
17
  *
6
18
  * The admin app uses these to create, rename and drop databases and
7
19
  * collections, and to preview documents. Collections can be created with a
@@ -27,10 +39,25 @@ const DOC_PREVIEW_LIMIT = 10;
27
39
  /** Ugly but real: dropping a db's only collection deletes the whole database. */
28
40
  const BOOTSTRAP_COLLECTION = '_nexus_bootstrap';
29
41
 
42
+ /** Bootstrap doc id — materializes implicit Fusion collections; hidden from listings. */
43
+ const BOOTSTRAP_DOC = '_nexus_bootstrap';
44
+
30
45
  /** Databases managed by MongoDB itself — hidden from the admin surface. */
31
46
  const SYSTEM_DATABASES = new Set(['admin', 'local', 'config']);
32
47
 
33
- export function registerDatabaseAdminRoutes(router: Router, lazy: LazyDb): void {
48
+ export function registerDatabaseAdminRoutes(
49
+ router: Router,
50
+ lazy: LazyDb,
51
+ active: ActiveDatabase = 'mongodb',
52
+ ): void {
53
+ if (active === 'fusion') {
54
+ registerFusionDatabaseAdminRoutes(router);
55
+ return;
56
+ }
57
+ registerMongoDatabaseAdminRoutes(router, lazy);
58
+ }
59
+
60
+ function registerMongoDatabaseAdminRoutes(router: Router, lazy: LazyDb): void {
34
61
  router.get('/admin/databases', async (ctx) => {
35
62
  const db = await lazy.db();
36
63
  if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
@@ -184,6 +211,148 @@ export function registerDatabaseAdminRoutes(router: Router, lazy: LazyDb): void
184
211
  });
185
212
  }
186
213
 
214
+ function registerFusionDatabaseAdminRoutes(router: Router): void {
215
+ // Branches stand in for databases; the engine's fixed db is 'main'.
216
+ const view = (branch: string): FusionDatabase => getAppFusion().branchView(branch);
217
+
218
+ router.get('/admin/databases', async (ctx) => {
219
+ try {
220
+ const fusion = getAppFusion();
221
+ const databases = [];
222
+ for (const branch of fusion.listBranches()) {
223
+ const bcols = view(branch).listCollections().filter((c) => c !== BOOTSTRAP_COLLECTION);
224
+ const collections = [];
225
+ for (const c of bcols) {
226
+ let count = 0;
227
+ try {
228
+ const snap = await view(branch).collection(c).get();
229
+ count = snap.docs.filter((d) => d.id !== BOOTSTRAP_DOC).length;
230
+ } catch { /* stats unavailable */ }
231
+ collections.push({ name: c, count });
232
+ }
233
+ databases.push({ name: branch, sizeOnDisk: 0, collections });
234
+ }
235
+ ctx.json({ databases });
236
+ } catch (err) {
237
+ ctx.json({ error: (err as Error).message }, 500);
238
+ }
239
+ });
240
+
241
+ router.post('/admin/databases', async (ctx) => {
242
+ const name = assertDbName((ctx.body as { name?: unknown } | undefined)?.name, ctx);
243
+ if (!name) return;
244
+ try {
245
+ getAppFusion().createBranch(name);
246
+ ctx.json({ ok: true, name });
247
+ } catch (err) {
248
+ ctx.json({ error: (err as Error).message }, 500);
249
+ }
250
+ });
251
+
252
+ router.delete('/admin/databases/:db', async (ctx) => {
253
+ const name = assertDbName(ctx.params.db, ctx);
254
+ if (!name) return;
255
+ try {
256
+ getAppFusion().dropBranch(name);
257
+ ctx.json({ ok: true, dropped: name });
258
+ } catch (err) {
259
+ ctx.json({ error: (err as Error).message }, 500);
260
+ }
261
+ });
262
+
263
+ router.post('/admin/databases/:db/collections', async (ctx) => {
264
+ const branch = assertDbName(ctx.params.db, ctx);
265
+ if (!branch) return;
266
+ const body = (ctx.body ?? {}) as { name?: unknown; jsonSchema?: unknown; validator?: unknown };
267
+ const name = assertCollectionName(body.name, ctx);
268
+ if (!name) return;
269
+ if (body.jsonSchema !== undefined || body.validator !== undefined) {
270
+ ctx.json({ error: '$jsonSchema validators are Mongo-only — validate with @bhooai/nexus-fusion/schemas in code instead' }, 400);
271
+ return;
272
+ }
273
+ try {
274
+ // Collections are implicit in Fusion — a bootstrap doc materializes it.
275
+ await view(branch).collection(name).doc(BOOTSTRAP_DOC).set({ _bootstrap: true });
276
+ ctx.json({ ok: true, db: branch, collection: name, validator: null });
277
+ } catch (err) {
278
+ ctx.json({ error: (err as Error).message }, 500);
279
+ }
280
+ });
281
+
282
+ router.put('/admin/databases/:db/collections/:name', async (ctx) => {
283
+ const branch = assertDbName(ctx.params.db, ctx);
284
+ if (!branch) return;
285
+ const name = assertCollectionName(ctx.params.name, ctx);
286
+ if (!name) return;
287
+ const body = (ctx.body ?? {}) as { newName?: unknown; validator?: unknown };
288
+ if (body.validator !== undefined) {
289
+ ctx.json({ error: '$jsonSchema validators are Mongo-only — validate with @bhooai/nexus-fusion/schemas in code instead' }, 400);
290
+ return;
291
+ }
292
+ const changed: string[] = [];
293
+ try {
294
+ if (body.newName !== undefined) {
295
+ const newName = assertCollectionName(body.newName, ctx);
296
+ if (!newName) return;
297
+ if (newName !== name) {
298
+ // No engine rename op — copy then delete.
299
+ const snap = await view(branch).collection(name).get();
300
+ for (const d of snap.docs) {
301
+ if (d.id === BOOTSTRAP_DOC) continue;
302
+ await view(branch).collection(newName).doc(d.id).set(d.data as Record<string, unknown>);
303
+ }
304
+ for (const d of snap.docs) {
305
+ await view(branch).collection(name).doc(d.id).delete();
306
+ }
307
+ await view(branch).collection(newName).doc(BOOTSTRAP_DOC).set({ _bootstrap: true });
308
+ changed.push(`renamed to ${newName}`);
309
+ }
310
+ }
311
+ if (!changed.length) {
312
+ ctx.json({ error: 'nothing to modify — send newName' }, 400);
313
+ return;
314
+ }
315
+ ctx.json({ ok: true, db: branch, collection: name, changed });
316
+ } catch (err) {
317
+ ctx.json({ error: (err as Error).message }, 500);
318
+ }
319
+ });
320
+
321
+ router.delete('/admin/databases/:db/collections/:name', async (ctx) => {
322
+ const branch = assertDbName(ctx.params.db, ctx);
323
+ if (!branch) return;
324
+ const name = assertCollectionName(ctx.params.name, ctx);
325
+ if (!name) return;
326
+ try {
327
+ const snap = await view(branch).collection(name).get();
328
+ for (const d of snap.docs) {
329
+ await view(branch).collection(name).doc(d.id).delete();
330
+ }
331
+ ctx.json({ ok: true, dropped: { db: branch, collection: name } });
332
+ } catch (err) {
333
+ ctx.json({ error: (err as Error).message }, 500);
334
+ }
335
+ });
336
+
337
+ router.get('/admin/databases/:db/collections/:name/docs', async (ctx) => {
338
+ const branch = assertDbName(ctx.params.db, ctx);
339
+ if (!branch) return;
340
+ const name = assertCollectionName(ctx.params.name, ctx);
341
+ if (!name) return;
342
+ try {
343
+ const snap = await view(branch).collection<Record<string, unknown>>(name).get();
344
+ const docs = snap.docs
345
+ .filter((d) => d.id !== BOOTSTRAP_DOC)
346
+ .slice(0, DOC_PREVIEW_LIMIT)
347
+ .map((d) => ({ id: d.id, ...d.data }));
348
+ const count = snap.docs.filter((d) => d.id !== BOOTSTRAP_DOC).length;
349
+ ctx.json({ db: branch, collection: name, count, docs });
350
+ } catch (err) {
351
+ ctx.json({ error: (err as Error).message }, 500);
352
+ }
353
+ });
354
+ }
355
+
187
356
  function assertDbName(v: unknown, ctx: { json(data: unknown, status?: number): void; res: { writableEnded: boolean } }): string | null {
188
357
  const name = typeof v === 'string' ? v.trim() : '';
189
358
  if (!name || name.length > 63) { ctx.json({ error: 'database name is required (max 63 chars)' }, 400); return null; }
@@ -65,6 +65,7 @@ export interface DiscoveryResult {
65
65
  migrations: DiscoveredFile[];
66
66
  plugins: DiscoveredFile[];
67
67
  config: DiscoveredFile[];
68
+ live: DiscoveredFile[];
68
69
  }
69
70
 
70
71
  /**
@@ -74,7 +75,7 @@ export interface DiscoveryResult {
74
75
  export async function discoverBackend(srcRoot: string): Promise<DiscoveryResult> {
75
76
  const abs = resolve(srcRoot);
76
77
 
77
- const [routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config] =
78
+ const [routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config, live] =
78
79
  await Promise.all([
79
80
  collectWithModules(abs, 'routes', /\.(ts|js)$/),
80
81
  collectWithModules(abs, 'graphql', /\.graph\.(ts|js)$/),
@@ -90,6 +91,7 @@ export async function discoverBackend(srcRoot: string): Promise<DiscoveryResult>
90
91
  collect(join(abs, 'database', 'migrations'), /^\d{4}_\d{2}_\d{2}_\d{6}_.*\.(ts|js)$/),
91
92
  collectPluginDirs(join(abs, 'plugins')),
92
93
  collect(join(abs, 'config'), /\.(ts|js)$/),
94
+ collect(join(abs, 'live'), /\.live\.(ts|js)$/),
93
95
  ]);
94
96
 
95
97
  // errors/Handler.ts is a single well-known file.
@@ -98,7 +100,7 @@ export async function discoverBackend(srcRoot: string): Promise<DiscoveryResult>
98
100
  ? { path: errorHandlerPath, name: 'Handler', subdir: '' }
99
101
  : null;
100
102
 
101
- return { routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config, errorHandler };
103
+ return { routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config, live, errorHandler };
102
104
  }
103
105
 
104
106
  async function collect(dir: string, pattern: RegExp): Promise<DiscoveredFile[]> {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * ErrorPages — renders a templated HTML page for a given HTTP status.
3
3
  *
4
- * Reads `mail/pages/<status>.html` (project-level) if present, else falls back
5
- * to the framework default template. Substitutes simple {{PLACEHOLDER}}
6
- * markers. Production-safe (no stack traces).
4
+ * Reads `errors/pages/<status>.html` (backend src-level) if present, else
5
+ * falls back to the framework default template. Substitutes simple
6
+ * {{PLACEHOLDER}} markers. Production-safe (no stack traces).
7
7
  */
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
@@ -37,7 +37,10 @@ export interface ErrorPageRenderContext {
37
37
  }
38
38
 
39
39
  export class ErrorPages {
40
- constructor(private projectRoot: string) {}
40
+ constructor(
41
+ private projectRoot: string,
42
+ private srcRoot?: string,
43
+ ) {}
41
44
 
42
45
  /** Render the HTML for a given status. */
43
46
  async render(status: number, ctx: ErrorPageRenderContext = {}): Promise<string> {
@@ -59,8 +62,21 @@ export class ErrorPages {
59
62
 
60
63
  private async tryCustomPage(status: number): Promise<string | null> {
61
64
  const candidates = [
65
+ // Backend src root first — the layout used by real projects
66
+ // (e.g. apps/backend/src/errors/pages/404.html).
67
+ ...(this.srcRoot
68
+ ? [
69
+ join(this.srcRoot, 'errors', 'pages', `${status}.html`),
70
+ join(this.srcRoot, 'errors', 'pages', 'error.html'),
71
+ ]
72
+ : []),
73
+ // Project-root fallbacks (legacy + monorepo variants).
62
74
  join(this.projectRoot, 'src', 'errors', 'pages', `${status}.html`),
63
- join(this.projectRoot, 'src', 'errors', 'pages', 'error.html'), // generic catch-all
75
+ join(this.projectRoot, 'src', 'errors', 'pages', 'error.html'),
76
+ join(this.projectRoot, 'apps', 'backend', 'src', 'errors', 'pages', `${status}.html`),
77
+ join(this.projectRoot, 'apps', 'backend', 'src', 'errors', 'pages', 'error.html'),
78
+ join(this.projectRoot, 'errors', 'pages', `${status}.html`),
79
+ join(this.projectRoot, 'errors', 'pages', 'error.html'), // generic catch-all
64
80
  ];
65
81
  for (const cand of candidates) {
66
82
  if (existsSync(cand)) {
@@ -0,0 +1,53 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { FusionDatabase } from '@bhooai/nexus-fusion';
4
+ import type { NexusConfig } from '../config/types.js';
5
+ import { fusionDir } from '../config/dbAccess.js';
6
+
7
+ /**
8
+ * App Fusion engine lifecycle — one engine per backend process, opened at
9
+ * boot when `db.fusion.enabled` and closed on shutdown.
10
+ *
11
+ * `@bhooai/nexus-fusion` is dynamically imported so mongo-only backends never
12
+ * need the package (or its Rust core) installed.
13
+ */
14
+
15
+ let engine: FusionDatabase | null = null;
16
+
17
+ export async function openAppFusion(projectRoot: string, config: NexusConfig): Promise<FusionDatabase> {
18
+ if (engine) return engine;
19
+ let mod: typeof import('@bhooai/nexus-fusion');
20
+ try {
21
+ mod = await import('@bhooai/nexus-fusion');
22
+ } catch {
23
+ throw new Error(
24
+ "[db] db.fusion.enabled is true but '@bhooai/nexus-fusion' cannot be loaded — " +
25
+ 'add it as a dependency (file:../path/to/packages/nexus-fusion) and install.',
26
+ );
27
+ }
28
+ const fz = config.db.fusion;
29
+ const dir = fz.persist ? resolve(projectRoot, fusionDir(config)) : undefined;
30
+ if (dir) await mkdir(dir, { recursive: true });
31
+ engine = mod.fusion('main', 'prod', {
32
+ ...(dir ? { dir } : {}),
33
+ cacheCapacity: fz.cacheCapacity,
34
+ cacheTtlMs: fz.cacheTtlMs,
35
+ walCompactThreshold: fz.walCompactThreshold,
36
+ });
37
+ console.log(`[db] fusion engine open (${dir ?? 'in-memory'})`);
38
+ return engine;
39
+ }
40
+
41
+ export function getAppFusion(): FusionDatabase {
42
+ if (!engine) throw new Error('[db] Fusion engine is not open — enable db.fusion first.');
43
+ return engine;
44
+ }
45
+
46
+ export async function closeAppFusion(): Promise<void> {
47
+ if (!engine) return;
48
+ try {
49
+ engine.close();
50
+ } finally {
51
+ engine = null;
52
+ }
53
+ }
package/src/app/index.ts CHANGED
@@ -11,7 +11,10 @@ export * from './Mailable.js';
11
11
  export * from './ErrorHandler.js';
12
12
  export * from './Seeder.js';
13
13
  export * from './Storage.js';
14
+ export * from './Upload.js';
14
15
  export * from './maintenance.js';
15
16
  export * from './adminModule.js';
16
17
  export * from './authModule.js';
17
18
  export * from './userModel.js';
19
+ export * from './userStore.js';
20
+ export * from './fusionEngine.js';
@@ -1,5 +1,6 @@
1
1
  import { createConnection } from 'node:net';
2
2
  import type { Router, NexusConfig } from '../index.js';
3
+ import { mongoEnabled, mongoUri } from '../config/dbAccess.js';
3
4
 
4
5
  /**
5
6
  * Preflight diagnostics — native Node connectivity/latency probes.
@@ -197,6 +198,12 @@ export interface PreflightOptions {
197
198
  graphqlMounted?: boolean;
198
199
  }
199
200
 
201
+ /** Port for a named app from nexus.config.ts `apps[]` (undefined = not declared). */
202
+ function appPort(config: NexusConfig, name: string): number | undefined {
203
+ const entry = config.apps?.find((a) => a.name === name);
204
+ return entry && entry.enabled !== false && Number.isFinite(entry.port) ? entry.port : undefined;
205
+ }
206
+
200
207
  /** Register `POST /admin/preflight` (guarded by the caller's /admin middleware). */
201
208
  export function registerPreflightRoutes(router: Router, config: NexusConfig, opts: PreflightOptions = {}): void {
202
209
  router.post('/admin/preflight', async (ctx) => {
@@ -220,10 +227,12 @@ export function registerPreflightRoutes(router: Router, config: NexusConfig, opt
220
227
  targets.push({ name: 'GraphQL', kind: 'http', url: graphqlUrl, timeout: 3000 });
221
228
  }
222
229
 
223
- // Mongo + Redis as TCP reachability probes (authenticated ping is out of scope here).
224
- const dbUri = config.db?.uri ?? 'mongodb://127.0.0.1:27017';
225
- const mongo = endpointFromUrl(dbUri, 27017);
226
- targets.push({ name: 'MongoDB', kind: 'tcp', host: mongo.host, port: mongo.port, timeout: 2000 });
230
+ // MongoDB as a TCP reachability probe (authenticated ping is out of scope).
231
+ // Skipped when mongodb is disabled (fusion-active backends).
232
+ if (mongoEnabled(config)) {
233
+ const mongo = endpointFromUrl(mongoUri(config), 27017);
234
+ targets.push({ name: 'MongoDB', kind: 'tcp', host: mongo.host, port: mongo.port, timeout: 2000 });
235
+ }
227
236
 
228
237
  const redisUrl = config.redis?.url ?? '';
229
238
  if (redisUrl) {
@@ -231,6 +240,34 @@ export function registerPreflightRoutes(router: Router, config: NexusConfig, opt
231
240
  targets.push({ name: 'Redis', kind: 'tcp', host: redis.host, port: redis.port, timeout: 2000 });
232
241
  }
233
242
 
243
+ // Enabled AI providers — probe reachability of each base URL's host:port.
244
+ for (const p of config.ai?.providers ?? []) {
245
+ if (!p.enabled || !p.baseUrl) continue;
246
+ const ep = endpointFromUrl(p.baseUrl, p.baseUrl.startsWith('https:') ? 443 : 80);
247
+ targets.push({ name: `AI · ${p.label}`, kind: 'tcp', host: ep.host, port: ep.port, timeout: 2500 });
248
+ }
249
+
250
+ // SMTP relay — only when actually configured for delivery.
251
+ const smtp = config.email?.smtp;
252
+ if (config.email?.provider === 'smtp' && smtp?.host && smtp.port) {
253
+ targets.push({ name: 'Email (SMTP)', kind: 'tcp', host: normalizeProbeHost(smtp.host), port: smtp.port, timeout: 2500 });
254
+ }
255
+
256
+ // Frontend dev server — only when enabled in this stack.
257
+ if (config.frontend?.enabled) {
258
+ targets.push({ name: 'Frontend', kind: 'http', url: `http://${host}:${config.frontend.port}`, timeout: 2500 });
259
+ }
260
+
261
+ // Fusion DB server + Python pyserver — declared in nexus.config.ts `apps[]`.
262
+ const fusionPort = appPort(config, 'fusion');
263
+ if (fusionPort) {
264
+ targets.push({ name: 'Fusion DB', kind: 'http', url: `http://127.0.0.1:${fusionPort}/health`, timeout: 2500 });
265
+ }
266
+ const pyPort = appPort(config, 'pyserver');
267
+ if (pyPort) {
268
+ targets.push({ name: 'PyServer', kind: 'http', url: `http://127.0.0.1:${pyPort}/health`, timeout: 2500 });
269
+ }
270
+
234
271
  const runner = async (t: PreflightTarget): Promise<PreflightCheck> => {
235
272
  if (t.kind === 'http') {
236
273
  return httpProbe(t.url ?? '', t.timeout ?? 3000).then((c) => ({ ...c, name: t.name }));