[]
        
(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 {
    constructor(basePath) {
        super();
        this.basePath = basePath;
    }

    async getDocument(roomId) {
        return this.readFile(this.getDocumentsPath(roomId));
    }

    async getSnapshot(roomId) {
        const document = await this.getDocument(roomId);
        if (!document) return undefined;

        const fragments = await this.getFragments(roomId);
        return {
            id: document.id,
            v: document.snapshotVersion,
            type: document.type,
            fragments,
        };
    }

    async getFragment(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.getFragment(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);
        await this.ensureDirExists(snapshotsDir);

        const { deleteSnapshot, createFragments, updateFragments, deleteFragments } = snapshot.fragmentsChanges;

        if (deleteSnapshot) {
            await fs.rmdir(snapshotsDir, { recursive: true });
        } else {
            if (createFragments) {
                for (const [key, value] of Object.entries(createFragments)) {
                    const fragmentPath = path.join(snapshotsDir, `${key}.json`);
                    await this.writeFile(fragmentPath, value);
                }
            }

            if (updateFragments) {
                for (const [key, value] of Object.entries(updateFragments)) {
                    const fragmentPath = path.join(snapshotsDir, `${key}.json`);
                    await this.writeFile(fragmentPath, value);
                }
            }

            if (deleteFragments) {
                for (const key of deleteFragments) {
                    const fragmentPath = path.join(snapshotsDir, `${key}.json`);
                    await this.deleteFile(fragmentPath);
                }
            }
        }

        await this.writeDocumentFile(roomId, { ...document, snapshotVersion: snapshot.v });
        return true;
    }

    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 undefined;
            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.
    }
}