From 22e010c554367ca8beaca554486c2eaae680a815 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 5 Apr 2026 09:48:10 +0000 Subject: [PATCH] fix(rustdb-storage): detect stale hint files using data file size metadata and add restart persistence regression tests --- changelog.md | 8 + rust/crates/rustdb-storage/src/file.rs | 65 ++++--- rust/crates/rustdb-storage/src/keydir.rs | 21 ++- rust/crates/rustdb-storage/src/record.rs | 22 ++- rust/crates/rustdb-storage/src/validate.rs | 8 +- test/test.delete-persistence.ts | 191 +++++++++++++++++++++ test/test.header-recovery.ts | 126 ++++++++++++++ test/test.stale-sockets.ts | 82 +++++++++ test/test.unique-index.ts | 180 +++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- 10 files changed, 674 insertions(+), 31 deletions(-) create mode 100644 test/test.delete-persistence.ts create mode 100644 test/test.header-recovery.ts create mode 100644 test/test.stale-sockets.ts create mode 100644 test/test.unique-index.ts diff --git a/changelog.md b/changelog.md index 89edffd..eefb155 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-05 - 2.5.8 - fix(rustdb-storage) +detect stale hint files using data file size metadata and add restart persistence regression tests + +- Store the current data.rdb size in hint file headers and validate it on load to rebuild KeyDir when hints are stale or written in the old format. +- Persist updated hint metadata after compaction and shutdown to avoid missing appended tombstones after restart. +- Add validation reporting for stale hint files based on recorded versus actual data file size. +- Add regression tests covering delete persistence across restarts, missing hint recovery, stale socket cleanup, and unique index enforcement persistence. + ## 2026-04-05 - 2.5.7 - fix(repo) no changes to commit diff --git a/rust/crates/rustdb-storage/src/file.rs b/rust/crates/rustdb-storage/src/file.rs index 3ae8bf3..88ff590 100644 --- a/rust/crates/rustdb-storage/src/file.rs +++ b/rust/crates/rustdb-storage/src/file.rs @@ -178,7 +178,8 @@ impl CollectionState { tracing::warn!("compaction failed for {:?}: {e}", self.coll_dir); } else { // Persist hint file after successful compaction to prevent stale hints - if let Err(e) = self.keydir.persist_to_hint_file(&self.hint_path()) { + let current_size = self.data_file_size.load(Ordering::Relaxed); + if let Err(e) = self.keydir.persist_to_hint_file(&self.hint_path(), current_size) { tracing::warn!("failed to persist hint after compaction for {:?}: {e}", self.coll_dir); } } @@ -257,26 +258,47 @@ impl FileStorageAdapter { // Try loading from hint file first, fall back to data file scan let (keydir, dead_bytes, loaded_from_hint) = if hint_path.exists() && data_path.exists() { match KeyDir::load_from_hint_file(&hint_path) { - Ok(Some(kd)) => { - // Validate hint against actual data file - let hint_valid = kd.validate_against_data_file(&data_path, 16) - .unwrap_or(false); - if hint_valid { - debug!("loaded KeyDir from hint file: {:?}", hint_path); - let file_size = std::fs::metadata(&data_path) - .map(|m| m.len()) - .unwrap_or(FILE_HEADER_SIZE as u64); - let live_bytes: u64 = { - let mut total = 0u64; - kd.for_each(|_, e| total += e.record_len as u64); - total - }; - let dead = file_size.saturating_sub(FILE_HEADER_SIZE as u64).saturating_sub(live_bytes); - (kd, dead, true) - } else { - tracing::warn!("hint file {:?} is stale, rebuilding from data file", hint_path); + Ok(Some((kd, stored_size))) => { + let actual_size = std::fs::metadata(&data_path) + .map(|m| m.len()) + .unwrap_or(0); + + // Check if data.rdb changed since the hint was written. + // If stored_size is 0, this is an old-format hint without size tracking. + let size_matches = stored_size > 0 && stored_size == actual_size; + + if !size_matches { + // data.rdb size differs from hint snapshot — records were appended + // (inserts, tombstones) after the hint was written. Full scan required + // to pick up tombstones that would otherwise be invisible. + if stored_size == 0 { + debug!("hint file {:?} has no size tracking, rebuilding from data file", hint_path); + } else { + tracing::warn!( + "hint file {:?} is stale: data size changed ({} -> {}), rebuilding", + hint_path, stored_size, actual_size + ); + } let (kd, dead, _stats) = KeyDir::build_from_data_file(&data_path)?; (kd, dead, false) + } else { + // Size matches — validate entry integrity with spot-checks + let hint_valid = kd.validate_against_data_file(&data_path, 16) + .unwrap_or(false); + if hint_valid { + debug!("loaded KeyDir from hint file: {:?}", hint_path); + let live_bytes: u64 = { + let mut total = 0u64; + kd.for_each(|_, e| total += e.record_len as u64); + total + }; + let dead = actual_size.saturating_sub(FILE_HEADER_SIZE as u64).saturating_sub(live_bytes); + (kd, dead, true) + } else { + tracing::warn!("hint file {:?} failed validation, rebuilding from data file", hint_path); + let (kd, dead, _stats) = KeyDir::build_from_data_file(&data_path)?; + (kd, dead, false) + } } } _ => { @@ -510,10 +532,11 @@ impl StorageAdapter for FileStorageAdapter { handle.abort(); } - // Persist all KeyDir hint files + // Persist all KeyDir hint files with current data file sizes for entry in self.collections.iter() { let state = entry.value(); - let _ = state.keydir.persist_to_hint_file(&state.hint_path()); + let current_size = state.data_file_size.load(Ordering::Relaxed); + let _ = state.keydir.persist_to_hint_file(&state.hint_path(), current_size); } debug!("FileStorageAdapter closed"); Ok(()) diff --git a/rust/crates/rustdb-storage/src/keydir.rs b/rust/crates/rustdb-storage/src/keydir.rs index 1a63a1b..650460c 100644 --- a/rust/crates/rustdb-storage/src/keydir.rs +++ b/rust/crates/rustdb-storage/src/keydir.rs @@ -198,14 +198,17 @@ impl KeyDir { /// Persist the KeyDir to a hint file for fast restart. /// + /// `data_file_size` is the current size of data.rdb — stored in the hint header + /// so that on next load we can detect if data.rdb changed (stale hint). + /// /// Hint file format (after the 64-byte file header): /// For each entry: [key_len:u32 LE][key bytes][offset:u64 LE][record_len:u32 LE][value_len:u32 LE][timestamp:u64 LE] - pub fn persist_to_hint_file(&self, path: &Path) -> StorageResult<()> { + pub fn persist_to_hint_file(&self, path: &Path, data_file_size: u64) -> StorageResult<()> { let file = std::fs::File::create(path)?; let mut writer = BufWriter::new(file); - // Write file header - let hdr = FileHeader::new(FileType::Hint); + // Write file header with data_file_size for staleness detection + let hdr = FileHeader::new_hint(data_file_size); writer.write_all(&hdr.encode())?; // Write entries @@ -225,7 +228,9 @@ impl KeyDir { } /// Load a KeyDir from a hint file. Returns None if the file doesn't exist. - pub fn load_from_hint_file(path: &Path) -> StorageResult> { + /// Returns `(keydir, stored_data_file_size)` where `stored_data_file_size` is the + /// data.rdb size recorded when the hint was written (0 = old format, unknown). + pub fn load_from_hint_file(path: &Path) -> StorageResult> { if !path.exists() { return Ok(None); } @@ -254,6 +259,7 @@ impl KeyDir { ))); } + let stored_data_file_size = hdr.data_file_size; let keydir = KeyDir::new(); loop { @@ -292,7 +298,7 @@ impl KeyDir { ); } - Ok(Some(keydir)) + Ok(Some((keydir, stored_data_file_size))) } // ----------------------------------------------------------------------- @@ -517,9 +523,10 @@ mod tests { }, ); - kd.persist_to_hint_file(&hint_path).unwrap(); - let loaded = KeyDir::load_from_hint_file(&hint_path).unwrap().unwrap(); + kd.persist_to_hint_file(&hint_path, 12345).unwrap(); + let (loaded, stored_size) = KeyDir::load_from_hint_file(&hint_path).unwrap().unwrap(); + assert_eq!(stored_size, 12345); assert_eq!(loaded.len(), 2); let e1 = loaded.get("doc1").unwrap(); assert_eq!(e1.offset, 64); diff --git a/rust/crates/rustdb-storage/src/record.rs b/rust/crates/rustdb-storage/src/record.rs index 077032a..8cd92c0 100644 --- a/rust/crates/rustdb-storage/src/record.rs +++ b/rust/crates/rustdb-storage/src/record.rs @@ -79,6 +79,9 @@ pub struct FileHeader { pub file_type: FileType, pub flags: u32, pub created_ms: u64, + /// For hint files: the data.rdb file size at the time the hint was written. + /// Used to detect stale hints after ungraceful shutdown. 0 = unknown (old format). + pub data_file_size: u64, } impl FileHeader { @@ -89,6 +92,18 @@ impl FileHeader { file_type, flags: 0, created_ms: now_ms(), + data_file_size: 0, + } + } + + /// Create a new hint header that records the data file size. + pub fn new_hint(data_file_size: u64) -> Self { + Self { + version: FORMAT_VERSION, + file_type: FileType::Hint, + flags: 0, + created_ms: now_ms(), + data_file_size, } } @@ -100,7 +115,8 @@ impl FileHeader { buf[10] = self.file_type as u8; buf[11..15].copy_from_slice(&self.flags.to_le_bytes()); buf[15..23].copy_from_slice(&self.created_ms.to_le_bytes()); - // bytes 23..64 are reserved (zeros) + buf[23..31].copy_from_slice(&self.data_file_size.to_le_bytes()); + // bytes 31..64 are reserved (zeros) buf } @@ -127,11 +143,15 @@ impl FileHeader { let created_ms = u64::from_le_bytes([ buf[15], buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], ]); + let data_file_size = u64::from_le_bytes([ + buf[23], buf[24], buf[25], buf[26], buf[27], buf[28], buf[29], buf[30], + ]); Ok(Self { version, file_type, flags, created_ms, + data_file_size, }) } } diff --git a/rust/crates/rustdb-storage/src/validate.rs b/rust/crates/rustdb-storage/src/validate.rs index 150e944..9cc3774 100644 --- a/rust/crates/rustdb-storage/src/validate.rs +++ b/rust/crates/rustdb-storage/src/validate.rs @@ -295,7 +295,13 @@ fn validate_collection(db: &str, coll: &str, coll_dir: &Path) -> CollectionRepor // Validate hint file if present if hint_path.exists() { match KeyDir::load_from_hint_file(&hint_path) { - Ok(Some(hint_kd)) => { + Ok(Some((hint_kd, stored_size))) => { + if stored_size > 0 && stored_size != report.data_file_size { + report.errors.push(format!( + "hint file is stale: recorded data size {} but actual is {}", + stored_size, report.data_file_size + )); + } // Check for orphaned entries: keys in hint but not live in data hint_kd.for_each(|key, _entry| { if !live_ids.contains(key) { diff --git a/test/test.delete-persistence.ts b/test/test.delete-persistence.ts new file mode 100644 index 0000000..1bfe437 --- /dev/null +++ b/test/test.delete-persistence.ts @@ -0,0 +1,191 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartdb from '../ts/index.js'; +import { MongoClient, Db } from 'mongodb'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// --------------------------------------------------------------------------- +// Test: Deletes persist across restart (tombstone + hint staleness detection) +// Covers: append_tombstone to data.rdb, hint file data_file_size tracking, +// stale hint detection on restart +// --------------------------------------------------------------------------- + +let tmpDir: string; +let localDb: smartdb.LocalSmartDb; +let client: MongoClient; +let db: Db; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'smartdb-delete-test-')); +} + +function cleanTmpDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ============================================================================ +// Setup +// ============================================================================ + +tap.test('setup: start local db and insert documents', async () => { + tmpDir = makeTmpDir(); + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('deletetest'); + + const coll = db.collection('items'); + await coll.insertMany([ + { name: 'keep-1', value: 100 }, + { name: 'keep-2', value: 200 }, + { name: 'delete-me', value: 999 }, + { name: 'keep-3', value: 300 }, + ]); + const count = await coll.countDocuments(); + expect(count).toEqual(4); +}); + +// ============================================================================ +// Delete and verify +// ============================================================================ + +tap.test('delete-persistence: delete a document', async () => { + const coll = db.collection('items'); + const result = await coll.deleteOne({ name: 'delete-me' }); + expect(result.deletedCount).toEqual(1); + + const remaining = await coll.countDocuments(); + expect(remaining).toEqual(3); + + const deleted = await coll.findOne({ name: 'delete-me' }); + expect(deleted).toBeNull(); +}); + +// ============================================================================ +// Graceful restart: delete survives +// ============================================================================ + +tap.test('delete-persistence: graceful stop and restart', async () => { + await client.close(); + await localDb.stop(); // graceful — writes hint file + + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('deletetest'); +}); + +tap.test('delete-persistence: deleted doc stays deleted after graceful restart', async () => { + const coll = db.collection('items'); + const count = await coll.countDocuments(); + expect(count).toEqual(3); + + const deleted = await coll.findOne({ name: 'delete-me' }); + expect(deleted).toBeNull(); + + // The remaining docs are intact + const keep1 = await coll.findOne({ name: 'keep-1' }); + expect(keep1).toBeTruthy(); + expect(keep1!.value).toEqual(100); +}); + +// ============================================================================ +// Simulate ungraceful restart: delete after hint write, then restart +// The hint file data_file_size check should detect the stale hint +// ============================================================================ + +tap.test('delete-persistence: insert and delete more docs, then restart', async () => { + const coll = db.collection('items'); + + // Insert a new doc + await coll.insertOne({ name: 'temporary', value: 777 }); + expect(await coll.countDocuments()).toEqual(4); + + // Delete it + await coll.deleteOne({ name: 'temporary' }); + expect(await coll.countDocuments()).toEqual(3); + + const gone = await coll.findOne({ name: 'temporary' }); + expect(gone).toBeNull(); +}); + +tap.test('delete-persistence: stop and restart again', async () => { + await client.close(); + await localDb.stop(); + + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('deletetest'); +}); + +tap.test('delete-persistence: all deletes survived second restart', async () => { + const coll = db.collection('items'); + const count = await coll.countDocuments(); + expect(count).toEqual(3); + + // Both deletes are permanent + expect(await coll.findOne({ name: 'delete-me' })).toBeNull(); + expect(await coll.findOne({ name: 'temporary' })).toBeNull(); + + // Survivors intact + const names = (await coll.find({}).toArray()).map(d => d.name).sort(); + expect(names).toEqual(['keep-1', 'keep-2', 'keep-3']); +}); + +// ============================================================================ +// Delete all docs and verify empty after restart +// ============================================================================ + +tap.test('delete-persistence: delete all remaining docs', async () => { + const coll = db.collection('items'); + await coll.deleteMany({}); + expect(await coll.countDocuments()).toEqual(0); +}); + +tap.test('delete-persistence: restart with empty collection', async () => { + await client.close(); + await localDb.stop(); + + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('deletetest'); +}); + +tap.test('delete-persistence: collection is empty after restart', async () => { + const coll = db.collection('items'); + const count = await coll.countDocuments(); + expect(count).toEqual(0); +}); + +// ============================================================================ +// Cleanup +// ============================================================================ + +tap.test('delete-persistence: cleanup', async () => { + await client.close(); + await localDb.stop(); + cleanTmpDir(tmpDir); +}); + +export default tap.start(); diff --git a/test/test.header-recovery.ts b/test/test.header-recovery.ts new file mode 100644 index 0000000..7ad6007 --- /dev/null +++ b/test/test.header-recovery.ts @@ -0,0 +1,126 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartdb from '../ts/index.js'; +import { MongoClient, Db } from 'mongodb'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// --------------------------------------------------------------------------- +// Test: Missing data.rdb header recovery + startup logging +// Covers: ensure_data_header, BuildStats, info-level startup logging +// --------------------------------------------------------------------------- + +let tmpDir: string; +let localDb: smartdb.LocalSmartDb; +let client: MongoClient; +let db: Db; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'smartdb-header-test-')); +} + +function cleanTmpDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ============================================================================ +// Setup: create data, then corrupt it +// ============================================================================ + +tap.test('setup: start, insert data, stop', async () => { + tmpDir = makeTmpDir(); + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('headertest'); + + const coll = db.collection('docs'); + await coll.insertMany([ + { key: 'a', val: 1 }, + { key: 'b', val: 2 }, + { key: 'c', val: 3 }, + ]); + + await client.close(); + await localDb.stop(); +}); + +// ============================================================================ +// Delete hint file and restart: should rebuild from data.rdb scan +// ============================================================================ + +tap.test('header-recovery: delete hint file and restart', async () => { + // Find and delete hint files + const dbDir = path.join(tmpDir, 'headertest', 'docs'); + const hintPath = path.join(dbDir, 'keydir.hint'); + if (fs.existsSync(hintPath)) { + fs.unlinkSync(hintPath); + } + + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('headertest'); +}); + +tap.test('header-recovery: data intact after hint deletion', async () => { + const coll = db.collection('docs'); + const count = await coll.countDocuments(); + expect(count).toEqual(3); + + const a = await coll.findOne({ key: 'a' }); + expect(a!.val).toEqual(1); +}); + +// ============================================================================ +// Write new data after restart, stop, restart again +// ============================================================================ + +tap.test('header-recovery: write after hint-less restart', async () => { + const coll = db.collection('docs'); + await coll.insertOne({ key: 'd', val: 4 }); + expect(await coll.countDocuments()).toEqual(4); +}); + +tap.test('header-recovery: restart and verify all data', async () => { + await client.close(); + await localDb.stop(); + + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('headertest'); + + const coll = db.collection('docs'); + const count = await coll.countDocuments(); + expect(count).toEqual(4); + + const keys = (await coll.find({}).toArray()).map(d => d.key).sort(); + expect(keys).toEqual(['a', 'b', 'c', 'd']); +}); + +// ============================================================================ +// Cleanup +// ============================================================================ + +tap.test('header-recovery: cleanup', async () => { + await client.close(); + await localDb.stop(); + cleanTmpDir(tmpDir); +}); + +export default tap.start(); diff --git a/test/test.stale-sockets.ts b/test/test.stale-sockets.ts new file mode 100644 index 0000000..5e6c3c4 --- /dev/null +++ b/test/test.stale-sockets.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartdb from '../ts/index.js'; +import * as fs from 'fs'; +import * as net from 'net'; +import * as path from 'path'; +import * as os from 'os'; + +// --------------------------------------------------------------------------- +// Test: Stale socket cleanup on startup +// Covers: LocalSmartDb.cleanStaleSockets(), isSocketAlive() +// --------------------------------------------------------------------------- + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'smartdb-socket-test-')); +} + +function cleanTmpDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ============================================================================ +// Stale socket cleanup: active sockets are preserved +// ============================================================================ + +tap.test('stale-sockets: does not remove active sockets', async () => { + const tmpDir = makeTmpDir(); + const activeSocketPath = path.join(os.tmpdir(), `smartdb-active-${Date.now()}.sock`); + + // Create an active socket (server still listening) + const activeServer = net.createServer(); + await new Promise((resolve) => activeServer.listen(activeSocketPath, resolve)); + + expect(fs.existsSync(activeSocketPath)).toBeTrue(); + + // Start LocalSmartDb — should NOT remove the active socket + const localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + await localDb.start(); + + expect(fs.existsSync(activeSocketPath)).toBeTrue(); + + // Cleanup + await localDb.stop(); + await new Promise((resolve) => activeServer.close(() => resolve())); + try { fs.unlinkSync(activeSocketPath); } catch {} + cleanTmpDir(tmpDir); +}); + +// ============================================================================ +// Stale socket cleanup: startup works with no stale sockets +// ============================================================================ + +tap.test('stale-sockets: startup works cleanly with no stale sockets', async () => { + const tmpDir = makeTmpDir(); + const localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + expect(localDb.running).toBeTrue(); + expect(info.socketPath).toBeTruthy(); + await localDb.stop(); + cleanTmpDir(tmpDir); +}); + +// ============================================================================ +// Stale socket cleanup: the socket file for the current instance is cleaned on stop +// ============================================================================ + +tap.test('stale-sockets: own socket file is removed on stop', async () => { + const tmpDir = makeTmpDir(); + const localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + + expect(fs.existsSync(info.socketPath)).toBeTrue(); + + await localDb.stop(); + + // Socket file should be gone after graceful stop + expect(fs.existsSync(info.socketPath)).toBeFalse(); + cleanTmpDir(tmpDir); +}); + +export default tap.start(); diff --git a/test/test.unique-index.ts b/test/test.unique-index.ts new file mode 100644 index 0000000..c71388a --- /dev/null +++ b/test/test.unique-index.ts @@ -0,0 +1,180 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartdb from '../ts/index.js'; +import { MongoClient, Db } from 'mongodb'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// --------------------------------------------------------------------------- +// Test: Unique index enforcement via wire protocol +// Covers: unique index pre-check, createIndexes persistence, index restoration +// --------------------------------------------------------------------------- + +let tmpDir: string; +let localDb: smartdb.LocalSmartDb; +let client: MongoClient; +let db: Db; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'smartdb-unique-test-')); +} + +function cleanTmpDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ============================================================================ +// Setup +// ============================================================================ + +tap.test('setup: start local db', async () => { + tmpDir = makeTmpDir(); + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('uniquetest'); +}); + +// ============================================================================ +// Unique index enforcement on insert +// ============================================================================ + +tap.test('unique-index: createIndex with unique: true', async () => { + const coll = db.collection('users'); + await coll.insertOne({ email: 'alice@example.com', name: 'Alice' }); + const indexName = await coll.createIndex({ email: 1 }, { unique: true }); + expect(indexName).toBeTruthy(); +}); + +tap.test('unique-index: reject duplicate on insertOne', async () => { + const coll = db.collection('users'); + let threw = false; + try { + await coll.insertOne({ email: 'alice@example.com', name: 'Alice2' }); + } catch (err: any) { + threw = true; + expect(err.code).toEqual(11000); + } + expect(threw).toBeTrue(); + + // Verify only 1 document exists + const count = await coll.countDocuments(); + expect(count).toEqual(1); +}); + +tap.test('unique-index: allow insert with different unique value', async () => { + const coll = db.collection('users'); + await coll.insertOne({ email: 'bob@example.com', name: 'Bob' }); + const count = await coll.countDocuments(); + expect(count).toEqual(2); +}); + +// ============================================================================ +// Unique index enforcement on update +// ============================================================================ + +tap.test('unique-index: reject duplicate on updateOne that changes unique field', async () => { + const coll = db.collection('users'); + let threw = false; + try { + await coll.updateOne( + { email: 'bob@example.com' }, + { $set: { email: 'alice@example.com' } } + ); + } catch (err: any) { + threw = true; + expect(err.code).toEqual(11000); + } + expect(threw).toBeTrue(); + + // Bob's email should be unchanged + const bob = await coll.findOne({ name: 'Bob' }); + expect(bob!.email).toEqual('bob@example.com'); +}); + +tap.test('unique-index: allow update that keeps same unique value', async () => { + const coll = db.collection('users'); + await coll.updateOne( + { email: 'bob@example.com' }, + { $set: { name: 'Robert' } } + ); + const bob = await coll.findOne({ email: 'bob@example.com' }); + expect(bob!.name).toEqual('Robert'); +}); + +// ============================================================================ +// Unique index enforcement on upsert +// ============================================================================ + +tap.test('unique-index: reject duplicate on upsert insert', async () => { + const coll = db.collection('users'); + let threw = false; + try { + await coll.updateOne( + { email: 'new@example.com' }, + { $set: { email: 'alice@example.com', name: 'Imposter' } }, + { upsert: true } + ); + } catch (err: any) { + threw = true; + } + expect(threw).toBeTrue(); +}); + +// ============================================================================ +// Unique index survives restart (persistence + restoration) +// ============================================================================ + +tap.test('unique-index: stop and restart', async () => { + await client.close(); + await localDb.stop(); + + localDb = new smartdb.LocalSmartDb({ folderPath: tmpDir }); + const info = await localDb.start(); + client = new MongoClient(info.connectionUri, { + directConnection: true, + serverSelectionTimeoutMS: 5000, + }); + await client.connect(); + db = client.db('uniquetest'); +}); + +tap.test('unique-index: enforcement persists after restart', async () => { + const coll = db.collection('users'); + + // Data should still be there + const count = await coll.countDocuments(); + expect(count).toEqual(2); + + // Unique constraint should still be enforced without calling createIndex again + let threw = false; + try { + await coll.insertOne({ email: 'alice@example.com', name: 'Alice3' }); + } catch (err: any) { + threw = true; + expect(err.code).toEqual(11000); + } + expect(threw).toBeTrue(); + + // Count unchanged + const countAfter = await coll.countDocuments(); + expect(countAfter).toEqual(2); +}); + +// ============================================================================ +// Cleanup +// ============================================================================ + +tap.test('unique-index: cleanup', async () => { + await client.close(); + await localDb.stop(); + cleanTmpDir(tmpDir); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ce3465a..f8cca68 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartdb', - version: '2.5.7', + version: '2.5.8', description: 'A MongoDB-compatible embedded database server with wire protocol support, backed by a high-performance Rust engine.' }