From 04a80fb9baeade62316b2a64526398678af4d206 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 2 Apr 2026 21:03:00 -0500 Subject: [PATCH] feat(config): add MosaicConfig schema + loader with tier auto-detection Co-Authored-By: Claude Opus 4.6 --- apps/gateway/package.json | 1 + apps/gateway/src/app.module.ts | 2 + apps/gateway/src/config/config.module.ts | 16 +++ apps/gateway/src/database/database.module.ts | 15 +- apps/gateway/src/memory/memory.module.ts | 24 +++- apps/gateway/src/queue/queue.module.ts | 9 +- mosaic.config.json | 6 + packages/config/package.json | 35 +++++ packages/config/src/index.ts | 7 + packages/config/src/mosaic-config.ts | 140 +++++++++++++++++++ packages/config/tsconfig.json | 9 ++ pnpm-lock.yaml | 22 +++ 12 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 apps/gateway/src/config/config.module.ts create mode 100644 mosaic.config.json create mode 100644 packages/config/package.json create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/src/mosaic-config.ts create mode 100644 packages/config/tsconfig.json diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 97fed5c..0b61511 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -19,6 +19,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@mosaic/auth": "workspace:^", "@mosaic/brain": "workspace:^", + "@mosaic/config": "workspace:^", "@mosaic/coord": "workspace:^", "@mosaic/db": "workspace:^", "@mosaic/discord-plugin": "workspace:^", diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 8482785..3c7a833 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { HealthController } from './health/health.controller.js'; +import { ConfigModule } from './config/config.module.js'; import { DatabaseModule } from './database/database.module.js'; import { AuthModule } from './auth/auth.module.js'; import { BrainModule } from './brain/brain.module.js'; @@ -28,6 +29,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]), + ConfigModule, DatabaseModule, AuthModule, BrainModule, diff --git a/apps/gateway/src/config/config.module.ts b/apps/gateway/src/config/config.module.ts new file mode 100644 index 0000000..77e393c --- /dev/null +++ b/apps/gateway/src/config/config.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { loadConfig, type MosaicConfig } from '@mosaic/config'; + +export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; + +@Global() +@Module({ + providers: [ + { + provide: MOSAIC_CONFIG, + useFactory: (): MosaicConfig => loadConfig(), + }, + ], + exports: [MOSAIC_CONFIG], +}) +export class ConfigModule {} diff --git a/apps/gateway/src/database/database.module.ts b/apps/gateway/src/database/database.module.ts index 39206a8..aaddf5a 100644 --- a/apps/gateway/src/database/database.module.ts +++ b/apps/gateway/src/database/database.module.ts @@ -1,19 +1,21 @@ import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common'; import { createDb, type Db, type DbHandle } from '@mosaic/db'; import { createStorageAdapter, type StorageAdapter } from '@mosaic/storage'; +import type { MosaicConfig } from '@mosaic/config'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; export const DB_HANDLE = 'DB_HANDLE'; export const DB = 'DB'; export const STORAGE_ADAPTER = 'STORAGE_ADAPTER'; -const DEFAULT_DATABASE_URL = 'postgresql://mosaic:mosaic@localhost:5432/mosaic'; - @Global() @Module({ providers: [ { provide: DB_HANDLE, - useFactory: (): DbHandle => createDb(), + useFactory: (config: MosaicConfig): DbHandle => + createDb(config.storage.type === 'postgres' ? config.storage.url : undefined), + inject: [MOSAIC_CONFIG], }, { provide: DB, @@ -22,11 +24,8 @@ const DEFAULT_DATABASE_URL = 'postgresql://mosaic:mosaic@localhost:5432/mosaic'; }, { provide: STORAGE_ADAPTER, - useFactory: (): StorageAdapter => - createStorageAdapter({ - type: 'postgres', - url: process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, - }), + useFactory: (config: MosaicConfig): StorageAdapter => createStorageAdapter(config.storage), + inject: [MOSAIC_CONFIG], }, ], exports: [DB, STORAGE_ADAPTER], diff --git a/apps/gateway/src/memory/memory.module.ts b/apps/gateway/src/memory/memory.module.ts index 8b8d51b..31aeef0 100644 --- a/apps/gateway/src/memory/memory.module.ts +++ b/apps/gateway/src/memory/memory.module.ts @@ -1,13 +1,29 @@ import { Global, Module } from '@nestjs/common'; -import { createMemory, type Memory, createMemoryAdapter, type MemoryAdapter } from '@mosaic/memory'; +import { + createMemory, + type Memory, + createMemoryAdapter, + type MemoryAdapter, + type MemoryConfig, +} from '@mosaic/memory'; import type { Db } from '@mosaic/db'; -import { DB } from '../database/database.module.js'; +import type { StorageAdapter } from '@mosaic/storage'; +import type { MosaicConfig } from '@mosaic/config'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; +import { DB, STORAGE_ADAPTER } from '../database/database.module.js'; import { MEMORY } from './memory.tokens.js'; import { MemoryController } from './memory.controller.js'; import { EmbeddingService } from './embedding.service.js'; export const MEMORY_ADAPTER = 'MEMORY_ADAPTER'; +function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter): MemoryConfig { + if (config.memory.type === 'keyword') { + return { type: 'keyword', storage: storageAdapter }; + } + return { type: config.memory.type }; +} + @Global() @Module({ providers: [ @@ -18,7 +34,9 @@ export const MEMORY_ADAPTER = 'MEMORY_ADAPTER'; }, { provide: MEMORY_ADAPTER, - useFactory: (): MemoryAdapter => createMemoryAdapter({ type: 'pgvector' }), + useFactory: (config: MosaicConfig, storageAdapter: StorageAdapter): MemoryAdapter => + createMemoryAdapter(buildMemoryConfig(config, storageAdapter)), + inject: [MOSAIC_CONFIG, STORAGE_ADAPTER], }, EmbeddingService, ], diff --git a/apps/gateway/src/queue/queue.module.ts b/apps/gateway/src/queue/queue.module.ts index b4c1e36..57bbcfd 100644 --- a/apps/gateway/src/queue/queue.module.ts +++ b/apps/gateway/src/queue/queue.module.ts @@ -1,5 +1,7 @@ import { Global, Module } from '@nestjs/common'; import { createQueueAdapter, type QueueAdapter } from '@mosaic/queue'; +import type { MosaicConfig } from '@mosaic/config'; +import { MOSAIC_CONFIG } from '../config/config.module.js'; import { QueueService } from './queue.service.js'; export const QUEUE_ADAPTER = 'QUEUE_ADAPTER'; @@ -10,11 +12,8 @@ export const QUEUE_ADAPTER = 'QUEUE_ADAPTER'; QueueService, { provide: QUEUE_ADAPTER, - useFactory: (): QueueAdapter => - createQueueAdapter({ - type: 'bullmq', - url: process.env['VALKEY_URL'], - }), + useFactory: (config: MosaicConfig): QueueAdapter => createQueueAdapter(config.queue), + inject: [MOSAIC_CONFIG], }, ], exports: [QueueService, QUEUE_ADAPTER], diff --git a/mosaic.config.json b/mosaic.config.json new file mode 100644 index 0000000..cb5a702 --- /dev/null +++ b/mosaic.config.json @@ -0,0 +1,6 @@ +{ + "tier": "local", + "storage": { "type": "sqlite", "path": ".mosaic/data.db" }, + "queue": { "type": "local", "dataDir": ".mosaic/queue" }, + "memory": { "type": "keyword" } +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..1643785 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,35 @@ +{ + "name": "@mosaic/config", + "version": "0.0.1", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@mosaic/memory": "workspace:^", + "@mosaic/queue": "workspace:^", + "@mosaic/storage": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.8.0", + "vitest": "^2.0.0" + }, + "publishConfig": { + "registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm/", + "access": "public" + }, + "files": [ + "dist" + ] +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000..79e2526 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,7 @@ +export type { MosaicConfig, StorageTier, MemoryConfigRef } from './mosaic-config.js'; +export { + DEFAULT_LOCAL_CONFIG, + DEFAULT_TEAM_CONFIG, + loadConfig, + validateConfig, +} from './mosaic-config.js'; diff --git a/packages/config/src/mosaic-config.ts b/packages/config/src/mosaic-config.ts new file mode 100644 index 0000000..c7bf045 --- /dev/null +++ b/packages/config/src/mosaic-config.ts @@ -0,0 +1,140 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { StorageConfig } from '@mosaic/storage'; +import type { QueueAdapterConfig as QueueConfig } from '@mosaic/queue'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type StorageTier = 'local' | 'team'; + +export interface MemoryConfigRef { + type: 'pgvector' | 'sqlite-vec' | 'keyword'; +} + +export interface MosaicConfig { + tier: StorageTier; + storage: StorageConfig; + queue: QueueConfig; + memory: MemoryConfigRef; +} + +/* ------------------------------------------------------------------ */ +/* Defaults */ +/* ------------------------------------------------------------------ */ + +export const DEFAULT_LOCAL_CONFIG: MosaicConfig = { + tier: 'local', + storage: { type: 'sqlite', path: '.mosaic/data.db' }, + queue: { type: 'local', dataDir: '.mosaic/queue' }, + memory: { type: 'keyword' }, +}; + +export const DEFAULT_TEAM_CONFIG: MosaicConfig = { + tier: 'team', + storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' }, + queue: { type: 'bullmq' }, + memory: { type: 'pgvector' }, +}; + +/* ------------------------------------------------------------------ */ +/* Validation */ +/* ------------------------------------------------------------------ */ + +const VALID_TIERS = new Set(['local', 'team']); +const VALID_STORAGE_TYPES = new Set(['postgres', 'sqlite', 'files']); +const VALID_QUEUE_TYPES = new Set(['bullmq', 'local']); +const VALID_MEMORY_TYPES = new Set(['pgvector', 'sqlite-vec', 'keyword']); + +export function validateConfig(raw: unknown): MosaicConfig { + if (typeof raw !== 'object' || raw === null) { + throw new Error('MosaicConfig must be a non-null object'); + } + + const obj = raw as Record; + + // tier + const tier = obj['tier']; + if (typeof tier !== 'string' || !VALID_TIERS.has(tier)) { + throw new Error(`Invalid tier "${String(tier)}" — expected "local" or "team"`); + } + + // storage + const storage = obj['storage']; + if (typeof storage !== 'object' || storage === null) { + throw new Error('config.storage must be a non-null object'); + } + const storageType = (storage as Record)['type']; + if (typeof storageType !== 'string' || !VALID_STORAGE_TYPES.has(storageType)) { + throw new Error(`Invalid storage.type "${String(storageType)}"`); + } + + // queue + const queue = obj['queue']; + if (typeof queue !== 'object' || queue === null) { + throw new Error('config.queue must be a non-null object'); + } + const queueType = (queue as Record)['type']; + if (typeof queueType !== 'string' || !VALID_QUEUE_TYPES.has(queueType)) { + throw new Error(`Invalid queue.type "${String(queueType)}"`); + } + + // memory + const memory = obj['memory']; + if (typeof memory !== 'object' || memory === null) { + throw new Error('config.memory must be a non-null object'); + } + const memoryType = (memory as Record)['type']; + if (typeof memoryType !== 'string' || !VALID_MEMORY_TYPES.has(memoryType)) { + throw new Error(`Invalid memory.type "${String(memoryType)}"`); + } + + return { + tier: tier as StorageTier, + storage: storage as StorageConfig, + queue: queue as QueueConfig, + memory: memory as MemoryConfigRef, + }; +} + +/* ------------------------------------------------------------------ */ +/* Loader */ +/* ------------------------------------------------------------------ */ + +function detectFromEnv(): MosaicConfig { + if (process.env['DATABASE_URL']) { + return { + ...DEFAULT_TEAM_CONFIG, + storage: { + type: 'postgres', + url: process.env['DATABASE_URL'], + }, + queue: { + type: 'bullmq', + url: process.env['VALKEY_URL'], + }, + }; + } + return DEFAULT_LOCAL_CONFIG; +} + +export function loadConfig(configPath?: string): MosaicConfig { + // 1. Explicit path or default location + const paths = configPath + ? [resolve(configPath)] + : [ + resolve(process.cwd(), 'mosaic.config.json'), + resolve(process.cwd(), '../../mosaic.config.json'), // monorepo root when cwd is apps/gateway + ]; + + for (const p of paths) { + if (existsSync(p)) { + const raw: unknown = JSON.parse(readFileSync(p, 'utf-8')); + return validateConfig(raw); + } + } + + // 2. Fall back to env-var detection + return detectFromEnv(); +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000..c973386 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d03125d..7e1726f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@mosaic/brain': specifier: workspace:^ version: link:../../packages/brain + '@mosaic/config': + specifier: workspace:^ + version: link:../../packages/config '@mosaic/coord': specifier: workspace:^ version: link:../../packages/coord @@ -351,6 +354,25 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) + packages/config: + dependencies: + '@mosaic/memory': + specifier: workspace:^ + version: link:../memory + '@mosaic/queue': + specifier: workspace:^ + version: link:../queue + '@mosaic/storage': + specifier: workspace:^ + version: link:../storage + devDependencies: + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) + packages/coord: dependencies: '@mosaic/types':