BREAKING CHANGE(localtsmdb): add Unix socket support and change LocalTsmDb API to return connection info instead of a MongoClient

This commit is contained in:
2026-02-03 16:42:49 +00:00
parent e6a36ecb5f
commit 09f60de56f
7 changed files with 171 additions and 76 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartmongo',
version: '4.3.0',
version: '5.0.0',
description: 'A module for creating and managing a local MongoDB instance for testing purposes.'
}

View File

@@ -1,98 +1,106 @@
import * as plugins from './plugins.js';
import * as crypto from 'crypto';
import * as path from 'path';
import * as os from 'os';
import { TsmdbServer } from '../ts_tsmdb/index.js';
import type { MongoClient } from 'mongodb';
/**
* Connection information returned by LocalTsmDb.start()
*/
export interface ILocalTsmDbConnectionInfo {
/** The Unix socket file path */
socketPath: string;
/** MongoDB connection URI ready for MongoClient */
connectionUri: string;
}
export interface ILocalTsmDbOptions {
/** Required: where to store data */
folderPath: string;
port?: number;
host?: string;
/** Optional: custom socket path (default: auto-generated in /tmp) */
socketPath?: string;
}
/**
* LocalTsmDb - Convenience class for local MongoDB-compatible database
* LocalTsmDb - Lightweight local MongoDB-compatible database using Unix sockets
*
* This class wraps TsmdbServer and provides a simple interface for
* starting a local file-based MongoDB-compatible server and connecting to it.
* starting a local file-based MongoDB-compatible server. Returns connection
* info that you can use with your own MongoDB driver instance.
*
* @example
* ```typescript
* import { LocalTsmDb } from '@push.rocks/smartmongo';
* import { MongoClient } from 'mongodb';
*
* const db = new LocalTsmDb({ folderPath: './data' });
* const client = await db.start();
* const { connectionUri } = await db.start();
*
* // Connect with your own MongoDB client
* const client = new MongoClient(connectionUri, { directConnection: true });
* await client.connect();
*
* // Use the MongoDB client
* const collection = client.db('mydb').collection('users');
* await collection.insertOne({ name: 'Alice' });
*
* // When done
* await client.close();
* await db.stop();
* ```
*/
export class LocalTsmDb {
private options: ILocalTsmDbOptions;
private server: TsmdbServer | null = null;
private client: MongoClient | null = null;
private generatedSocketPath: string | null = null;
constructor(options: ILocalTsmDbOptions) {
this.options = options;
}
/**
* Find an available port starting from the given port
* Generate a unique socket path in /tmp
*/
private async findAvailablePort(startPort = 27017): Promise<number> {
return new Promise((resolve, reject) => {
const server = plugins.net.createServer();
server.listen(startPort, '127.0.0.1', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : startPort;
server.close(() => resolve(port));
});
server.on('error', () => {
this.findAvailablePort(startPort + 1).then(resolve).catch(reject);
});
});
private generateSocketPath(): string {
const randomId = crypto.randomBytes(8).toString('hex');
return path.join(os.tmpdir(), `smartmongo-${randomId}.sock`);
}
/**
* Start the local TsmDB server and return a connected MongoDB client
* Start the local TsmDB server and return connection info
*/
async start(): Promise<MongoClient> {
if (this.server && this.client) {
async start(): Promise<ILocalTsmDbConnectionInfo> {
if (this.server) {
throw new Error('LocalTsmDb is already running');
}
const port = this.options.port ?? await this.findAvailablePort();
const host = this.options.host ?? '127.0.0.1';
// Use provided socket path or generate one
this.generatedSocketPath = this.options.socketPath ?? this.generateSocketPath();
this.server = new TsmdbServer({
port,
host,
socketPath: this.generatedSocketPath,
storage: 'file',
storagePath: this.options.folderPath,
});
await this.server.start();
// Dynamically import mongodb to avoid requiring it as a hard dependency
const mongodb = await import('mongodb');
this.client = new mongodb.MongoClient(this.server.getConnectionUri(), {
directConnection: true,
serverSelectionTimeoutMS: 5000,
});
await this.client.connect();
return this.client;
return {
socketPath: this.generatedSocketPath,
connectionUri: this.server.getConnectionUri(),
};
}
/**
* Get the MongoDB client (throws if not started)
* Get connection info (throws if not started)
*/
getClient(): MongoClient {
if (!this.client) {
getConnectionInfo(): ILocalTsmDbConnectionInfo {
if (!this.server || !this.generatedSocketPath) {
throw new Error('LocalTsmDb is not running. Call start() first.');
}
return this.client;
return {
socketPath: this.generatedSocketPath,
connectionUri: this.server.getConnectionUri(),
};
}
/**
@@ -123,16 +131,13 @@ export class LocalTsmDb {
}
/**
* Stop the local TsmDB server and close the client connection
* Stop the local TsmDB server
*/
async stop(): Promise<void> {
if (this.client) {
await this.client.close();
this.client = null;
}
if (this.server) {
await this.server.stop();
this.server = null;
this.generatedSocketPath = null;
}
}
}

View File

@@ -1,2 +1,2 @@
export { LocalTsmDb } from './classes.localtsmdb.js';
export type { ILocalTsmDbOptions } from './classes.localtsmdb.js';
export type { ILocalTsmDbOptions, ILocalTsmDbConnectionInfo } from './classes.localtsmdb.js';

View File

@@ -1,4 +1,3 @@
import * as smartpromise from '@push.rocks/smartpromise';
import * as net from 'net';
export { smartpromise, net };
export { smartpromise };

View File

@@ -1,4 +1,5 @@
import * as net from 'net';
import * as fs from 'fs/promises';
import * as plugins from '../plugins.js';
import { WireProtocol, OP_QUERY } from './WireProtocol.js';
import { CommandRouter } from './CommandRouter.js';
@@ -10,10 +11,12 @@ import type { IStorageAdapter } from '../storage/IStorageAdapter.js';
* Server configuration options
*/
export interface ITsmdbServerOptions {
/** Port to listen on (default: 27017) */
/** Port to listen on (default: 27017) - ignored if socketPath is set */
port?: number;
/** Host to bind to (default: 127.0.0.1) */
/** Host to bind to (default: 127.0.0.1) - ignored if socketPath is set */
host?: string;
/** Unix socket path - if set, server listens on socket instead of TCP */
socketPath?: string;
/** Storage type: 'memory' or 'file' (default: 'memory') */
storage?: 'memory' | 'file';
/** Path for file storage (required if storage is 'file') */
@@ -54,7 +57,7 @@ interface IConnectionState {
* ```
*/
export class TsmdbServer {
private options: Required<ITsmdbServerOptions>;
private options: Required<Omit<ITsmdbServerOptions, 'socketPath'>> & { socketPath: string };
private server: net.Server | null = null;
private storage: IStorageAdapter;
private commandRouter: CommandRouter;
@@ -62,11 +65,14 @@ export class TsmdbServer {
private connectionIdCounter = 0;
private isRunning = false;
private startTime: Date = new Date();
private useSocket: boolean;
constructor(options: ITsmdbServerOptions = {}) {
this.useSocket = !!options.socketPath;
this.options = {
port: options.port ?? 27017,
host: options.host ?? '127.0.0.1',
socketPath: options.socketPath ?? '',
storage: options.storage ?? 'memory',
storagePath: options.storagePath ?? './data',
persistPath: options.persistPath ?? '',
@@ -119,6 +125,18 @@ export class TsmdbServer {
// Initialize storage
await this.storage.initialize();
// Clean up stale socket file if using Unix socket
if (this.useSocket && this.options.socketPath) {
try {
await fs.unlink(this.options.socketPath);
} catch (err: any) {
// Ignore ENOENT (file doesn't exist)
if (err.code !== 'ENOENT') {
throw err;
}
}
}
return new Promise((resolve, reject) => {
this.server = net.createServer((socket) => {
this.handleConnection(socket);
@@ -132,11 +150,21 @@ export class TsmdbServer {
}
});
this.server.listen(this.options.port, this.options.host, () => {
this.isRunning = true;
this.startTime = new Date();
resolve();
});
if (this.useSocket && this.options.socketPath) {
// Listen on Unix socket
this.server.listen(this.options.socketPath, () => {
this.isRunning = true;
this.startTime = new Date();
resolve();
});
} else {
// Listen on TCP
this.server.listen(this.options.port, this.options.host, () => {
this.isRunning = true;
this.startTime = new Date();
resolve();
});
}
});
}
@@ -161,9 +189,22 @@ export class TsmdbServer {
await this.storage.close();
return new Promise((resolve) => {
this.server!.close(() => {
this.server!.close(async () => {
this.isRunning = false;
this.server = null;
// Clean up socket file if using Unix socket
if (this.useSocket && this.options.socketPath) {
try {
await fs.unlink(this.options.socketPath);
} catch (err: any) {
// Ignore ENOENT (file doesn't exist)
if (err.code !== 'ENOENT') {
console.error('Failed to remove socket file:', err);
}
}
}
resolve();
});
});
@@ -275,9 +316,21 @@ export class TsmdbServer {
* Get the connection URI for this server
*/
getConnectionUri(): string {
if (this.useSocket && this.options.socketPath) {
// URL-encode the socket path (replace / with %2F)
const encodedPath = encodeURIComponent(this.options.socketPath);
return `mongodb://${encodedPath}`;
}
return `mongodb://${this.options.host}:${this.options.port}`;
}
/**
* Get the socket path (if using Unix socket mode)
*/
get socketPath(): string | undefined {
return this.useSocket ? this.options.socketPath : undefined;
}
/**
* Check if the server is running
*/