Add data layer with MongoDB and JSON fallback store

Introduce database config, MongoDB and JSON store implementations,
and a datastore factory that connects to MongoDB by default and falls
back to a local JSON file when MongoDB is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
francesco448
2026-03-18 16:52:34 +01:00
parent 3e3fa11951
commit e0d53d6e74
5 changed files with 455 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
const boolFromEnv = (value, fallback = false) => {
if (value === undefined || value === '') return fallback
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase())
}
const numberFromEnv = (value, fallback) => {
if (value === undefined || value === '') return fallback
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
const config = {
uri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017',
dbName: process.env.MONGO_DB || 'chatapp',
options: {
user: process.env.MONGO_USER || undefined,
pass: process.env.MONGO_PASSWORD || undefined,
socketTimeoutMS: numberFromEnv(process.env.MONGO_SOCKET_TIMEOUT, 45000),
connectTimeoutMS: numberFromEnv(process.env.MONGO_CONNECT_TIMEOUT, 30000),
replicaSet: process.env.MONGO_REPLICA_SET || undefined,
tls: boolFromEnv(process.env.MONGO_TLS, false),
},
}
module.exports = config
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs')
const path = require('path')
loadLocalEnv()
module.exports = {
port: process.env.PORT || 4000,
clientOrigin: process.env.CLIENT_ORIGIN || '*',
}
function loadLocalEnv() {
const envPath = path.join(__dirname, '..', '.env')
if (!fs.existsSync(envPath)) {
return
}
const content = fs.readFileSync(envPath, 'utf-8')
for (const line of content.split(/\r?\n/)) {
if (!line || line.trim().startsWith('#')) continue
const [key, ...rest] = line.split('=')
if (!key) continue
const value = rest.join('=').trim()
if (key && value && process.env[key] === undefined) {
process.env[key] = value
}
}
}
+34
View File
@@ -0,0 +1,34 @@
const path = require('path')
const JsonStore = require('./stores/jsonStore')
async function createDataStore() {
const requestedStore = process.env.DATA_STORE
const forceJson = requestedStore === 'json'
const strictMongo = requestedStore === 'mongo'
if (forceJson) {
const filePath = path.join(__dirname, '..', 'data', 'db.json')
console.log(`Using JSON fallback store at ${filePath}`)
return new JsonStore(filePath)
}
try {
const createMongoStore = require('./stores/mongoStore')
return await createMongoStore()
} catch (error) {
if (strictMongo) {
throw error
}
console.warn(
'MongoDB store unavailable, falling back to JSON store.',
error.message,
)
}
const filePath = path.join(__dirname, '..', 'data', 'db.json')
console.log(`Using JSON fallback store at ${filePath}`)
return new JsonStore(filePath)
}
module.exports = createDataStore
+150
View File
@@ -0,0 +1,150 @@
const fs = require('fs')
const path = require('path')
class JsonStore {
constructor(filePath) {
this.filePath = filePath
this.state = { users: [], messages: [] }
this._ensureStore()
}
_ensureStore() {
const dir = path.dirname(this.filePath)
fs.mkdirSync(dir, { recursive: true })
if (!fs.existsSync(this.filePath)) {
this._persist()
return
}
try {
const contents = fs.readFileSync(this.filePath, 'utf-8')
this.state = JSON.parse(contents)
} catch (error) {
console.warn('JSON store damaged, recreating', error)
this._persist()
}
}
_persist() {
fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8')
}
async upsertUser({ id, username, room }) {
const trimmed = username?.trim()
if (!trimmed) {
throw new Error('Username is required')
}
const now = new Date().toISOString()
const index = this.state.users.findIndex((user) => user.id === id)
if (index >= 0) {
const updated = {
...this.state.users[index],
username: trimmed,
room,
isOnline: true,
lastActiveAt: now,
}
this.state.users[index] = updated
this._persist()
return updated
}
const user = {
id,
username: trimmed,
room,
isOnline: true,
joinedAt: now,
lastActiveAt: now,
}
this.state.users.push(user)
this._persist()
return user
}
async setUserOnline(userId, room) {
const user = this.state.users.find((entry) => entry.id === userId)
if (!user) {
return null
}
Object.assign(user, {
isOnline: true,
room,
lastActiveAt: new Date().toISOString(),
})
this._persist()
return user
}
async setUserOffline(userId) {
const user = this.state.users.find((entry) => entry.id === userId)
if (!user) {
return null
}
Object.assign(user, {
isOnline: false,
lastActiveAt: new Date().toISOString(),
})
this._persist()
return user
}
async updateUsername(userId, username) {
const user = this.state.users.find((entry) => entry.id === userId)
if (!user) return null
user.username = username.trim()
user.lastActiveAt = new Date().toISOString()
this._persist()
return user
}
async removeUser(userId) {
const index = this.state.users.findIndex((entry) => entry.id === userId)
if (index === -1) return null
const [removed] = this.state.users.splice(index, 1)
this._persist()
return removed
}
async getUser(userId) {
return this.state.users.find((entry) => entry.id === userId) || null
}
async getParticipants(room) {
return this.state.users
.filter((entry) => entry.room === room)
.map(({ id, username, isOnline, joinedAt, lastActiveAt }) => ({
id,
username,
isOnline,
joinedAt,
lastActiveAt,
}))
.sort((a, b) => {
if (a.isOnline === b.isOnline) {
return new Date(b.lastActiveAt) - new Date(a.lastActiveAt)
}
return a.isOnline ? -1 : 1
})
}
async addMessage({ id, room, userId, username, text }) {
const message = {
id,
room,
userId,
username,
text,
createdAt: new Date().toISOString(),
}
this.state.messages.push(message)
this._persist()
return message
}
async getMessages(room, limit = 100) {
return this.state.messages
.filter((message) => message.room === room)
.slice(-limit)
}
}
module.exports = JsonStore
+217
View File
@@ -0,0 +1,217 @@
const config = require('../../config/database')
let MongoClient
try {
;({ MongoClient } = require('mongodb'))
} catch (error) {
MongoClient = null
}
const collectionNames = {
users: 'users',
messages: 'messages',
}
function normalizeUser(doc) {
if (!doc) return null
const {
_id,
username,
room,
isOnline,
joinedAt,
lastActiveAt,
} = doc
return {
id: _id,
username,
room,
isOnline,
joinedAt,
lastActiveAt,
}
}
function normalizeMessage(doc) {
if (!doc) return null
const {
_id,
room,
userId,
username,
text,
createdAt,
} = doc
return {
id: _id,
room,
userId,
username,
text,
createdAt,
}
}
function extractDocument(result) {
if (!result) return null
return Object.prototype.hasOwnProperty.call(result, 'value')
? result.value
: result
}
async function createMongoStore() {
// Guard: fail fast with an actionable message if the driver is missing.
if (!MongoClient) {
const message =
'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.'
const error = new Error(message)
error.code = 'MODULE_NOT_INSTALLED'
throw error
}
const client = new MongoClient(config.uri, {
...config.options,
appName: 'chat-app',
})
await client.connect()
const db = client.db(config.dbName)
const users = db.collection(collectionNames.users)
const messages = db.collection(collectionNames.messages)
await Promise.all([
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }),
messages.createIndex({ room: 1, createdAt: 1 }),
])
console.log(
`Connected to MongoDB at ${
config.uri
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
)
return {
async upsertUser({ id, username, room }) {
const now = new Date()
const result = await users.findOneAndUpdate(
{ _id: id },
{
$set: {
username: username.trim(),
room,
isOnline: true,
lastActiveAt: now,
updatedAt: now,
},
$setOnInsert: {
joinedAt: now,
},
},
{
upsert: true,
returnDocument: 'after',
},
)
const document = extractDocument(result)
if (document) {
return normalizeUser(document)
}
const inserted = await users.findOne({ _id: id })
return normalizeUser(inserted)
},
async setUserOnline(userId, room) {
const result = await users.findOneAndUpdate(
{ _id: userId },
{
$set: {
isOnline: true,
room,
lastActiveAt: new Date(),
},
},
{ returnDocument: 'after' },
)
return normalizeUser(extractDocument(result))
},
async setUserOffline(userId) {
const result = await users.findOneAndUpdate(
{ _id: userId },
{
$set: {
isOnline: false,
lastActiveAt: new Date(),
},
},
{ returnDocument: 'after' },
)
return normalizeUser(extractDocument(result))
},
async updateUsername(userId, username) {
const result = await users.findOneAndUpdate(
{ _id: userId },
{
$set: {
username: username.trim(),
lastActiveAt: new Date(),
},
},
{ returnDocument: 'after' },
)
return normalizeUser(extractDocument(result))
},
async removeUser(userId) {
const result = await users.findOneAndDelete({ _id: userId })
return normalizeUser(extractDocument(result))
},
async getUser(userId) {
const doc = await users.findOne({ _id: userId })
return normalizeUser(doc)
},
async getParticipants(room) {
const docs = await users
.find({ room })
.sort({ isOnline: -1, lastActiveAt: -1 })
.toArray()
return docs.map(normalizeUser)
},
async addMessage({ id, room, userId, username, text }) {
const message = {
_id: id,
room,
userId,
username,
text,
createdAt: new Date(),
}
await messages.insertOne(message)
return normalizeMessage(message)
},
async getMessages(room, limit = 100) {
const docs = await messages
.find({ room })
.sort({ createdAt: 1 })
.limit(limit)
.toArray()
return docs.map(normalizeMessage)
},
async disconnect() {
await client.close()
},
}
}
module.exports = createMongoStore