270 lines
8.8 KiB
TypeScript
270 lines
8.8 KiB
TypeScript
|
|
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';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
let tmpDir: string;
|
||
|
|
|
||
|
|
function makeTmpDir(): string {
|
||
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'smartdb-migration-test-'));
|
||
|
|
}
|
||
|
|
|
||
|
|
function cleanTmpDir(dir: string): void {
|
||
|
|
if (fs.existsSync(dir)) {
|
||
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a v0 (legacy JSON) storage layout:
|
||
|
|
* {base}/{db}/{coll}.json
|
||
|
|
* {base}/{db}/{coll}.indexes.json
|
||
|
|
*/
|
||
|
|
function createV0Layout(basePath: string, dbName: string, collName: string, docs: any[]): void {
|
||
|
|
const dbDir = path.join(basePath, dbName);
|
||
|
|
fs.mkdirSync(dbDir, { recursive: true });
|
||
|
|
|
||
|
|
// Convert docs to the extended JSON format that the old Rust engine wrote:
|
||
|
|
// ObjectId is stored as { "$oid": "hex" }
|
||
|
|
const jsonDocs = docs.map(doc => {
|
||
|
|
const clone = { ...doc };
|
||
|
|
if (!clone._id) {
|
||
|
|
// Generate a fake ObjectId-like hex string
|
||
|
|
const hex = [...Array(24)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||
|
|
clone._id = { '$oid': hex };
|
||
|
|
}
|
||
|
|
return clone;
|
||
|
|
});
|
||
|
|
|
||
|
|
const collPath = path.join(dbDir, `${collName}.json`);
|
||
|
|
fs.writeFileSync(collPath, JSON.stringify(jsonDocs, null, 2));
|
||
|
|
|
||
|
|
const indexPath = path.join(dbDir, `${collName}.indexes.json`);
|
||
|
|
fs.writeFileSync(indexPath, JSON.stringify([
|
||
|
|
{ name: '_id_', key: { _id: 1 } },
|
||
|
|
], null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Migration: v0 → v1 basic
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
tap.test('migration: detects v0 format and migrates on startup', async () => {
|
||
|
|
tmpDir = makeTmpDir();
|
||
|
|
|
||
|
|
// Create v0 layout with test data
|
||
|
|
createV0Layout(tmpDir, 'mydb', 'users', [
|
||
|
|
{ name: 'Alice', age: 30, email: 'alice@test.com' },
|
||
|
|
{ name: 'Bob', age: 25, email: 'bob@test.com' },
|
||
|
|
{ name: 'Charlie', age: 35, email: 'charlie@test.com' },
|
||
|
|
]);
|
||
|
|
|
||
|
|
createV0Layout(tmpDir, 'mydb', 'products', [
|
||
|
|
{ sku: 'W001', name: 'Widget', price: 9.99 },
|
||
|
|
{ sku: 'G001', name: 'Gadget', price: 19.99 },
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Verify v0 files exist
|
||
|
|
expect(fs.existsSync(path.join(tmpDir, 'mydb', 'users.json'))).toBeTrue();
|
||
|
|
expect(fs.existsSync(path.join(tmpDir, 'mydb', 'products.json'))).toBeTrue();
|
||
|
|
|
||
|
|
// Start server — migration should run automatically
|
||
|
|
const server = new smartdb.SmartdbServer({
|
||
|
|
socketPath: path.join(os.tmpdir(), `smartdb-mig-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`),
|
||
|
|
storage: 'file',
|
||
|
|
storagePath: tmpDir,
|
||
|
|
});
|
||
|
|
await server.start();
|
||
|
|
|
||
|
|
// v1 directories should now exist
|
||
|
|
expect(fs.existsSync(path.join(tmpDir, 'mydb', 'users', 'data.rdb'))).toBeTrue();
|
||
|
|
expect(fs.existsSync(path.join(tmpDir, 'mydb', 'products', 'data.rdb'))).toBeTrue();
|
||
|
|
|
||
|
|
// v0 files should still exist (not deleted)
|
||
|
|
expect(fs.existsSync(path.join(tmpDir, 'mydb', 'users.json'))).toBeTrue();
|
||
|
|
expect(fs.existsSync(path.join(tmpDir, 'mydb', 'products.json'))).toBeTrue();
|
||
|
|
|
||
|
|
// Connect and verify data is accessible
|
||
|
|
const client = new MongoClient(server.getConnectionUri(), {
|
||
|
|
directConnection: true,
|
||
|
|
serverSelectionTimeoutMS: 5000,
|
||
|
|
});
|
||
|
|
await client.connect();
|
||
|
|
const db = client.db('mydb');
|
||
|
|
|
||
|
|
// Users collection
|
||
|
|
const users = await db.collection('users').find({}).toArray();
|
||
|
|
expect(users.length).toEqual(3);
|
||
|
|
const alice = users.find(u => u.name === 'Alice');
|
||
|
|
expect(alice).toBeTruthy();
|
||
|
|
expect(alice!.age).toEqual(30);
|
||
|
|
expect(alice!.email).toEqual('alice@test.com');
|
||
|
|
|
||
|
|
// Products collection
|
||
|
|
const products = await db.collection('products').find({}).toArray();
|
||
|
|
expect(products.length).toEqual(2);
|
||
|
|
const widget = products.find(p => p.sku === 'W001');
|
||
|
|
expect(widget).toBeTruthy();
|
||
|
|
expect(widget!.price).toEqual(9.99);
|
||
|
|
|
||
|
|
await client.close();
|
||
|
|
await server.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Migration: migrated data survives another restart
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
tap.test('migration: migrated data persists across restart', async () => {
|
||
|
|
const server = new smartdb.SmartdbServer({
|
||
|
|
socketPath: path.join(os.tmpdir(), `smartdb-mig-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`),
|
||
|
|
storage: 'file',
|
||
|
|
storagePath: tmpDir,
|
||
|
|
});
|
||
|
|
await server.start();
|
||
|
|
|
||
|
|
const client = new MongoClient(server.getConnectionUri(), {
|
||
|
|
directConnection: true,
|
||
|
|
serverSelectionTimeoutMS: 5000,
|
||
|
|
});
|
||
|
|
await client.connect();
|
||
|
|
const db = client.db('mydb');
|
||
|
|
|
||
|
|
const users = await db.collection('users').find({}).toArray();
|
||
|
|
expect(users.length).toEqual(3);
|
||
|
|
|
||
|
|
const products = await db.collection('products').find({}).toArray();
|
||
|
|
expect(products.length).toEqual(2);
|
||
|
|
|
||
|
|
await client.close();
|
||
|
|
await server.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Migration: can write new data after migration
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
tap.test('migration: new writes work after migration', async () => {
|
||
|
|
const server = new smartdb.SmartdbServer({
|
||
|
|
socketPath: path.join(os.tmpdir(), `smartdb-mig-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`),
|
||
|
|
storage: 'file',
|
||
|
|
storagePath: tmpDir,
|
||
|
|
});
|
||
|
|
await server.start();
|
||
|
|
|
||
|
|
const client = new MongoClient(server.getConnectionUri(), {
|
||
|
|
directConnection: true,
|
||
|
|
serverSelectionTimeoutMS: 5000,
|
||
|
|
});
|
||
|
|
await client.connect();
|
||
|
|
const db = client.db('mydb');
|
||
|
|
|
||
|
|
// Insert new documents
|
||
|
|
await db.collection('users').insertOne({ name: 'Diana', age: 28 });
|
||
|
|
const count = await db.collection('users').countDocuments();
|
||
|
|
expect(count).toEqual(4);
|
||
|
|
|
||
|
|
// Update existing migrated document
|
||
|
|
await db.collection('users').updateOne(
|
||
|
|
{ name: 'Alice' },
|
||
|
|
{ $set: { age: 31 } }
|
||
|
|
);
|
||
|
|
const alice = await db.collection('users').findOne({ name: 'Alice' });
|
||
|
|
expect(alice!.age).toEqual(31);
|
||
|
|
|
||
|
|
// Delete a migrated document
|
||
|
|
await db.collection('products').deleteOne({ sku: 'G001' });
|
||
|
|
const prodCount = await db.collection('products').countDocuments();
|
||
|
|
expect(prodCount).toEqual(1);
|
||
|
|
|
||
|
|
await client.close();
|
||
|
|
await server.stop();
|
||
|
|
cleanTmpDir(tmpDir);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Migration: skips already-migrated data
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
tap.test('migration: no-op for v1 format', async () => {
|
||
|
|
tmpDir = makeTmpDir();
|
||
|
|
|
||
|
|
// Start fresh to create v1 layout
|
||
|
|
const server = new smartdb.SmartdbServer({
|
||
|
|
socketPath: path.join(os.tmpdir(), `smartdb-mig-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`),
|
||
|
|
storage: 'file',
|
||
|
|
storagePath: tmpDir,
|
||
|
|
});
|
||
|
|
await server.start();
|
||
|
|
|
||
|
|
const client = new MongoClient(server.getConnectionUri(), {
|
||
|
|
directConnection: true,
|
||
|
|
serverSelectionTimeoutMS: 5000,
|
||
|
|
});
|
||
|
|
await client.connect();
|
||
|
|
const db = client.db('v1test');
|
||
|
|
await db.collection('items').insertOne({ x: 1 });
|
||
|
|
await client.close();
|
||
|
|
await server.stop();
|
||
|
|
|
||
|
|
// Restart — migration should detect v1 and skip
|
||
|
|
const server2 = new smartdb.SmartdbServer({
|
||
|
|
socketPath: path.join(os.tmpdir(), `smartdb-mig-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`),
|
||
|
|
storage: 'file',
|
||
|
|
storagePath: tmpDir,
|
||
|
|
});
|
||
|
|
await server2.start();
|
||
|
|
|
||
|
|
const client2 = new MongoClient(server2.getConnectionUri(), {
|
||
|
|
directConnection: true,
|
||
|
|
serverSelectionTimeoutMS: 5000,
|
||
|
|
});
|
||
|
|
await client2.connect();
|
||
|
|
const db2 = client2.db('v1test');
|
||
|
|
const doc = await db2.collection('items').findOne({ x: 1 });
|
||
|
|
expect(doc).toBeTruthy();
|
||
|
|
|
||
|
|
await client2.close();
|
||
|
|
await server2.stop();
|
||
|
|
cleanTmpDir(tmpDir);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Migration: empty storage is handled gracefully
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
tap.test('migration: empty storage directory works', async () => {
|
||
|
|
tmpDir = makeTmpDir();
|
||
|
|
|
||
|
|
const server = new smartdb.SmartdbServer({
|
||
|
|
socketPath: path.join(os.tmpdir(), `smartdb-mig-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`),
|
||
|
|
storage: 'file',
|
||
|
|
storagePath: tmpDir,
|
||
|
|
});
|
||
|
|
await server.start();
|
||
|
|
|
||
|
|
const client = new MongoClient(server.getConnectionUri(), {
|
||
|
|
directConnection: true,
|
||
|
|
serverSelectionTimeoutMS: 5000,
|
||
|
|
});
|
||
|
|
await client.connect();
|
||
|
|
|
||
|
|
// Should work fine with empty storage
|
||
|
|
const db = client.db('emptytest');
|
||
|
|
await db.collection('first').insertOne({ hello: 'world' });
|
||
|
|
const doc = await db.collection('first').findOne({ hello: 'world' });
|
||
|
|
expect(doc).toBeTruthy();
|
||
|
|
|
||
|
|
await client.close();
|
||
|
|
await server.stop();
|
||
|
|
cleanTmpDir(tmpDir);
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|