@bhooai/nexus-core 2.0.17 → 2.0.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-core",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -40,7 +40,7 @@ import type { Router } from '../http/Router.js';
40
40
  import type { Middleware, RequestContext } from '../http/context.js';
41
41
  import { AiClient } from '@bhooai/nexus-ai-client';
42
42
  import { discoverUserConfigPath, frameworkDefaultConfigPath, mergeConfig } from '../config/ConfigLoader.js';
43
- import { mongoEnabled, mongoUri } from '../config/dbAccess.js';
43
+ import { mongoEnabled, mongoUri, cacheActive } from '../config/dbAccess.js';
44
44
  import { readRuntimeJson, writeRuntimeJson, mergeRuntimeJson } from '../config/runtimeJson.js';
45
45
  import { readEnvFile, writeEnvEntries, writeEnvKey, deleteEnvKey } from '../config/envFile.js';
46
46
  import {
@@ -173,17 +173,19 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
173
173
  const redisHost = hostOf(config.redis.url, 'localhost');
174
174
  const redisPort = portOf(config.redis.url, 6379);
175
175
  const aiPort = portOf(config.ai.serverUrl, 8000);
176
+ const useRedis = cacheActive(config) === 'redis';
176
177
 
177
178
  const [mongo, redis, ai] = await Promise.all([
178
179
  tcpReachable(mongoHost, mongoPort),
179
- tcpReachable(redisHost, redisPort),
180
+ // No Redis server is required when fusion is the active cache backend.
181
+ useRedis ? tcpReachable(redisHost, redisPort) : Promise.resolve(true),
180
182
  tcpReachable('127.0.0.1', aiPort),
181
183
  ]);
182
184
 
183
185
  ctx.json({
184
186
  services: [
185
187
  { id: 'mongo', label: 'MongoDB', ok: mongo, detail: `${mongoHost}:${mongoPort}` },
186
- { id: 'redis', label: 'Redis', ok: redis, detail: `${redisHost}:${redisPort}` },
188
+ { id: 'redis', label: 'Redis', ok: redis, detail: useRedis ? `${redisHost}:${redisPort}` : 'bypassed — fusion cache active' },
187
189
  { id: 'ai', label: 'AI server', ok: ai, detail: `127.0.0.1:${aiPort}` },
188
190
  { id: 'storage', label: 'Storage', ok: true, detail: 'local disks configured' },
189
191
  ],
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import type { NexusConfig } from '../config/types.js';
23
23
  import type { Router } from '../http/Router.js';
24
+ import type { Middleware } from '../http/context.js';
25
+ import { getNexusConfig } from '../config/current.js';
24
26
  import {
25
27
  AuthService,
26
28
  MemorySessionStore,
@@ -73,10 +75,51 @@ function jwtOptions(config: NexusConfig) {
73
75
  };
74
76
  }
75
77
 
76
- function authService(config: NexusConfig): AuthService {
78
+ /**
79
+ * Build the framework AuthService for a config. Exported so app routes and
80
+ * subgraphs can verify the same session JWTs (issuer/audience/secret) without
81
+ * re-declaring identity values — e.g. the license routes.
82
+ */
83
+ export function createAuthService(config: NexusConfig): AuthService {
77
84
  return new AuthService(jwtOptions(config), getSessions());
78
85
  }
79
86
 
87
+ function authService(config: NexusConfig): AuthService {
88
+ return createAuthService(config);
89
+ }
90
+
91
+ let cachedAuth: { config: NexusConfig; service: AuthService } | null = null;
92
+
93
+ /**
94
+ * Memoized AuthService for a config — safe to call per request. Rebuilt only
95
+ * when the config object identity changes.
96
+ */
97
+ export function getAuthService(config: NexusConfig): AuthService {
98
+ if (cachedAuth?.config === config) return cachedAuth.service;
99
+ const service = createAuthService(config);
100
+ cachedAuth = { config, service };
101
+ return service;
102
+ }
103
+
104
+ /**
105
+ * Session-auth middleware that reads its identity from the request's config
106
+ * (`ctx.config`, falling back to the process config) instead of hardcoding a
107
+ * cookie name. Use before `requireAuth()`:
108
+ *
109
+ * middleware: [sessionAuth(), requireAuth()]
110
+ */
111
+ export function sessionAuth(options: { required?: boolean } = {}): Middleware {
112
+ return async (ctx, next) => {
113
+ const config = (ctx.config ?? getNexusConfig()) as NexusConfig | null;
114
+ if (!config) throw new Error('sessionAuth: no config available (ctx.config / setNexusConfig)');
115
+ const verify = authToken(getAuthService(config), {
116
+ required: options.required ?? false,
117
+ cookieName: config.auth.cookieName,
118
+ });
119
+ await verify(ctx, next);
120
+ };
121
+ }
122
+
80
123
  /** Register /auth/* routes onto the given router. The user store is initialized
81
124
  * by `createNexusApp()` (or lazily falls back to Mongo on first use). */
82
125
  export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCreated?: () => void): AuthService {
@@ -15,7 +15,7 @@
15
15
  import { existsSync } from 'node:fs';
16
16
  import { resolve, dirname, join } from 'node:path';
17
17
  import { readFile } from 'node:fs/promises';
18
- import { loadConfigAuto, activeDatabase, mongoEnabled, mongoUri, fusionEnabled, type NexusConfig } from '../config/index.js';
18
+ import { loadConfigAuto, activeDatabase, mongoEnabled, mongoUri, fusionEnabled, fusionRemote, setNexusConfig, type NexusConfig } from '../config/index.js';
19
19
  import { Container } from '../di/Container.js';
20
20
  import { Router } from '../http/Router.js';
21
21
  import { NexusServer } from '../http/Server.js';
@@ -29,6 +29,7 @@ import { ConfigError, NotFoundError, toNexusError } from '../errors.js';
29
29
  import { initUserModel } from './userModel.js';
30
30
  import { initUserStore, MongoUserStore, FusionUserStore } from './userStore.js';
31
31
  import { openAppFusion, closeAppFusion } from './fusionEngine.js';
32
+ import { registerFusionDevRoutes } from './fusionDevApi.js';
32
33
  import { eventBus, type EventBus, type Listener } from './events.js';
33
34
  import { configureQueue, InMemoryQueueAdapter, type JobQueueAdapter } from './Job.js';
34
35
  import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mailable.js';
@@ -51,8 +52,19 @@ export interface CreateNexusAppOptions {
51
52
  tech?: 'react' | 'future';
52
53
  /** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
53
54
  srcRoot?: string;
54
- /** Project root (where .nexus-down and storage/ live). Defaults to srcRoot/.. */
55
+ /** Project root anchors DB data, cache, logs and certs (`storage/fusion`,
56
+ * `storage/logs`, `certs/`, .nexus-down). Defaults to srcRoot/.. */
55
57
  projectRoot?: string;
58
+ /**
59
+ * Root for file storage (uploads + private). Resolves to
60
+ * `<storageRoot>/storage/{uploads,private}` and is served at `/uploads`.
61
+ *
62
+ * Split from `projectRoot` so a monorepo can keep durable DB data at the
63
+ * workspace root while file uploads live next to the backend
64
+ * (e.g. projectRoot = repo root, storageRoot = apps/backend).
65
+ * Defaults to `projectRoot`.
66
+ */
67
+ storageRoot?: string;
56
68
  /** Override the auto-discovered config (for tests). */
57
69
  config?: NexusConfig;
58
70
  /** Additional global middleware applied after built-ins. */
@@ -102,6 +114,9 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
102
114
  const name = opts.name ?? 'backend';
103
115
  const srcRoot = resolve(opts.srcRoot ?? resolve(process.cwd(), 'src'));
104
116
  const projectRoot = resolve(opts.projectRoot ?? dirname(srcRoot));
117
+ // File storage (uploads/private) can be anchored separately from projectRoot
118
+ // so DB data stays at the workspace root while files live with the backend.
119
+ const storageRoot = resolve(opts.storageRoot ?? projectRoot);
105
120
  let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
106
121
 
107
122
  // License check at project start — verify the token (signature via fetched
@@ -137,6 +152,10 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
137
152
  config = Object.freeze({ ...config, server: { ...config.server, port: finalPort } }) as NexusConfig;
138
153
  }
139
154
 
155
+ // Publish the resolved config process-wide so subgraphs/services without a
156
+ // request context can read identity/namespace values (see getNexusConfig).
157
+ setNexusConfig(config);
158
+
140
159
  const container = new Container();
141
160
  const router = new Router();
142
161
 
@@ -161,7 +180,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
161
180
  }
162
181
 
163
182
  // ------------------------------------------------------------------
164
- // User store — Mongo (default) or Fusion (db.active: 'fusion').
183
+ // User store — Fusion (default) or Mongo (db.active: 'mongodb').
165
184
  // ------------------------------------------------------------------
166
185
  if (activeDatabase(config) === 'fusion') {
167
186
  if (!fusionEnabled(config)) {
@@ -171,6 +190,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
171
190
  }
172
191
  const fusionDb = await openAppFusion(projectRoot, config);
173
192
  initUserStore(new FusionUserStore(fusionDb));
193
+ // Dev-only: expose this same engine over HTTP so `nexus dev`'s Fusion View
194
+ // and `nexus db:view` read/write the SAME state the backend serves from —
195
+ // no second engine, no stale reads, no refresh needed.
196
+ //
197
+ // Skipped in remote mode: the standalone Fusion server already speaks HTTP,
198
+ // so tooling talks to it directly (the backend owns no engine to expose).
199
+ if (config.env === 'development' && !fusionRemote(config)) {
200
+ registerFusionDevRoutes(router);
201
+ console.log(`[${name}] Fusion dev API mounted (dev only)`);
202
+ }
174
203
  } else {
175
204
  if (mongoEnabled(config)) initUserModel();
176
205
  initUserStore(new MongoUserStore());
@@ -179,7 +208,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
179
208
  // ------------------------------------------------------------------
180
209
  // Storage facade + queue + mail — always configured, even if minimal
181
210
  // ------------------------------------------------------------------
182
- configureStorageFromEnv(projectRoot, config.auth?.jwt?.secret ?? 'nexus-insecure');
211
+ configureStorageFromEnv(storageRoot, config.auth?.jwt?.secret ?? 'nexus-insecure');
183
212
  configureQueue(opts.queueAdapter ?? new InMemoryQueueAdapter());
184
213
  configureMailDriver(logMailDriver);
185
214
  configureMailRenderer(async (templateName, data) => renderMailTemplate(projectRoot, templateName, data));
@@ -386,7 +415,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
386
415
  gateway,
387
416
  introspection: config.graphql?.introspection ?? true,
388
417
  requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
389
- context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }),
418
+ context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }),
390
419
  });
391
420
  if (explorerEnabled) {
392
421
  const explorerHtml = getExplorerHtml({
@@ -433,7 +462,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
433
462
  if (helloEnabled) {
434
463
  // hello is available even in fallback — route through its gateway
435
464
  const gw = createGateway({ subgraph: helloSubgraph });
436
- const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) });
465
+ const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }) });
437
466
  return h(ctx);
438
467
  }
439
468
  ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
@@ -443,7 +472,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
443
472
  try {
444
473
  if (helloEnabled) {
445
474
  const gw = createGateway({ subgraph: helloSubgraph });
446
- router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) }));
475
+ router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }) }));
447
476
  } else {
448
477
  router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
449
478
  }
@@ -510,6 +539,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
510
539
  // ------------------------------------------------------------------
511
540
  const server = new NexusServer({
512
541
  router,
542
+ config,
513
543
  bodyLimit: config.server.bodyLimit,
514
544
  trustProxy: config.server.trustProxy,
515
545
  ...(config.server.https && config.server.certFile && config.server.keyFile
@@ -717,9 +747,9 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
717
747
  }
718
748
  }
719
749
 
720
- // Public uploads: serve GET /uploads/* from storage/uploads (file-storage example).
750
+ // Public uploads: serve GET /uploads/* from <storageRoot>/storage/uploads.
721
751
  // This was missing — 404 `No route for GET /uploads/media/...`.
722
- server.use(serveStatic(join(projectRoot, 'storage', 'uploads'), { prefix: '/uploads' }));
752
+ server.use(serveStatic(join(storageRoot, 'storage', 'uploads'), { prefix: '/uploads' }));
723
753
 
724
754
  // Signed private files: GET /files/<encodedPath>?expires=&sig=
725
755
  // Handles LocalDisk.signedUrl() URLs (HMAC, 5min TTL). Streams from whichever local disk holds the file.
@@ -1,7 +1,7 @@
1
1
  import type { Router } from '../index.js';
2
2
  import type { ActiveDatabase } from '../config/types.js';
3
3
  import { getAppFusion } from './fusionEngine.js';
4
- import type { FusionDatabase } from '@bhooai/nexus-fusion';
4
+ import type { FusionLike } from '@bhooai/nexus-fusion';
5
5
 
6
6
  /**
7
7
  * Admin routes for database / collection administration.
@@ -213,14 +213,14 @@ function registerMongoDatabaseAdminRoutes(router: Router, lazy: LazyDb): void {
213
213
 
214
214
  function registerFusionDatabaseAdminRoutes(router: Router): void {
215
215
  // Branches stand in for databases; the engine's fixed db is 'main'.
216
- const view = (branch: string): FusionDatabase => getAppFusion().branchView(branch);
216
+ const view = (branch: string): FusionLike => getAppFusion().branchView(branch);
217
217
 
218
218
  router.get('/admin/databases', async (ctx) => {
219
219
  try {
220
220
  const fusion = getAppFusion();
221
221
  const databases = [];
222
- for (const branch of fusion.listBranches()) {
223
- const bcols = view(branch).listCollections().filter((c) => c !== BOOTSTRAP_COLLECTION);
222
+ for (const branch of await fusion.listBranches()) {
223
+ const bcols = (await view(branch).listCollections()).filter((c) => c !== BOOTSTRAP_COLLECTION);
224
224
  const collections = [];
225
225
  for (const c of bcols) {
226
226
  let count = 0;
@@ -242,7 +242,7 @@ function registerFusionDatabaseAdminRoutes(router: Router): void {
242
242
  const name = assertDbName((ctx.body as { name?: unknown } | undefined)?.name, ctx);
243
243
  if (!name) return;
244
244
  try {
245
- getAppFusion().createBranch(name);
245
+ await getAppFusion().createBranch(name);
246
246
  ctx.json({ ok: true, name });
247
247
  } catch (err) {
248
248
  ctx.json({ error: (err as Error).message }, 500);
@@ -253,7 +253,7 @@ function registerFusionDatabaseAdminRoutes(router: Router): void {
253
253
  const name = assertDbName(ctx.params.db, ctx);
254
254
  if (!name) return;
255
255
  try {
256
- getAppFusion().dropBranch(name);
256
+ await getAppFusion().dropBranch(name);
257
257
  ctx.json({ ok: true, dropped: name });
258
258
  } catch (err) {
259
259
  ctx.json({ error: (err as Error).message }, 500);
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Fusion dev API — exposes the backend's **own** in-process Fusion engine over
3
+ * HTTP so the CLI tools (`nexus dev` Fusion View, `nexus db:view`) read and
4
+ * write the SAME engine the app serves from.
5
+ *
6
+ * Before this existed, the standalone `fusion` service opened a *second*
7
+ * engine on the same `storage/fusion/fusion.wal`. Edits made in the browser
8
+ * landed in engine B, so the running backend (engine A) kept serving stale
9
+ * data until it restarted and replayed the WAL.
10
+ *
11
+ * Mounted only in development (`config.env === 'development'`) and only when
12
+ * `db.active === 'fusion'`. Routes mirror the standalone server's surface
13
+ * (see `nexus-cli/src/fusion/server.ts`) so one browser client works against
14
+ * either backend:
15
+ *
16
+ * GET <prefix>/health
17
+ * GET <prefix>/tree
18
+ * GET <prefix>/docs?collection=&branch=&where=&orderBy=&limit=&skip=
19
+ * POST <prefix>/docs { branch, collection, id?, data }
20
+ * PATCH <prefix>/docs/:id?collection=&branch= (body = patch)
21
+ * DELETE <prefix>/docs/:id?collection=&branch=
22
+ * POST <prefix>/branches { branch }
23
+ * DELETE <prefix>/branches/:name
24
+ */
25
+ import type { Router } from '../http/Router.js';
26
+ import type { RequestContext } from '../http/context.js';
27
+ import { getAppFusion } from './fusionEngine.js';
28
+
29
+ /** Reserved mount path — double underscore marks it framework-internal. */
30
+ export const FUSION_DEV_PATH = '/__nexus/fusion';
31
+
32
+ /** Map a Fusion engine error (`.code`) to an HTTP status. */
33
+ function httpStatus(err: unknown): number {
34
+ const code = (err as { code?: string })?.code;
35
+ switch (code) {
36
+ case 'NOT_FOUND':
37
+ case 'PATH_NOT_FOUND':
38
+ return 404;
39
+ case 'VERSION_CONFLICT':
40
+ return 409;
41
+ case 'INVALID_NAME':
42
+ case 'INVALID_ID':
43
+ case 'QUERY_ERROR':
44
+ return 400;
45
+ default:
46
+ return 500;
47
+ }
48
+ }
49
+
50
+ function fail(ctx: RequestContext, err: unknown): void {
51
+ ctx.json({ error: err instanceof Error ? err.message : String(err) }, httpStatus(err));
52
+ }
53
+
54
+ /**
55
+ * Register the Fusion dev routes on `router` under `prefix`. Safe to call for
56
+ * non-fusion backends? No — the caller must gate on db.active === 'fusion'
57
+ * (getAppFusion throws otherwise), which `createNexusApp` does.
58
+ */
59
+ export function registerFusionDevRoutes(router: Router, prefix: string = FUSION_DEV_PATH): void {
60
+ const at = (path: string): string => `${prefix.replace(/\/+$/, '')}${path}`;
61
+
62
+ router.get(at('/health'), (ctx) => {
63
+ ctx.json({ ok: true, db: getAppFusion().db });
64
+ });
65
+
66
+ // Tree — branches → collections with counts (same shape as the standalone server).
67
+ router.get(at('/tree'), async (ctx) => {
68
+ try {
69
+ const db = getAppFusion();
70
+ const tree: {
71
+ db: string;
72
+ branches: Array<{ name: string; collections: Array<{ name: string; count: number }> }>;
73
+ } = { db: db.db, branches: [] };
74
+ for (const name of await db.listBranches()) {
75
+ const view = db.branchView(name);
76
+ const collections: Array<{ name: string; count: number }> = [];
77
+ for (const coll of await view.listCollections()) {
78
+ collections.push({ name: coll, count: await view.collection(coll).count() });
79
+ }
80
+ tree.branches.push({ name, collections });
81
+ }
82
+ ctx.json({ tree });
83
+ } catch (err) {
84
+ fail(ctx, err);
85
+ }
86
+ });
87
+
88
+ // Docs list.
89
+ router.get(at('/docs'), async (ctx) => {
90
+ try {
91
+ const q = ctx.query as Record<string, string>;
92
+ const collection = q.collection;
93
+ if (!collection) return ctx.json({ error: 'collection query param required' }, 400);
94
+ const view = getAppFusion().branchView(q.branch || 'prod');
95
+ let query = view.collection(collection).query();
96
+ if (q.where) {
97
+ const parsed = JSON.parse(q.where) as
98
+ | { field: string; op: string; value: unknown }
99
+ | Array<{ field: string; op: string; value: unknown }>;
100
+ const conds = Array.isArray(parsed) ? parsed : [parsed];
101
+ for (const c of conds) query = query.where(c.field, c.op as never, c.value);
102
+ }
103
+ if (q.orderBy) {
104
+ const [field, dir] = q.orderBy.split(':');
105
+ if (field) query = query.orderByField(field, dir === 'desc' ? 'desc' : 'asc');
106
+ }
107
+ if (q.skip) query = query.offset(Number(q.skip) || 0);
108
+ if (q.limit) query = query.limit(Number(q.limit) || 50);
109
+ const snap = await query.get();
110
+ ctx.json({
111
+ docs: snap.docs.map((d) => ({
112
+ id: d.id,
113
+ version: d.__version,
114
+ updatedAt: d.__updatedAt,
115
+ data: d.data,
116
+ })),
117
+ size: snap.size,
118
+ });
119
+ } catch (err) {
120
+ fail(ctx, err);
121
+ }
122
+ });
123
+
124
+ // Create doc.
125
+ router.post(at('/docs'), async (ctx) => {
126
+ try {
127
+ const body = (ctx.body ?? {}) as { branch?: string; collection?: string; id?: string; data?: Record<string, unknown> };
128
+ const collection = body.collection;
129
+ const data = body.data;
130
+ if (!collection || typeof data !== 'object' || data === null) {
131
+ return ctx.json({ error: 'collection and data required' }, 400);
132
+ }
133
+ const view = getAppFusion().branchView(body.branch || 'prod');
134
+ const doc = body.id
135
+ ? await view.collection(collection).doc(body.id).set(data)
136
+ : await view.collection(collection).add(data);
137
+ const id = body.id ?? (doc as { id: string }).id;
138
+ const saved = await view.collection(collection).doc(id).get();
139
+ ctx.json(
140
+ {
141
+ doc: saved
142
+ ? { id: saved.id, version: saved.__version, updatedAt: saved.__updatedAt, data: saved.data }
143
+ : null,
144
+ },
145
+ 201,
146
+ );
147
+ } catch (err) {
148
+ fail(ctx, err);
149
+ }
150
+ });
151
+
152
+ // Patch doc.
153
+ router.patch(at('/docs/:id'), async (ctx) => {
154
+ try {
155
+ const q = ctx.query as Record<string, string>;
156
+ const collection = q.collection;
157
+ if (!collection) return ctx.json({ error: 'collection query param required' }, 400);
158
+ const patch = (ctx.body ?? {}) as Record<string, unknown>;
159
+ const updated = await getAppFusion()
160
+ .branchView(q.branch || 'prod')
161
+ .collection(collection)
162
+ .doc(ctx.params.id ?? '')
163
+ .update(patch);
164
+ ctx.json({
165
+ doc: { id: updated.id, version: updated.__version, updatedAt: updated.__updatedAt, data: updated.data },
166
+ });
167
+ } catch (err) {
168
+ fail(ctx, err);
169
+ }
170
+ });
171
+
172
+ // Delete doc.
173
+ router.delete(at('/docs/:id'), async (ctx) => {
174
+ try {
175
+ const q = ctx.query as Record<string, string>;
176
+ const collection = q.collection;
177
+ if (!collection) return ctx.json({ error: 'collection query param required' }, 400);
178
+ await getAppFusion()
179
+ .branchView(q.branch || 'prod')
180
+ .collection(collection)
181
+ .doc(ctx.params.id ?? '')
182
+ .delete();
183
+ ctx.json({ ok: true });
184
+ } catch (err) {
185
+ fail(ctx, err);
186
+ }
187
+ });
188
+
189
+ // Create branch.
190
+ router.post(at('/branches'), async (ctx) => {
191
+ try {
192
+ const body = (ctx.body ?? {}) as { branch?: string };
193
+ if (!body.branch) return ctx.json({ error: 'branch required' }, 400);
194
+ await getAppFusion().createBranch(body.branch);
195
+ ctx.json({ ok: true, branch: body.branch }, 201);
196
+ } catch (err) {
197
+ fail(ctx, err);
198
+ }
199
+ });
200
+
201
+ // Drop branch.
202
+ router.delete(at('/branches/:name'), async (ctx) => {
203
+ try {
204
+ await getAppFusion().dropBranch(ctx.params.name ?? '');
205
+ ctx.json({ ok: true });
206
+ } catch (err) {
207
+ fail(ctx, err);
208
+ }
209
+ });
210
+ }
@@ -1,20 +1,31 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
- import type { FusionDatabase } from '@bhooai/nexus-fusion';
3
+ import type { FusionLike } from '@bhooai/nexus-fusion';
4
4
  import type { NexusConfig } from '../config/types.js';
5
- import { fusionDir } from '../config/dbAccess.js';
5
+ import { fusionDir, fusionRemote, fusionUrl } from '../config/dbAccess.js';
6
6
 
7
7
  /**
8
- * App Fusion engine lifecycle — one engine per backend process, opened at
9
- * boot when `db.fusion.enabled` and closed on shutdown.
8
+ * App Fusion engine lifecycle — one per backend process.
9
+ *
10
+ * Two modes, selected by config:
11
+ *
12
+ * - **Embedded** (default): opens the native `@bhooai/nexus-fusion` engine at
13
+ * boot when `db.fusion.enabled` and closes it on shutdown. The backend owns
14
+ * `storage/fusion/fusion.wal`.
15
+ *
16
+ * - **Remote**: when `db.fusion.remote` is true (or `db.fusion.url` is set) the
17
+ * backend opens **no** engine. All reads/writes go over HTTP to the
18
+ * standalone `fusion` service started by `nexus dev`, which is the sole
19
+ * owner of the WAL. This lets several apps share one Fusion database and
20
+ * keeps the backend process free of the Rust engine.
10
21
  *
11
22
  * `@bhooai/nexus-fusion` is dynamically imported so mongo-only backends never
12
23
  * need the package (or its Rust core) installed.
13
24
  */
14
25
 
15
- let engine: FusionDatabase | null = null;
26
+ let engine: FusionLike | null = null;
16
27
 
17
- export async function openAppFusion(projectRoot: string, config: NexusConfig): Promise<FusionDatabase> {
28
+ export async function openAppFusion(projectRoot: string, config: NexusConfig): Promise<FusionLike> {
18
29
  if (engine) return engine;
19
30
  let mod: typeof import('@bhooai/nexus-fusion');
20
31
  try {
@@ -25,6 +36,29 @@ export async function openAppFusion(projectRoot: string, config: NexusConfig): P
25
36
  'add it as a dependency (file:../path/to/packages/nexus-fusion) and install.',
26
37
  );
27
38
  }
39
+
40
+ if (fusionRemote(config)) {
41
+ const url = fusionUrl(config);
42
+ if (!url) {
43
+ throw new Error(
44
+ "[db] db.fusion.remote is true but db.fusion.url is empty — set it to the standalone " +
45
+ 'Fusion server (e.g. http://127.0.0.1:5000).',
46
+ );
47
+ }
48
+ engine = mod.fusionHttp({ url });
49
+ // Confirm the standalone server answers before the backend serves traffic.
50
+ try {
51
+ await (engine as { listBranches(): Promise<string[]> }).listBranches();
52
+ console.log(`[db] fusion remote client → ${url}`);
53
+ } catch (err) {
54
+ console.warn(
55
+ `[db] fusion server at ${url} is not reachable yet (${(err as Error).message}); ` +
56
+ 'requests will retry once it is up.',
57
+ );
58
+ }
59
+ return engine;
60
+ }
61
+
28
62
  const fz = config.db.fusion;
29
63
  const dir = fz.persist ? resolve(projectRoot, fusionDir(config)) : undefined;
30
64
  if (dir) await mkdir(dir, { recursive: true });
@@ -38,7 +72,7 @@ export async function openAppFusion(projectRoot: string, config: NexusConfig): P
38
72
  return engine;
39
73
  }
40
74
 
41
- export function getAppFusion(): FusionDatabase {
75
+ export function getAppFusion(): FusionLike {
42
76
  if (!engine) throw new Error('[db] Fusion engine is not open — enable db.fusion first.');
43
77
  return engine;
44
78
  }
@@ -46,7 +80,7 @@ export function getAppFusion(): FusionDatabase {
46
80
  export async function closeAppFusion(): Promise<void> {
47
81
  if (!engine) return;
48
82
  try {
49
- engine.close();
83
+ await engine.close();
50
84
  } finally {
51
85
  engine = null;
52
86
  }
package/src/app/index.ts CHANGED
@@ -18,3 +18,4 @@ export * from './authModule.js';
18
18
  export * from './userModel.js';
19
19
  export * from './userStore.js';
20
20
  export * from './fusionEngine.js';
21
+ export * from './fusionDevApi.js';
@@ -1,6 +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
+ import { mongoEnabled, mongoUri, redisCacheEnabled } from '../config/dbAccess.js';
4
4
 
5
5
  /**
6
6
  * Preflight diagnostics — native Node connectivity/latency probes.
@@ -235,7 +235,9 @@ export function registerPreflightRoutes(router: Router, config: NexusConfig, opt
235
235
  }
236
236
 
237
237
  const redisUrl = config.redis?.url ?? '';
238
- if (redisUrl) {
238
+ // Probed only when redis is the active cache backend — fusion-active
239
+ // backends don't need a Redis server at all.
240
+ if (redisCacheEnabled(config) && redisUrl) {
239
241
  const redis = endpointFromUrl(redisUrl, 6379);
240
242
  targets.push({ name: 'Redis', kind: 'tcp', host: redis.host, port: redis.port, timeout: 2000 });
241
243
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import type { FusionDatabase } from '@bhooai/nexus-fusion';
2
+ import type { FusionLike } from '@bhooai/nexus-fusion';
3
3
  import { ObjectId } from '@bhooai/nexus-data';
4
4
  import { ConflictError } from '../errors.js';
5
5
  import { initUserModel, getUserModel, findUserForLogin, type UserInstance } from './userModel.js';
@@ -172,16 +172,16 @@ async function getDocOrNull<T>(ref: { get(): Promise<{ id: string; data: T } | n
172
172
  }
173
173
 
174
174
  /** Query paths throw on missing collections too — treat as empty. */
175
- function hasCollection(db: FusionDatabase, name: string): boolean {
175
+ async function hasCollection(db: FusionLike, name: string): Promise<boolean> {
176
176
  try {
177
- return db.listCollections().includes(name);
177
+ return (await db.listCollections()).includes(name);
178
178
  } catch {
179
179
  return false;
180
180
  }
181
181
  }
182
182
 
183
183
  export class FusionUserStore implements UserStore {
184
- constructor(private db: FusionDatabase) {}
184
+ constructor(private db: FusionLike) {}
185
185
 
186
186
  private toStored(data: FusionUserData): StoredUser {
187
187
  return { _id: data.id, ...data };
@@ -197,7 +197,7 @@ export class FusionUserStore implements UserStore {
197
197
  }
198
198
 
199
199
  async findById(id: string): Promise<StoredUser | null> {
200
- if (!hasCollection(this.db, USERS)) return null;
200
+ if (!(await hasCollection(this.db, USERS))) return null;
201
201
  const snap = await this.db.collection<FusionUserData>(USERS).where('id', '==', id).limit(1).get();
202
202
  const hit = snap.docs[0];
203
203
  return hit ? this.toStored(hit.data) : null;
@@ -255,20 +255,20 @@ export class FusionUserStore implements UserStore {
255
255
  }
256
256
 
257
257
  async count(): Promise<number> {
258
- if (!hasCollection(this.db, USERS)) return 0;
258
+ if (!(await hasCollection(this.db, USERS))) return 0;
259
259
  const snap = await this.db.collection(USERS).get();
260
260
  return snap.size;
261
261
  }
262
262
 
263
263
  async countAdmins(): Promise<number> {
264
264
  // No array-contains op in the query compiler — filter the (small) users table.
265
- if (!hasCollection(this.db, USERS)) return 0;
265
+ if (!(await hasCollection(this.db, USERS))) return 0;
266
266
  const snap = await this.db.collection<FusionUserData>(USERS).get();
267
267
  return snap.docs.filter((d) => (d.data.roles ?? []).includes('admin')).length;
268
268
  }
269
269
 
270
270
  async list(limit: number): Promise<StoredUser[]> {
271
- if (!hasCollection(this.db, USERS)) return [];
271
+ if (!(await hasCollection(this.db, USERS))) return [];
272
272
  const snap = await this.db
273
273
  .collection<FusionUserData>(USERS)
274
274
  .orderByField('createdAt', 'desc')