diff --git a/backend/package.json b/backend/package.json index 2563d45b..d62b774b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "jest", - "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts", + "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseRepository.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", diff --git a/backend/src/services/__tests__/enterpriseDb.test.ts b/backend/src/services/__tests__/enterpriseDb.test.ts new file mode 100644 index 00000000..f2527380 --- /dev/null +++ b/backend/src/services/__tests__/enterpriseDb.test.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { + openEnterpriseDb, + resolveEnterpriseDbPath, + ENTERPRISE_DB_PATH_ENV, +} from '../enterpriseDb'; + +describe('enterprise SQLite WAL database', () => { + let tmpDir: string | undefined; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test('resolves the configured database path', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-db-')); + const configuredPath = path.join(tmpDir, 'enterprise.sqlite'); + + expect(resolveEnterpriseDbPath({ + [ENTERPRISE_DB_PATH_ENV]: configuredPath, + } as NodeJS.ProcessEnv)).toBe(configuredPath); + }); + + test('opens SQLite with WAL, foreign keys, busy timeout, and schema migrations', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-db-')); + const dbPath = path.join(tmpDir, 'enterprise.sqlite'); + const db = openEnterpriseDb(dbPath); + + try { + expect(db.pragma('journal_mode', { simple: true })).toBe('wal'); + expect(db.pragma('foreign_keys', { simple: true })).toBe(1); + expect(db.pragma('busy_timeout', { simple: true })).toBe(5000); + + const rows = db.prepare( + 'SELECT version FROM enterprise_schema_migrations ORDER BY version', + ).all(); + expect(rows).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }]); + } finally { + db.close(); + } + }); +}); diff --git a/backend/src/services/__tests__/enterpriseRepository.test.ts b/backend/src/services/__tests__/enterpriseRepository.test.ts new file mode 100644 index 00000000..22d2a7e7 --- /dev/null +++ b/backend/src/services/__tests__/enterpriseRepository.test.ts @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import Database from 'better-sqlite3'; +import { type RequestContext } from '../../middleware/auth'; +import { applyEnterpriseMinimalSchema } from '../enterpriseSchema'; +import { + buildWorkspaceScopedWhere, + createEnterpriseWorkspaceRepository, + repositoryScopeFromRequestContext, +} from '../enterpriseRepository'; + +interface TraceAssetRow extends Record { + id: string; + tenant_id: string; + workspace_id: string; + status: string; +} + +function seedWorkspace(db: Database.Database, tenantId: string, workspaceId: string): void { + const now = Date.now(); + db.prepare(` + INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES (?, ?, 'active', 'enterprise', ?, ?) + `).run(tenantId, tenantId, now, now); + db.prepare(` + INSERT INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run(workspaceId, tenantId, workspaceId, now, now); +} + +function seedTrace( + db: Database.Database, + input: { id: string; tenantId: string; workspaceId: string; status?: string }, +): void { + db.prepare(` + INSERT INTO trace_assets + (id, tenant_id, workspace_id, local_path, status, created_at) + VALUES + (?, ?, ?, ?, ?, ?) + `).run( + input.id, + input.tenantId, + input.workspaceId, + `/tmp/${input.id}.pftrace`, + input.status ?? 'ready', + Date.now(), + ); +} + +describe('enterprise repository scope abstraction', () => { + let db: Database.Database | undefined; + + beforeEach(() => { + db = new Database(':memory:'); + applyEnterpriseMinimalSchema(db); + seedWorkspace(db, 'tenant-a', 'workspace-a'); + seedWorkspace(db, 'tenant-a', 'workspace-b'); + seedWorkspace(db, 'tenant-b', 'workspace-c'); + seedTrace(db, { id: 'trace-a', tenantId: 'tenant-a', workspaceId: 'workspace-a' }); + seedTrace(db, { id: 'trace-b', tenantId: 'tenant-a', workspaceId: 'workspace-b' }); + seedTrace(db, { id: 'trace-c', tenantId: 'tenant-b', workspaceId: 'workspace-c' }); + }); + + afterEach(() => { + db?.close(); + db = undefined; + }); + + test('derives repository scope from RequestContext', () => { + const context: RequestContext = { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + authType: 'sso', + roles: ['analyst'], + scopes: ['trace:read'], + requestId: 'req-a', + }; + + expect(repositoryScopeFromRequestContext(context)).toEqual({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + }); + }); + + test('always prepends tenant and workspace filters to generated WHERE clauses', () => { + const where = buildWorkspaceScopedWhere( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + { status: 'ready' }, + ); + + expect(where.sql).toBe( + 'tenant_id = @scopeTenantId AND workspace_id = @scopeWorkspaceId AND status = @criteria_status', + ); + expect(where.params).toEqual({ + scopeTenantId: 'tenant-a', + scopeWorkspaceId: 'workspace-a', + criteria_status: 'ready', + }); + }); + + test('does not allow callers to override tenant or workspace criteria', () => { + expect(() => buildWorkspaceScopedWhere( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + { tenant_id: 'tenant-b' }, + )).toThrow('tenant_id must come from EnterpriseRepositoryScope'); + + expect(() => buildWorkspaceScopedWhere( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + { workspace_id: 'workspace-b' }, + )).toThrow('workspace_id must come from EnterpriseRepositoryScope'); + }); + + test('lists only rows inside the scope tenant and workspace', () => { + const repo = createEnterpriseWorkspaceRepository(db!, 'trace_assets'); + + const rows = repo.list( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + { status: 'ready' }, + { orderBy: 'id' }, + ); + + expect(rows.map(row => row.id)).toEqual(['trace-a']); + }); + + test('get, update, and delete stay scoped by tenant and workspace', () => { + const repo = createEnterpriseWorkspaceRepository(db!, 'trace_assets'); + const scopeA = { tenantId: 'tenant-a', workspaceId: 'workspace-a' }; + const scopeB = { tenantId: 'tenant-b', workspaceId: 'workspace-c' }; + + expect(repo.getById(scopeA, 'trace-c')).toBeNull(); + expect(repo.updateById(scopeA, 'trace-c', { status: 'deleted' })).toBe(0); + expect(repo.deleteById(scopeA, 'trace-c')).toBe(0); + expect(repo.getById(scopeB, 'trace-c')?.status).toBe('ready'); + + expect(repo.updateById(scopeA, 'trace-a', { status: 'archived' })).toBe(1); + expect(repo.getById(scopeA, 'trace-a')?.status).toBe('archived'); + expect(repo.deleteById(scopeA, 'trace-a')).toBe(1); + expect(repo.getById(scopeA, 'trace-a')).toBeNull(); + }); + + test('rejects non-workspace tables and unsafe dynamic columns', () => { + expect(() => createEnterpriseWorkspaceRepository(db!, 'organizations' as never)).toThrow( + 'Table is not workspace-scoped', + ); + expect(() => buildWorkspaceScopedWhere( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + { 'status; DROP TABLE trace_assets': 'ready' }, + )).toThrow('Invalid criteria column'); + + const repo = createEnterpriseWorkspaceRepository(db!, 'trace_assets'); + expect(() => repo.list( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + {}, + { orderBy: 'id; DROP TABLE trace_assets' }, + )).toThrow('Invalid orderBy column'); + }); + + test('does not allow scoped updates to mutate identity columns', () => { + const repo = createEnterpriseWorkspaceRepository(db!, 'trace_assets'); + + expect(() => repo.updateById( + { tenantId: 'tenant-a', workspaceId: 'workspace-a' }, + 'trace-a', + { workspace_id: 'workspace-b' }, + )).toThrow('workspace_id cannot be updated through a scoped repository'); + }); +}); diff --git a/backend/src/services/enterpriseRepository.ts b/backend/src/services/enterpriseRepository.ts new file mode 100644 index 00000000..63dc6ed4 --- /dev/null +++ b/backend/src/services/enterpriseRepository.ts @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import type Database from 'better-sqlite3'; +import type { RequestContext } from '../middleware/auth'; + +type SqliteBindValue = string | number | bigint | Buffer | null; + +export interface EnterpriseRepositoryScope { + tenantId: string; + workspaceId: string; + userId?: string; +} + +export type EnterpriseWorkspaceScopedTable = + | 'trace_assets' + | 'analysis_sessions' + | 'analysis_runs' + | 'agent_events'; + +export const ENTERPRISE_WORKSPACE_SCOPED_TABLES: readonly EnterpriseWorkspaceScopedTable[] = [ + 'trace_assets', + 'analysis_sessions', + 'analysis_runs', + 'agent_events', +]; + +export type EnterpriseQueryCriteria = Record; +export type EnterpriseUpdateValues = Record; + +export interface ScopedWhereClause { + sql: string; + params: Record; +} + +export interface ListOptions { + orderBy?: string; + direction?: 'ASC' | 'DESC'; + limit?: number; +} + +const IDENTIFIER_RE = /^[a-z][a-z0-9_]*$/; +const SCOPE_COLUMNS = new Set(['tenant_id', 'workspace_id']); +const IMMUTABLE_UPDATE_COLUMNS = new Set(['id', 'tenant_id', 'workspace_id']); + +function assertNonEmptyId(value: string, name: string): void { + if (!value.trim()) { + throw new Error(`${name} is required`); + } +} + +function assertIdentifier(value: string, label: string): void { + if (!IDENTIFIER_RE.test(value)) { + throw new Error(`Invalid ${label}: ${value}`); + } +} + +function assertWorkspaceScopedTable(table: string): asserts table is EnterpriseWorkspaceScopedTable { + if (!(ENTERPRISE_WORKSPACE_SCOPED_TABLES as readonly string[]).includes(table)) { + throw new Error(`Table is not workspace-scoped: ${table}`); + } +} + +export function repositoryScopeFromRequestContext(context: RequestContext): EnterpriseRepositoryScope { + return { + tenantId: context.tenantId, + workspaceId: context.workspaceId, + userId: context.userId, + }; +} + +export function buildWorkspaceScopedWhere( + scope: EnterpriseRepositoryScope, + criteria: EnterpriseQueryCriteria = {}, +): ScopedWhereClause { + assertNonEmptyId(scope.tenantId, 'tenantId'); + assertNonEmptyId(scope.workspaceId, 'workspaceId'); + + const clauses = [ + 'tenant_id = @scopeTenantId', + 'workspace_id = @scopeWorkspaceId', + ]; + const params: Record = { + scopeTenantId: scope.tenantId, + scopeWorkspaceId: scope.workspaceId, + }; + + for (const [column, value] of Object.entries(criteria)) { + if (value === undefined) continue; + assertIdentifier(column, 'criteria column'); + if (SCOPE_COLUMNS.has(column)) { + throw new Error(`${column} must come from EnterpriseRepositoryScope`); + } + const paramName = `criteria_${column}`; + clauses.push(`${column} = @${paramName}`); + params[paramName] = value; + } + + return { + sql: clauses.join(' AND '), + params, + }; +} + +function buildOrderClause(options: ListOptions): string { + if (!options.orderBy) return ''; + assertIdentifier(options.orderBy, 'orderBy column'); + const direction = options.direction ?? 'ASC'; + return ` ORDER BY ${options.orderBy} ${direction}`; +} + +function buildLimitClause(options: ListOptions): string { + if (options.limit === undefined) return ''; + if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1000) { + throw new Error('limit must be an integer between 1 and 1000'); + } + return ` LIMIT ${options.limit}`; +} + +function normalizeUpdateValues(values: EnterpriseUpdateValues): { + assignments: string[]; + params: Record; +} { + const assignments: string[] = []; + const params: Record = {}; + + for (const [column, value] of Object.entries(values)) { + if (value === undefined) continue; + assertIdentifier(column, 'update column'); + if (IMMUTABLE_UPDATE_COLUMNS.has(column)) { + throw new Error(`${column} cannot be updated through a scoped repository`); + } + const paramName = `update_${column}`; + assignments.push(`${column} = @${paramName}`); + params[paramName] = value; + } + + if (assignments.length === 0) { + throw new Error('At least one update value is required'); + } + + return { assignments, params }; +} + +export class EnterpriseWorkspaceRepository> { + constructor( + private readonly db: Database.Database, + private readonly table: EnterpriseWorkspaceScopedTable, + ) { + assertWorkspaceScopedTable(table); + } + + getById(scope: EnterpriseRepositoryScope, id: string): Row | null { + assertNonEmptyId(id, 'id'); + const where = buildWorkspaceScopedWhere(scope, { id }); + return this.db.prepare(` + SELECT * FROM ${this.table} + WHERE ${where.sql} + LIMIT 1 + `).get(where.params) as Row | undefined ?? null; + } + + list( + scope: EnterpriseRepositoryScope, + criteria: EnterpriseQueryCriteria = {}, + options: ListOptions = {}, + ): Row[] { + const where = buildWorkspaceScopedWhere(scope, criteria); + return this.db.prepare(` + SELECT * FROM ${this.table} + WHERE ${where.sql}${buildOrderClause(options)}${buildLimitClause(options)} + `).all(where.params) as Row[]; + } + + updateById(scope: EnterpriseRepositoryScope, id: string, values: EnterpriseUpdateValues): number { + assertNonEmptyId(id, 'id'); + const where = buildWorkspaceScopedWhere(scope, { id }); + const update = normalizeUpdateValues(values); + const result = this.db.prepare(` + UPDATE ${this.table} + SET ${update.assignments.join(', ')} + WHERE ${where.sql} + `).run({ + ...where.params, + ...update.params, + }); + return result.changes; + } + + deleteById(scope: EnterpriseRepositoryScope, id: string): number { + assertNonEmptyId(id, 'id'); + const where = buildWorkspaceScopedWhere(scope, { id }); + const result = this.db.prepare(` + DELETE FROM ${this.table} + WHERE ${where.sql} + `).run(where.params); + return result.changes; + } +} + +export function createEnterpriseWorkspaceRepository>( + db: Database.Database, + table: EnterpriseWorkspaceScopedTable, +): EnterpriseWorkspaceRepository { + return new EnterpriseWorkspaceRepository(db, table); +} diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 0b5b779d..c77b0ecc 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -39,7 +39,7 @@ - [x] 2.8 单元测试覆盖:RequestContext 解析 / RBAC / owner guard / 旧路径包装 ### 0.3 主线 B:存储与持久化(§18) -- [ ] 3.1 选 SQLite WAL 还是单 Postgres,落地 ADR;建立 repository 抽象(默认追加 tenantId/workspaceId filter) +- [x] 3.1 选 SQLite WAL 还是单 Postgres,落地 ADR;建立 repository 抽象(默认追加 tenantId/workspaceId filter) - [ ] 3.2 实现 §10.2 全部核心表 + 索引 + migration(含 audit / tombstone) - [ ] 3.3 trace metadata 入 DB;trace 文件迁到 `data/{tenantId}/{workspaceId}/traces/` - [ ] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) @@ -137,6 +137,7 @@ - [x] `docs/features/enterprise-multi-tenant/agent-goal.md`(AI 接手用 self-contained goal prompt) - [x] `docs/features/enterprise-multi-tenant/baseline.md`(0.0 baseline 命令与实测结果) - [ ] (新增 ADR / 设计 / runbook 在此追加,例如:`docs/adr/0001-enterprise-db-choice.md` …) +- [x] `docs/features/enterprise-multi-tenant/adr-0001-sqlite-wal-repository.md`(§0.3.1 SQLite WAL + repository abstraction 决策) ### 0.10 PR / 提交收尾(每次 PR 都要走) - [ ] 每个主线 / 子主线一个独立 PR;不跨主线串改动 diff --git a/docs/features/enterprise-multi-tenant/adr-0001-sqlite-wal-repository.md b/docs/features/enterprise-multi-tenant/adr-0001-sqlite-wal-repository.md new file mode 100644 index 00000000..9ac72b8b --- /dev/null +++ b/docs/features/enterprise-multi-tenant/adr-0001-sqlite-wal-repository.md @@ -0,0 +1,81 @@ +# ADR-0001: Enterprise v1 Storage Uses SQLite WAL Behind Repository Abstractions + +## Status + +Accepted. + +## Date + +2026-05-08 + +## Context + +SmartPerfetto enterprise v1 targets about 100 internal users, 30-50 concurrent +online users, and 5-15 running analysis runs on a single node or a small-node +deployment. The main feature document explicitly rules out Redis, NATS, Vault +HA, Postgres HA, independent API gateways, and multi-pod stateless scale-out for +the first phase. + +The storage decision in §0.3.1 is between SQLite WAL and a single Postgres +instance. The user decision for this milestone is: SQLite WAL plus repository +abstractions. + +## Decision + +Use SQLite WAL as the authoritative enterprise metadata store for v1. + +The backend will keep using `better-sqlite3` with: + +- `PRAGMA journal_mode = WAL` +- `PRAGMA busy_timeout = 5000` +- `PRAGMA foreign_keys = ON` + +All new enterprise workspace-scoped metadata access must go through repository +abstractions that take an explicit scope: + +```ts +interface EnterpriseRepositoryScope { + tenantId: string; + workspaceId: string; + userId?: string; +} +``` + +Workspace-scoped repositories must append both filters by default: + +```sql +tenant_id = @scopeTenantId AND workspace_id = @scopeWorkspaceId +``` + +Callers may add resource predicates such as `id` or `status`, but they must not +override `tenant_id` or `workspace_id` through ad hoc criteria. This keeps owner +guard behavior in the data access layer instead of relying only on route-level +checks. + +## Scope + +This ADR settles the v1 storage engine and establishes the repository boundary. +It does not complete every table in §10.2 and does not migrate trace/report/ +provider/memory data by itself. Those remain separate §0.3 tasks. + +## Consequences + +Benefits: + +- Minimal new operational dependency for local, source, and Docker users. +- Matches current single-node target while still supporting durable metadata. +- WAL mode gives concurrent readers and one writer, which fits current write + volume and queue-shadow usage. +- A typed repository boundary gives future Postgres migration a real seam. + +Tradeoffs: + +- SQLite is not the final shape for multi-region or high-availability SaaS. +- Synchronous `better-sqlite3` writes can block the Node event loop if write + volume grows beyond the v1 target. +- Cross-process writer coordination remains intentionally out of scope for v1. + +Future unlock: + +- A Postgres adapter can implement the same repository contracts when the + deployment target moves to multi-node API workers or HA requirements.