virtual-storage-device-for-chat-systems
Version:
Virtual Storage Device for Chat Systems.
180 lines (158 loc) • 6.37 kB
JavaScript
/*
storedev.js
Simple file-backed chat store (storage.json) for development / prototyping.
Data model (storage.json):
{
"chat1": {
"users": ["alice", "me"],
"messages": [
{ "id": "...", "sender": "alice", "content": "Hello Pawan", "ts": "2025-08-09T09:00:00.000Z" },
{ "id": "...", "sender": "me", "content": "Hi! there", "ts": "2025-08-09T09:00:03.000Z" }
]
},
"chat2": { "users": ["someone","me"], "messages": [] }
}
Why this model?
- JSON object keyed by chatId keeps sessions easy to list.
- "messages" is an array (preserves order) and allows multiple messages from the same sender.
- Duplicate object keys are not allowed in JSON, so using an array is the correct approach.
Exports: FileSystem with these async functions:
- createSession(chatId, users = [])
- addUserToSession(chatId, username)
- removeUserFromSession(chatId, username)
- appendMessage(chatId, sender, content, opts = { autoCreate: false })
- getMessages(chatId, opts = { since, limit })
- listSessions()
- deleteSession(chatId)
- getStoragePath()
Notes:
- This implementation uses an in-process queue to avoid races within the same Node process.
- Writes are atomic (write to temp file, then rename) to minimize file corruption.
- If you need multi-process safety or high concurrency, use a real DB (SQLite/ostgres/LevelDB).
*/
import fs from "fs/promises";
import path from "path";
import crypto from "crypto";
const DEFAULT_STORAGE = path.join(process.cwd(), "storage.json");
let STORAGE_FILE = DEFAULT_STORAGE;
// simple in-process queue keyed by 'storage' to serialize read/write operations
const queues = new Map();
function queueWork(key, work) {
const prev = queues.get(key) || Promise.resolve();
const next = prev.then(() => work());
// attach a catch so rejections don't break the chain
queues.set(key, next.catch(() => {}));
// when done, remove if it's still the same promise
next.finally(() => {
if (queues.get(key) === next) queues.delete(key);
});
return next;
}
async function readStorage() {
try {
const txt = await fs.readFile(STORAGE_FILE, "utf8");
return txt.trim() ? JSON.parse(txt) : {};
} catch (err) {
if (err.code === "ENOENT") return {};
throw err;
}
}
async function writeAtomic(filePath, dataString) {
const dir = path.dirname(filePath);
const tmp = path.join(dir, ".storage.tmp." + crypto.randomBytes(6).toString("hex"));
await fs.writeFile(tmp, dataString, "utf8");
await fs.rename(tmp, filePath);
}
function makeMsg(sender, content) {
return {
id: (crypto.randomUUID && crypto.randomUUID()) || crypto.randomBytes(8).toString("hex"),
sender,
content,
ts: new Date().toISOString()
};
}
export const FileSystem = {
// you can change storage path before using the API
setStoragePath(p) {
STORAGE_FILE = path.resolve(p);
},
getStoragePath() {
return STORAGE_FILE;
},
async createSession(chatId, users = []) {
return queueWork("storage", async () => {
const data = await readStorage();
if (data[chatId]) throw new Error(`Session '${chatId}' already exists`);
data[chatId] = { users: Array.from(new Set(users)), messages: [] };
await writeAtomic(STORAGE_FILE, JSON.stringify(data, null, 2));
return data[chatId];
});
},
async addUserToSession(chatId, username) {
return queueWork("storage", async () => {
const data = await readStorage();
if (!data[chatId]) throw new Error(`Session '${chatId}' not found`);
if (!data[chatId].users.includes(username)) data[chatId].users.push(username);
await writeAtomic(STORAGE_FILE, JSON.stringify(data, null, 2));
return data[chatId];
});
},
async removeUserFromSession(chatId, username) {
return queueWork("storage", async () => {
const data = await readStorage();
if (!data[chatId]) throw new Error(`Session '${chatId}' not found`);
data[chatId].users = data[chatId].users.filter(u => u !== username);
await writeAtomic(STORAGE_FILE, JSON.stringify(data, null, 2));
return data[chatId];
});
},
async appendMessage(chatId, sender, content, opts = { autoCreate: false }) {
return queueWork("storage", async () => {
const data = await readStorage();
if (!data[chatId]) {
if (!opts.autoCreate) throw new Error(`Session '${chatId}' not found`);
data[chatId] = { users: [sender], messages: [] };
}
// ensure sender in users list (optional behavior)
if (!data[chatId].users.includes(sender)) data[chatId].users.push(sender);
const msg = makeMsg(sender, content);
data[chatId].messages.push(msg);
await writeAtomic(STORAGE_FILE, JSON.stringify(data, null, 2));
return msg;
});
},
// get messages, optional since timestamp or limit
async getMessages(chatId, opts = { since: null, limit: null }) {
// read only; still queue to keep consistent view with writes
return queueWork("storage", async () => {
const data = await readStorage();
if (!data[chatId]) return null;
let msgs = data[chatId].messages.slice();
if (opts.since) msgs = msgs.filter(m => new Date(m.ts) > new Date(opts.since));
if (opts.limit) msgs = msgs.slice(-opts.limit);
return { users: data[chatId].users.slice(), messages: msgs };
});
},
async listSessions() {
const data = await readStorage();
return Object.keys(data);
},
async deleteSession(chatId) {
return queueWork("storage", async () => {
const data = await readStorage();
if (!data[chatId]) throw new Error(`Session '${chatId}' not found`);
delete data[chatId];
await writeAtomic(STORAGE_FILE, JSON.stringify(data, null, 2));
return true;
});
}
};
// If you want encryption hooks, add wrapper functions around appendMessage/getMessages
// using your AES-GCM sharedKey and store ciphertext in `content` instead of plaintext.
// Example usage (outside this module):
// import { FileSystem } from './storedev.js';
// await FileSystem.createSession('chat1', ['username','me']);
// await FileSystem.appendMessage('chat1', 'username', 'Hello, Pawan');
// await FileSystem.appendMessage('chat1', 'me', "Hi! there");
// const chat = await FileSystem.getMessages('chat1');
// console.log(chat);