BREAKING CHANGE(tsmdb): rename CongoDB to TsmDB and relocate/rename wire-protocol server implementation and public exports

This commit is contained in:
2026-02-01 14:34:07 +00:00
parent 28e166ee35
commit a0df731bc0
32 changed files with 201 additions and 197 deletions

View File

@@ -0,0 +1,283 @@
import * as plugins from '../tsmdb.plugins.js';
import type { Document, IStoredDocument, IAggregateOptions } from '../types/interfaces.js';
// Import mingo Aggregator
import { Aggregator } from 'mingo';
/**
* Aggregation engine using mingo for MongoDB-compatible aggregation pipeline execution
*/
export class AggregationEngine {
/**
* Execute an aggregation pipeline on a collection of documents
*/
static aggregate(
documents: IStoredDocument[],
pipeline: Document[],
options?: IAggregateOptions
): Document[] {
if (!pipeline || pipeline.length === 0) {
return documents;
}
// Create mingo aggregator with the pipeline
const aggregator = new Aggregator(pipeline, {
collation: options?.collation as any,
});
// Run the aggregation
const result = aggregator.run(documents);
return Array.isArray(result) ? result : [];
}
/**
* Execute aggregation and return an iterator for lazy evaluation
*/
static *aggregateIterator(
documents: IStoredDocument[],
pipeline: Document[],
options?: IAggregateOptions
): Generator<Document> {
const aggregator = new Aggregator(pipeline, {
collation: options?.collation as any,
});
// Get the cursor from mingo
const cursor = aggregator.stream(documents);
for (const doc of cursor) {
yield doc;
}
}
/**
* Execute a $lookup stage manually (for cross-collection lookups)
* This is used when the lookup references another collection in the same database
*/
static executeLookup(
documents: IStoredDocument[],
lookupSpec: {
from: string;
localField: string;
foreignField: string;
as: string;
},
foreignCollection: IStoredDocument[]
): Document[] {
const { localField, foreignField, as } = lookupSpec;
return documents.map(doc => {
const localValue = this.getNestedValue(doc, localField);
const matches = foreignCollection.filter(foreignDoc => {
const foreignValue = this.getNestedValue(foreignDoc, foreignField);
return this.valuesMatch(localValue, foreignValue);
});
return {
...doc,
[as]: matches,
};
});
}
/**
* Execute a $graphLookup stage manually
*/
static executeGraphLookup(
documents: IStoredDocument[],
graphLookupSpec: {
from: string;
startWith: string | Document;
connectFromField: string;
connectToField: string;
as: string;
maxDepth?: number;
depthField?: string;
restrictSearchWithMatch?: Document;
},
foreignCollection: IStoredDocument[]
): Document[] {
const {
startWith,
connectFromField,
connectToField,
as,
maxDepth = 10,
depthField,
restrictSearchWithMatch,
} = graphLookupSpec;
return documents.map(doc => {
const startValue = typeof startWith === 'string' && startWith.startsWith('$')
? this.getNestedValue(doc, startWith.slice(1))
: startWith;
const results: Document[] = [];
const visited = new Set<string>();
const queue: Array<{ value: any; depth: number }> = [];
// Initialize with start value(s)
const startValues = Array.isArray(startValue) ? startValue : [startValue];
for (const val of startValues) {
queue.push({ value: val, depth: 0 });
}
while (queue.length > 0) {
const { value, depth } = queue.shift()!;
if (depth > maxDepth) continue;
const valueKey = JSON.stringify(value);
if (visited.has(valueKey)) continue;
visited.add(valueKey);
// Find matching documents
for (const foreignDoc of foreignCollection) {
const foreignValue = this.getNestedValue(foreignDoc, connectToField);
if (this.valuesMatch(value, foreignValue)) {
// Check restrictSearchWithMatch
if (restrictSearchWithMatch) {
const matchQuery = new plugins.mingo.Query(restrictSearchWithMatch);
if (!matchQuery.test(foreignDoc)) continue;
}
const resultDoc = depthField
? { ...foreignDoc, [depthField]: depth }
: { ...foreignDoc };
// Avoid duplicates in results
const docKey = foreignDoc._id.toHexString();
if (!results.some(r => r._id?.toHexString?.() === docKey)) {
results.push(resultDoc);
// Add connected values to queue
const nextValue = this.getNestedValue(foreignDoc, connectFromField);
if (nextValue !== undefined) {
const nextValues = Array.isArray(nextValue) ? nextValue : [nextValue];
for (const nv of nextValues) {
queue.push({ value: nv, depth: depth + 1 });
}
}
}
}
}
}
return {
...doc,
[as]: results,
};
});
}
/**
* Execute a $facet stage manually
*/
static executeFacet(
documents: IStoredDocument[],
facetSpec: Record<string, Document[]>
): Document {
const result: Document = {};
for (const [facetName, pipeline] of Object.entries(facetSpec)) {
result[facetName] = this.aggregate(documents, pipeline);
}
return result;
}
/**
* Execute a $unionWith stage
*/
static executeUnionWith(
documents: IStoredDocument[],
otherDocuments: IStoredDocument[],
pipeline?: Document[]
): Document[] {
let unionDocs: Document[] = otherDocuments;
if (pipeline && pipeline.length > 0) {
unionDocs = this.aggregate(otherDocuments, pipeline);
}
return [...documents, ...unionDocs];
}
/**
* Execute a $merge stage (output to another collection)
* Returns the documents that would be inserted/updated
*/
static prepareMerge(
documents: Document[],
mergeSpec: {
into: string;
on?: string | string[];
whenMatched?: 'replace' | 'keepExisting' | 'merge' | 'fail' | Document[];
whenNotMatched?: 'insert' | 'discard' | 'fail';
}
): {
toInsert: Document[];
toUpdate: Array<{ filter: Document; update: Document }>;
onField: string | string[];
whenMatched: string | Document[];
whenNotMatched: string;
} {
const onField = mergeSpec.on || '_id';
const whenMatched = mergeSpec.whenMatched || 'merge';
const whenNotMatched = mergeSpec.whenNotMatched || 'insert';
return {
toInsert: [],
toUpdate: [],
onField,
whenMatched,
whenNotMatched,
};
}
// ============================================================================
// Helper Methods
// ============================================================================
private static getNestedValue(obj: any, path: string): any {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
private static valuesMatch(a: any, b: any): boolean {
if (a === b) return true;
// Handle ObjectId comparison
if (a instanceof plugins.bson.ObjectId && b instanceof plugins.bson.ObjectId) {
return a.equals(b);
}
// Handle array contains check
if (Array.isArray(a)) {
return a.some(item => this.valuesMatch(item, b));
}
if (Array.isArray(b)) {
return b.some(item => this.valuesMatch(a, item));
}
// Handle Date comparison
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// Handle object comparison
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}
}

View File

@@ -0,0 +1,479 @@
import * as plugins from '../tsmdb.plugins.js';
import type { IStorageAdapter } from '../storage/IStorageAdapter.js';
import type {
Document,
IStoredDocument,
IIndexSpecification,
IIndexInfo,
ICreateIndexOptions,
} from '../types/interfaces.js';
import { TsmdbDuplicateKeyError, TsmdbIndexError } from '../errors/TsmdbErrors.js';
import { QueryEngine } from './QueryEngine.js';
/**
* Index data structure for fast lookups
*/
interface IIndexData {
name: string;
key: Record<string, 1 | -1 | string>;
unique: boolean;
sparse: boolean;
expireAfterSeconds?: number;
// Map from index key value to document _id(s)
entries: Map<string, Set<string>>;
}
/**
* Index engine for managing indexes and query optimization
*/
export class IndexEngine {
private dbName: string;
private collName: string;
private storage: IStorageAdapter;
private indexes: Map<string, IIndexData> = new Map();
private initialized = false;
constructor(dbName: string, collName: string, storage: IStorageAdapter) {
this.dbName = dbName;
this.collName = collName;
this.storage = storage;
}
/**
* Initialize indexes from storage
*/
async initialize(): Promise<void> {
if (this.initialized) return;
const storedIndexes = await this.storage.getIndexes(this.dbName, this.collName);
const documents = await this.storage.findAll(this.dbName, this.collName);
for (const indexSpec of storedIndexes) {
const indexData: IIndexData = {
name: indexSpec.name,
key: indexSpec.key,
unique: indexSpec.unique || false,
sparse: indexSpec.sparse || false,
expireAfterSeconds: indexSpec.expireAfterSeconds,
entries: new Map(),
};
// Build index entries
for (const doc of documents) {
const keyValue = this.extractKeyValue(doc, indexSpec.key);
if (keyValue !== null || !indexData.sparse) {
const keyStr = JSON.stringify(keyValue);
if (!indexData.entries.has(keyStr)) {
indexData.entries.set(keyStr, new Set());
}
indexData.entries.get(keyStr)!.add(doc._id.toHexString());
}
}
this.indexes.set(indexSpec.name, indexData);
}
this.initialized = true;
}
/**
* Create a new index
*/
async createIndex(
key: Record<string, 1 | -1 | 'text' | '2dsphere'>,
options?: ICreateIndexOptions
): Promise<string> {
await this.initialize();
// Generate index name if not provided
const name = options?.name || this.generateIndexName(key);
// Check if index already exists
if (this.indexes.has(name)) {
return name;
}
// Create index data structure
const indexData: IIndexData = {
name,
key: key as Record<string, 1 | -1 | string>,
unique: options?.unique || false,
sparse: options?.sparse || false,
expireAfterSeconds: options?.expireAfterSeconds,
entries: new Map(),
};
// Build index from existing documents
const documents = await this.storage.findAll(this.dbName, this.collName);
for (const doc of documents) {
const keyValue = this.extractKeyValue(doc, key);
if (keyValue === null && indexData.sparse) {
continue;
}
const keyStr = JSON.stringify(keyValue);
if (indexData.unique && indexData.entries.has(keyStr)) {
throw new TsmdbDuplicateKeyError(
`E11000 duplicate key error index: ${this.dbName}.${this.collName}.$${name}`,
key as Record<string, 1>,
keyValue
);
}
if (!indexData.entries.has(keyStr)) {
indexData.entries.set(keyStr, new Set());
}
indexData.entries.get(keyStr)!.add(doc._id.toHexString());
}
// Store index
this.indexes.set(name, indexData);
await this.storage.saveIndex(this.dbName, this.collName, name, {
key,
unique: options?.unique,
sparse: options?.sparse,
expireAfterSeconds: options?.expireAfterSeconds,
});
return name;
}
/**
* Drop an index
*/
async dropIndex(name: string): Promise<void> {
await this.initialize();
if (name === '_id_') {
throw new TsmdbIndexError('cannot drop _id index');
}
if (!this.indexes.has(name)) {
throw new TsmdbIndexError(`index not found: ${name}`);
}
this.indexes.delete(name);
await this.storage.dropIndex(this.dbName, this.collName, name);
}
/**
* Drop all indexes except _id
*/
async dropAllIndexes(): Promise<void> {
await this.initialize();
const names = Array.from(this.indexes.keys()).filter(n => n !== '_id_');
for (const name of names) {
this.indexes.delete(name);
await this.storage.dropIndex(this.dbName, this.collName, name);
}
}
/**
* List all indexes
*/
async listIndexes(): Promise<IIndexInfo[]> {
await this.initialize();
return Array.from(this.indexes.values()).map(idx => ({
v: 2,
key: idx.key,
name: idx.name,
unique: idx.unique || undefined,
sparse: idx.sparse || undefined,
expireAfterSeconds: idx.expireAfterSeconds,
}));
}
/**
* Check if an index exists
*/
async indexExists(name: string): Promise<boolean> {
await this.initialize();
return this.indexes.has(name);
}
/**
* Update index entries after document insert
*/
async onInsert(doc: IStoredDocument): Promise<void> {
await this.initialize();
for (const [name, indexData] of this.indexes) {
const keyValue = this.extractKeyValue(doc, indexData.key);
if (keyValue === null && indexData.sparse) {
continue;
}
const keyStr = JSON.stringify(keyValue);
// Check unique constraint
if (indexData.unique) {
const existing = indexData.entries.get(keyStr);
if (existing && existing.size > 0) {
throw new TsmdbDuplicateKeyError(
`E11000 duplicate key error collection: ${this.dbName}.${this.collName} index: ${name}`,
indexData.key as Record<string, 1>,
keyValue
);
}
}
if (!indexData.entries.has(keyStr)) {
indexData.entries.set(keyStr, new Set());
}
indexData.entries.get(keyStr)!.add(doc._id.toHexString());
}
}
/**
* Update index entries after document update
*/
async onUpdate(oldDoc: IStoredDocument, newDoc: IStoredDocument): Promise<void> {
await this.initialize();
for (const [name, indexData] of this.indexes) {
const oldKeyValue = this.extractKeyValue(oldDoc, indexData.key);
const newKeyValue = this.extractKeyValue(newDoc, indexData.key);
const oldKeyStr = JSON.stringify(oldKeyValue);
const newKeyStr = JSON.stringify(newKeyValue);
// Remove old entry if key changed
if (oldKeyStr !== newKeyStr) {
if (oldKeyValue !== null || !indexData.sparse) {
const oldSet = indexData.entries.get(oldKeyStr);
if (oldSet) {
oldSet.delete(oldDoc._id.toHexString());
if (oldSet.size === 0) {
indexData.entries.delete(oldKeyStr);
}
}
}
// Add new entry
if (newKeyValue !== null || !indexData.sparse) {
// Check unique constraint
if (indexData.unique) {
const existing = indexData.entries.get(newKeyStr);
if (existing && existing.size > 0) {
throw new TsmdbDuplicateKeyError(
`E11000 duplicate key error collection: ${this.dbName}.${this.collName} index: ${name}`,
indexData.key as Record<string, 1>,
newKeyValue
);
}
}
if (!indexData.entries.has(newKeyStr)) {
indexData.entries.set(newKeyStr, new Set());
}
indexData.entries.get(newKeyStr)!.add(newDoc._id.toHexString());
}
}
}
}
/**
* Update index entries after document delete
*/
async onDelete(doc: IStoredDocument): Promise<void> {
await this.initialize();
for (const indexData of this.indexes.values()) {
const keyValue = this.extractKeyValue(doc, indexData.key);
if (keyValue === null && indexData.sparse) {
continue;
}
const keyStr = JSON.stringify(keyValue);
const set = indexData.entries.get(keyStr);
if (set) {
set.delete(doc._id.toHexString());
if (set.size === 0) {
indexData.entries.delete(keyStr);
}
}
}
}
/**
* Find the best index for a query
*/
selectIndex(filter: Document): { name: string; data: IIndexData } | null {
if (!filter || Object.keys(filter).length === 0) {
return null;
}
// Get filter fields
const filterFields = new Set(this.getFilterFields(filter));
// Score each index
let bestIndex: { name: string; data: IIndexData } | null = null;
let bestScore = 0;
for (const [name, indexData] of this.indexes) {
const indexFields = Object.keys(indexData.key);
let score = 0;
// Count how many index fields are in the filter
for (const field of indexFields) {
if (filterFields.has(field)) {
score++;
} else {
break; // Index fields must be contiguous
}
}
// Prefer unique indexes
if (indexData.unique && score > 0) {
score += 0.5;
}
if (score > bestScore) {
bestScore = score;
bestIndex = { name, data: indexData };
}
}
return bestIndex;
}
/**
* Use index to find candidate document IDs
*/
async findCandidateIds(filter: Document): Promise<Set<string> | null> {
await this.initialize();
const index = this.selectIndex(filter);
if (!index) return null;
// Try to use the index for equality matches
const indexFields = Object.keys(index.data.key);
const equalityValues: Record<string, any> = {};
for (const field of indexFields) {
const filterValue = this.getFilterValue(filter, field);
if (filterValue === undefined) break;
// Only use equality matches for index lookup
if (typeof filterValue === 'object' && filterValue !== null) {
if (filterValue.$eq !== undefined) {
equalityValues[field] = filterValue.$eq;
} else if (filterValue.$in !== undefined) {
// Handle $in with multiple lookups
const results = new Set<string>();
for (const val of filterValue.$in) {
equalityValues[field] = val;
const keyStr = JSON.stringify(this.buildKeyValue(equalityValues, index.data.key));
const ids = index.data.entries.get(keyStr);
if (ids) {
for (const id of ids) {
results.add(id);
}
}
}
return results;
} else {
break; // Non-equality operator, stop here
}
} else {
equalityValues[field] = filterValue;
}
}
if (Object.keys(equalityValues).length === 0) {
return null;
}
const keyStr = JSON.stringify(this.buildKeyValue(equalityValues, index.data.key));
return index.data.entries.get(keyStr) || new Set();
}
// ============================================================================
// Helper Methods
// ============================================================================
private generateIndexName(key: Record<string, any>): string {
return Object.entries(key)
.map(([field, dir]) => `${field}_${dir}`)
.join('_');
}
private extractKeyValue(doc: Document, key: Record<string, any>): any {
const values: any[] = [];
for (const field of Object.keys(key)) {
const value = QueryEngine.getNestedValue(doc, field);
values.push(value === undefined ? null : value);
}
// For single-field index, return the value directly
if (values.length === 1) {
return values[0];
}
return values;
}
private buildKeyValue(values: Record<string, any>, key: Record<string, any>): any {
const result: any[] = [];
for (const field of Object.keys(key)) {
result.push(values[field] !== undefined ? values[field] : null);
}
if (result.length === 1) {
return result[0];
}
return result;
}
private getFilterFields(filter: Document, prefix = ''): string[] {
const fields: string[] = [];
for (const [key, value] of Object.entries(filter)) {
if (key.startsWith('$')) {
// Logical operator
if (key === '$and' || key === '$or' || key === '$nor') {
for (const subFilter of value as Document[]) {
fields.push(...this.getFilterFields(subFilter, prefix));
}
}
} else {
const fullKey = prefix ? `${prefix}.${key}` : key;
fields.push(fullKey);
// Check for nested filters
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const subKeys = Object.keys(value);
if (subKeys.length > 0 && !subKeys[0].startsWith('$')) {
fields.push(...this.getFilterFields(value, fullKey));
}
}
}
}
return fields;
}
private getFilterValue(filter: Document, field: string): any {
// Handle dot notation
const parts = field.split('.');
let current: any = filter;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
}

View File

@@ -0,0 +1,301 @@
import * as plugins from '../tsmdb.plugins.js';
import type { Document, IStoredDocument, ISortSpecification, ISortDirection } from '../types/interfaces.js';
// Import mingo Query class
import { Query } from 'mingo';
/**
* Query engine using mingo for MongoDB-compatible query matching
*/
export class QueryEngine {
/**
* Filter documents by a MongoDB query filter
*/
static filter(documents: IStoredDocument[], filter: Document): IStoredDocument[] {
if (!filter || Object.keys(filter).length === 0) {
return documents;
}
const query = new Query(filter);
return documents.filter(doc => query.test(doc));
}
/**
* Test if a single document matches a filter
*/
static matches(document: Document, filter: Document): boolean {
if (!filter || Object.keys(filter).length === 0) {
return true;
}
const query = new Query(filter);
return query.test(document);
}
/**
* Find a single document matching the filter
*/
static findOne(documents: IStoredDocument[], filter: Document): IStoredDocument | null {
if (!filter || Object.keys(filter).length === 0) {
return documents[0] || null;
}
const query = new Query(filter);
for (const doc of documents) {
if (query.test(doc)) {
return doc;
}
}
return null;
}
/**
* Sort documents by a sort specification
*/
static sort(documents: IStoredDocument[], sort: ISortSpecification): IStoredDocument[] {
if (!sort) {
return documents;
}
// Normalize sort specification to array of [field, direction] pairs
const sortFields: Array<[string, number]> = [];
if (Array.isArray(sort)) {
for (const [field, direction] of sort) {
sortFields.push([field, this.normalizeDirection(direction)]);
}
} else {
for (const [field, direction] of Object.entries(sort)) {
sortFields.push([field, this.normalizeDirection(direction)]);
}
}
return [...documents].sort((a, b) => {
for (const [field, direction] of sortFields) {
const aVal = this.getNestedValue(a, field);
const bVal = this.getNestedValue(b, field);
const comparison = this.compareValues(aVal, bVal);
if (comparison !== 0) {
return comparison * direction;
}
}
return 0;
});
}
/**
* Apply projection to documents
*/
static project(documents: IStoredDocument[], projection: Document): Document[] {
if (!projection || Object.keys(projection).length === 0) {
return documents;
}
// Determine if this is inclusion or exclusion projection
const keys = Object.keys(projection);
const hasInclusion = keys.some(k => k !== '_id' && projection[k] === 1);
const hasExclusion = keys.some(k => k !== '_id' && projection[k] === 0);
// Can't mix inclusion and exclusion (except for _id)
if (hasInclusion && hasExclusion) {
throw new Error('Cannot mix inclusion and exclusion in projection');
}
return documents.map(doc => {
if (hasInclusion) {
// Inclusion projection
const result: Document = {};
// Handle _id
if (projection._id !== 0 && projection._id !== false) {
result._id = doc._id;
}
for (const key of keys) {
if (key === '_id') continue;
if (projection[key] === 1 || projection[key] === true) {
const value = this.getNestedValue(doc, key);
if (value !== undefined) {
this.setNestedValue(result, key, value);
}
}
}
return result;
} else {
// Exclusion projection - start with copy and remove fields
const result = { ...doc };
for (const key of keys) {
if (projection[key] === 0 || projection[key] === false) {
this.deleteNestedValue(result, key);
}
}
return result;
}
});
}
/**
* Get distinct values for a field
*/
static distinct(documents: IStoredDocument[], field: string, filter?: Document): any[] {
let docs = documents;
if (filter && Object.keys(filter).length > 0) {
docs = this.filter(documents, filter);
}
const values = new Set<any>();
for (const doc of docs) {
const value = this.getNestedValue(doc, field);
if (value !== undefined) {
if (Array.isArray(value)) {
// For arrays, add each element
for (const v of value) {
values.add(this.toComparable(v));
}
} else {
values.add(this.toComparable(value));
}
}
}
return Array.from(values);
}
/**
* Normalize sort direction to 1 or -1
*/
private static normalizeDirection(direction: ISortDirection): number {
if (typeof direction === 'number') {
return direction > 0 ? 1 : -1;
}
if (direction === 'asc' || direction === 'ascending') {
return 1;
}
return -1;
}
/**
* Get a nested value from an object using dot notation
*/
static getNestedValue(obj: any, path: string): any {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
// Handle array access
const index = parseInt(part, 10);
if (!isNaN(index)) {
current = current[index];
} else {
// Get the field from all array elements
return current.map(item => this.getNestedValue(item, part)).flat();
}
} else {
current = current[part];
}
}
return current;
}
/**
* Set a nested value in an object using dot notation
*/
private static setNestedValue(obj: any, path: string, value: any): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part];
}
current[parts[parts.length - 1]] = value;
}
/**
* Delete a nested value from an object using dot notation
*/
private static deleteNestedValue(obj: any, path: string): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
return;
}
current = current[part];
}
delete current[parts[parts.length - 1]];
}
/**
* Compare two values for sorting
*/
private static compareValues(a: any, b: any): number {
// Handle undefined/null
if (a === undefined && b === undefined) return 0;
if (a === undefined) return -1;
if (b === undefined) return 1;
if (a === null && b === null) return 0;
if (a === null) return -1;
if (b === null) return 1;
// Handle ObjectId
if (a instanceof plugins.bson.ObjectId && b instanceof plugins.bson.ObjectId) {
return a.toHexString().localeCompare(b.toHexString());
}
// Handle dates
if (a instanceof Date && b instanceof Date) {
return a.getTime() - b.getTime();
}
// Handle numbers
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
// Handle strings
if (typeof a === 'string' && typeof b === 'string') {
return a.localeCompare(b);
}
// Handle booleans
if (typeof a === 'boolean' && typeof b === 'boolean') {
return (a ? 1 : 0) - (b ? 1 : 0);
}
// Fall back to string comparison
return String(a).localeCompare(String(b));
}
/**
* Convert a value to a comparable form (for distinct)
*/
private static toComparable(value: any): any {
if (value instanceof plugins.bson.ObjectId) {
return value.toHexString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value);
}
return value;
}
}

View File

@@ -0,0 +1,351 @@
import * as plugins from '../tsmdb.plugins.js';
import type { IStorageAdapter } from '../storage/IStorageAdapter.js';
import type { Document, IStoredDocument, ITransactionOptions } from '../types/interfaces.js';
import { TsmdbTransactionError, TsmdbWriteConflictError } from '../errors/TsmdbErrors.js';
/**
* Transaction state
*/
export interface ITransactionState {
id: string;
sessionId: string;
startTime: plugins.bson.Timestamp;
status: 'active' | 'committed' | 'aborted';
readSet: Map<string, Set<string>>; // ns -> document _ids read
writeSet: Map<string, Map<string, { op: 'insert' | 'update' | 'delete'; doc?: IStoredDocument; originalDoc?: IStoredDocument }>>; // ns -> _id -> operation
snapshots: Map<string, IStoredDocument[]>; // ns -> snapshot of documents
}
/**
* Transaction engine for ACID transaction support
*/
export class TransactionEngine {
private storage: IStorageAdapter;
private transactions: Map<string, ITransactionState> = new Map();
private txnCounter = 0;
constructor(storage: IStorageAdapter) {
this.storage = storage;
}
/**
* Start a new transaction
*/
startTransaction(sessionId: string, options?: ITransactionOptions): string {
this.txnCounter++;
const txnId = `txn_${sessionId}_${this.txnCounter}`;
const transaction: ITransactionState = {
id: txnId,
sessionId,
startTime: new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: this.txnCounter }),
status: 'active',
readSet: new Map(),
writeSet: new Map(),
snapshots: new Map(),
};
this.transactions.set(txnId, transaction);
return txnId;
}
/**
* Get a transaction by ID
*/
getTransaction(txnId: string): ITransactionState | undefined {
return this.transactions.get(txnId);
}
/**
* Check if a transaction is active
*/
isActive(txnId: string): boolean {
const txn = this.transactions.get(txnId);
return txn?.status === 'active';
}
/**
* Get or create a snapshot for a namespace
*/
async getSnapshot(txnId: string, dbName: string, collName: string): Promise<IStoredDocument[]> {
const txn = this.transactions.get(txnId);
if (!txn || txn.status !== 'active') {
throw new TsmdbTransactionError('Transaction is not active');
}
const ns = `${dbName}.${collName}`;
if (!txn.snapshots.has(ns)) {
const snapshot = await this.storage.createSnapshot(dbName, collName);
txn.snapshots.set(ns, snapshot);
}
// Apply transaction writes to snapshot
const snapshot = txn.snapshots.get(ns)!;
const writes = txn.writeSet.get(ns);
if (!writes) {
return snapshot;
}
// Create a modified view of the snapshot
const result: IStoredDocument[] = [];
const deletedIds = new Set<string>();
const modifiedDocs = new Map<string, IStoredDocument>();
for (const [idStr, write] of writes) {
if (write.op === 'delete') {
deletedIds.add(idStr);
} else if (write.op === 'update' || write.op === 'insert') {
if (write.doc) {
modifiedDocs.set(idStr, write.doc);
}
}
}
// Add existing documents (not deleted, possibly modified)
for (const doc of snapshot) {
const idStr = doc._id.toHexString();
if (deletedIds.has(idStr)) {
continue;
}
if (modifiedDocs.has(idStr)) {
result.push(modifiedDocs.get(idStr)!);
modifiedDocs.delete(idStr);
} else {
result.push(doc);
}
}
// Add new documents (inserts)
for (const doc of modifiedDocs.values()) {
result.push(doc);
}
return result;
}
/**
* Record a read operation
*/
recordRead(txnId: string, dbName: string, collName: string, docIds: string[]): void {
const txn = this.transactions.get(txnId);
if (!txn || txn.status !== 'active') return;
const ns = `${dbName}.${collName}`;
if (!txn.readSet.has(ns)) {
txn.readSet.set(ns, new Set());
}
const readSet = txn.readSet.get(ns)!;
for (const id of docIds) {
readSet.add(id);
}
}
/**
* Record a write operation (insert)
*/
recordInsert(txnId: string, dbName: string, collName: string, doc: IStoredDocument): void {
const txn = this.transactions.get(txnId);
if (!txn || txn.status !== 'active') {
throw new TsmdbTransactionError('Transaction is not active');
}
const ns = `${dbName}.${collName}`;
if (!txn.writeSet.has(ns)) {
txn.writeSet.set(ns, new Map());
}
txn.writeSet.get(ns)!.set(doc._id.toHexString(), {
op: 'insert',
doc,
});
}
/**
* Record a write operation (update)
*/
recordUpdate(
txnId: string,
dbName: string,
collName: string,
originalDoc: IStoredDocument,
updatedDoc: IStoredDocument
): void {
const txn = this.transactions.get(txnId);
if (!txn || txn.status !== 'active') {
throw new TsmdbTransactionError('Transaction is not active');
}
const ns = `${dbName}.${collName}`;
if (!txn.writeSet.has(ns)) {
txn.writeSet.set(ns, new Map());
}
const idStr = originalDoc._id.toHexString();
const existing = txn.writeSet.get(ns)!.get(idStr);
// If we already have a write for this document, update it
if (existing) {
existing.doc = updatedDoc;
} else {
txn.writeSet.get(ns)!.set(idStr, {
op: 'update',
doc: updatedDoc,
originalDoc,
});
}
}
/**
* Record a write operation (delete)
*/
recordDelete(txnId: string, dbName: string, collName: string, doc: IStoredDocument): void {
const txn = this.transactions.get(txnId);
if (!txn || txn.status !== 'active') {
throw new TsmdbTransactionError('Transaction is not active');
}
const ns = `${dbName}.${collName}`;
if (!txn.writeSet.has(ns)) {
txn.writeSet.set(ns, new Map());
}
const idStr = doc._id.toHexString();
const existing = txn.writeSet.get(ns)!.get(idStr);
if (existing && existing.op === 'insert') {
// If we inserted and then deleted, just remove the write
txn.writeSet.get(ns)!.delete(idStr);
} else {
txn.writeSet.get(ns)!.set(idStr, {
op: 'delete',
originalDoc: doc,
});
}
}
/**
* Commit a transaction
*/
async commitTransaction(txnId: string): Promise<void> {
const txn = this.transactions.get(txnId);
if (!txn) {
throw new TsmdbTransactionError('Transaction not found');
}
if (txn.status !== 'active') {
throw new TsmdbTransactionError(`Cannot commit transaction in state: ${txn.status}`);
}
// Check for write conflicts
for (const [ns, writes] of txn.writeSet) {
const [dbName, collName] = ns.split('.');
const ids = Array.from(writes.keys()).map(id => new plugins.bson.ObjectId(id));
const hasConflicts = await this.storage.hasConflicts(dbName, collName, ids, txn.startTime);
if (hasConflicts) {
txn.status = 'aborted';
throw new TsmdbWriteConflictError();
}
}
// Apply all writes
for (const [ns, writes] of txn.writeSet) {
const [dbName, collName] = ns.split('.');
for (const [idStr, write] of writes) {
switch (write.op) {
case 'insert':
if (write.doc) {
await this.storage.insertOne(dbName, collName, write.doc);
}
break;
case 'update':
if (write.doc) {
await this.storage.updateById(dbName, collName, new plugins.bson.ObjectId(idStr), write.doc);
}
break;
case 'delete':
await this.storage.deleteById(dbName, collName, new plugins.bson.ObjectId(idStr));
break;
}
}
}
txn.status = 'committed';
}
/**
* Abort a transaction
*/
async abortTransaction(txnId: string): Promise<void> {
const txn = this.transactions.get(txnId);
if (!txn) {
throw new TsmdbTransactionError('Transaction not found');
}
if (txn.status !== 'active') {
// Already committed or aborted, just return
return;
}
// Simply discard all buffered writes
txn.writeSet.clear();
txn.readSet.clear();
txn.snapshots.clear();
txn.status = 'aborted';
}
/**
* End a transaction (cleanup)
*/
endTransaction(txnId: string): void {
this.transactions.delete(txnId);
}
/**
* Get all pending writes for a namespace
*/
getPendingWrites(txnId: string, dbName: string, collName: string): Map<string, { op: 'insert' | 'update' | 'delete'; doc?: IStoredDocument }> | undefined {
const txn = this.transactions.get(txnId);
if (!txn) return undefined;
const ns = `${dbName}.${collName}`;
return txn.writeSet.get(ns);
}
/**
* Execute a callback within a transaction, with automatic retry on conflict
*/
async withTransaction<T>(
sessionId: string,
callback: (txnId: string) => Promise<T>,
options?: ITransactionOptions & { maxRetries?: number }
): Promise<T> {
const maxRetries = options?.maxRetries ?? 3;
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const txnId = this.startTransaction(sessionId, options);
try {
const result = await callback(txnId);
await this.commitTransaction(txnId);
this.endTransaction(txnId);
return result;
} catch (error: any) {
await this.abortTransaction(txnId);
this.endTransaction(txnId);
if (error instanceof TsmdbWriteConflictError && attempt < maxRetries - 1) {
// Retry on write conflict
lastError = error;
continue;
}
throw error;
}
}
throw lastError || new TsmdbTransactionError('Transaction failed after max retries');
}
}

View File

@@ -0,0 +1,506 @@
import * as plugins from '../tsmdb.plugins.js';
import type { Document, IStoredDocument } from '../types/interfaces.js';
import { QueryEngine } from './QueryEngine.js';
/**
* Update engine for MongoDB-compatible update operations
*/
export class UpdateEngine {
/**
* Apply an update specification to a document
* Returns the updated document or null if no update was applied
*/
static applyUpdate(document: IStoredDocument, update: Document, arrayFilters?: Document[]): IStoredDocument {
// Check if this is an aggregation pipeline update
if (Array.isArray(update)) {
// Aggregation pipeline updates are not yet supported
throw new Error('Aggregation pipeline updates are not yet supported');
}
// Check if this is a replacement (no $ operators at top level)
const hasOperators = Object.keys(update).some(k => k.startsWith('$'));
if (!hasOperators) {
// This is a replacement - preserve _id
return {
_id: document._id,
...update,
};
}
// Apply update operators
const result = this.deepClone(document);
for (const [operator, operand] of Object.entries(update)) {
switch (operator) {
case '$set':
this.applySet(result, operand);
break;
case '$unset':
this.applyUnset(result, operand);
break;
case '$inc':
this.applyInc(result, operand);
break;
case '$mul':
this.applyMul(result, operand);
break;
case '$min':
this.applyMin(result, operand);
break;
case '$max':
this.applyMax(result, operand);
break;
case '$rename':
this.applyRename(result, operand);
break;
case '$currentDate':
this.applyCurrentDate(result, operand);
break;
case '$setOnInsert':
// Only applied during upsert insert, handled elsewhere
break;
case '$push':
this.applyPush(result, operand, arrayFilters);
break;
case '$pop':
this.applyPop(result, operand);
break;
case '$pull':
this.applyPull(result, operand, arrayFilters);
break;
case '$pullAll':
this.applyPullAll(result, operand);
break;
case '$addToSet':
this.applyAddToSet(result, operand);
break;
case '$bit':
this.applyBit(result, operand);
break;
default:
throw new Error(`Unknown update operator: ${operator}`);
}
}
return result;
}
/**
* Apply $setOnInsert for upsert operations
*/
static applySetOnInsert(document: IStoredDocument, setOnInsert: Document): IStoredDocument {
const result = this.deepClone(document);
this.applySet(result, setOnInsert);
return result;
}
/**
* Deep clone a document
*/
private static deepClone(obj: any): any {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof plugins.bson.ObjectId) {
return new plugins.bson.ObjectId(obj.toHexString());
}
if (obj instanceof Date) {
return new Date(obj.getTime());
}
if (obj instanceof plugins.bson.Timestamp) {
return new plugins.bson.Timestamp({ t: obj.high, i: obj.low });
}
if (Array.isArray(obj)) {
return obj.map(item => this.deepClone(item));
}
const cloned: any = {};
for (const key of Object.keys(obj)) {
cloned[key] = this.deepClone(obj[key]);
}
return cloned;
}
/**
* Set a nested value
*/
private static setNestedValue(obj: any, path: string, value: any): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
// Handle array index notation
const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, fieldName, indexStr] = arrayMatch;
const index = parseInt(indexStr, 10);
if (!(fieldName in current)) {
current[fieldName] = [];
}
if (!current[fieldName][index]) {
current[fieldName][index] = {};
}
current = current[fieldName][index];
continue;
}
// Handle numeric index (array positional)
const numIndex = parseInt(part, 10);
if (!isNaN(numIndex) && Array.isArray(current)) {
if (!current[numIndex]) {
current[numIndex] = {};
}
current = current[numIndex];
continue;
}
if (!(part in current) || current[part] === null) {
current[part] = {};
}
current = current[part];
}
const lastPart = parts[parts.length - 1];
const numIndex = parseInt(lastPart, 10);
if (!isNaN(numIndex) && Array.isArray(current)) {
current[numIndex] = value;
} else {
current[lastPart] = value;
}
}
/**
* Get a nested value
*/
private static getNestedValue(obj: any, path: string): any {
return QueryEngine.getNestedValue(obj, path);
}
/**
* Delete a nested value
*/
private static deleteNestedValue(obj: any, path: string): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
return;
}
current = current[part];
}
delete current[parts[parts.length - 1]];
}
// ============================================================================
// Field Update Operators
// ============================================================================
private static applySet(doc: any, fields: Document): void {
for (const [path, value] of Object.entries(fields)) {
this.setNestedValue(doc, path, this.deepClone(value));
}
}
private static applyUnset(doc: any, fields: Document): void {
for (const path of Object.keys(fields)) {
this.deleteNestedValue(doc, path);
}
}
private static applyInc(doc: any, fields: Document): void {
for (const [path, value] of Object.entries(fields)) {
const current = this.getNestedValue(doc, path) || 0;
if (typeof current !== 'number') {
throw new Error(`Cannot apply $inc to non-numeric field: ${path}`);
}
this.setNestedValue(doc, path, current + (value as number));
}
}
private static applyMul(doc: any, fields: Document): void {
for (const [path, value] of Object.entries(fields)) {
const current = this.getNestedValue(doc, path) || 0;
if (typeof current !== 'number') {
throw new Error(`Cannot apply $mul to non-numeric field: ${path}`);
}
this.setNestedValue(doc, path, current * (value as number));
}
}
private static applyMin(doc: any, fields: Document): void {
for (const [path, value] of Object.entries(fields)) {
const current = this.getNestedValue(doc, path);
if (current === undefined || this.compareValues(value, current) < 0) {
this.setNestedValue(doc, path, this.deepClone(value));
}
}
}
private static applyMax(doc: any, fields: Document): void {
for (const [path, value] of Object.entries(fields)) {
const current = this.getNestedValue(doc, path);
if (current === undefined || this.compareValues(value, current) > 0) {
this.setNestedValue(doc, path, this.deepClone(value));
}
}
}
private static applyRename(doc: any, fields: Document): void {
for (const [oldPath, newPath] of Object.entries(fields)) {
const value = this.getNestedValue(doc, oldPath);
if (value !== undefined) {
this.deleteNestedValue(doc, oldPath);
this.setNestedValue(doc, newPath as string, value);
}
}
}
private static applyCurrentDate(doc: any, fields: Document): void {
for (const [path, spec] of Object.entries(fields)) {
if (spec === true) {
this.setNestedValue(doc, path, new Date());
} else if (typeof spec === 'object' && spec.$type === 'date') {
this.setNestedValue(doc, path, new Date());
} else if (typeof spec === 'object' && spec.$type === 'timestamp') {
this.setNestedValue(doc, path, new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: 0 }));
}
}
}
// ============================================================================
// Array Update Operators
// ============================================================================
private static applyPush(doc: any, fields: Document, arrayFilters?: Document[]): void {
for (const [path, spec] of Object.entries(fields)) {
let arr = this.getNestedValue(doc, path);
if (arr === undefined) {
arr = [];
this.setNestedValue(doc, path, arr);
}
if (!Array.isArray(arr)) {
throw new Error(`Cannot apply $push to non-array field: ${path}`);
}
if (spec && typeof spec === 'object' && '$each' in spec) {
// $push with modifiers
let elements = (spec.$each as any[]).map(e => this.deepClone(e));
const position = spec.$position as number | undefined;
const slice = spec.$slice as number | undefined;
const sortSpec = spec.$sort;
if (position !== undefined) {
arr.splice(position, 0, ...elements);
} else {
arr.push(...elements);
}
if (sortSpec !== undefined) {
if (typeof sortSpec === 'number') {
arr.sort((a, b) => (a - b) * sortSpec);
} else {
// Sort by field(s)
const entries = Object.entries(sortSpec as Document);
arr.sort((a, b) => {
for (const [field, dir] of entries) {
const av = this.getNestedValue(a, field);
const bv = this.getNestedValue(b, field);
const cmp = this.compareValues(av, bv) * (dir as number);
if (cmp !== 0) return cmp;
}
return 0;
});
}
}
if (slice !== undefined) {
if (slice >= 0) {
arr.splice(slice);
} else {
arr.splice(0, arr.length + slice);
}
}
} else {
// Simple push
arr.push(this.deepClone(spec));
}
}
}
private static applyPop(doc: any, fields: Document): void {
for (const [path, direction] of Object.entries(fields)) {
const arr = this.getNestedValue(doc, path);
if (!Array.isArray(arr)) {
throw new Error(`Cannot apply $pop to non-array field: ${path}`);
}
if ((direction as number) === 1) {
arr.pop();
} else {
arr.shift();
}
}
}
private static applyPull(doc: any, fields: Document, arrayFilters?: Document[]): void {
for (const [path, condition] of Object.entries(fields)) {
const arr = this.getNestedValue(doc, path);
if (!Array.isArray(arr)) {
continue; // Skip if not an array
}
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
// Condition is a query filter
const hasOperators = Object.keys(condition).some(k => k.startsWith('$'));
if (hasOperators) {
// Filter using query operators
const remaining = arr.filter(item => !QueryEngine.matches(item, condition));
arr.length = 0;
arr.push(...remaining);
} else {
// Match documents with all specified fields
const remaining = arr.filter(item => {
if (typeof item !== 'object' || item === null) {
return true;
}
return !Object.entries(condition).every(([k, v]) => {
const itemVal = this.getNestedValue(item, k);
return this.valuesEqual(itemVal, v);
});
});
arr.length = 0;
arr.push(...remaining);
}
} else {
// Direct value match
const remaining = arr.filter(item => !this.valuesEqual(item, condition));
arr.length = 0;
arr.push(...remaining);
}
}
}
private static applyPullAll(doc: any, fields: Document): void {
for (const [path, values] of Object.entries(fields)) {
const arr = this.getNestedValue(doc, path);
if (!Array.isArray(arr)) {
continue;
}
if (!Array.isArray(values)) {
throw new Error(`$pullAll requires an array argument`);
}
const valueSet = new Set(values.map(v => JSON.stringify(v)));
const remaining = arr.filter(item => !valueSet.has(JSON.stringify(item)));
arr.length = 0;
arr.push(...remaining);
}
}
private static applyAddToSet(doc: any, fields: Document): void {
for (const [path, spec] of Object.entries(fields)) {
let arr = this.getNestedValue(doc, path);
if (arr === undefined) {
arr = [];
this.setNestedValue(doc, path, arr);
}
if (!Array.isArray(arr)) {
throw new Error(`Cannot apply $addToSet to non-array field: ${path}`);
}
const existingSet = new Set(arr.map(v => JSON.stringify(v)));
if (spec && typeof spec === 'object' && '$each' in spec) {
for (const item of spec.$each as any[]) {
const key = JSON.stringify(item);
if (!existingSet.has(key)) {
arr.push(this.deepClone(item));
existingSet.add(key);
}
}
} else {
const key = JSON.stringify(spec);
if (!existingSet.has(key)) {
arr.push(this.deepClone(spec));
}
}
}
}
private static applyBit(doc: any, fields: Document): void {
for (const [path, operations] of Object.entries(fields)) {
let current = this.getNestedValue(doc, path) || 0;
if (typeof current !== 'number') {
throw new Error(`Cannot apply $bit to non-numeric field: ${path}`);
}
for (const [op, value] of Object.entries(operations as Document)) {
switch (op) {
case 'and':
current = current & (value as number);
break;
case 'or':
current = current | (value as number);
break;
case 'xor':
current = current ^ (value as number);
break;
}
}
this.setNestedValue(doc, path, current);
}
}
// ============================================================================
// Helper Methods
// ============================================================================
private static compareValues(a: any, b: any): number {
if (a === b) return 0;
if (a === null || a === undefined) return -1;
if (b === null || b === undefined) return 1;
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() - b.getTime();
}
if (typeof a === 'string' && typeof b === 'string') {
return a.localeCompare(b);
}
return String(a).localeCompare(String(b));
}
private static valuesEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a instanceof plugins.bson.ObjectId && b instanceof plugins.bson.ObjectId) {
return a.equals(b);
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}
}

View File

@@ -0,0 +1,181 @@
/**
* Base error class for all TsmDB errors
* Mirrors MongoDB driver error hierarchy
*/
export class TsmdbError extends Error {
public code?: number;
public codeName?: string;
constructor(message: string, code?: number, codeName?: string) {
super(message);
this.name = 'TsmdbError';
this.code = code;
this.codeName = codeName;
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Error thrown during connection issues
*/
export class TsmdbConnectionError extends TsmdbError {
constructor(message: string) {
super(message);
this.name = 'TsmdbConnectionError';
}
}
/**
* Error thrown when an operation times out
*/
export class TsmdbTimeoutError extends TsmdbError {
constructor(message: string) {
super(message, 50, 'MaxTimeMSExpired');
this.name = 'TsmdbTimeoutError';
}
}
/**
* Error thrown during write operations
*/
export class TsmdbWriteError extends TsmdbError {
public writeErrors?: IWriteError[];
public result?: any;
constructor(message: string, code?: number, writeErrors?: IWriteError[]) {
super(message, code);
this.name = 'TsmdbWriteError';
this.writeErrors = writeErrors;
}
}
/**
* Error thrown for duplicate key violations
*/
export class TsmdbDuplicateKeyError extends TsmdbWriteError {
public keyPattern?: Record<string, 1>;
public keyValue?: Record<string, any>;
constructor(message: string, keyPattern?: Record<string, 1>, keyValue?: Record<string, any>) {
super(message, 11000);
this.name = 'TsmdbDuplicateKeyError';
this.codeName = 'DuplicateKey';
this.keyPattern = keyPattern;
this.keyValue = keyValue;
}
}
/**
* Error thrown for bulk write failures
*/
export class TsmdbBulkWriteError extends TsmdbError {
public writeErrors: IWriteError[];
public result: any;
constructor(message: string, writeErrors: IWriteError[], result: any) {
super(message, 65);
this.name = 'TsmdbBulkWriteError';
this.writeErrors = writeErrors;
this.result = result;
}
}
/**
* Error thrown during transaction operations
*/
export class TsmdbTransactionError extends TsmdbError {
constructor(message: string, code?: number) {
super(message, code);
this.name = 'TsmdbTransactionError';
}
}
/**
* Error thrown when a transaction is aborted due to conflict
*/
export class TsmdbWriteConflictError extends TsmdbTransactionError {
constructor(message: string = 'Write conflict during transaction') {
super(message, 112);
this.name = 'TsmdbWriteConflictError';
this.codeName = 'WriteConflict';
}
}
/**
* Error thrown for invalid arguments
*/
export class TsmdbArgumentError extends TsmdbError {
constructor(message: string) {
super(message);
this.name = 'TsmdbArgumentError';
}
}
/**
* Error thrown when an operation is not supported
*/
export class TsmdbNotSupportedError extends TsmdbError {
constructor(message: string) {
super(message, 115);
this.name = 'TsmdbNotSupportedError';
this.codeName = 'CommandNotSupported';
}
}
/**
* Error thrown when cursor is exhausted or closed
*/
export class TsmdbCursorError extends TsmdbError {
constructor(message: string) {
super(message);
this.name = 'TsmdbCursorError';
}
}
/**
* Error thrown when a namespace (database.collection) is invalid
*/
export class TsmdbNamespaceError extends TsmdbError {
constructor(message: string) {
super(message, 73);
this.name = 'TsmdbNamespaceError';
this.codeName = 'InvalidNamespace';
}
}
/**
* Error thrown when an index operation fails
*/
export class TsmdbIndexError extends TsmdbError {
constructor(message: string, code?: number) {
super(message, code || 86);
this.name = 'TsmdbIndexError';
}
}
/**
* Write error detail for bulk operations
*/
export interface IWriteError {
index: number;
code: number;
errmsg: string;
op: any;
}
/**
* Convert any error to a TsmdbError
*/
export function toTsmdbError(error: any): TsmdbError {
if (error instanceof TsmdbError) {
return error;
}
const tsmdbError = new TsmdbError(error.message || String(error));
if (error.code) {
tsmdbError.code = error.code;
}
if (error.codeName) {
tsmdbError.codeName = error.codeName;
}
return tsmdbError;
}

37
ts/tsmdb/index.ts Normal file
View File

@@ -0,0 +1,37 @@
// TsmDB - MongoDB Wire Protocol compatible in-memory database server
// Use the official MongoDB driver to connect to TsmdbServer
// Re-export plugins for external use
import * as plugins from './tsmdb.plugins.js';
export { plugins };
// Export BSON types for convenience
export { ObjectId, Binary, Timestamp, Long, Decimal128, UUID } from 'bson';
// Export all types
export * from './types/interfaces.js';
// Export errors
export * from './errors/TsmdbErrors.js';
// Export storage adapters
export type { IStorageAdapter } from './storage/IStorageAdapter.js';
export { MemoryStorageAdapter } from './storage/MemoryStorageAdapter.js';
export { FileStorageAdapter } from './storage/FileStorageAdapter.js';
export { OpLog } from './storage/OpLog.js';
// Export engines
export { QueryEngine } from './engine/QueryEngine.js';
export { UpdateEngine } from './engine/UpdateEngine.js';
export { AggregationEngine } from './engine/AggregationEngine.js';
export { IndexEngine } from './engine/IndexEngine.js';
export { TransactionEngine } from './engine/TransactionEngine.js';
// Export server (the main entry point for using TsmDB)
export { TsmdbServer } from './server/TsmdbServer.js';
export type { ITsmdbServerOptions } from './server/TsmdbServer.js';
// Export wire protocol utilities (for advanced usage)
export { WireProtocol } from './server/WireProtocol.js';
export { CommandRouter } from './server/CommandRouter.js';
export type { ICommandHandler, IHandlerContext, ICursorState } from './server/CommandRouter.js';

View File

@@ -0,0 +1,180 @@
import * as plugins from '../tsmdb.plugins.js';
import type { IStorageAdapter } from '../storage/IStorageAdapter.js';
import type { IParsedCommand } from './WireProtocol.js';
import type { TsmdbServer } from './TsmdbServer.js';
// Import handlers
import { HelloHandler } from './handlers/HelloHandler.js';
import { InsertHandler } from './handlers/InsertHandler.js';
import { FindHandler } from './handlers/FindHandler.js';
import { UpdateHandler } from './handlers/UpdateHandler.js';
import { DeleteHandler } from './handlers/DeleteHandler.js';
import { AggregateHandler } from './handlers/AggregateHandler.js';
import { IndexHandler } from './handlers/IndexHandler.js';
import { AdminHandler } from './handlers/AdminHandler.js';
/**
* Handler context passed to command handlers
*/
export interface IHandlerContext {
storage: IStorageAdapter;
server: TsmdbServer;
database: string;
command: plugins.bson.Document;
documentSequences?: Map<string, plugins.bson.Document[]>;
}
/**
* Command handler interface
*/
export interface ICommandHandler {
handle(context: IHandlerContext): Promise<plugins.bson.Document>;
}
/**
* CommandRouter - Routes incoming commands to appropriate handlers
*/
export class CommandRouter {
private storage: IStorageAdapter;
private server: TsmdbServer;
private handlers: Map<string, ICommandHandler> = new Map();
// Cursor state for getMore operations
private cursors: Map<bigint, ICursorState> = new Map();
private cursorIdCounter: bigint = BigInt(1);
constructor(storage: IStorageAdapter, server: TsmdbServer) {
this.storage = storage;
this.server = server;
this.registerHandlers();
}
/**
* Register all command handlers
*/
private registerHandlers(): void {
// Create handler instances with shared state
const helloHandler = new HelloHandler();
const findHandler = new FindHandler(this.cursors, () => this.cursorIdCounter++);
const insertHandler = new InsertHandler();
const updateHandler = new UpdateHandler();
const deleteHandler = new DeleteHandler();
const aggregateHandler = new AggregateHandler(this.cursors, () => this.cursorIdCounter++);
const indexHandler = new IndexHandler();
const adminHandler = new AdminHandler();
// Handshake commands
this.handlers.set('hello', helloHandler);
this.handlers.set('ismaster', helloHandler);
this.handlers.set('isMaster', helloHandler);
// CRUD commands
this.handlers.set('find', findHandler);
this.handlers.set('insert', insertHandler);
this.handlers.set('update', updateHandler);
this.handlers.set('delete', deleteHandler);
this.handlers.set('findAndModify', updateHandler);
this.handlers.set('getMore', findHandler);
this.handlers.set('killCursors', findHandler);
// Aggregation
this.handlers.set('aggregate', aggregateHandler);
this.handlers.set('count', findHandler);
this.handlers.set('distinct', findHandler);
// Index operations
this.handlers.set('createIndexes', indexHandler);
this.handlers.set('dropIndexes', indexHandler);
this.handlers.set('listIndexes', indexHandler);
// Admin/Database operations
this.handlers.set('ping', adminHandler);
this.handlers.set('listDatabases', adminHandler);
this.handlers.set('listCollections', adminHandler);
this.handlers.set('drop', adminHandler);
this.handlers.set('dropDatabase', adminHandler);
this.handlers.set('create', adminHandler);
this.handlers.set('serverStatus', adminHandler);
this.handlers.set('buildInfo', adminHandler);
this.handlers.set('whatsmyuri', adminHandler);
this.handlers.set('getLog', adminHandler);
this.handlers.set('hostInfo', adminHandler);
this.handlers.set('replSetGetStatus', adminHandler);
this.handlers.set('isMaster', helloHandler);
this.handlers.set('saslStart', adminHandler);
this.handlers.set('saslContinue', adminHandler);
this.handlers.set('endSessions', adminHandler);
this.handlers.set('abortTransaction', adminHandler);
this.handlers.set('commitTransaction', adminHandler);
this.handlers.set('collStats', adminHandler);
this.handlers.set('dbStats', adminHandler);
this.handlers.set('connectionStatus', adminHandler);
this.handlers.set('currentOp', adminHandler);
this.handlers.set('collMod', adminHandler);
this.handlers.set('renameCollection', adminHandler);
}
/**
* Route a command to its handler
*/
async route(parsedCommand: IParsedCommand): Promise<plugins.bson.Document> {
const { commandName, command, database, documentSequences } = parsedCommand;
// Create handler context
const context: IHandlerContext = {
storage: this.storage,
server: this.server,
database,
command,
documentSequences,
};
// Find handler
const handler = this.handlers.get(commandName);
if (!handler) {
// Unknown command
return {
ok: 0,
errmsg: `no such command: '${commandName}'`,
code: 59,
codeName: 'CommandNotFound',
};
}
try {
return await handler.handle(context);
} catch (error: any) {
// Handle known error types
if (error.code) {
return {
ok: 0,
errmsg: error.message,
code: error.code,
codeName: error.codeName || 'UnknownError',
};
}
// Generic error
return {
ok: 0,
errmsg: error.message || 'Internal error',
code: 1,
codeName: 'InternalError',
};
}
}
}
/**
* Cursor state for multi-batch queries
*/
export interface ICursorState {
id: bigint;
database: string;
collection: string;
documents: plugins.bson.Document[];
position: number;
batchSize: number;
createdAt: Date;
}

View File

@@ -0,0 +1,298 @@
import * as net from 'net';
import * as plugins from '../tsmdb.plugins.js';
import { WireProtocol, OP_QUERY } from './WireProtocol.js';
import { CommandRouter } from './CommandRouter.js';
import { MemoryStorageAdapter } from '../storage/MemoryStorageAdapter.js';
import { FileStorageAdapter } from '../storage/FileStorageAdapter.js';
import type { IStorageAdapter } from '../storage/IStorageAdapter.js';
/**
* Server configuration options
*/
export interface ITsmdbServerOptions {
/** Port to listen on (default: 27017) */
port?: number;
/** Host to bind to (default: 127.0.0.1) */
host?: string;
/** Storage type: 'memory' or 'file' (default: 'memory') */
storage?: 'memory' | 'file';
/** Path for file storage (required if storage is 'file') */
storagePath?: string;
/** Enable persistence for memory storage */
persistPath?: string;
/** Persistence interval in ms (default: 60000) */
persistIntervalMs?: number;
}
/**
* Connection state for each client
*/
interface IConnectionState {
id: number;
socket: net.Socket;
buffer: Buffer;
authenticated: boolean;
database: string;
}
/**
* TsmdbServer - MongoDB Wire Protocol compatible server
*
* This server implements the MongoDB wire protocol (OP_MSG) to allow
* official MongoDB drivers to connect and perform operations.
*
* @example
* ```typescript
* import { TsmdbServer } from '@push.rocks/smartmongo/tsmdb';
* import { MongoClient } from 'mongodb';
*
* const server = new TsmdbServer({ port: 27017 });
* await server.start();
*
* const client = new MongoClient('mongodb://127.0.0.1:27017');
* await client.connect();
* ```
*/
export class TsmdbServer {
private options: Required<ITsmdbServerOptions>;
private server: net.Server | null = null;
private storage: IStorageAdapter;
private commandRouter: CommandRouter;
private connections: Map<number, IConnectionState> = new Map();
private connectionIdCounter = 0;
private isRunning = false;
private startTime: Date = new Date();
constructor(options: ITsmdbServerOptions = {}) {
this.options = {
port: options.port ?? 27017,
host: options.host ?? '127.0.0.1',
storage: options.storage ?? 'memory',
storagePath: options.storagePath ?? './data',
persistPath: options.persistPath ?? '',
persistIntervalMs: options.persistIntervalMs ?? 60000,
};
// Create storage adapter
if (this.options.storage === 'file') {
this.storage = new FileStorageAdapter(this.options.storagePath);
} else {
this.storage = new MemoryStorageAdapter({
persistPath: this.options.persistPath || undefined,
persistIntervalMs: this.options.persistPath ? this.options.persistIntervalMs : undefined,
});
}
// Create command router
this.commandRouter = new CommandRouter(this.storage, this);
}
/**
* Get the storage adapter (for testing/debugging)
*/
getStorage(): IStorageAdapter {
return this.storage;
}
/**
* Get server uptime in seconds
*/
getUptime(): number {
return Math.floor((Date.now() - this.startTime.getTime()) / 1000);
}
/**
* Get current connection count
*/
getConnectionCount(): number {
return this.connections.size;
}
/**
* Start the server
*/
async start(): Promise<void> {
if (this.isRunning) {
throw new Error('Server is already running');
}
// Initialize storage
await this.storage.initialize();
return new Promise((resolve, reject) => {
this.server = net.createServer((socket) => {
this.handleConnection(socket);
});
this.server.on('error', (err) => {
if (!this.isRunning) {
reject(err);
} else {
console.error('Server error:', err);
}
});
this.server.listen(this.options.port, this.options.host, () => {
this.isRunning = true;
this.startTime = new Date();
resolve();
});
});
}
/**
* Stop the server
*/
async stop(): Promise<void> {
if (!this.isRunning || !this.server) {
return;
}
// Close all connections
for (const conn of this.connections.values()) {
conn.socket.destroy();
}
this.connections.clear();
// Close storage
await this.storage.close();
return new Promise((resolve) => {
this.server!.close(() => {
this.isRunning = false;
this.server = null;
resolve();
});
});
}
/**
* Handle a new client connection
*/
private handleConnection(socket: net.Socket): void {
const connectionId = ++this.connectionIdCounter;
const state: IConnectionState = {
id: connectionId,
socket,
buffer: Buffer.alloc(0),
authenticated: true, // No auth required for now
database: 'test',
};
this.connections.set(connectionId, state);
socket.on('data', (data) => {
this.handleData(state, Buffer.isBuffer(data) ? data : Buffer.from(data));
});
socket.on('close', () => {
this.connections.delete(connectionId);
});
socket.on('error', (err) => {
// Connection errors are expected when clients disconnect
this.connections.delete(connectionId);
});
}
/**
* Handle incoming data from a client
*/
private handleData(state: IConnectionState, data: Buffer): void {
// Append new data to buffer
state.buffer = Buffer.concat([state.buffer, data]);
// Process messages from buffer
this.processMessages(state);
}
/**
* Process complete messages from the buffer
*/
private async processMessages(state: IConnectionState): Promise<void> {
while (state.buffer.length >= 16) {
try {
const result = WireProtocol.parseMessage(state.buffer);
if (!result) {
// Not enough data for a complete message
break;
}
const { command, bytesConsumed } = result;
// Remove processed bytes from buffer
state.buffer = state.buffer.subarray(bytesConsumed);
// Process the command
const response = await this.commandRouter.route(command);
// Encode and send response
let responseBuffer: Buffer;
if (command.opCode === OP_QUERY) {
// Legacy OP_QUERY gets OP_REPLY response
responseBuffer = WireProtocol.encodeOpReplyResponse(
command.requestID,
[response]
);
} else {
// OP_MSG gets OP_MSG response
responseBuffer = WireProtocol.encodeOpMsgResponse(
command.requestID,
response
);
}
if (!state.socket.destroyed) {
state.socket.write(responseBuffer);
}
} catch (error: any) {
// Send error response
const errorResponse = WireProtocol.encodeErrorResponse(
0, // We don't have the requestID at this point
1,
error.message || 'Internal error'
);
if (!state.socket.destroyed) {
state.socket.write(errorResponse);
}
// Clear buffer on parse errors to avoid infinite loops
if (error.message?.includes('opCode') || error.message?.includes('section')) {
state.buffer = Buffer.alloc(0);
}
break;
}
}
}
/**
* Get the connection URI for this server
*/
getConnectionUri(): string {
return `mongodb://${this.options.host}:${this.options.port}`;
}
/**
* Check if the server is running
*/
get running(): boolean {
return this.isRunning;
}
/**
* Get the port the server is listening on
*/
get port(): number {
return this.options.port;
}
/**
* Get the host the server is bound to
*/
get host(): string {
return this.options.host;
}
}

View File

@@ -0,0 +1,416 @@
import * as plugins from '../tsmdb.plugins.js';
/**
* MongoDB Wire Protocol Implementation
* Handles parsing and encoding of MongoDB wire protocol messages (OP_MSG primarily)
*
* Wire Protocol Message Format:
* - Header (16 bytes): messageLength (4), requestID (4), responseTo (4), opCode (4)
* - OP_MSG: flagBits (4), sections[], optional checksum (4)
*
* References:
* - https://www.mongodb.com/docs/manual/reference/mongodb-wire-protocol/
*/
// OpCodes
export const OP_REPLY = 1; // Legacy reply
export const OP_UPDATE = 2001; // Legacy update
export const OP_INSERT = 2002; // Legacy insert
export const OP_QUERY = 2004; // Legacy query (still used for initial handshake)
export const OP_GET_MORE = 2005; // Legacy getMore
export const OP_DELETE = 2006; // Legacy delete
export const OP_KILL_CURSORS = 2007; // Legacy kill cursors
export const OP_COMPRESSED = 2012; // Compressed message
export const OP_MSG = 2013; // Modern protocol (MongoDB 3.6+)
// OP_MSG Section Types
export const SECTION_BODY = 0; // Single BSON document
export const SECTION_DOCUMENT_SEQUENCE = 1; // Document sequence for bulk operations
// OP_MSG Flag Bits
export const MSG_FLAG_CHECKSUM_PRESENT = 1 << 0;
export const MSG_FLAG_MORE_TO_COME = 1 << 1;
export const MSG_FLAG_EXHAUST_ALLOWED = 1 << 16;
/**
* Parsed message header
*/
export interface IMessageHeader {
messageLength: number;
requestID: number;
responseTo: number;
opCode: number;
}
/**
* Parsed OP_MSG message
*/
export interface IOpMsgMessage {
header: IMessageHeader;
flagBits: number;
sections: IOpMsgSection[];
checksum?: number;
}
/**
* OP_MSG section (either body or document sequence)
*/
export interface IOpMsgSection {
type: number;
payload: plugins.bson.Document;
sequenceIdentifier?: string;
documents?: plugins.bson.Document[];
}
/**
* Parsed OP_QUERY message (legacy, but used for initial handshake)
*/
export interface IOpQueryMessage {
header: IMessageHeader;
flags: number;
fullCollectionName: string;
numberToSkip: number;
numberToReturn: number;
query: plugins.bson.Document;
returnFieldsSelector?: plugins.bson.Document;
}
/**
* Parsed command from any message type
*/
export interface IParsedCommand {
commandName: string;
command: plugins.bson.Document;
database: string;
requestID: number;
opCode: number;
documentSequences?: Map<string, plugins.bson.Document[]>;
}
/**
* Wire Protocol parser and encoder
*/
export class WireProtocol {
/**
* Parse a complete message from a buffer
* Returns the parsed command and the number of bytes consumed
*/
static parseMessage(buffer: Buffer): { command: IParsedCommand; bytesConsumed: number } | null {
if (buffer.length < 16) {
return null; // Not enough data for header
}
const header = this.parseHeader(buffer);
if (buffer.length < header.messageLength) {
return null; // Not enough data for complete message
}
const messageBuffer = buffer.subarray(0, header.messageLength);
switch (header.opCode) {
case OP_MSG:
return this.parseOpMsg(messageBuffer, header);
case OP_QUERY:
return this.parseOpQuery(messageBuffer, header);
default:
throw new Error(`Unsupported opCode: ${header.opCode}`);
}
}
/**
* Parse message header (16 bytes)
*/
private static parseHeader(buffer: Buffer): IMessageHeader {
return {
messageLength: buffer.readInt32LE(0),
requestID: buffer.readInt32LE(4),
responseTo: buffer.readInt32LE(8),
opCode: buffer.readInt32LE(12),
};
}
/**
* Parse OP_MSG message
*/
private static parseOpMsg(buffer: Buffer, header: IMessageHeader): { command: IParsedCommand; bytesConsumed: number } {
let offset = 16; // Skip header
const flagBits = buffer.readUInt32LE(offset);
offset += 4;
const sections: IOpMsgSection[] = [];
const documentSequences = new Map<string, plugins.bson.Document[]>();
// Parse sections until we reach the end (or checksum)
const messageEnd = (flagBits & MSG_FLAG_CHECKSUM_PRESENT)
? header.messageLength - 4
: header.messageLength;
while (offset < messageEnd) {
const sectionType = buffer.readUInt8(offset);
offset += 1;
if (sectionType === SECTION_BODY) {
// Single BSON document
const docSize = buffer.readInt32LE(offset);
const docBuffer = buffer.subarray(offset, offset + docSize);
const doc = plugins.bson.deserialize(docBuffer);
sections.push({ type: SECTION_BODY, payload: doc });
offset += docSize;
} else if (sectionType === SECTION_DOCUMENT_SEQUENCE) {
// Document sequence
const sectionSize = buffer.readInt32LE(offset);
const sectionEnd = offset + sectionSize;
offset += 4;
// Read sequence identifier (C string)
let identifierEnd = offset;
while (buffer[identifierEnd] !== 0 && identifierEnd < sectionEnd) {
identifierEnd++;
}
const identifier = buffer.subarray(offset, identifierEnd).toString('utf8');
offset = identifierEnd + 1; // Skip null terminator
// Read documents
const documents: plugins.bson.Document[] = [];
while (offset < sectionEnd) {
const docSize = buffer.readInt32LE(offset);
const docBuffer = buffer.subarray(offset, offset + docSize);
documents.push(plugins.bson.deserialize(docBuffer));
offset += docSize;
}
sections.push({
type: SECTION_DOCUMENT_SEQUENCE,
payload: {},
sequenceIdentifier: identifier,
documents
});
documentSequences.set(identifier, documents);
} else {
throw new Error(`Unknown section type: ${sectionType}`);
}
}
// The first section body contains the command
const commandSection = sections.find(s => s.type === SECTION_BODY);
if (!commandSection) {
throw new Error('OP_MSG missing command body section');
}
const command = commandSection.payload;
const commandName = Object.keys(command)[0];
const database = command.$db || 'admin';
return {
command: {
commandName,
command,
database,
requestID: header.requestID,
opCode: header.opCode,
documentSequences: documentSequences.size > 0 ? documentSequences : undefined,
},
bytesConsumed: header.messageLength,
};
}
/**
* Parse OP_QUERY message (legacy, used for initial handshake)
*/
private static parseOpQuery(buffer: Buffer, header: IMessageHeader): { command: IParsedCommand; bytesConsumed: number } {
let offset = 16; // Skip header
const flags = buffer.readInt32LE(offset);
offset += 4;
// Read full collection name (C string)
let nameEnd = offset;
while (buffer[nameEnd] !== 0 && nameEnd < buffer.length) {
nameEnd++;
}
const fullCollectionName = buffer.subarray(offset, nameEnd).toString('utf8');
offset = nameEnd + 1;
const numberToSkip = buffer.readInt32LE(offset);
offset += 4;
const numberToReturn = buffer.readInt32LE(offset);
offset += 4;
// Read query document
const querySize = buffer.readInt32LE(offset);
const queryBuffer = buffer.subarray(offset, offset + querySize);
const query = plugins.bson.deserialize(queryBuffer);
offset += querySize;
// Extract database from collection name (format: "dbname.$cmd" or "dbname.collection")
const parts = fullCollectionName.split('.');
const database = parts[0];
// For OP_QUERY to .$cmd, the query IS the command
let commandName = 'find';
let command = query;
if (parts[1] === '$cmd') {
// This is a command
commandName = Object.keys(query)[0];
// Handle special commands like isMaster, hello
if (commandName === 'isMaster' || commandName === 'ismaster') {
commandName = 'hello';
}
}
return {
command: {
commandName,
command,
database,
requestID: header.requestID,
opCode: header.opCode,
},
bytesConsumed: header.messageLength,
};
}
/**
* Encode a response as OP_MSG
*/
static encodeOpMsgResponse(
responseTo: number,
response: plugins.bson.Document,
requestID: number = Math.floor(Math.random() * 0x7FFFFFFF)
): Buffer {
// Add $db if not present (optional in response)
const responseDoc = { ...response };
// Serialize the response document
const bodyBson = plugins.bson.serialize(responseDoc);
// Calculate message length
// Header (16) + flagBits (4) + section type (1) + body BSON
const messageLength = 16 + 4 + 1 + bodyBson.length;
const buffer = Buffer.alloc(messageLength);
let offset = 0;
// Write header
buffer.writeInt32LE(messageLength, offset); // messageLength
offset += 4;
buffer.writeInt32LE(requestID, offset); // requestID
offset += 4;
buffer.writeInt32LE(responseTo, offset); // responseTo
offset += 4;
buffer.writeInt32LE(OP_MSG, offset); // opCode
offset += 4;
// Write flagBits (0 = no flags)
buffer.writeUInt32LE(0, offset);
offset += 4;
// Write section type 0 (body)
buffer.writeUInt8(SECTION_BODY, offset);
offset += 1;
// Write body BSON
Buffer.from(bodyBson).copy(buffer, offset);
return buffer;
}
/**
* Encode a response as OP_REPLY (legacy, for OP_QUERY responses)
*/
static encodeOpReplyResponse(
responseTo: number,
documents: plugins.bson.Document[],
requestID: number = Math.floor(Math.random() * 0x7FFFFFFF),
cursorId: bigint = BigInt(0)
): Buffer {
// Serialize all documents
const docBuffers = documents.map(doc => plugins.bson.serialize(doc));
const totalDocsSize = docBuffers.reduce((sum, buf) => sum + buf.length, 0);
// Message format:
// Header (16) + responseFlags (4) + cursorID (8) + startingFrom (4) + numberReturned (4) + documents
const messageLength = 16 + 4 + 8 + 4 + 4 + totalDocsSize;
const buffer = Buffer.alloc(messageLength);
let offset = 0;
// Write header
buffer.writeInt32LE(messageLength, offset); // messageLength
offset += 4;
buffer.writeInt32LE(requestID, offset); // requestID
offset += 4;
buffer.writeInt32LE(responseTo, offset); // responseTo
offset += 4;
buffer.writeInt32LE(OP_REPLY, offset); // opCode
offset += 4;
// Write OP_REPLY fields
buffer.writeInt32LE(0, offset); // responseFlags (0 = no errors)
offset += 4;
buffer.writeBigInt64LE(cursorId, offset); // cursorID
offset += 8;
buffer.writeInt32LE(0, offset); // startingFrom
offset += 4;
buffer.writeInt32LE(documents.length, offset); // numberReturned
offset += 4;
// Write documents
for (const docBuffer of docBuffers) {
Buffer.from(docBuffer).copy(buffer, offset);
offset += docBuffer.length;
}
return buffer;
}
/**
* Encode an error response
*/
static encodeErrorResponse(
responseTo: number,
errorCode: number,
errorMessage: string,
commandName?: string
): Buffer {
const response: plugins.bson.Document = {
ok: 0,
errmsg: errorMessage,
code: errorCode,
codeName: this.getErrorCodeName(errorCode),
};
return this.encodeOpMsgResponse(responseTo, response);
}
/**
* Get error code name from error code
*/
private static getErrorCodeName(code: number): string {
const errorNames: Record<number, string> = {
0: 'OK',
1: 'InternalError',
2: 'BadValue',
11000: 'DuplicateKey',
11001: 'DuplicateKeyValue',
13: 'Unauthorized',
26: 'NamespaceNotFound',
27: 'IndexNotFound',
48: 'NamespaceExists',
59: 'CommandNotFound',
66: 'ImmutableField',
73: 'InvalidNamespace',
85: 'IndexOptionsConflict',
112: 'WriteConflict',
121: 'DocumentValidationFailure',
211: 'KeyNotFound',
251: 'NoSuchTransaction',
};
return errorNames[code] || 'UnknownError';
}
}

View File

@@ -0,0 +1,614 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext } from '../CommandRouter.js';
/**
* AdminHandler - Handles administrative commands
*/
export class AdminHandler implements ICommandHandler {
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command } = context;
// Determine which command to handle
if (command.ping !== undefined) {
return this.handlePing(context);
} else if (command.listDatabases !== undefined) {
return this.handleListDatabases(context);
} else if (command.listCollections !== undefined) {
return this.handleListCollections(context);
} else if (command.drop !== undefined) {
return this.handleDrop(context);
} else if (command.dropDatabase !== undefined) {
return this.handleDropDatabase(context);
} else if (command.create !== undefined) {
return this.handleCreate(context);
} else if (command.serverStatus !== undefined) {
return this.handleServerStatus(context);
} else if (command.buildInfo !== undefined) {
return this.handleBuildInfo(context);
} else if (command.whatsmyuri !== undefined) {
return this.handleWhatsMyUri(context);
} else if (command.getLog !== undefined) {
return this.handleGetLog(context);
} else if (command.hostInfo !== undefined) {
return this.handleHostInfo(context);
} else if (command.replSetGetStatus !== undefined) {
return this.handleReplSetGetStatus(context);
} else if (command.saslStart !== undefined) {
return this.handleSaslStart(context);
} else if (command.saslContinue !== undefined) {
return this.handleSaslContinue(context);
} else if (command.endSessions !== undefined) {
return this.handleEndSessions(context);
} else if (command.abortTransaction !== undefined) {
return this.handleAbortTransaction(context);
} else if (command.commitTransaction !== undefined) {
return this.handleCommitTransaction(context);
} else if (command.collStats !== undefined) {
return this.handleCollStats(context);
} else if (command.dbStats !== undefined) {
return this.handleDbStats(context);
} else if (command.connectionStatus !== undefined) {
return this.handleConnectionStatus(context);
} else if (command.currentOp !== undefined) {
return this.handleCurrentOp(context);
} else if (command.collMod !== undefined) {
return this.handleCollMod(context);
} else if (command.renameCollection !== undefined) {
return this.handleRenameCollection(context);
}
return {
ok: 0,
errmsg: 'Unknown admin command',
code: 59,
codeName: 'CommandNotFound',
};
}
/**
* Handle ping command
*/
private async handlePing(context: IHandlerContext): Promise<plugins.bson.Document> {
return { ok: 1 };
}
/**
* Handle listDatabases command
*/
private async handleListDatabases(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, command } = context;
const dbNames = await storage.listDatabases();
const nameOnly = command.nameOnly || false;
if (nameOnly) {
return {
ok: 1,
databases: dbNames.map(name => ({ name })),
};
}
// Build database list with sizes
const databases: plugins.bson.Document[] = [];
let totalSize = 0;
for (const name of dbNames) {
const collections = await storage.listCollections(name);
let dbSize = 0;
for (const collName of collections) {
const docs = await storage.findAll(name, collName);
// Estimate size (rough approximation)
dbSize += docs.reduce((sum, doc) => sum + JSON.stringify(doc).length, 0);
}
totalSize += dbSize;
databases.push({
name,
sizeOnDisk: dbSize,
empty: dbSize === 0,
});
}
return {
ok: 1,
databases,
totalSize,
totalSizeMb: totalSize / (1024 * 1024),
};
}
/**
* Handle listCollections command
*/
private async handleListCollections(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const filter = command.filter || {};
const nameOnly = command.nameOnly || false;
const cursor = command.cursor || {};
const batchSize = cursor.batchSize || 101;
const collNames = await storage.listCollections(database);
let collections: plugins.bson.Document[] = [];
for (const name of collNames) {
// Apply name filter
if (filter.name && filter.name !== name) {
// Check regex
if (filter.name.$regex) {
const regex = new RegExp(filter.name.$regex, filter.name.$options);
if (!regex.test(name)) continue;
} else {
continue;
}
}
if (nameOnly) {
collections.push({ name });
} else {
collections.push({
name,
type: 'collection',
options: {},
info: {
readOnly: false,
uuid: new plugins.bson.UUID(),
},
idIndex: {
v: 2,
key: { _id: 1 },
name: '_id_',
},
});
}
}
return {
ok: 1,
cursor: {
id: plugins.bson.Long.fromNumber(0),
ns: `${database}.$cmd.listCollections`,
firstBatch: collections,
},
};
}
/**
* Handle drop command (drop collection)
*/
private async handleDrop(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.drop;
const existed = await storage.dropCollection(database, collection);
if (!existed) {
return {
ok: 0,
errmsg: `ns not found ${database}.${collection}`,
code: 26,
codeName: 'NamespaceNotFound',
};
}
return { ok: 1, ns: `${database}.${collection}` };
}
/**
* Handle dropDatabase command
*/
private async handleDropDatabase(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database } = context;
await storage.dropDatabase(database);
return { ok: 1, dropped: database };
}
/**
* Handle create command (create collection)
*/
private async handleCreate(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.create;
// Check if already exists
const exists = await storage.collectionExists(database, collection);
if (exists) {
return {
ok: 0,
errmsg: `Collection ${database}.${collection} already exists.`,
code: 48,
codeName: 'NamespaceExists',
};
}
await storage.createCollection(database, collection);
return { ok: 1 };
}
/**
* Handle serverStatus command
*/
private async handleServerStatus(context: IHandlerContext): Promise<plugins.bson.Document> {
const { server } = context;
const uptime = server.getUptime();
const connections = server.getConnectionCount();
return {
ok: 1,
host: `${server.host}:${server.port}`,
version: '7.0.0',
process: 'tsmdb',
pid: process.pid,
uptime,
uptimeMillis: uptime * 1000,
uptimeEstimate: uptime,
localTime: new Date(),
mem: {
resident: Math.floor(process.memoryUsage().rss / (1024 * 1024)),
virtual: Math.floor(process.memoryUsage().heapTotal / (1024 * 1024)),
supported: true,
},
connections: {
current: connections,
available: 1000 - connections,
totalCreated: connections,
active: connections,
},
network: {
bytesIn: 0,
bytesOut: 0,
numRequests: 0,
},
storageEngine: {
name: 'tsmdb',
supportsCommittedReads: true,
persistent: false,
},
};
}
/**
* Handle buildInfo command
*/
private async handleBuildInfo(context: IHandlerContext): Promise<plugins.bson.Document> {
return {
ok: 1,
version: '7.0.0',
gitVersion: 'tsmdb',
modules: [],
allocator: 'system',
javascriptEngine: 'none',
sysInfo: 'deprecated',
versionArray: [7, 0, 0, 0],
openssl: {
running: 'disabled',
compiled: 'disabled',
},
buildEnvironment: {
distmod: 'tsmdb',
distarch: process.arch,
cc: '',
ccflags: '',
cxx: '',
cxxflags: '',
linkflags: '',
target_arch: process.arch,
target_os: process.platform,
},
bits: 64,
debug: false,
maxBsonObjectSize: 16777216,
storageEngines: ['tsmdb'],
};
}
/**
* Handle whatsmyuri command
*/
private async handleWhatsMyUri(context: IHandlerContext): Promise<plugins.bson.Document> {
const { server } = context;
return {
ok: 1,
you: `127.0.0.1:${server.port}`,
};
}
/**
* Handle getLog command
*/
private async handleGetLog(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command } = context;
if (command.getLog === '*') {
return {
ok: 1,
names: ['global', 'startupWarnings'],
};
}
return {
ok: 1,
totalLinesWritten: 0,
log: [],
};
}
/**
* Handle hostInfo command
*/
private async handleHostInfo(context: IHandlerContext): Promise<plugins.bson.Document> {
return {
ok: 1,
system: {
currentTime: new Date(),
hostname: 'localhost',
cpuAddrSize: 64,
memSizeMB: Math.floor(process.memoryUsage().heapTotal / (1024 * 1024)),
numCores: 1,
cpuArch: process.arch,
numaEnabled: false,
},
os: {
type: process.platform,
name: process.platform,
version: process.version,
},
extra: {},
};
}
/**
* Handle replSetGetStatus command
*/
private async handleReplSetGetStatus(context: IHandlerContext): Promise<plugins.bson.Document> {
// We're standalone, not a replica set
return {
ok: 0,
errmsg: 'not running with --replSet',
code: 76,
codeName: 'NoReplicationEnabled',
};
}
/**
* Handle saslStart command (authentication)
*/
private async handleSaslStart(context: IHandlerContext): Promise<plugins.bson.Document> {
// We don't require authentication, but we need to respond properly
// to let drivers know auth is "successful"
return {
ok: 1,
conversationId: 1,
done: true,
payload: Buffer.from([]),
};
}
/**
* Handle saslContinue command
*/
private async handleSaslContinue(context: IHandlerContext): Promise<plugins.bson.Document> {
return {
ok: 1,
conversationId: 1,
done: true,
payload: Buffer.from([]),
};
}
/**
* Handle endSessions command
*/
private async handleEndSessions(context: IHandlerContext): Promise<plugins.bson.Document> {
return { ok: 1 };
}
/**
* Handle abortTransaction command
*/
private async handleAbortTransaction(context: IHandlerContext): Promise<plugins.bson.Document> {
// Transactions are not fully supported, but acknowledge the command
return { ok: 1 };
}
/**
* Handle commitTransaction command
*/
private async handleCommitTransaction(context: IHandlerContext): Promise<plugins.bson.Document> {
// Transactions are not fully supported, but acknowledge the command
return { ok: 1 };
}
/**
* Handle collStats command
*/
private async handleCollStats(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.collStats;
const exists = await storage.collectionExists(database, collection);
if (!exists) {
return {
ok: 0,
errmsg: `ns not found ${database}.${collection}`,
code: 26,
codeName: 'NamespaceNotFound',
};
}
const docs = await storage.findAll(database, collection);
const size = docs.reduce((sum, doc) => sum + JSON.stringify(doc).length, 0);
const count = docs.length;
const avgObjSize = count > 0 ? size / count : 0;
const indexes = await storage.getIndexes(database, collection);
return {
ok: 1,
ns: `${database}.${collection}`,
count,
size,
avgObjSize,
storageSize: size,
totalIndexSize: 0,
indexSizes: indexes.reduce((acc: any, idx: any) => {
acc[idx.name] = 0;
return acc;
}, {}),
nindexes: indexes.length,
};
}
/**
* Handle dbStats command
*/
private async handleDbStats(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database } = context;
const collections = await storage.listCollections(database);
let totalSize = 0;
let totalObjects = 0;
for (const collName of collections) {
const docs = await storage.findAll(database, collName);
totalObjects += docs.length;
totalSize += docs.reduce((sum, doc) => sum + JSON.stringify(doc).length, 0);
}
return {
ok: 1,
db: database,
collections: collections.length,
views: 0,
objects: totalObjects,
avgObjSize: totalObjects > 0 ? totalSize / totalObjects : 0,
dataSize: totalSize,
storageSize: totalSize,
indexes: collections.length, // At least _id index per collection
indexSize: 0,
totalSize,
};
}
/**
* Handle connectionStatus command
*/
private async handleConnectionStatus(context: IHandlerContext): Promise<plugins.bson.Document> {
return {
ok: 1,
authInfo: {
authenticatedUsers: [],
authenticatedUserRoles: [],
},
};
}
/**
* Handle currentOp command
*/
private async handleCurrentOp(context: IHandlerContext): Promise<plugins.bson.Document> {
return {
ok: 1,
inprog: [],
};
}
/**
* Handle collMod command
*/
private async handleCollMod(context: IHandlerContext): Promise<plugins.bson.Document> {
// We don't support modifying collection options, but acknowledge the command
return { ok: 1 };
}
/**
* Handle renameCollection command
*/
private async handleRenameCollection(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, command } = context;
const from = command.renameCollection;
const to = command.to;
const dropTarget = command.dropTarget || false;
if (!from || !to) {
return {
ok: 0,
errmsg: 'renameCollection requires both source and target',
code: 2,
codeName: 'BadValue',
};
}
// Parse namespace (format: "db.collection")
const fromParts = from.split('.');
const toParts = to.split('.');
if (fromParts.length < 2 || toParts.length < 2) {
return {
ok: 0,
errmsg: 'Invalid namespace format',
code: 73,
codeName: 'InvalidNamespace',
};
}
const fromDb = fromParts[0];
const fromColl = fromParts.slice(1).join('.');
const toDb = toParts[0];
const toColl = toParts.slice(1).join('.');
// Check if source exists
const sourceExists = await storage.collectionExists(fromDb, fromColl);
if (!sourceExists) {
return {
ok: 0,
errmsg: `source namespace ${from} does not exist`,
code: 26,
codeName: 'NamespaceNotFound',
};
}
// Check if target exists
const targetExists = await storage.collectionExists(toDb, toColl);
if (targetExists) {
if (dropTarget) {
await storage.dropCollection(toDb, toColl);
} else {
return {
ok: 0,
errmsg: `target namespace ${to} already exists`,
code: 48,
codeName: 'NamespaceExists',
};
}
}
// Same database rename
if (fromDb === toDb) {
await storage.renameCollection(fromDb, fromColl, toColl);
} else {
// Cross-database rename: copy documents then drop source
await storage.createCollection(toDb, toColl);
const docs = await storage.findAll(fromDb, fromColl);
for (const doc of docs) {
await storage.insertOne(toDb, toColl, doc);
}
await storage.dropCollection(fromDb, fromColl);
}
return { ok: 1 };
}
}

View File

@@ -0,0 +1,342 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext, ICursorState } from '../CommandRouter.js';
import { AggregationEngine } from '../../engine/AggregationEngine.js';
/**
* AggregateHandler - Handles aggregate command
*/
export class AggregateHandler implements ICommandHandler {
private cursors: Map<bigint, ICursorState>;
private nextCursorId: () => bigint;
constructor(
cursors: Map<bigint, ICursorState>,
nextCursorId: () => bigint
) {
this.cursors = cursors;
this.nextCursorId = nextCursorId;
}
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.aggregate;
const pipeline = command.pipeline || [];
const cursor = command.cursor || {};
const batchSize = cursor.batchSize || 101;
// Validate
if (typeof collection !== 'string' && collection !== 1) {
return {
ok: 0,
errmsg: 'aggregate command requires a collection name or 1',
code: 2,
codeName: 'BadValue',
};
}
if (!Array.isArray(pipeline)) {
return {
ok: 0,
errmsg: 'pipeline must be an array',
code: 2,
codeName: 'BadValue',
};
}
try {
// Get source documents
let documents: plugins.bson.Document[] = [];
if (collection === 1 || collection === '1') {
// Database-level aggregation (e.g., $listLocalSessions)
documents = [];
} else {
// Collection-level aggregation
const exists = await storage.collectionExists(database, collection);
if (exists) {
documents = await storage.findAll(database, collection);
}
}
// Handle $lookup and $graphLookup stages that reference other collections
const processedPipeline = await this.preprocessPipeline(
storage,
database,
pipeline,
documents
);
// Run aggregation
let results: plugins.bson.Document[];
// Check for special stages that we handle manually
if (this.hasSpecialStages(pipeline)) {
results = await this.executeWithSpecialStages(
storage,
database,
documents,
pipeline
);
} else {
results = AggregationEngine.aggregate(documents as any, processedPipeline);
}
// Handle $out and $merge stages
const lastStage = pipeline[pipeline.length - 1];
if (lastStage && lastStage.$out) {
await this.handleOut(storage, database, results, lastStage.$out);
return { ok: 1, cursor: { id: plugins.bson.Long.fromNumber(0), ns: `${database}.${collection}`, firstBatch: [] } };
}
if (lastStage && lastStage.$merge) {
await this.handleMerge(storage, database, results, lastStage.$merge);
return { ok: 1, cursor: { id: plugins.bson.Long.fromNumber(0), ns: `${database}.${collection}`, firstBatch: [] } };
}
// Build cursor response
const effectiveBatchSize = Math.min(batchSize, results.length);
const firstBatch = results.slice(0, effectiveBatchSize);
const remaining = results.slice(effectiveBatchSize);
let cursorId = BigInt(0);
if (remaining.length > 0) {
cursorId = this.nextCursorId();
this.cursors.set(cursorId, {
id: cursorId,
database,
collection: typeof collection === 'string' ? collection : '$cmd.aggregate',
documents: remaining,
position: 0,
batchSize,
createdAt: new Date(),
});
}
return {
ok: 1,
cursor: {
id: plugins.bson.Long.fromBigInt(cursorId),
ns: `${database}.${typeof collection === 'string' ? collection : '$cmd.aggregate'}`,
firstBatch,
},
};
} catch (error: any) {
return {
ok: 0,
errmsg: error.message || 'Aggregation failed',
code: 1,
codeName: 'InternalError',
};
}
}
/**
* Preprocess pipeline to handle cross-collection lookups
*/
private async preprocessPipeline(
storage: any,
database: string,
pipeline: plugins.bson.Document[],
documents: plugins.bson.Document[]
): Promise<plugins.bson.Document[]> {
// For now, return the pipeline as-is
// Cross-collection lookups are handled in executeWithSpecialStages
return pipeline;
}
/**
* Check if pipeline has stages that need special handling
*/
private hasSpecialStages(pipeline: plugins.bson.Document[]): boolean {
return pipeline.some(stage =>
stage.$lookup ||
stage.$graphLookup ||
stage.$unionWith
);
}
/**
* Execute pipeline with special stage handling
*/
private async executeWithSpecialStages(
storage: any,
database: string,
documents: plugins.bson.Document[],
pipeline: plugins.bson.Document[]
): Promise<plugins.bson.Document[]> {
let results: plugins.bson.Document[] = [...documents];
for (const stage of pipeline) {
if (stage.$lookup) {
const lookupSpec = stage.$lookup;
const fromCollection = lookupSpec.from;
// Get foreign collection documents
const foreignExists = await storage.collectionExists(database, fromCollection);
const foreignDocs = foreignExists
? await storage.findAll(database, fromCollection)
: [];
results = AggregationEngine.executeLookup(results as any, lookupSpec, foreignDocs);
} else if (stage.$graphLookup) {
const graphLookupSpec = stage.$graphLookup;
const fromCollection = graphLookupSpec.from;
const foreignExists = await storage.collectionExists(database, fromCollection);
const foreignDocs = foreignExists
? await storage.findAll(database, fromCollection)
: [];
results = AggregationEngine.executeGraphLookup(results as any, graphLookupSpec, foreignDocs);
} else if (stage.$unionWith) {
let unionSpec = stage.$unionWith;
let unionColl: string;
let unionPipeline: plugins.bson.Document[] | undefined;
if (typeof unionSpec === 'string') {
unionColl = unionSpec;
} else {
unionColl = unionSpec.coll;
unionPipeline = unionSpec.pipeline;
}
const unionExists = await storage.collectionExists(database, unionColl);
const unionDocs = unionExists
? await storage.findAll(database, unionColl)
: [];
results = AggregationEngine.executeUnionWith(results as any, unionDocs, unionPipeline);
} else if (stage.$facet) {
// Execute each facet pipeline separately
const facetResults: plugins.bson.Document = {};
for (const [facetName, facetPipeline] of Object.entries(stage.$facet)) {
const facetDocs = await this.executeWithSpecialStages(
storage,
database,
results,
facetPipeline as plugins.bson.Document[]
);
facetResults[facetName] = facetDocs;
}
results = [facetResults];
} else {
// Regular stage - pass to mingo
results = AggregationEngine.aggregate(results as any, [stage]);
}
}
return results;
}
/**
* Handle $out stage - write results to a collection
*/
private async handleOut(
storage: any,
database: string,
results: plugins.bson.Document[],
outSpec: string | { db?: string; coll: string }
): Promise<void> {
let targetDb = database;
let targetColl: string;
if (typeof outSpec === 'string') {
targetColl = outSpec;
} else {
targetDb = outSpec.db || database;
targetColl = outSpec.coll;
}
// Drop existing collection
await storage.dropCollection(targetDb, targetColl);
// Create new collection and insert results
await storage.createCollection(targetDb, targetColl);
for (const doc of results) {
if (!doc._id) {
doc._id = new plugins.bson.ObjectId();
}
await storage.insertOne(targetDb, targetColl, doc);
}
}
/**
* Handle $merge stage - merge results into a collection
*/
private async handleMerge(
storage: any,
database: string,
results: plugins.bson.Document[],
mergeSpec: any
): Promise<void> {
let targetDb = database;
let targetColl: string;
if (typeof mergeSpec === 'string') {
targetColl = mergeSpec;
} else if (typeof mergeSpec.into === 'string') {
targetColl = mergeSpec.into;
} else {
targetDb = mergeSpec.into.db || database;
targetColl = mergeSpec.into.coll;
}
const on = mergeSpec.on || '_id';
const whenMatched = mergeSpec.whenMatched || 'merge';
const whenNotMatched = mergeSpec.whenNotMatched || 'insert';
// Ensure target collection exists
await storage.createCollection(targetDb, targetColl);
for (const doc of results) {
// Find matching document
const existingDocs = await storage.findAll(targetDb, targetColl);
const onFields = Array.isArray(on) ? on : [on];
let matchingDoc = null;
for (const existing of existingDocs) {
let matches = true;
for (const field of onFields) {
if (JSON.stringify(existing[field]) !== JSON.stringify(doc[field])) {
matches = false;
break;
}
}
if (matches) {
matchingDoc = existing;
break;
}
}
if (matchingDoc) {
// Handle whenMatched
if (whenMatched === 'replace') {
await storage.updateById(targetDb, targetColl, matchingDoc._id, doc);
} else if (whenMatched === 'keepExisting') {
// Do nothing
} else if (whenMatched === 'merge') {
const merged = { ...matchingDoc, ...doc };
await storage.updateById(targetDb, targetColl, matchingDoc._id, merged);
} else if (whenMatched === 'fail') {
throw new Error('Document matched but whenMatched is fail');
}
} else {
// Handle whenNotMatched
if (whenNotMatched === 'insert') {
if (!doc._id) {
doc._id = new plugins.bson.ObjectId();
}
await storage.insertOne(targetDb, targetColl, doc);
} else if (whenNotMatched === 'discard') {
// Do nothing
} else if (whenNotMatched === 'fail') {
throw new Error('Document not matched but whenNotMatched is fail');
}
}
}
}
}

View File

@@ -0,0 +1,100 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext } from '../CommandRouter.js';
import { QueryEngine } from '../../engine/QueryEngine.js';
/**
* DeleteHandler - Handles delete commands
*/
export class DeleteHandler implements ICommandHandler {
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command, documentSequences } = context;
const collection = command.delete;
if (typeof collection !== 'string') {
return {
ok: 0,
errmsg: 'delete command requires a collection name',
code: 2,
codeName: 'BadValue',
};
}
// Get deletes from command or document sequences
let deletes: plugins.bson.Document[] = command.deletes || [];
// Check for OP_MSG document sequences
if (documentSequences && documentSequences.has('deletes')) {
deletes = documentSequences.get('deletes')!;
}
if (!Array.isArray(deletes) || deletes.length === 0) {
return {
ok: 0,
errmsg: 'delete command requires deletes array',
code: 2,
codeName: 'BadValue',
};
}
const ordered = command.ordered !== false;
const writeErrors: plugins.bson.Document[] = [];
let totalDeleted = 0;
// Check if collection exists
const exists = await storage.collectionExists(database, collection);
if (!exists) {
// Collection doesn't exist, return success with 0 deleted
return { ok: 1, n: 0 };
}
for (let i = 0; i < deletes.length; i++) {
const deleteSpec = deletes[i];
const filter = deleteSpec.q || deleteSpec.filter || {};
const limit = deleteSpec.limit;
// limit: 0 means delete all matching, limit: 1 means delete one
const deleteAll = limit === 0;
try {
// Get all documents
const documents = await storage.findAll(database, collection);
// Apply filter
const matchingDocs = QueryEngine.filter(documents, filter);
if (matchingDocs.length === 0) {
continue;
}
// Determine which documents to delete
const docsToDelete = deleteAll ? matchingDocs : matchingDocs.slice(0, 1);
// Delete the documents
const idsToDelete = docsToDelete.map(doc => doc._id);
const deleted = await storage.deleteByIds(database, collection, idsToDelete);
totalDeleted += deleted;
} catch (error: any) {
writeErrors.push({
index: i,
code: error.code || 1,
errmsg: error.message || 'Delete failed',
});
if (ordered) {
break;
}
}
}
const response: plugins.bson.Document = {
ok: 1,
n: totalDeleted,
};
if (writeErrors.length > 0) {
response.writeErrors = writeErrors;
}
return response;
}
}

View File

@@ -0,0 +1,301 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext, ICursorState } from '../CommandRouter.js';
import { QueryEngine } from '../../engine/QueryEngine.js';
/**
* FindHandler - Handles find, getMore, killCursors, count, distinct commands
*/
export class FindHandler implements ICommandHandler {
private cursors: Map<bigint, ICursorState>;
private nextCursorId: () => bigint;
constructor(
cursors: Map<bigint, ICursorState>,
nextCursorId: () => bigint
) {
this.cursors = cursors;
this.nextCursorId = nextCursorId;
}
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command } = context;
// Determine which operation to perform
if (command.find) {
return this.handleFind(context);
} else if (command.getMore !== undefined) {
return this.handleGetMore(context);
} else if (command.killCursors) {
return this.handleKillCursors(context);
} else if (command.count) {
return this.handleCount(context);
} else if (command.distinct) {
return this.handleDistinct(context);
}
return {
ok: 0,
errmsg: 'Unknown find-related command',
code: 59,
codeName: 'CommandNotFound',
};
}
/**
* Handle find command
*/
private async handleFind(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.find;
const filter = command.filter || {};
const projection = command.projection;
const sort = command.sort;
const skip = command.skip || 0;
const limit = command.limit || 0;
const batchSize = command.batchSize || 101;
const singleBatch = command.singleBatch || false;
// Ensure collection exists
const exists = await storage.collectionExists(database, collection);
if (!exists) {
// Return empty cursor for non-existent collection
return {
ok: 1,
cursor: {
id: plugins.bson.Long.fromNumber(0),
ns: `${database}.${collection}`,
firstBatch: [],
},
};
}
// Get all documents
let documents = await storage.findAll(database, collection);
// Apply filter
documents = QueryEngine.filter(documents, filter);
// Apply sort
if (sort) {
documents = QueryEngine.sort(documents, sort);
}
// Apply skip
if (skip > 0) {
documents = documents.slice(skip);
}
// Apply limit
if (limit > 0) {
documents = documents.slice(0, limit);
}
// Apply projection
if (projection) {
documents = QueryEngine.project(documents, projection) as any[];
}
// Determine how many documents to return in first batch
const effectiveBatchSize = Math.min(batchSize, documents.length);
const firstBatch = documents.slice(0, effectiveBatchSize);
const remaining = documents.slice(effectiveBatchSize);
// Create cursor if there are more documents
let cursorId = BigInt(0);
if (remaining.length > 0 && !singleBatch) {
cursorId = this.nextCursorId();
this.cursors.set(cursorId, {
id: cursorId,
database,
collection,
documents: remaining,
position: 0,
batchSize,
createdAt: new Date(),
});
}
return {
ok: 1,
cursor: {
id: plugins.bson.Long.fromBigInt(cursorId),
ns: `${database}.${collection}`,
firstBatch,
},
};
}
/**
* Handle getMore command
*/
private async handleGetMore(context: IHandlerContext): Promise<plugins.bson.Document> {
const { database, command } = context;
const cursorIdInput = command.getMore;
const collection = command.collection;
const batchSize = command.batchSize || 101;
// Convert cursorId to bigint
let cursorId: bigint;
if (typeof cursorIdInput === 'bigint') {
cursorId = cursorIdInput;
} else if (cursorIdInput instanceof plugins.bson.Long) {
cursorId = cursorIdInput.toBigInt();
} else {
cursorId = BigInt(cursorIdInput);
}
const cursor = this.cursors.get(cursorId);
if (!cursor) {
return {
ok: 0,
errmsg: `cursor id ${cursorId} not found`,
code: 43,
codeName: 'CursorNotFound',
};
}
// Verify namespace
if (cursor.database !== database || cursor.collection !== collection) {
return {
ok: 0,
errmsg: 'cursor namespace mismatch',
code: 43,
codeName: 'CursorNotFound',
};
}
// Get next batch
const start = cursor.position;
const end = Math.min(start + batchSize, cursor.documents.length);
const nextBatch = cursor.documents.slice(start, end);
cursor.position = end;
// Check if cursor is exhausted
let returnCursorId = cursorId;
if (cursor.position >= cursor.documents.length) {
this.cursors.delete(cursorId);
returnCursorId = BigInt(0);
}
return {
ok: 1,
cursor: {
id: plugins.bson.Long.fromBigInt(returnCursorId),
ns: `${database}.${collection}`,
nextBatch,
},
};
}
/**
* Handle killCursors command
*/
private async handleKillCursors(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command } = context;
const collection = command.killCursors;
const cursorIds = command.cursors || [];
const cursorsKilled: plugins.bson.Long[] = [];
const cursorsNotFound: plugins.bson.Long[] = [];
const cursorsUnknown: plugins.bson.Long[] = [];
for (const idInput of cursorIds) {
let cursorId: bigint;
if (typeof idInput === 'bigint') {
cursorId = idInput;
} else if (idInput instanceof plugins.bson.Long) {
cursorId = idInput.toBigInt();
} else {
cursorId = BigInt(idInput);
}
if (this.cursors.has(cursorId)) {
this.cursors.delete(cursorId);
cursorsKilled.push(plugins.bson.Long.fromBigInt(cursorId));
} else {
cursorsNotFound.push(plugins.bson.Long.fromBigInt(cursorId));
}
}
return {
ok: 1,
cursorsKilled,
cursorsNotFound,
cursorsUnknown,
cursorsAlive: [],
};
}
/**
* Handle count command
*/
private async handleCount(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.count;
const query = command.query || {};
const skip = command.skip || 0;
const limit = command.limit || 0;
// Check if collection exists
const exists = await storage.collectionExists(database, collection);
if (!exists) {
return { ok: 1, n: 0 };
}
// Get all documents
let documents = await storage.findAll(database, collection);
// Apply filter
documents = QueryEngine.filter(documents, query);
// Apply skip
if (skip > 0) {
documents = documents.slice(skip);
}
// Apply limit
if (limit > 0) {
documents = documents.slice(0, limit);
}
return { ok: 1, n: documents.length };
}
/**
* Handle distinct command
*/
private async handleDistinct(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.distinct;
const key = command.key;
const query = command.query || {};
if (!key) {
return {
ok: 0,
errmsg: 'distinct requires a key',
code: 2,
codeName: 'BadValue',
};
}
// Check if collection exists
const exists = await storage.collectionExists(database, collection);
if (!exists) {
return { ok: 1, values: [] };
}
// Get all documents
const documents = await storage.findAll(database, collection);
// Get distinct values
const values = QueryEngine.distinct(documents, key, query);
return { ok: 1, values };
}
}

View File

@@ -0,0 +1,78 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext } from '../CommandRouter.js';
/**
* HelloHandler - Handles hello/isMaster handshake commands
*
* This is the first command sent by MongoDB drivers to establish a connection.
* It returns server capabilities and configuration.
*/
export class HelloHandler implements ICommandHandler {
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command, server } = context;
// Build response with server capabilities
const response: plugins.bson.Document = {
ismaster: true,
ok: 1,
// Maximum sizes
maxBsonObjectSize: 16777216, // 16 MB
maxMessageSizeBytes: 48000000, // 48 MB
maxWriteBatchSize: 100000, // 100k documents per batch
// Timestamps
localTime: new Date(),
// Session support
logicalSessionTimeoutMinutes: 30,
// Connection info
connectionId: 1,
// Wire protocol versions (support MongoDB 3.6 through 7.0)
minWireVersion: 0,
maxWireVersion: 21,
// Server mode
readOnly: false,
// Topology info (standalone mode)
isWritablePrimary: true,
// Additional info
topologyVersion: {
processId: new plugins.bson.ObjectId(),
counter: plugins.bson.Long.fromNumber(0),
},
};
// Handle hello-specific fields
if (command.hello || command.hello === 1) {
response.helloOk = true;
}
// Handle client metadata
if (command.client) {
// Client is providing metadata about itself
// We just acknowledge it - no need to do anything special
}
// Handle SASL mechanisms query
if (command.saslSupportedMechs) {
response.saslSupportedMechs = [
// We don't actually support auth, but the driver needs to see this
];
}
// Compression support (none for now)
if (command.compression) {
response.compression = [];
}
// Server version info
response.version = '7.0.0';
return response;
}
}

View File

@@ -0,0 +1,207 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext } from '../CommandRouter.js';
import { IndexEngine } from '../../engine/IndexEngine.js';
// Cache of index engines per collection
const indexEngines: Map<string, IndexEngine> = new Map();
/**
* Get or create an IndexEngine for a collection
*/
function getIndexEngine(storage: any, database: string, collection: string): IndexEngine {
const key = `${database}.${collection}`;
let engine = indexEngines.get(key);
if (!engine) {
engine = new IndexEngine(database, collection, storage);
indexEngines.set(key, engine);
}
return engine;
}
/**
* IndexHandler - Handles createIndexes, dropIndexes, listIndexes commands
*/
export class IndexHandler implements ICommandHandler {
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command } = context;
if (command.createIndexes) {
return this.handleCreateIndexes(context);
} else if (command.dropIndexes) {
return this.handleDropIndexes(context);
} else if (command.listIndexes) {
return this.handleListIndexes(context);
}
return {
ok: 0,
errmsg: 'Unknown index command',
code: 59,
codeName: 'CommandNotFound',
};
}
/**
* Handle createIndexes command
*/
private async handleCreateIndexes(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.createIndexes;
const indexes = command.indexes || [];
if (!Array.isArray(indexes)) {
return {
ok: 0,
errmsg: 'indexes must be an array',
code: 2,
codeName: 'BadValue',
};
}
// Ensure collection exists
await storage.createCollection(database, collection);
const indexEngine = getIndexEngine(storage, database, collection);
const createdNames: string[] = [];
let numIndexesBefore = 0;
let numIndexesAfter = 0;
try {
const existingIndexes = await indexEngine.listIndexes();
numIndexesBefore = existingIndexes.length;
for (const indexSpec of indexes) {
const key = indexSpec.key;
const options = {
name: indexSpec.name,
unique: indexSpec.unique,
sparse: indexSpec.sparse,
expireAfterSeconds: indexSpec.expireAfterSeconds,
background: indexSpec.background,
partialFilterExpression: indexSpec.partialFilterExpression,
};
const name = await indexEngine.createIndex(key, options);
createdNames.push(name);
}
const finalIndexes = await indexEngine.listIndexes();
numIndexesAfter = finalIndexes.length;
} catch (error: any) {
return {
ok: 0,
errmsg: error.message || 'Failed to create index',
code: error.code || 1,
codeName: error.codeName || 'InternalError',
};
}
return {
ok: 1,
numIndexesBefore,
numIndexesAfter,
createdCollectionAutomatically: false,
commitQuorum: 'votingMembers',
};
}
/**
* Handle dropIndexes command
*/
private async handleDropIndexes(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.dropIndexes;
const indexName = command.index;
// Check if collection exists
const exists = await storage.collectionExists(database, collection);
if (!exists) {
return {
ok: 0,
errmsg: `ns not found ${database}.${collection}`,
code: 26,
codeName: 'NamespaceNotFound',
};
}
const indexEngine = getIndexEngine(storage, database, collection);
try {
if (indexName === '*') {
// Drop all indexes except _id
await indexEngine.dropAllIndexes();
} else if (typeof indexName === 'string') {
// Drop specific index by name
await indexEngine.dropIndex(indexName);
} else if (typeof indexName === 'object') {
// Drop index by key specification
const indexes = await indexEngine.listIndexes();
const keyStr = JSON.stringify(indexName);
for (const idx of indexes) {
if (JSON.stringify(idx.key) === keyStr) {
await indexEngine.dropIndex(idx.name);
break;
}
}
}
return { ok: 1, nIndexesWas: 1 };
} catch (error: any) {
return {
ok: 0,
errmsg: error.message || 'Failed to drop index',
code: error.code || 27,
codeName: error.codeName || 'IndexNotFound',
};
}
}
/**
* Handle listIndexes command
*/
private async handleListIndexes(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.listIndexes;
const cursor = command.cursor || {};
const batchSize = cursor.batchSize || 101;
// Check if collection exists
const exists = await storage.collectionExists(database, collection);
if (!exists) {
return {
ok: 0,
errmsg: `ns not found ${database}.${collection}`,
code: 26,
codeName: 'NamespaceNotFound',
};
}
const indexEngine = getIndexEngine(storage, database, collection);
const indexes = await indexEngine.listIndexes();
// Format indexes for response
const indexDocs = indexes.map(idx => ({
v: idx.v || 2,
key: idx.key,
name: idx.name,
...(idx.unique ? { unique: idx.unique } : {}),
...(idx.sparse ? { sparse: idx.sparse } : {}),
...(idx.expireAfterSeconds !== undefined ? { expireAfterSeconds: idx.expireAfterSeconds } : {}),
}));
return {
ok: 1,
cursor: {
id: plugins.bson.Long.fromNumber(0),
ns: `${database}.${collection}`,
firstBatch: indexDocs,
},
};
}
}

View File

@@ -0,0 +1,91 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext } from '../CommandRouter.js';
/**
* InsertHandler - Handles insert commands
*/
export class InsertHandler implements ICommandHandler {
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command, documentSequences } = context;
const collection = command.insert;
if (typeof collection !== 'string') {
return {
ok: 0,
errmsg: 'insert command requires a collection name',
code: 2,
codeName: 'BadValue',
};
}
// Get documents from command or document sequences
let documents: plugins.bson.Document[] = command.documents || [];
// Check for OP_MSG document sequences (for bulk inserts)
if (documentSequences && documentSequences.has('documents')) {
documents = documentSequences.get('documents')!;
}
if (!Array.isArray(documents) || documents.length === 0) {
return {
ok: 0,
errmsg: 'insert command requires documents array',
code: 2,
codeName: 'BadValue',
};
}
const ordered = command.ordered !== false;
const writeErrors: plugins.bson.Document[] = [];
let insertedCount = 0;
// Ensure collection exists
await storage.createCollection(database, collection);
// Insert documents
for (let i = 0; i < documents.length; i++) {
const doc = documents[i];
try {
// Ensure _id exists
if (!doc._id) {
doc._id = new plugins.bson.ObjectId();
}
await storage.insertOne(database, collection, doc);
insertedCount++;
} catch (error: any) {
const writeError: plugins.bson.Document = {
index: i,
code: error.code || 11000,
errmsg: error.message || 'Insert failed',
};
// Check for duplicate key error
if (error.message?.includes('Duplicate key')) {
writeError.code = 11000;
writeError.keyPattern = { _id: 1 };
writeError.keyValue = { _id: doc._id };
}
writeErrors.push(writeError);
if (ordered) {
// Stop on first error for ordered inserts
break;
}
}
}
const response: plugins.bson.Document = {
ok: 1,
n: insertedCount,
};
if (writeErrors.length > 0) {
response.writeErrors = writeErrors;
}
return response;
}
}

View File

@@ -0,0 +1,315 @@
import * as plugins from '../../tsmdb.plugins.js';
import type { ICommandHandler, IHandlerContext } from '../CommandRouter.js';
import { QueryEngine } from '../../engine/QueryEngine.js';
import { UpdateEngine } from '../../engine/UpdateEngine.js';
/**
* UpdateHandler - Handles update, findAndModify commands
*/
export class UpdateHandler implements ICommandHandler {
async handle(context: IHandlerContext): Promise<plugins.bson.Document> {
const { command } = context;
// Check findAndModify first since it also has an 'update' field
if (command.findAndModify) {
return this.handleFindAndModify(context);
} else if (command.update && typeof command.update === 'string') {
// 'update' command has collection name as the value
return this.handleUpdate(context);
}
return {
ok: 0,
errmsg: 'Unknown update-related command',
code: 59,
codeName: 'CommandNotFound',
};
}
/**
* Handle update command
*/
private async handleUpdate(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command, documentSequences } = context;
const collection = command.update;
if (typeof collection !== 'string') {
return {
ok: 0,
errmsg: 'update command requires a collection name',
code: 2,
codeName: 'BadValue',
};
}
// Get updates from command or document sequences
let updates: plugins.bson.Document[] = command.updates || [];
// Check for OP_MSG document sequences
if (documentSequences && documentSequences.has('updates')) {
updates = documentSequences.get('updates')!;
}
if (!Array.isArray(updates) || updates.length === 0) {
return {
ok: 0,
errmsg: 'update command requires updates array',
code: 2,
codeName: 'BadValue',
};
}
const ordered = command.ordered !== false;
const writeErrors: plugins.bson.Document[] = [];
let totalMatched = 0;
let totalModified = 0;
let totalUpserted = 0;
const upserted: plugins.bson.Document[] = [];
// Ensure collection exists
await storage.createCollection(database, collection);
for (let i = 0; i < updates.length; i++) {
const updateSpec = updates[i];
const filter = updateSpec.q || updateSpec.filter || {};
const update = updateSpec.u || updateSpec.update || {};
const multi = updateSpec.multi || false;
const upsert = updateSpec.upsert || false;
const arrayFilters = updateSpec.arrayFilters;
try {
// Get all documents
let documents = await storage.findAll(database, collection);
// Apply filter
let matchingDocs = QueryEngine.filter(documents, filter);
if (matchingDocs.length === 0 && upsert) {
// Upsert: create new document
const newDoc: plugins.bson.Document = { _id: new plugins.bson.ObjectId() };
// Apply filter fields to the new document
this.applyFilterToDoc(newDoc, filter);
// Apply update
const updatedDoc = UpdateEngine.applyUpdate(newDoc as any, update, arrayFilters);
// Handle $setOnInsert
if (update.$setOnInsert) {
Object.assign(updatedDoc, update.$setOnInsert);
}
await storage.insertOne(database, collection, updatedDoc);
totalUpserted++;
upserted.push({ index: i, _id: updatedDoc._id });
} else {
// Update existing documents
const docsToUpdate = multi ? matchingDocs : matchingDocs.slice(0, 1);
totalMatched += docsToUpdate.length;
for (const doc of docsToUpdate) {
const updatedDoc = UpdateEngine.applyUpdate(doc, update, arrayFilters);
// Check if document actually changed
const changed = JSON.stringify(doc) !== JSON.stringify(updatedDoc);
if (changed) {
await storage.updateById(database, collection, doc._id, updatedDoc);
totalModified++;
}
}
}
} catch (error: any) {
writeErrors.push({
index: i,
code: error.code || 1,
errmsg: error.message || 'Update failed',
});
if (ordered) {
break;
}
}
}
const response: plugins.bson.Document = {
ok: 1,
n: totalMatched + totalUpserted,
nModified: totalModified,
};
if (upserted.length > 0) {
response.upserted = upserted;
}
if (writeErrors.length > 0) {
response.writeErrors = writeErrors;
}
return response;
}
/**
* Handle findAndModify command
*/
private async handleFindAndModify(context: IHandlerContext): Promise<plugins.bson.Document> {
const { storage, database, command } = context;
const collection = command.findAndModify;
const query = command.query || {};
const update = command.update;
const remove = command.remove || false;
const returnNew = command.new || false;
const upsert = command.upsert || false;
const sort = command.sort;
const fields = command.fields;
const arrayFilters = command.arrayFilters;
// Validate - either update or remove, not both
if (update && remove) {
return {
ok: 0,
errmsg: 'cannot specify both update and remove',
code: 2,
codeName: 'BadValue',
};
}
if (!update && !remove) {
return {
ok: 0,
errmsg: 'either update or remove is required',
code: 2,
codeName: 'BadValue',
};
}
// Ensure collection exists
await storage.createCollection(database, collection);
// Get matching documents
let documents = await storage.findAll(database, collection);
let matchingDocs = QueryEngine.filter(documents, query);
// Apply sort if specified
if (sort) {
matchingDocs = QueryEngine.sort(matchingDocs, sort);
}
const doc = matchingDocs[0];
if (remove) {
// Delete operation
if (!doc) {
return { ok: 1, value: null };
}
await storage.deleteById(database, collection, doc._id);
let result = doc;
if (fields) {
result = QueryEngine.project([doc], fields)[0] as any;
}
return {
ok: 1,
value: result,
lastErrorObject: {
n: 1,
},
};
} else {
// Update operation
if (!doc && !upsert) {
return { ok: 1, value: null };
}
let resultDoc: plugins.bson.Document;
let originalDoc: plugins.bson.Document | null = null;
let isUpsert = false;
if (doc) {
// Update existing
originalDoc = { ...doc };
resultDoc = UpdateEngine.applyUpdate(doc, update, arrayFilters);
await storage.updateById(database, collection, doc._id, resultDoc as any);
} else {
// Upsert
isUpsert = true;
const newDoc: plugins.bson.Document = { _id: new plugins.bson.ObjectId() };
this.applyFilterToDoc(newDoc, query);
resultDoc = UpdateEngine.applyUpdate(newDoc as any, update, arrayFilters);
if (update.$setOnInsert) {
Object.assign(resultDoc, update.$setOnInsert);
}
await storage.insertOne(database, collection, resultDoc);
}
// Apply projection
let returnValue = returnNew ? resultDoc : (originalDoc || null);
if (returnValue && fields) {
returnValue = QueryEngine.project([returnValue as any], fields)[0];
}
const response: plugins.bson.Document = {
ok: 1,
value: returnValue,
lastErrorObject: {
n: 1,
updatedExisting: !isUpsert && doc !== undefined,
},
};
if (isUpsert) {
response.lastErrorObject.upserted = resultDoc._id;
}
return response;
}
}
/**
* Apply filter equality conditions to a new document (for upsert)
*/
private applyFilterToDoc(doc: plugins.bson.Document, filter: plugins.bson.Document): void {
for (const [key, value] of Object.entries(filter)) {
// Skip operators
if (key.startsWith('$')) continue;
// Handle nested paths
if (typeof value === 'object' && value !== null) {
// Check if it's an operator
const valueKeys = Object.keys(value);
if (valueKeys.some(k => k.startsWith('$'))) {
// Extract equality value from $eq if present
if ('$eq' in value) {
this.setNestedValue(doc, key, value.$eq);
}
continue;
}
}
// Direct value assignment
this.setNestedValue(doc, key, value);
}
}
/**
* Set a nested value using dot notation
*/
private setNestedValue(obj: plugins.bson.Document, path: string, value: any): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part];
}
current[parts[parts.length - 1]] = value;
}
}

View File

@@ -0,0 +1,10 @@
// Export all command handlers
export { HelloHandler } from './HelloHandler.js';
export { InsertHandler } from './InsertHandler.js';
export { FindHandler } from './FindHandler.js';
export { UpdateHandler } from './UpdateHandler.js';
export { DeleteHandler } from './DeleteHandler.js';
export { AggregateHandler } from './AggregateHandler.js';
export { IndexHandler } from './IndexHandler.js';
export { AdminHandler } from './AdminHandler.js';

10
ts/tsmdb/server/index.ts Normal file
View File

@@ -0,0 +1,10 @@
// Server module exports
export { TsmdbServer } from './TsmdbServer.js';
export type { ITsmdbServerOptions } from './TsmdbServer.js';
export { WireProtocol } from './WireProtocol.js';
export { CommandRouter } from './CommandRouter.js';
export type { ICommandHandler, IHandlerContext, ICursorState } from './CommandRouter.js';
// Export handlers
export * from './handlers/index.js';

View File

@@ -0,0 +1,479 @@
import * as plugins from '../tsmdb.plugins.js';
import type { IStorageAdapter } from './IStorageAdapter.js';
import type { IStoredDocument, IOpLogEntry, Document } from '../types/interfaces.js';
/**
* File-based storage adapter for TsmDB
* Stores data in JSON files on disk for persistence
*/
export class FileStorageAdapter implements IStorageAdapter {
private basePath: string;
private opLogCounter = 0;
private initialized = false;
private fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
constructor(basePath: string) {
this.basePath = basePath;
}
// ============================================================================
// Helper Methods
// ============================================================================
private getDbPath(dbName: string): string {
return plugins.smartpath.join(this.basePath, dbName);
}
private getCollectionPath(dbName: string, collName: string): string {
return plugins.smartpath.join(this.basePath, dbName, `${collName}.json`);
}
private getIndexPath(dbName: string, collName: string): string {
return plugins.smartpath.join(this.basePath, dbName, `${collName}.indexes.json`);
}
private getOpLogPath(): string {
return plugins.smartpath.join(this.basePath, '_oplog.json');
}
private getMetaPath(): string {
return plugins.smartpath.join(this.basePath, '_meta.json');
}
private async readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const exists = await this.fs.file(filePath).exists();
if (!exists) return defaultValue;
const content = await this.fs.file(filePath).encoding('utf8').read();
return JSON.parse(content as string);
} catch {
return defaultValue;
}
}
private async writeJsonFile(filePath: string, data: any): Promise<void> {
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
await this.fs.directory(dir).recursive().create();
await this.fs.file(filePath).encoding('utf8').write(JSON.stringify(data, null, 2));
}
private restoreObjectIds(doc: any): IStoredDocument {
if (doc._id) {
if (typeof doc._id === 'string') {
doc._id = new plugins.bson.ObjectId(doc._id);
} else if (typeof doc._id === 'object' && doc._id.$oid) {
doc._id = new plugins.bson.ObjectId(doc._id.$oid);
}
}
return doc;
}
// ============================================================================
// Initialization
// ============================================================================
async initialize(): Promise<void> {
if (this.initialized) return;
await this.fs.directory(this.basePath).recursive().create();
// Load metadata
const meta = await this.readJsonFile(this.getMetaPath(), { opLogCounter: 0 });
this.opLogCounter = meta.opLogCounter || 0;
this.initialized = true;
}
async close(): Promise<void> {
// Save metadata
await this.writeJsonFile(this.getMetaPath(), { opLogCounter: this.opLogCounter });
this.initialized = false;
}
// ============================================================================
// Database Operations
// ============================================================================
async listDatabases(): Promise<string[]> {
await this.initialize();
try {
const entries = await this.fs.directory(this.basePath).list();
return entries
.filter(entry => entry.isDirectory && !entry.name.startsWith('_'))
.map(entry => entry.name);
} catch {
return [];
}
}
async createDatabase(dbName: string): Promise<void> {
await this.initialize();
const dbPath = this.getDbPath(dbName);
await this.fs.directory(dbPath).recursive().create();
}
async dropDatabase(dbName: string): Promise<boolean> {
await this.initialize();
const dbPath = this.getDbPath(dbName);
try {
const exists = await this.fs.directory(dbPath).exists();
if (exists) {
await this.fs.directory(dbPath).recursive().delete();
return true;
}
return false;
} catch {
return false;
}
}
async databaseExists(dbName: string): Promise<boolean> {
await this.initialize();
const dbPath = this.getDbPath(dbName);
return this.fs.directory(dbPath).exists();
}
// ============================================================================
// Collection Operations
// ============================================================================
async listCollections(dbName: string): Promise<string[]> {
await this.initialize();
const dbPath = this.getDbPath(dbName);
try {
const entries = await this.fs.directory(dbPath).list();
return entries
.filter(entry => entry.isFile && entry.name.endsWith('.json') && !entry.name.endsWith('.indexes.json'))
.map(entry => entry.name.replace('.json', ''));
} catch {
return [];
}
}
async createCollection(dbName: string, collName: string): Promise<void> {
await this.createDatabase(dbName);
const collPath = this.getCollectionPath(dbName, collName);
const exists = await this.fs.file(collPath).exists();
if (!exists) {
await this.writeJsonFile(collPath, []);
// Create default _id index
await this.writeJsonFile(this.getIndexPath(dbName, collName), [
{ name: '_id_', key: { _id: 1 }, unique: true }
]);
}
}
async dropCollection(dbName: string, collName: string): Promise<boolean> {
await this.initialize();
const collPath = this.getCollectionPath(dbName, collName);
const indexPath = this.getIndexPath(dbName, collName);
try {
const exists = await this.fs.file(collPath).exists();
if (exists) {
await this.fs.file(collPath).delete();
try {
await this.fs.file(indexPath).delete();
} catch {}
return true;
}
return false;
} catch {
return false;
}
}
async collectionExists(dbName: string, collName: string): Promise<boolean> {
await this.initialize();
const collPath = this.getCollectionPath(dbName, collName);
return this.fs.file(collPath).exists();
}
async renameCollection(dbName: string, oldName: string, newName: string): Promise<void> {
await this.initialize();
const oldPath = this.getCollectionPath(dbName, oldName);
const newPath = this.getCollectionPath(dbName, newName);
const oldIndexPath = this.getIndexPath(dbName, oldName);
const newIndexPath = this.getIndexPath(dbName, newName);
const exists = await this.fs.file(oldPath).exists();
if (!exists) {
throw new Error(`Collection ${oldName} not found`);
}
// Read, write to new, delete old
const docs = await this.readJsonFile<any[]>(oldPath, []);
await this.writeJsonFile(newPath, docs);
await this.fs.file(oldPath).delete();
// Handle indexes
const indexes = await this.readJsonFile<any[]>(oldIndexPath, []);
await this.writeJsonFile(newIndexPath, indexes);
try {
await this.fs.file(oldIndexPath).delete();
} catch {}
}
// ============================================================================
// Document Operations
// ============================================================================
async insertOne(dbName: string, collName: string, doc: Document): Promise<IStoredDocument> {
await this.createCollection(dbName, collName);
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
const storedDoc: IStoredDocument = {
...doc,
_id: doc._id ? (doc._id instanceof plugins.bson.ObjectId ? doc._id : new plugins.bson.ObjectId(doc._id)) : new plugins.bson.ObjectId(),
};
// Check for duplicate
const idStr = storedDoc._id.toHexString();
if (docs.some(d => d._id === idStr || (d._id && d._id.toString() === idStr))) {
throw new Error(`Duplicate key error: _id ${idStr}`);
}
docs.push(storedDoc);
await this.writeJsonFile(collPath, docs);
return storedDoc;
}
async insertMany(dbName: string, collName: string, docsToInsert: Document[]): Promise<IStoredDocument[]> {
await this.createCollection(dbName, collName);
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
const results: IStoredDocument[] = [];
const existingIds = new Set(docs.map(d => d._id?.toString?.() || d._id));
for (const doc of docsToInsert) {
const storedDoc: IStoredDocument = {
...doc,
_id: doc._id ? (doc._id instanceof plugins.bson.ObjectId ? doc._id : new plugins.bson.ObjectId(doc._id)) : new plugins.bson.ObjectId(),
};
const idStr = storedDoc._id.toHexString();
if (existingIds.has(idStr)) {
throw new Error(`Duplicate key error: _id ${idStr}`);
}
existingIds.add(idStr);
docs.push(storedDoc);
results.push(storedDoc);
}
await this.writeJsonFile(collPath, docs);
return results;
}
async findAll(dbName: string, collName: string): Promise<IStoredDocument[]> {
await this.createCollection(dbName, collName);
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
return docs.map(doc => this.restoreObjectIds(doc));
}
async findById(dbName: string, collName: string, id: plugins.bson.ObjectId): Promise<IStoredDocument | null> {
const docs = await this.findAll(dbName, collName);
const idStr = id.toHexString();
return docs.find(d => d._id.toHexString() === idStr) || null;
}
async updateById(dbName: string, collName: string, id: plugins.bson.ObjectId, doc: IStoredDocument): Promise<boolean> {
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
const idStr = id.toHexString();
const idx = docs.findIndex(d => {
const docId = d._id?.toHexString?.() || d._id?.toString?.() || d._id;
return docId === idStr;
});
if (idx === -1) return false;
docs[idx] = doc;
await this.writeJsonFile(collPath, docs);
return true;
}
async deleteById(dbName: string, collName: string, id: plugins.bson.ObjectId): Promise<boolean> {
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
const idStr = id.toHexString();
const idx = docs.findIndex(d => {
const docId = d._id?.toHexString?.() || d._id?.toString?.() || d._id;
return docId === idStr;
});
if (idx === -1) return false;
docs.splice(idx, 1);
await this.writeJsonFile(collPath, docs);
return true;
}
async deleteByIds(dbName: string, collName: string, ids: plugins.bson.ObjectId[]): Promise<number> {
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
const idStrs = new Set(ids.map(id => id.toHexString()));
const originalLength = docs.length;
const filtered = docs.filter(d => {
const docId = d._id?.toHexString?.() || d._id?.toString?.() || d._id;
return !idStrs.has(docId);
});
await this.writeJsonFile(collPath, filtered);
return originalLength - filtered.length;
}
async count(dbName: string, collName: string): Promise<number> {
const collPath = this.getCollectionPath(dbName, collName);
const docs = await this.readJsonFile<any[]>(collPath, []);
return docs.length;
}
// ============================================================================
// Index Operations
// ============================================================================
async saveIndex(
dbName: string,
collName: string,
indexName: string,
indexSpec: { key: Record<string, any>; unique?: boolean; sparse?: boolean; expireAfterSeconds?: number }
): Promise<void> {
await this.createCollection(dbName, collName);
const indexPath = this.getIndexPath(dbName, collName);
const indexes = await this.readJsonFile<any[]>(indexPath, [
{ name: '_id_', key: { _id: 1 }, unique: true }
]);
const existingIdx = indexes.findIndex(i => i.name === indexName);
if (existingIdx >= 0) {
indexes[existingIdx] = { name: indexName, ...indexSpec };
} else {
indexes.push({ name: indexName, ...indexSpec });
}
await this.writeJsonFile(indexPath, indexes);
}
async getIndexes(dbName: string, collName: string): Promise<Array<{
name: string;
key: Record<string, any>;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
}>> {
const indexPath = this.getIndexPath(dbName, collName);
return this.readJsonFile(indexPath, [{ name: '_id_', key: { _id: 1 }, unique: true }]);
}
async dropIndex(dbName: string, collName: string, indexName: string): Promise<boolean> {
if (indexName === '_id_') {
throw new Error('Cannot drop _id index');
}
const indexPath = this.getIndexPath(dbName, collName);
const indexes = await this.readJsonFile<any[]>(indexPath, []);
const idx = indexes.findIndex(i => i.name === indexName);
if (idx >= 0) {
indexes.splice(idx, 1);
await this.writeJsonFile(indexPath, indexes);
return true;
}
return false;
}
// ============================================================================
// OpLog Operations
// ============================================================================
async appendOpLog(entry: IOpLogEntry): Promise<void> {
const opLogPath = this.getOpLogPath();
const opLog = await this.readJsonFile<IOpLogEntry[]>(opLogPath, []);
opLog.push(entry);
// Trim oplog if it gets too large
if (opLog.length > 10000) {
opLog.splice(0, opLog.length - 10000);
}
await this.writeJsonFile(opLogPath, opLog);
}
async getOpLogAfter(ts: plugins.bson.Timestamp, limit: number = 1000): Promise<IOpLogEntry[]> {
const opLogPath = this.getOpLogPath();
const opLog = await this.readJsonFile<any[]>(opLogPath, []);
const tsValue = ts.toNumber();
const entries = opLog.filter(e => {
const entryTs = e.ts.toNumber ? e.ts.toNumber() : (e.ts.t * 4294967296 + e.ts.i);
return entryTs > tsValue;
});
return entries.slice(0, limit);
}
async getLatestOpLogTimestamp(): Promise<plugins.bson.Timestamp | null> {
const opLogPath = this.getOpLogPath();
const opLog = await this.readJsonFile<any[]>(opLogPath, []);
if (opLog.length === 0) return null;
const last = opLog[opLog.length - 1];
if (last.ts instanceof plugins.bson.Timestamp) {
return last.ts;
}
return new plugins.bson.Timestamp({ t: last.ts.t, i: last.ts.i });
}
generateTimestamp(): plugins.bson.Timestamp {
this.opLogCounter++;
return new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: this.opLogCounter });
}
// ============================================================================
// Transaction Support
// ============================================================================
async createSnapshot(dbName: string, collName: string): Promise<IStoredDocument[]> {
const docs = await this.findAll(dbName, collName);
return docs.map(doc => JSON.parse(JSON.stringify(doc)));
}
async hasConflicts(
dbName: string,
collName: string,
ids: plugins.bson.ObjectId[],
snapshotTime: plugins.bson.Timestamp
): Promise<boolean> {
const opLogPath = this.getOpLogPath();
const opLog = await this.readJsonFile<any[]>(opLogPath, []);
const ns = `${dbName}.${collName}`;
const snapshotTs = snapshotTime.toNumber();
const modifiedIds = new Set<string>();
for (const entry of opLog) {
const entryTs = entry.ts.toNumber ? entry.ts.toNumber() : (entry.ts.t * 4294967296 + entry.ts.i);
if (entryTs > snapshotTs && entry.ns === ns) {
if (entry.o._id) {
modifiedIds.add(entry.o._id.toString());
}
if (entry.o2?._id) {
modifiedIds.add(entry.o2._id.toString());
}
}
}
for (const id of ids) {
if (modifiedIds.has(id.toString())) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,202 @@
import type * as plugins from '../tsmdb.plugins.js';
import type { IStoredDocument, IOpLogEntry, Document } from '../types/interfaces.js';
/**
* Storage adapter interface for TsmDB
* Implementations can provide different storage backends (memory, file, etc.)
*/
export interface IStorageAdapter {
/**
* Initialize the storage adapter
*/
initialize(): Promise<void>;
/**
* Close the storage adapter and release resources
*/
close(): Promise<void>;
// ============================================================================
// Database Operations
// ============================================================================
/**
* List all database names
*/
listDatabases(): Promise<string[]>;
/**
* Create a database (typically lazy - just marks it as existing)
*/
createDatabase(dbName: string): Promise<void>;
/**
* Drop a database and all its collections
*/
dropDatabase(dbName: string): Promise<boolean>;
/**
* Check if a database exists
*/
databaseExists(dbName: string): Promise<boolean>;
// ============================================================================
// Collection Operations
// ============================================================================
/**
* List all collection names in a database
*/
listCollections(dbName: string): Promise<string[]>;
/**
* Create a collection
*/
createCollection(dbName: string, collName: string): Promise<void>;
/**
* Drop a collection
*/
dropCollection(dbName: string, collName: string): Promise<boolean>;
/**
* Check if a collection exists
*/
collectionExists(dbName: string, collName: string): Promise<boolean>;
/**
* Rename a collection
*/
renameCollection(dbName: string, oldName: string, newName: string): Promise<void>;
// ============================================================================
// Document Operations
// ============================================================================
/**
* Insert a single document
* @returns The inserted document with _id
*/
insertOne(dbName: string, collName: string, doc: Document): Promise<IStoredDocument>;
/**
* Insert multiple documents
* @returns Array of inserted documents with _ids
*/
insertMany(dbName: string, collName: string, docs: Document[]): Promise<IStoredDocument[]>;
/**
* Find all documents in a collection
*/
findAll(dbName: string, collName: string): Promise<IStoredDocument[]>;
/**
* Find a document by _id
*/
findById(dbName: string, collName: string, id: plugins.bson.ObjectId): Promise<IStoredDocument | null>;
/**
* Update a document by _id
* @returns true if document was updated
*/
updateById(dbName: string, collName: string, id: plugins.bson.ObjectId, doc: IStoredDocument): Promise<boolean>;
/**
* Delete a document by _id
* @returns true if document was deleted
*/
deleteById(dbName: string, collName: string, id: plugins.bson.ObjectId): Promise<boolean>;
/**
* Delete multiple documents by _id
* @returns Number of deleted documents
*/
deleteByIds(dbName: string, collName: string, ids: plugins.bson.ObjectId[]): Promise<number>;
/**
* Get the count of documents in a collection
*/
count(dbName: string, collName: string): Promise<number>;
// ============================================================================
// Index Operations
// ============================================================================
/**
* Store index metadata
*/
saveIndex(
dbName: string,
collName: string,
indexName: string,
indexSpec: { key: Record<string, any>; unique?: boolean; sparse?: boolean; expireAfterSeconds?: number }
): Promise<void>;
/**
* Get all index metadata for a collection
*/
getIndexes(dbName: string, collName: string): Promise<Array<{
name: string;
key: Record<string, any>;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
}>>;
/**
* Drop an index
*/
dropIndex(dbName: string, collName: string, indexName: string): Promise<boolean>;
// ============================================================================
// OpLog Operations (for change streams)
// ============================================================================
/**
* Append an operation to the oplog
*/
appendOpLog(entry: IOpLogEntry): Promise<void>;
/**
* Get oplog entries after a timestamp
*/
getOpLogAfter(ts: plugins.bson.Timestamp, limit?: number): Promise<IOpLogEntry[]>;
/**
* Get the latest oplog timestamp
*/
getLatestOpLogTimestamp(): Promise<plugins.bson.Timestamp | null>;
// ============================================================================
// Transaction Support
// ============================================================================
/**
* Create a snapshot of current data for transaction isolation
*/
createSnapshot(dbName: string, collName: string): Promise<IStoredDocument[]>;
/**
* Check if any documents have been modified since the snapshot
*/
hasConflicts(
dbName: string,
collName: string,
ids: plugins.bson.ObjectId[],
snapshotTime: plugins.bson.Timestamp
): Promise<boolean>;
// ============================================================================
// Persistence (optional, for MemoryStorageAdapter with file backup)
// ============================================================================
/**
* Persist current state to disk (if supported)
*/
persist?(): Promise<void>;
/**
* Load state from disk (if supported)
*/
restore?(): Promise<void>;
}

View File

@@ -0,0 +1,443 @@
import * as plugins from '../tsmdb.plugins.js';
import type { IStorageAdapter } from './IStorageAdapter.js';
import type { IStoredDocument, IOpLogEntry, Document } from '../types/interfaces.js';
/**
* In-memory storage adapter for TsmDB
* Optionally supports persistence to a file
*/
export class MemoryStorageAdapter implements IStorageAdapter {
// Database -> Collection -> Documents
private databases: Map<string, Map<string, Map<string, IStoredDocument>>> = new Map();
// Database -> Collection -> Indexes
private indexes: Map<string, Map<string, Array<{
name: string;
key: Record<string, any>;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
}>>> = new Map();
// OpLog entries
private opLog: IOpLogEntry[] = [];
private opLogCounter = 0;
// Persistence settings
private persistPath?: string;
private persistInterval?: ReturnType<typeof setInterval>;
private fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
constructor(options?: { persistPath?: string; persistIntervalMs?: number }) {
this.persistPath = options?.persistPath;
if (this.persistPath && options?.persistIntervalMs) {
this.persistInterval = setInterval(() => {
this.persist().catch(console.error);
}, options.persistIntervalMs);
}
}
async initialize(): Promise<void> {
if (this.persistPath) {
await this.restore();
}
}
async close(): Promise<void> {
if (this.persistInterval) {
clearInterval(this.persistInterval);
}
if (this.persistPath) {
await this.persist();
}
}
// ============================================================================
// Database Operations
// ============================================================================
async listDatabases(): Promise<string[]> {
return Array.from(this.databases.keys());
}
async createDatabase(dbName: string): Promise<void> {
if (!this.databases.has(dbName)) {
this.databases.set(dbName, new Map());
this.indexes.set(dbName, new Map());
}
}
async dropDatabase(dbName: string): Promise<boolean> {
const existed = this.databases.has(dbName);
this.databases.delete(dbName);
this.indexes.delete(dbName);
return existed;
}
async databaseExists(dbName: string): Promise<boolean> {
return this.databases.has(dbName);
}
// ============================================================================
// Collection Operations
// ============================================================================
async listCollections(dbName: string): Promise<string[]> {
const db = this.databases.get(dbName);
return db ? Array.from(db.keys()) : [];
}
async createCollection(dbName: string, collName: string): Promise<void> {
await this.createDatabase(dbName);
const db = this.databases.get(dbName)!;
if (!db.has(collName)) {
db.set(collName, new Map());
// Initialize default _id index
const dbIndexes = this.indexes.get(dbName)!;
dbIndexes.set(collName, [{ name: '_id_', key: { _id: 1 }, unique: true }]);
}
}
async dropCollection(dbName: string, collName: string): Promise<boolean> {
const db = this.databases.get(dbName);
if (!db) return false;
const existed = db.has(collName);
db.delete(collName);
const dbIndexes = this.indexes.get(dbName);
if (dbIndexes) {
dbIndexes.delete(collName);
}
return existed;
}
async collectionExists(dbName: string, collName: string): Promise<boolean> {
const db = this.databases.get(dbName);
return db ? db.has(collName) : false;
}
async renameCollection(dbName: string, oldName: string, newName: string): Promise<void> {
const db = this.databases.get(dbName);
if (!db || !db.has(oldName)) {
throw new Error(`Collection ${oldName} not found`);
}
const collection = db.get(oldName)!;
db.set(newName, collection);
db.delete(oldName);
// Also rename indexes
const dbIndexes = this.indexes.get(dbName);
if (dbIndexes && dbIndexes.has(oldName)) {
const collIndexes = dbIndexes.get(oldName)!;
dbIndexes.set(newName, collIndexes);
dbIndexes.delete(oldName);
}
}
// ============================================================================
// Document Operations
// ============================================================================
private getCollection(dbName: string, collName: string): Map<string, IStoredDocument> {
const db = this.databases.get(dbName);
if (!db) {
throw new Error(`Database ${dbName} not found`);
}
const collection = db.get(collName);
if (!collection) {
throw new Error(`Collection ${collName} not found`);
}
return collection;
}
private ensureCollection(dbName: string, collName: string): Map<string, IStoredDocument> {
if (!this.databases.has(dbName)) {
this.databases.set(dbName, new Map());
this.indexes.set(dbName, new Map());
}
const db = this.databases.get(dbName)!;
if (!db.has(collName)) {
db.set(collName, new Map());
const dbIndexes = this.indexes.get(dbName)!;
dbIndexes.set(collName, [{ name: '_id_', key: { _id: 1 }, unique: true }]);
}
return db.get(collName)!;
}
async insertOne(dbName: string, collName: string, doc: Document): Promise<IStoredDocument> {
const collection = this.ensureCollection(dbName, collName);
const storedDoc: IStoredDocument = {
...doc,
_id: doc._id instanceof plugins.bson.ObjectId ? doc._id : new plugins.bson.ObjectId(doc._id),
};
if (!storedDoc._id) {
storedDoc._id = new plugins.bson.ObjectId();
}
const idStr = storedDoc._id.toHexString();
if (collection.has(idStr)) {
throw new Error(`Duplicate key error: _id ${idStr}`);
}
collection.set(idStr, storedDoc);
return storedDoc;
}
async insertMany(dbName: string, collName: string, docs: Document[]): Promise<IStoredDocument[]> {
const results: IStoredDocument[] = [];
for (const doc of docs) {
results.push(await this.insertOne(dbName, collName, doc));
}
return results;
}
async findAll(dbName: string, collName: string): Promise<IStoredDocument[]> {
const collection = this.ensureCollection(dbName, collName);
return Array.from(collection.values());
}
async findById(dbName: string, collName: string, id: plugins.bson.ObjectId): Promise<IStoredDocument | null> {
const collection = this.ensureCollection(dbName, collName);
return collection.get(id.toHexString()) || null;
}
async updateById(dbName: string, collName: string, id: plugins.bson.ObjectId, doc: IStoredDocument): Promise<boolean> {
const collection = this.ensureCollection(dbName, collName);
const idStr = id.toHexString();
if (!collection.has(idStr)) {
return false;
}
collection.set(idStr, doc);
return true;
}
async deleteById(dbName: string, collName: string, id: plugins.bson.ObjectId): Promise<boolean> {
const collection = this.ensureCollection(dbName, collName);
return collection.delete(id.toHexString());
}
async deleteByIds(dbName: string, collName: string, ids: plugins.bson.ObjectId[]): Promise<number> {
let count = 0;
for (const id of ids) {
if (await this.deleteById(dbName, collName, id)) {
count++;
}
}
return count;
}
async count(dbName: string, collName: string): Promise<number> {
const collection = this.ensureCollection(dbName, collName);
return collection.size;
}
// ============================================================================
// Index Operations
// ============================================================================
async saveIndex(
dbName: string,
collName: string,
indexName: string,
indexSpec: { key: Record<string, any>; unique?: boolean; sparse?: boolean; expireAfterSeconds?: number }
): Promise<void> {
await this.createCollection(dbName, collName);
const dbIndexes = this.indexes.get(dbName)!;
let collIndexes = dbIndexes.get(collName);
if (!collIndexes) {
collIndexes = [{ name: '_id_', key: { _id: 1 }, unique: true }];
dbIndexes.set(collName, collIndexes);
}
// Check if index already exists
const existingIndex = collIndexes.findIndex(i => i.name === indexName);
if (existingIndex >= 0) {
collIndexes[existingIndex] = { name: indexName, ...indexSpec };
} else {
collIndexes.push({ name: indexName, ...indexSpec });
}
}
async getIndexes(dbName: string, collName: string): Promise<Array<{
name: string;
key: Record<string, any>;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
}>> {
const dbIndexes = this.indexes.get(dbName);
if (!dbIndexes) return [{ name: '_id_', key: { _id: 1 }, unique: true }];
const collIndexes = dbIndexes.get(collName);
return collIndexes || [{ name: '_id_', key: { _id: 1 }, unique: true }];
}
async dropIndex(dbName: string, collName: string, indexName: string): Promise<boolean> {
if (indexName === '_id_') {
throw new Error('Cannot drop _id index');
}
const dbIndexes = this.indexes.get(dbName);
if (!dbIndexes) return false;
const collIndexes = dbIndexes.get(collName);
if (!collIndexes) return false;
const idx = collIndexes.findIndex(i => i.name === indexName);
if (idx >= 0) {
collIndexes.splice(idx, 1);
return true;
}
return false;
}
// ============================================================================
// OpLog Operations
// ============================================================================
async appendOpLog(entry: IOpLogEntry): Promise<void> {
this.opLog.push(entry);
// Trim oplog if it gets too large (keep last 10000 entries)
if (this.opLog.length > 10000) {
this.opLog = this.opLog.slice(-10000);
}
}
async getOpLogAfter(ts: plugins.bson.Timestamp, limit: number = 1000): Promise<IOpLogEntry[]> {
const tsValue = ts.toNumber();
const entries = this.opLog.filter(e => e.ts.toNumber() > tsValue);
return entries.slice(0, limit);
}
async getLatestOpLogTimestamp(): Promise<plugins.bson.Timestamp | null> {
if (this.opLog.length === 0) return null;
return this.opLog[this.opLog.length - 1].ts;
}
/**
* Generate a new timestamp for oplog entries
*/
generateTimestamp(): plugins.bson.Timestamp {
this.opLogCounter++;
return new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: this.opLogCounter });
}
// ============================================================================
// Transaction Support
// ============================================================================
async createSnapshot(dbName: string, collName: string): Promise<IStoredDocument[]> {
const docs = await this.findAll(dbName, collName);
// Deep clone the documents for snapshot isolation
return docs.map(doc => JSON.parse(JSON.stringify(doc)));
}
async hasConflicts(
dbName: string,
collName: string,
ids: plugins.bson.ObjectId[],
snapshotTime: plugins.bson.Timestamp
): Promise<boolean> {
// Check if any of the given document IDs have been modified after snapshotTime
const ns = `${dbName}.${collName}`;
const modifiedIds = new Set<string>();
for (const entry of this.opLog) {
if (entry.ts.greaterThan(snapshotTime) && entry.ns === ns) {
if (entry.o._id) {
modifiedIds.add(entry.o._id.toString());
}
if (entry.o2?._id) {
modifiedIds.add(entry.o2._id.toString());
}
}
}
for (const id of ids) {
if (modifiedIds.has(id.toString())) {
return true;
}
}
return false;
}
// ============================================================================
// Persistence
// ============================================================================
async persist(): Promise<void> {
if (!this.persistPath) return;
const data = {
databases: {} as Record<string, Record<string, IStoredDocument[]>>,
indexes: {} as Record<string, Record<string, any[]>>,
opLogCounter: this.opLogCounter,
};
for (const [dbName, collections] of this.databases) {
data.databases[dbName] = {};
for (const [collName, docs] of collections) {
data.databases[dbName][collName] = Array.from(docs.values());
}
}
for (const [dbName, collIndexes] of this.indexes) {
data.indexes[dbName] = {};
for (const [collName, indexes] of collIndexes) {
data.indexes[dbName][collName] = indexes;
}
}
// Ensure parent directory exists
const dir = this.persistPath.substring(0, this.persistPath.lastIndexOf('/'));
if (dir) {
await this.fs.directory(dir).recursive().create();
}
await this.fs.file(this.persistPath).encoding('utf8').write(JSON.stringify(data, null, 2));
}
async restore(): Promise<void> {
if (!this.persistPath) return;
try {
const exists = await this.fs.file(this.persistPath).exists();
if (!exists) return;
const content = await this.fs.file(this.persistPath).encoding('utf8').read();
const data = JSON.parse(content as string);
this.databases.clear();
this.indexes.clear();
for (const [dbName, collections] of Object.entries(data.databases || {})) {
const dbMap = new Map<string, Map<string, IStoredDocument>>();
this.databases.set(dbName, dbMap);
for (const [collName, docs] of Object.entries(collections as Record<string, any[]>)) {
const collMap = new Map<string, IStoredDocument>();
for (const doc of docs) {
// Restore ObjectId
if (doc._id && typeof doc._id === 'string') {
doc._id = new plugins.bson.ObjectId(doc._id);
} else if (doc._id && typeof doc._id === 'object' && doc._id.$oid) {
doc._id = new plugins.bson.ObjectId(doc._id.$oid);
}
collMap.set(doc._id.toHexString(), doc);
}
dbMap.set(collName, collMap);
}
}
for (const [dbName, collIndexes] of Object.entries(data.indexes || {})) {
const indexMap = new Map<string, any[]>();
this.indexes.set(dbName, indexMap);
for (const [collName, indexes] of Object.entries(collIndexes as Record<string, any[]>)) {
indexMap.set(collName, indexes);
}
}
this.opLogCounter = data.opLogCounter || 0;
} catch (error) {
// If restore fails, start fresh
console.warn('Failed to restore from persistence:', error);
}
}
}

282
ts/tsmdb/storage/OpLog.ts Normal file
View File

@@ -0,0 +1,282 @@
import * as plugins from '../tsmdb.plugins.js';
import type { IStorageAdapter } from './IStorageAdapter.js';
import type { IOpLogEntry, Document, IResumeToken, ChangeStreamOperationType } from '../types/interfaces.js';
/**
* Operation Log for tracking all mutations
* Used primarily for change stream support
*/
export class OpLog {
private storage: IStorageAdapter;
private counter = 0;
private listeners: Array<(entry: IOpLogEntry) => void> = [];
constructor(storage: IStorageAdapter) {
this.storage = storage;
}
/**
* Generate a new timestamp for oplog entries
*/
generateTimestamp(): plugins.bson.Timestamp {
this.counter++;
return new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: this.counter });
}
/**
* Generate a resume token from a timestamp
*/
generateResumeToken(ts: plugins.bson.Timestamp): IResumeToken {
// Create a resume token similar to MongoDB's format
// It's a base64-encoded BSON document containing the timestamp
const tokenData = {
_data: Buffer.from(JSON.stringify({
ts: { t: ts.high, i: ts.low },
version: 1,
})).toString('base64'),
};
return tokenData;
}
/**
* Parse a resume token to get the timestamp
*/
parseResumeToken(token: IResumeToken): plugins.bson.Timestamp {
try {
const data = JSON.parse(Buffer.from(token._data, 'base64').toString('utf-8'));
return new plugins.bson.Timestamp({ t: data.ts.t, i: data.ts.i });
} catch {
throw new Error('Invalid resume token');
}
}
/**
* Record an insert operation
*/
async recordInsert(
dbName: string,
collName: string,
document: Document,
txnInfo?: { txnNumber?: number; lsid?: { id: plugins.bson.Binary } }
): Promise<IOpLogEntry> {
const entry: IOpLogEntry = {
ts: this.generateTimestamp(),
op: 'i',
ns: `${dbName}.${collName}`,
o: document,
...txnInfo,
};
await this.storage.appendOpLog(entry);
this.notifyListeners(entry);
return entry;
}
/**
* Record an update operation
*/
async recordUpdate(
dbName: string,
collName: string,
filter: Document,
update: Document,
txnInfo?: { txnNumber?: number; lsid?: { id: plugins.bson.Binary } }
): Promise<IOpLogEntry> {
const entry: IOpLogEntry = {
ts: this.generateTimestamp(),
op: 'u',
ns: `${dbName}.${collName}`,
o: update,
o2: filter,
...txnInfo,
};
await this.storage.appendOpLog(entry);
this.notifyListeners(entry);
return entry;
}
/**
* Record a delete operation
*/
async recordDelete(
dbName: string,
collName: string,
filter: Document,
txnInfo?: { txnNumber?: number; lsid?: { id: plugins.bson.Binary } }
): Promise<IOpLogEntry> {
const entry: IOpLogEntry = {
ts: this.generateTimestamp(),
op: 'd',
ns: `${dbName}.${collName}`,
o: filter,
...txnInfo,
};
await this.storage.appendOpLog(entry);
this.notifyListeners(entry);
return entry;
}
/**
* Record a command (drop, rename, etc.)
*/
async recordCommand(
dbName: string,
command: Document
): Promise<IOpLogEntry> {
const entry: IOpLogEntry = {
ts: this.generateTimestamp(),
op: 'c',
ns: `${dbName}.$cmd`,
o: command,
};
await this.storage.appendOpLog(entry);
this.notifyListeners(entry);
return entry;
}
/**
* Get oplog entries after a timestamp
*/
async getEntriesAfter(ts: plugins.bson.Timestamp, limit?: number): Promise<IOpLogEntry[]> {
return this.storage.getOpLogAfter(ts, limit);
}
/**
* Get the latest timestamp
*/
async getLatestTimestamp(): Promise<plugins.bson.Timestamp | null> {
return this.storage.getLatestOpLogTimestamp();
}
/**
* Subscribe to oplog changes (for change streams)
*/
subscribe(listener: (entry: IOpLogEntry) => void): () => void {
this.listeners.push(listener);
return () => {
const idx = this.listeners.indexOf(listener);
if (idx >= 0) {
this.listeners.splice(idx, 1);
}
};
}
/**
* Notify all listeners of a new entry
*/
private notifyListeners(entry: IOpLogEntry): void {
for (const listener of this.listeners) {
try {
listener(entry);
} catch (error) {
console.error('Error in oplog listener:', error);
}
}
}
/**
* Convert an oplog entry to a change stream document
*/
opLogEntryToChangeEvent(
entry: IOpLogEntry,
fullDocument?: Document,
fullDocumentBeforeChange?: Document
): {
_id: IResumeToken;
operationType: ChangeStreamOperationType;
fullDocument?: Document;
fullDocumentBeforeChange?: Document;
ns: { db: string; coll?: string };
documentKey?: { _id: plugins.bson.ObjectId };
updateDescription?: {
updatedFields?: Document;
removedFields?: string[];
};
clusterTime: plugins.bson.Timestamp;
} {
const [db, coll] = entry.ns.split('.');
const resumeToken = this.generateResumeToken(entry.ts);
const baseEvent = {
_id: resumeToken,
ns: { db, coll: coll === '$cmd' ? undefined : coll },
clusterTime: entry.ts,
};
switch (entry.op) {
case 'i':
return {
...baseEvent,
operationType: 'insert' as ChangeStreamOperationType,
fullDocument: fullDocument || entry.o,
documentKey: entry.o._id ? { _id: entry.o._id } : undefined,
};
case 'u':
const updateEvent: any = {
...baseEvent,
operationType: 'update' as ChangeStreamOperationType,
documentKey: entry.o2?._id ? { _id: entry.o2._id } : undefined,
};
if (fullDocument) {
updateEvent.fullDocument = fullDocument;
}
if (fullDocumentBeforeChange) {
updateEvent.fullDocumentBeforeChange = fullDocumentBeforeChange;
}
// Parse update description
if (entry.o.$set || entry.o.$unset) {
updateEvent.updateDescription = {
updatedFields: entry.o.$set || {},
removedFields: entry.o.$unset ? Object.keys(entry.o.$unset) : [],
};
}
return updateEvent;
case 'd':
return {
...baseEvent,
operationType: 'delete' as ChangeStreamOperationType,
documentKey: entry.o._id ? { _id: entry.o._id } : undefined,
fullDocumentBeforeChange,
};
case 'c':
if (entry.o.drop) {
return {
...baseEvent,
operationType: 'drop' as ChangeStreamOperationType,
ns: { db, coll: entry.o.drop },
};
}
if (entry.o.dropDatabase) {
return {
...baseEvent,
operationType: 'dropDatabase' as ChangeStreamOperationType,
};
}
if (entry.o.renameCollection) {
return {
...baseEvent,
operationType: 'rename' as ChangeStreamOperationType,
};
}
return {
...baseEvent,
operationType: 'invalidate' as ChangeStreamOperationType,
};
default:
return {
...baseEvent,
operationType: 'invalidate' as ChangeStreamOperationType,
};
}
}
}

17
ts/tsmdb/tsmdb.plugins.ts Normal file
View File

@@ -0,0 +1,17 @@
// @push.rocks scope
import * as smartfs from '@push.rocks/smartfs';
import * as smartpath from '@push.rocks/smartpath';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
export { smartfs, smartpath, smartpromise, smartrx };
// thirdparty
import * as bson from 'bson';
import * as mingo from 'mingo';
export { bson, mingo };
// Re-export commonly used mingo classes
export { Query } from 'mingo';
export { Aggregator } from 'mingo';

View File

@@ -0,0 +1,433 @@
import type * as plugins from '../tsmdb.plugins.js';
// ============================================================================
// Document Types
// ============================================================================
export type Document = Record<string, any>;
export interface WithId<TSchema> {
_id: plugins.bson.ObjectId;
}
// ============================================================================
// Client Options
// ============================================================================
export interface ITsmdbClientOptions {
/** Storage adapter type: 'memory' or 'file' */
storageType?: 'memory' | 'file';
/** Path for file-based storage */
storagePath?: string;
/** Enable persistence for memory adapter */
persist?: boolean;
/** Path for persistence file when using memory adapter */
persistPath?: string;
}
// ============================================================================
// Connection String Parsing
// ============================================================================
export interface IParsedConnectionString {
protocol: 'tsmdb';
storageType: 'memory' | 'file';
options: {
persist?: string;
path?: string;
};
}
// ============================================================================
// CRUD Operation Options
// ============================================================================
export interface IInsertOneOptions {
/** Session for transaction support */
session?: IClientSession;
/** Custom write concern */
writeConcern?: IWriteConcern;
}
export interface IInsertManyOptions extends IInsertOneOptions {
/** If true, inserts are ordered and stop on first error */
ordered?: boolean;
}
export interface IFindOptions<TSchema = Document> {
/** Projection to apply */
projection?: Partial<Record<keyof TSchema | string, 0 | 1 | boolean>>;
/** Sort specification */
sort?: ISortSpecification;
/** Number of documents to skip */
skip?: number;
/** Maximum number of documents to return */
limit?: number;
/** Session for transaction support */
session?: IClientSession;
/** Hint for index usage */
hint?: string | Document;
}
export interface IUpdateOptions {
/** Create document if it doesn't exist */
upsert?: boolean;
/** Session for transaction support */
session?: IClientSession;
/** Array filters for positional updates */
arrayFilters?: Document[];
/** Custom write concern */
writeConcern?: IWriteConcern;
/** Hint for index usage */
hint?: string | Document;
}
export interface IReplaceOptions extends IUpdateOptions {}
export interface IDeleteOptions {
/** Session for transaction support */
session?: IClientSession;
/** Custom write concern */
writeConcern?: IWriteConcern;
/** Hint for index usage */
hint?: string | Document;
}
export interface IFindOneAndUpdateOptions extends IUpdateOptions {
/** Return the document before or after the update */
returnDocument?: 'before' | 'after';
/** Projection to apply */
projection?: Document;
/** Sort specification to determine which document to modify */
sort?: ISortSpecification;
}
export interface IFindOneAndReplaceOptions extends IFindOneAndUpdateOptions {}
export interface IFindOneAndDeleteOptions {
/** Projection to apply */
projection?: Document;
/** Sort specification to determine which document to delete */
sort?: ISortSpecification;
/** Session for transaction support */
session?: IClientSession;
}
// ============================================================================
// CRUD Results
// ============================================================================
export interface IInsertOneResult {
acknowledged: boolean;
insertedId: plugins.bson.ObjectId;
}
export interface IInsertManyResult {
acknowledged: boolean;
insertedCount: number;
insertedIds: Record<number, plugins.bson.ObjectId>;
}
export interface IUpdateResult {
acknowledged: boolean;
matchedCount: number;
modifiedCount: number;
upsertedCount: number;
upsertedId: plugins.bson.ObjectId | null;
}
export interface IDeleteResult {
acknowledged: boolean;
deletedCount: number;
}
export interface IModifyResult<TSchema> {
value: TSchema | null;
ok: 1 | 0;
lastErrorObject?: {
n: number;
updatedExisting?: boolean;
upserted?: plugins.bson.ObjectId;
};
}
// ============================================================================
// Sort and Index Types
// ============================================================================
export type ISortDirection = 1 | -1 | 'asc' | 'desc' | 'ascending' | 'descending';
export type ISortSpecification = Record<string, ISortDirection> | [string, ISortDirection][];
export interface IIndexSpecification {
key: Record<string, 1 | -1 | 'text' | '2dsphere'>;
name?: string;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
background?: boolean;
partialFilterExpression?: Document;
}
export interface IIndexInfo {
v: number;
key: Record<string, 1 | -1 | string>;
name: string;
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
}
export interface ICreateIndexOptions {
unique?: boolean;
sparse?: boolean;
expireAfterSeconds?: number;
name?: string;
background?: boolean;
partialFilterExpression?: Document;
}
// ============================================================================
// Write Concern
// ============================================================================
export interface IWriteConcern {
w?: number | 'majority';
j?: boolean;
wtimeout?: number;
}
// ============================================================================
// Aggregation Types
// ============================================================================
export interface IAggregateOptions {
/** Allow disk use for large aggregations */
allowDiskUse?: boolean;
/** Maximum time in ms */
maxTimeMS?: number;
/** Session for transaction support */
session?: IClientSession;
/** Batch size for cursor */
batchSize?: number;
/** Collation settings */
collation?: ICollation;
/** Hint for index usage */
hint?: string | Document;
/** Comment for profiling */
comment?: string;
}
export interface ICollation {
locale: string;
caseLevel?: boolean;
caseFirst?: string;
strength?: number;
numericOrdering?: boolean;
alternate?: string;
maxVariable?: string;
backwards?: boolean;
}
// ============================================================================
// Change Stream Types
// ============================================================================
export interface IChangeStreamOptions {
/** Resume after this token */
resumeAfter?: IResumeToken;
/** Start at this operation time */
startAtOperationTime?: plugins.bson.Timestamp;
/** Start after this token */
startAfter?: IResumeToken;
/** Full document lookup mode */
fullDocument?: 'default' | 'updateLookup' | 'whenAvailable' | 'required';
/** Full document before change */
fullDocumentBeforeChange?: 'off' | 'whenAvailable' | 'required';
/** Batch size */
batchSize?: number;
/** Maximum await time in ms */
maxAwaitTimeMS?: number;
}
export interface IResumeToken {
_data: string;
}
export type ChangeStreamOperationType =
| 'insert'
| 'update'
| 'replace'
| 'delete'
| 'drop'
| 'rename'
| 'dropDatabase'
| 'invalidate';
export interface IChangeStreamDocument<TSchema = Document> {
_id: IResumeToken;
operationType: ChangeStreamOperationType;
fullDocument?: TSchema;
fullDocumentBeforeChange?: TSchema;
ns: {
db: string;
coll?: string;
};
documentKey?: { _id: plugins.bson.ObjectId };
updateDescription?: {
updatedFields?: Document;
removedFields?: string[];
truncatedArrays?: Array<{ field: string; newSize: number }>;
};
clusterTime?: plugins.bson.Timestamp;
txnNumber?: number;
lsid?: { id: plugins.bson.Binary; uid: plugins.bson.Binary };
}
// ============================================================================
// Transaction Types
// ============================================================================
export interface IClientSession {
id: { id: plugins.bson.Binary };
inTransaction(): boolean;
startTransaction(options?: ITransactionOptions): void;
commitTransaction(): Promise<void>;
abortTransaction(): Promise<void>;
withTransaction<T>(fn: () => Promise<T>, options?: ITransactionOptions): Promise<T>;
endSession(): Promise<void>;
}
export interface ITransactionOptions {
readConcern?: IReadConcern;
writeConcern?: IWriteConcern;
readPreference?: string;
maxCommitTimeMS?: number;
}
export interface IReadConcern {
level: 'local' | 'available' | 'majority' | 'linearizable' | 'snapshot';
}
// ============================================================================
// Bulk Operation Types
// ============================================================================
export interface IBulkWriteOptions {
ordered?: boolean;
session?: IClientSession;
writeConcern?: IWriteConcern;
}
export interface IBulkWriteOperation<TSchema = Document> {
insertOne?: { document: TSchema };
updateOne?: { filter: Document; update: Document; upsert?: boolean; arrayFilters?: Document[]; hint?: Document | string };
updateMany?: { filter: Document; update: Document; upsert?: boolean; arrayFilters?: Document[]; hint?: Document | string };
replaceOne?: { filter: Document; replacement: TSchema; upsert?: boolean; hint?: Document | string };
deleteOne?: { filter: Document; hint?: Document | string };
deleteMany?: { filter: Document; hint?: Document | string };
}
export interface IBulkWriteResult {
acknowledged: boolean;
insertedCount: number;
matchedCount: number;
modifiedCount: number;
deletedCount: number;
upsertedCount: number;
insertedIds: Record<number, plugins.bson.ObjectId>;
upsertedIds: Record<number, plugins.bson.ObjectId>;
}
// ============================================================================
// Storage Types
// ============================================================================
export interface IStoredDocument extends Document {
_id: plugins.bson.ObjectId;
}
export interface IOpLogEntry {
ts: plugins.bson.Timestamp;
op: 'i' | 'u' | 'd' | 'c' | 'n';
ns: string;
o: Document;
o2?: Document;
txnNumber?: number;
lsid?: { id: plugins.bson.Binary };
}
// ============================================================================
// Admin Types
// ============================================================================
export interface IDatabaseInfo {
name: string;
sizeOnDisk: number;
empty: boolean;
}
export interface ICollectionInfo {
name: string;
type: 'collection' | 'view';
options: Document;
info: {
readOnly: boolean;
uuid?: plugins.bson.Binary;
};
idIndex?: IIndexInfo;
}
export interface IServerStatus {
host: string;
version: string;
process: string;
pid: number;
uptime: number;
uptimeMillis: number;
uptimeEstimate: number;
localTime: Date;
mem: {
resident: number;
virtual: number;
};
connections: {
current: number;
available: number;
totalCreated: number;
};
ok: 1;
}
export interface ICollectionStats {
ns: string;
count: number;
size: number;
avgObjSize: number;
storageSize: number;
totalIndexSize: number;
indexSizes: Record<string, number>;
nindexes: number;
ok: 1;
}
// ============================================================================
// Count Types
// ============================================================================
export interface ICountDocumentsOptions {
skip?: number;
limit?: number;
session?: IClientSession;
hint?: string | Document;
maxTimeMS?: number;
}
export interface IEstimatedDocumentCountOptions {
maxTimeMS?: number;
}
export interface IDistinctOptions {
session?: IClientSession;
maxTimeMS?: number;
}