Files
ruvnet__ruflo/tests/rvf-migration.test.ts
rUv dc8463872d feat(ADR-057): RVF native storage with security hardening (#1244)
* docs: add ADR-057 — RVF native storage backend to replace sql.js

Proposes replacing the 18MB sql.js WASM SQLite dependency with
RuVector Format (RVF) across @claude-flow/shared, memory, and
embeddings packages. Includes automatic and manual migration paths
for existing .db and .json files with full backward compatibility.

Key benefits: -18MB install size, 150x-12,500x faster vector search
via native HNSW, quantization support, crash-safe append-only format.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: extend ADR-057 with RVF embedding provider, ruvLLM persistence, and progressive download

Add three major sections to ADR-057:

- Section 3A: RVF as 5th EmbeddingProvider (52KB WASM replaces 540MB
  agentic-flow for basic use cases). Updated auto-select hierarchy:
  rvf > agentic-flow > transformers > mock.

- Section 3B: ruvLLM learning persistence via RVF segments. SONA
  patterns (ReasoningBank), LoRA adapters, and EWC++ weights now
  survive process restarts. RuVectorProvider.searchMemory works
  offline via RVF without HTTP server.

- Section 10: Progressive download architecture. Replaces npm
  optionalDependencies (all-or-nothing 1.3GB) with on-demand
  capability downloads stored as .rvf files. 5 phases from 22MB
  core CLI to full LLM/CNN/GNN/kernel capabilities.

Implementation plan expanded from 5 to 8 phases. Testing strategy
updated with 11 new test cases covering the additions.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat: add optimized Docker images for Ruflo lite/full/workers

- Dockerfile: Multi-stage Alpine build with aggressive dependency pruning.
  Result: 324MB image, 0.6s startup (vs 1.3GB/35s baseline).
  Uses --omit=optional + explicit rm -rf of heavy transitive deps.

- Dockerfile.full: Full image variant with all optional dependencies
  including native compilation (onnxruntime-node, better-sqlite3).

- Dockerfile.headless: Updated from node:20 to node:22, claude-flow
  branding to ruflo, same prune strategy as lite.

- docker-compose.yml: Three profiles (lite, full, workers) with
  appropriate resource limits and health checks.

- .dockerignore: Excludes node_modules, tests, source maps, IDE
  configs, and secrets from build context.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(ADR-057): implement RVF native storage backend across all packages

Replace sql.js (18MB WASM) with zero-dependency RVF format across
memory, events, embeddings, and learning subsystems. Pure TypeScript
fallback with optional native @ruvector/rvf acceleration.

New files:
- memory/rvf-backend.ts: IMemoryBackend impl with HNSW + binary persistence
- memory/hnsw-lite.ts: Lightweight HNSW graph for vector search
- memory/rvf-learning-store.ts: SONA pattern/LoRA/EWC/trajectory persistence
- memory/persistent-sona.ts: PersistentSonaCoordinator wrapping learning store
- shared/events/rvf-event-log.ts: Append-only event log (replaces EventStore)
- embeddings/rvf-embedding-cache.ts: Binary file embedding cache (FNV-1a keys)
- embeddings/rvf-embedding-service.ts: Hash-based embedding provider (<0.1ms)

Integrations:
- DatabaseProvider: 'rvf' added, auto-selected first (always available)
- EmbeddingProvider: 'rvf' added, auto-selected first in async factory
- All packages export new RVF types and classes

Tests: 163 passing (5 suites), validated in Docker (node:22-alpine)
- rvf-backend: 50 tests (CRUD, query, search, persistence, HNSW)
- rvf-event-log: 33 tests (append, replay, snapshots, crash recovery)
- rvf-embeddings: 30 tests (cache, service, determinism, normalization)
- rvf-learning-store: 31 tests (patterns, LoRA, EWC, trajectories)
- rvf-integration: 19 tests (provider selection, migration, concurrency)

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ADR-057): harden RVF storage with security, performance, and migration

Security: atomic writes (tmp+rename) across all backends, path validation,
input validation at boundaries. Performance: persist lock prevents concurrent
flushes, stack overflow fix for large datasets, timer.unref() prevents hangs,
O(1) eviction lookup in embedding cache. New: bidirectional RVF migration
utility (JSON/SQLite/RVF), format v2 with backward compat, auto-persist
patterns and trajectories before eviction. Added HnswLite and RvfMigrator
exports. 193 tests passing, 0 TS errors.

Refs #1242

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: doctor version detection and init settings warnings (#1235, #1230)

doctor: Replace unreliable path-walking heuristic with import.meta.url
traversal to find the correct package.json. Removes stale npx cache
scanning that could report wrong versions. Fixes #1235.

init: Remove invalid TeammateIdle and TaskCompleted keys from generated
.claude/settings.json hooks section. These are not recognized by Claude
Code and caused warnings. Config remains available under the claudeFlow
namespace. Fixes #1230.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(doctor): remove Windows-incompatible shell redirects, simplify root detection

- Remove fragile Windows root calculation (split on backslash); use
  dirname(dir) === dir instead which works on all platforms
- Remove 2>/dev/null from npm view command (fails on Windows cmd.exe);
  the catch block handles errors gracefully already

Co-Authored-By: claude-flow <ruv@ruv.net>

* security: harden RVF against crafted files and race conditions

- rvf-backend: validate dimensions (1-10000), validate RVF header
  structure and size before JSON.parse, guard persist interval
  against concurrent flushes
- rvf-embedding-cache: use random suffix for tmp files to prevent
  predictable-path attacks on multi-user systems
- rvf-event-log: add 100MB max payload size limit to prevent DoS
  from crafted event log files
- rvf-migration: add null byte and type validation on all paths

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-02-27 21:46:53 -05:00

242 lines
9.0 KiB
TypeScript

/**
* RVF Migration Utility Tests
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { RvfMigrator } from '../v3/@claude-flow/memory/src/rvf-migration.js';
import { RvfBackend } from '../v3/@claude-flow/memory/src/rvf-backend.js';
describe('RvfMigrator', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'rvf-mig-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe('detectFormat', () => {
it('detects RVF format by magic bytes', async () => {
const p = join(tmpDir, 'test.rvf');
// RVF magic: 0x52 0x56 0x46 0x00
writeFileSync(p, Buffer.from([0x52, 0x56, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00]));
assert.equal(await RvfMigrator.detectFormat(p), 'rvf');
});
it('detects JSON format', async () => {
const p = join(tmpDir, 'test.json');
writeFileSync(p, '[{"id":"1"}]');
assert.equal(await RvfMigrator.detectFormat(p), 'json');
});
it('detects JSON object format', async () => {
const p = join(tmpDir, 'test.json');
writeFileSync(p, '{"id":"1"}');
assert.equal(await RvfMigrator.detectFormat(p), 'json');
});
it('returns unknown for non-existent file', async () => {
assert.equal(await RvfMigrator.detectFormat(join(tmpDir, 'nope')), 'unknown');
});
it('returns unknown for unrecognized format', async () => {
const p = join(tmpDir, 'garbage.bin');
writeFileSync(p, Buffer.from([0xFF, 0xFE, 0xFD, 0xFC]));
assert.equal(await RvfMigrator.detectFormat(p), 'unknown');
});
});
describe('fromJsonFile', () => {
it('migrates a JSON array to RVF', async () => {
const jsonPath = join(tmpDir, 'source.json');
const rvfPath = join(tmpDir, 'target.rvf');
const entries = [
{ id: 'e1', key: 'k1', content: 'hello world', namespace: 'test', type: 'semantic', tags: ['a'], metadata: {} },
{ id: 'e2', key: 'k2', content: 'second entry', namespace: 'test', type: 'document', tags: ['b'], metadata: {} },
];
writeFileSync(jsonPath, JSON.stringify(entries));
const result = await RvfMigrator.fromJsonFile(jsonPath, rvfPath);
assert.equal(result.success, true);
assert.equal(result.entriesMigrated, 2);
assert.equal(result.sourceFormat, 'json');
assert.equal(result.targetFormat, 'rvf');
assert.equal(result.errors.length, 0);
assert.ok(result.durationMs >= 0);
// Verify the RVF file is readable
const backend = new RvfBackend({ databasePath: rvfPath });
await backend.initialize();
const e1 = await backend.get('e1');
assert.ok(e1);
assert.equal(e1.content, 'hello world');
await backend.shutdown();
});
it('migrates single JSON object (not array)', async () => {
const jsonPath = join(tmpDir, 'single.json');
const rvfPath = join(tmpDir, 'single.rvf');
writeFileSync(jsonPath, JSON.stringify({ id: 'solo', key: 'k', content: 'one entry', namespace: 'ns' }));
const result = await RvfMigrator.fromJsonFile(jsonPath, rvfPath);
assert.equal(result.success, true);
assert.equal(result.entriesMigrated, 1);
});
it('reports error on invalid JSON', async () => {
const jsonPath = join(tmpDir, 'bad.json');
const rvfPath = join(tmpDir, 'bad.rvf');
writeFileSync(jsonPath, 'not valid json{{{');
const result = await RvfMigrator.fromJsonFile(jsonPath, rvfPath);
assert.equal(result.success, false);
assert.equal(result.entriesMigrated, 0);
assert.ok(result.errors.length > 0);
});
it('fills in default fields for sparse entries', async () => {
const jsonPath = join(tmpDir, 'sparse.json');
const rvfPath = join(tmpDir, 'sparse.rvf');
writeFileSync(jsonPath, JSON.stringify([{ content: 'minimal' }]));
const result = await RvfMigrator.fromJsonFile(jsonPath, rvfPath);
assert.equal(result.success, true);
assert.equal(result.entriesMigrated, 1);
const backend = new RvfBackend({ databasePath: rvfPath });
await backend.initialize();
const entries = await backend.query({ type: 'hybrid', limit: 100 });
assert.equal(entries.length, 1);
assert.equal(entries[0].content, 'minimal');
assert.equal(entries[0].namespace, 'default');
assert.ok(entries[0].id); // auto-generated
await backend.shutdown();
});
it('reports progress via callback', async () => {
const jsonPath = join(tmpDir, 'progress.json');
const rvfPath = join(tmpDir, 'progress.rvf');
const entries = Array.from({ length: 10 }, (_, i) => ({
id: `e${i}`, key: `k${i}`, content: `content ${i}`,
}));
writeFileSync(jsonPath, JSON.stringify(entries));
const progressCalls: Array<{ current: number; total: number; phase: string }> = [];
await RvfMigrator.fromJsonFile(jsonPath, rvfPath, {
batchSize: 3,
onProgress: (p) => progressCalls.push({ ...p }),
});
assert.ok(progressCalls.length > 0);
assert.equal(progressCalls[progressCalls.length - 1].total, 10);
});
});
describe('toJsonFile', () => {
it('exports RVF to JSON and back', async () => {
const rvfPath = join(tmpDir, 'roundtrip.rvf');
const jsonPath = join(tmpDir, 'export.json');
// Create RVF with entries
const backend = new RvfBackend({ databasePath: rvfPath });
await backend.initialize();
await backend.store({
id: 'rt1', key: 'k1', content: 'roundtrip test', namespace: 'ns',
type: 'semantic', tags: ['rt'], metadata: {}, accessLevel: 'private',
createdAt: Date.now(), updatedAt: Date.now(), version: 1,
references: [], accessCount: 0, lastAccessedAt: Date.now(),
});
await backend.shutdown();
// Export to JSON
const result = await RvfMigrator.toJsonFile(rvfPath, jsonPath);
assert.equal(result.success, true);
assert.equal(result.entriesMigrated, 1);
assert.equal(result.sourceFormat, 'rvf');
assert.equal(result.targetFormat, 'json');
// Verify JSON file
const exported = JSON.parse(readFileSync(jsonPath, 'utf-8'));
assert.ok(Array.isArray(exported));
assert.equal(exported.length, 1);
assert.equal(exported[0].id, 'rt1');
assert.equal(exported[0].content, 'roundtrip test');
});
});
describe('autoMigrate', () => {
it('auto-detects JSON and migrates', async () => {
const jsonPath = join(tmpDir, 'auto.json');
const rvfPath = join(tmpDir, 'auto.rvf');
writeFileSync(jsonPath, JSON.stringify([
{ id: 'am1', key: 'k', content: 'auto migrate test' },
]));
const result = await RvfMigrator.autoMigrate(jsonPath, rvfPath);
assert.equal(result.success, true);
assert.equal(result.entriesMigrated, 1);
assert.equal(result.sourceFormat, 'json');
});
it('returns no-op for RVF source', async () => {
const rvfPath = join(tmpDir, 'existing.rvf');
const targetPath = join(tmpDir, 'target.rvf');
// Create a valid RVF file with at least one entry so it persists
const backend = new RvfBackend({ databasePath: rvfPath });
await backend.initialize();
await backend.store({
id: 'x', key: 'k', content: 'test', type: 'semantic', namespace: 'ns',
tags: [], metadata: {}, accessLevel: 'private',
createdAt: Date.now(), updatedAt: Date.now(), version: 1,
references: [], accessCount: 0, lastAccessedAt: Date.now(),
});
await backend.shutdown();
const result = await RvfMigrator.autoMigrate(rvfPath, targetPath);
assert.equal(result.success, true);
assert.equal(result.entriesMigrated, 0);
assert.equal(result.sourceFormat, 'rvf');
});
it('returns error for unknown format', async () => {
const result = await RvfMigrator.autoMigrate(join(tmpDir, 'nonexistent'), join(tmpDir, 'out.rvf'));
assert.equal(result.success, false);
assert.ok(result.errors.length > 0);
});
});
describe('embeddings migration', () => {
it('preserves embeddings through JSON roundtrip', async () => {
const jsonPath = join(tmpDir, 'emb.json');
const rvfPath = join(tmpDir, 'emb.rvf');
const jsonOutPath = join(tmpDir, 'emb-out.json');
const embedding = [0.1, 0.2, 0.3, 0.4];
writeFileSync(jsonPath, JSON.stringify([
{ id: 'emb1', key: 'k', content: 'with embedding', embedding },
]));
await RvfMigrator.fromJsonFile(jsonPath, rvfPath);
await RvfMigrator.toJsonFile(rvfPath, jsonOutPath);
const exported = JSON.parse(readFileSync(jsonOutPath, 'utf-8'));
assert.equal(exported.length, 1);
assert.ok(Array.isArray(exported[0].embedding));
// Check values are close (float32 precision)
for (let i = 0; i < embedding.length; i++) {
assert.ok(Math.abs(exported[0].embedding[i] - embedding[i]) < 0.001);
}
});
});
});