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:
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user