[]
        
(Showing Draft Content)

File Database Adapter

As a supplementary example of Custom Database Adapter, this document provides a reference implementation of a filesystem-based database adapter.

import { promises as fs } from "fs";
import * as path from "path";
import { Db } from '@mescius/js-collaboration-ot';
/**
 * Example directory structure: E:\\my-storage
 * ├── room1/
 * │     ├── documents.json
 * │     └── operations
 *              └── op_0.json
 *              └── op_1.json
 * │     └── snapshot_fragments
 *              └── default.json
 * ├── room2/
 * │     └── ...
 */
export class FileStorage extends Db {
    basePath;

    constructor(basePath) {
        super();
        this.basePath = basePath;
    }
    async getDocument(roomId) {
        return this.readFile(this.getDocumentsPath(roomId)) ?? null;
    }
    async getSnapshot(roomId) {
        const document = await this.getDocument(roomId);
        if (!document) return null;
        const fragments = await this.getFragments(roomId);
        return {
            id: document.id,
            v: document.snapshotVersion,
            type: document.type,
            fragments,
        };
    }
    async getFragment(roomId, fragmentId) {
        const document = await this.getDocument(roomId);
        if (!document) return null;

        const data = await this.getFragmentData(roomId, fragmentId);
        return {
            version: document.snapshotVersion,
            data,
        };
    }
    async getFragmentData(roomId, fragmentId) {
        const fragmentPath = path.join(this.getSnapshotsPath(roomId), `${fragmentId}.json`);
        return this.readFile(fragmentPath);
    }
    async getFragments(roomId) {
        const fragmentsDir = this.getSnapshotsPath(roomId);
        const files = await this.listFiles(fragmentsDir);
        const fragments = {};
        for (const file of files) {
            const fragmentId = path.basename(file, '.json');
            fragments[fragmentId] = await this.getFragmentData(roomId, fragmentId);
        }
        return fragments;
    }
    async getOps(roomId, fromVersion, toVersion) {
        const operationsDir = this.getOperationsPath(roomId);
        const files = await this.listFiles(operationsDir);
        const ops = [];
        for (const file of files) {
            const version = parseInt(path.basename(file, '.json').split('_')[1], 10);
            if (version >= fromVersion && (toVersion === undefined || version < toVersion)) {
                const op = await this.readFile(path.join(operationsDir, file));
                if (op) ops.push(op);
            }
        }
        return ops.sort((a, b) => a.v - b.v);
    }
    async commitOp(id, op, document) {
        const roomPath = this.getRoomPath(id);
        await this.ensureDirExists(roomPath);
        const currentDoc = await this.getDocument(id);
        if (op.create) {
            if (currentDoc) return false;
            await this.writeDocumentFile(id, document);
            await this.writeOpFile(id, op);
        } else if (op.del) {
            if (!currentDoc) return false;
            await fs.rmdir(roomPath, { recursive: true });
        } else {
            if (!currentDoc || op.v !== currentDoc.version) return false;
            await this.writeDocumentFile(id, document);
            await this.writeOpFile(id, op);
        }
        return true;
    }
    async commitSnapshot(roomId, snapshot) {
        const document = await this.getDocument(roomId);
        if (!document || snapshot.fromVersion !== document.snapshotVersion || snapshot.v <= document.snapshotVersion) {
            return false;
        }
        const snapshotsDir = this.getSnapshotsPath(roomId);
        const { deleteSnapshot, createFragments, updateFragments, deleteFragments, setSnapshotFragments } = snapshot.fragmentsChanges;
        if (deleteSnapshot) {
            await fs.rm(snapshotsDir, { recursive: true, force: true });
        } else if (setSnapshotFragments) {
            // Full replacement: clear directory and write all fragments
            await fs.rm(snapshotsDir, { recursive: true, force: true });
            await this.ensureDirExists(snapshotsDir);
            await this.writeFragments(snapshotsDir, setSnapshotFragments);
        } else {
            await this.ensureDirExists(snapshotsDir);
            if (createFragments) {
                await this.writeFragments(snapshotsDir, createFragments);
            }
            if (updateFragments) {
                await this.writeFragments(snapshotsDir, updateFragments);
            }
            if (deleteFragments) {
                await this.deleteFragments(snapshotsDir, deleteFragments);
            }
        }
        await this.writeDocumentFile(roomId, { ...document, snapshotVersion: snapshot.v });
        return true;
    }

    async writeFragments(snapshotsDir, fragments) {
        await Promise.all(
            Object.entries(fragments).map(([key, value]) =>
                this.writeFile(path.join(snapshotsDir, `${key}.json`), value)
            )
        );
    }

    async deleteFragments(snapshotsDir, fragmentIds) {
        await Promise.all(
            fragmentIds.map((key) =>
                this.deleteFile(path.join(snapshotsDir, `${key}.json`))
            )
        );
    }

    async ensureDirExists(dirPath) {
        try {
            await fs.mkdir(dirPath, { recursive: true });
        } catch (err) {
            if (err.code !== 'EEXIST') throw err;
        }
    }
    async writeDocumentFile(id, document) {
        const docPath = this.getDocumentsPath(id);
        await this.writeFile(docPath, document);
    }
    async writeOpFile(id, op) {
        const dirPath = this.getOperationsPath(id);
        const opPath = path.join(dirPath, `op_${op.v}.json`);
        await this.ensureDirExists(dirPath);
        await this.writeFile(opPath, op);
    }
    async writeFile(filePath, data) {
        await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
    }
    async readFile(filePath) {
        try {
            const content = await fs.readFile(filePath, 'utf-8');
            return JSON.parse(content);
        } catch (err) {
            if (err.code === 'ENOENT') return null;
            throw err;
        }
    }
    async deleteFile(filePath) {
        try {
            await fs.unlink(filePath);
        } catch (err) {
            if (err.code !== 'ENOENT') throw err;
        }
    }
    async listFiles(dirPath) {
        try {
            return await fs.readdir(dirPath);
        } catch (err) {
            if (err.code === 'ENOENT') return [];
            throw err;
        }
    }
    getRoomPath(roomId) {
        return path.join(this.basePath, roomId);
    }
    getDocumentsPath(roomId) {
        return path.join(this.getRoomPath(roomId), 'documents.json');
    }
    getOperationsPath(roomId) {
        return path.join(this.getRoomPath(roomId), 'operations');
    }
    getSnapshotsPath(roomId) {
        return path.join(this.getRoomPath(roomId), 'snapshot_fragments');
    }
    async close() {
        // No resources to close for file-based storage.
    }
}