feat: implement comprehensive CouchDB service and migration utilities
- Add production-ready CouchDB service with connection management - Implement design documents with views and Mango indexes - Create CRUD operations with proper error handling - Add specialized helper methods for all document types - Include batch operations and conflict resolution - Create comprehensive migration script from MongoDB to CouchDB - Add test suite with graceful handling when CouchDB unavailable - Include detailed documentation and usage guide - Update environment configuration for CouchDB support - Follow existing code patterns and conventions 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
@@ -1,94 +1,576 @@
|
||||
const Nano = require('nano');
|
||||
const nano = require("nano");
|
||||
|
||||
class CouchDBService {
|
||||
constructor() {
|
||||
this.nano = Nano(process.env.COUCHDB_URL || 'http://localhost:5984');
|
||||
this.dbName = process.env.COUCHDB_DB || 'adopt-a-street';
|
||||
this.connection = null;
|
||||
this.db = null;
|
||||
this.dbName = null;
|
||||
this.isConnected = false;
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CouchDB connection and database
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.isConnected || this.isConnecting) {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
this.db = this.nano.db.use(this.dbName);
|
||||
// Get configuration from environment variables
|
||||
const couchdbUrl = process.env.COUCHDB_URL || "http://localhost:5984";
|
||||
this.dbName = process.env.COUCHDB_DB_NAME || "adopt-a-street";
|
||||
|
||||
console.log(`Connecting to CouchDB at ${couchdbUrl}`);
|
||||
|
||||
// Initialize nano connection
|
||||
this.connection = nano(couchdbUrl);
|
||||
|
||||
// Test connection
|
||||
await this.connection.info();
|
||||
console.log("CouchDB connection established");
|
||||
|
||||
// Get or create database
|
||||
this.db = this.connection.use(this.dbName);
|
||||
|
||||
try {
|
||||
await this.db.info();
|
||||
console.log(`Database '${this.dbName}' exists`);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
console.log(`Creating database '${this.dbName}'`);
|
||||
await this.connection.db.create(this.dbName);
|
||||
this.db = this.connection.use(this.dbName);
|
||||
console.log(`Database '${this.dbName}' created successfully`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize design documents and indexes
|
||||
await this.initializeDesignDocuments();
|
||||
|
||||
this.isConnected = true;
|
||||
console.log("CouchDB service initialized successfully");
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
await this.nano.db.create(this.dbName);
|
||||
this.db = this.nano.db.use(this.dbName);
|
||||
} else {
|
||||
throw error;
|
||||
console.error("Failed to initialize CouchDB:", error.message);
|
||||
this.isConnected = false;
|
||||
this.isConnecting = false;
|
||||
throw error;
|
||||
} finally {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize design documents with indexes and views
|
||||
*/
|
||||
async initializeDesignDocuments() {
|
||||
const designDocs = [
|
||||
{
|
||||
_id: "_design/users",
|
||||
views: {
|
||||
"by-email": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "user" && doc.email) {
|
||||
emit(doc.email, null);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-points": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "user" && doc.points > 0) {
|
||||
emit(doc.points, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"user-by-email": {
|
||||
index: { fields: ["type", "email"] },
|
||||
name: "user-by-email",
|
||||
type: "json"
|
||||
},
|
||||
"users-by-points": {
|
||||
index: { fields: ["type", "points"] },
|
||||
name: "users-by-points",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/streets",
|
||||
views: {
|
||||
"by-status": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "street" && doc.status) {
|
||||
emit(doc.status, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-adopter": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "street" && doc.adoptedBy && doc.adoptedBy.userId) {
|
||||
emit(doc.adoptedBy.userId, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"streets-by-location": {
|
||||
index: { fields: ["type", "location"] },
|
||||
name: "streets-by-location",
|
||||
type: "json"
|
||||
},
|
||||
"streets-by-status": {
|
||||
index: { fields: ["type", "status"] },
|
||||
name: "streets-by-status",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/tasks",
|
||||
views: {
|
||||
"by-street": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "task" && doc.street && doc.street.streetId) {
|
||||
emit(doc.street.streetId, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-completer": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "task" && doc.completedBy && doc.completedBy.userId) {
|
||||
emit(doc.completedBy.userId, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-status": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "task" && doc.status) {
|
||||
emit(doc.status, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"tasks-by-user": {
|
||||
index: { fields: ["type", "completedBy.userId"] },
|
||||
name: "tasks-by-user",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/posts",
|
||||
views: {
|
||||
"by-user": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "post" && doc.user && doc.user.userId) {
|
||||
emit(doc.user.userId, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-date": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "post" && doc.createdAt) {
|
||||
emit(doc.createdAt, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"posts-by-date": {
|
||||
index: { fields: ["type", "createdAt"] },
|
||||
name: "posts-by-date",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/comments",
|
||||
views: {
|
||||
"by-post": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "comment" && doc.post && doc.post.postId) {
|
||||
emit(doc.post.postId, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-user": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "comment" && doc.user && doc.user.userId) {
|
||||
emit(doc.user.userId, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"comments-by-post": {
|
||||
index: { fields: ["type", "post.postId"] },
|
||||
name: "comments-by-post",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/events",
|
||||
views: {
|
||||
"by-date": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "event" && doc.date) {
|
||||
emit(doc.date, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-participant": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "event" && doc.participants) {
|
||||
doc.participants.forEach(function(participant) {
|
||||
emit(participant.userId, doc);
|
||||
});
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"events-by-date-status": {
|
||||
index: { fields: ["type", "date", "status"] },
|
||||
name: "events-by-date-status",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/reports",
|
||||
views: {
|
||||
"by-street": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "report" && doc.street && doc.street.streetId) {
|
||||
emit(doc.street.streetId, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-status": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "report" && doc.status) {
|
||||
emit(doc.status, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
},
|
||||
indexes: {
|
||||
"reports-by-status": {
|
||||
index: { fields: ["type", "status"] },
|
||||
name: "reports-by-status",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/badges",
|
||||
views: {
|
||||
"active-badges": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "badge" && doc.isActive) {
|
||||
emit(doc.order, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/transactions",
|
||||
views: {
|
||||
"by-user": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "point_transaction" && doc.user && doc.user.userId) {
|
||||
emit(doc.user.userId, doc);
|
||||
}
|
||||
}`
|
||||
},
|
||||
"by-date": {
|
||||
map: `function(doc) {
|
||||
if (doc.type === "point_transaction" && doc.createdAt) {
|
||||
emit(doc.createdAt, doc);
|
||||
}
|
||||
}`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: "_design/general",
|
||||
indexes: {
|
||||
"by-type": {
|
||||
index: { fields: ["type"] },
|
||||
name: "by-type",
|
||||
type: "json"
|
||||
},
|
||||
"by-user": {
|
||||
index: { fields: ["type", "user.userId"] },
|
||||
name: "by-user",
|
||||
type: "json"
|
||||
},
|
||||
"by-created-date": {
|
||||
index: { fields: ["type", "createdAt"] },
|
||||
name: "by-created-date",
|
||||
type: "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const designDoc of designDocs) {
|
||||
try {
|
||||
// Check if design document exists
|
||||
const existing = await this.db.get(designDoc._id);
|
||||
|
||||
// Update with new revision
|
||||
designDoc._rev = existing._rev;
|
||||
await this.db.insert(designDoc);
|
||||
console.log(`Updated design document: ${designDoc._id}`);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
// Create new design document
|
||||
await this.db.insert(designDoc);
|
||||
console.log(`Created design document: ${designDoc._id}`);
|
||||
} else {
|
||||
console.error(`Error creating design document ${designDoc._id}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database instance
|
||||
*/
|
||||
getDB() {
|
||||
if (!this.isConnected) {
|
||||
throw new Error("CouchDB not connected. Call initialize() first.");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connection status
|
||||
*/
|
||||
isReady() {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// Generic CRUD operations
|
||||
async create(document) {
|
||||
if (!this.db) await this.initialize();
|
||||
async createDocument(doc) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const result = await this.db.insert(document);
|
||||
return { ...document, _id: result.id, _rev: result.rev };
|
||||
const response = await this.db.insert(doc);
|
||||
return { ...doc, _id: response.id, _rev: response.rev };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create document: ${error.message}`);
|
||||
console.error("Error creating document:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
if (!this.db) await this.initialize();
|
||||
async getDocument(id, options = {}) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
return await this.db.get(id);
|
||||
const doc = await this.db.get(id, options);
|
||||
return doc;
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return null;
|
||||
throw new Error(`Failed to get document: ${error.message}`);
|
||||
if (error.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error("Error getting document:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id, document) {
|
||||
if (!this.db) await this.initialize();
|
||||
async updateDocument(doc) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const existing = await this.db.get(id);
|
||||
const updatedDoc = { ...document, _id: id, _rev: existing._rev };
|
||||
const result = await this.db.insert(updatedDoc);
|
||||
return { ...updatedDoc, _rev: result.rev };
|
||||
if (!doc._id || !doc._rev) {
|
||||
throw new Error("Document must have _id and _rev for update");
|
||||
}
|
||||
|
||||
const response = await this.db.insert(doc);
|
||||
return { ...doc, _rev: response.rev };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update document: ${error.message}`);
|
||||
console.error("Error updating document:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
if (!this.db) await this.initialize();
|
||||
async deleteDocument(id, rev) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const doc = await this.db.get(id);
|
||||
await this.db.destroy(id, doc._rev);
|
||||
return true;
|
||||
const response = await this.db.destroy(id, rev);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw new Error(`Failed to delete document: ${error.message}`);
|
||||
console.error("Error deleting document:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Query operations
|
||||
async find(selector, options = {}) {
|
||||
if (!this.db) await this.initialize();
|
||||
async find(query) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const query = { selector, ...options };
|
||||
const result = await this.db.find(query);
|
||||
return result.docs;
|
||||
const response = await this.db.find(query);
|
||||
return response.docs;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to find documents: ${error.message}`);
|
||||
console.error("Error executing query:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findOne(selector) {
|
||||
const docs = await this.find(selector, { limit: 1 });
|
||||
return docs.length > 0 ? docs[0] : null;
|
||||
const query = {
|
||||
selector,
|
||||
limit: 1
|
||||
};
|
||||
const docs = await this.find(query);
|
||||
return docs[0] || null;
|
||||
}
|
||||
|
||||
async findByType(type, selector = {}, options = {}) {
|
||||
return this.find({ type, ...selector }, options);
|
||||
const query = {
|
||||
selector: { type, ...selector },
|
||||
...options
|
||||
};
|
||||
return await this.find(query);
|
||||
}
|
||||
|
||||
// View query helper
|
||||
async view(designDoc, viewName, params = {}) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const response = await this.db.view(designDoc, viewName, params);
|
||||
return response.rows.map(row => row.value);
|
||||
} catch (error) {
|
||||
console.error("Error querying view:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Batch operation helper
|
||||
async bulkDocs(docs) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const response = await this.db.bulk({ docs });
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error in bulk operation:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict resolution helper
|
||||
async resolveConflict(id, conflictResolver) {
|
||||
if (!this.isConnected) await this.initialize();
|
||||
|
||||
try {
|
||||
const doc = await this.db.get(id, { open_revs: "all" });
|
||||
|
||||
if (!Array.isArray(doc) || doc.length === 1) {
|
||||
return doc[0] || doc; // No conflict
|
||||
}
|
||||
|
||||
// Multiple revisions exist - resolve conflict
|
||||
const resolvedDoc = await conflictResolver(doc);
|
||||
return await this.updateDocument(resolvedDoc);
|
||||
} catch (error) {
|
||||
console.error("Error resolving conflict:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Migration utilities
|
||||
async migrateDocument(mongoDoc, transformFn) {
|
||||
try {
|
||||
const couchDoc = transformFn(mongoDoc);
|
||||
return await this.createDocument(couchDoc);
|
||||
} catch (error) {
|
||||
console.error("Error migrating document:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate document structure
|
||||
validateDocument(doc, requiredFields = []) {
|
||||
const errors = [];
|
||||
|
||||
if (!doc.type) {
|
||||
errors.push("Document must have a 'type' field");
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!doc[field]) {
|
||||
errors.push(`Document must have '${field}' field`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Generate document ID with prefix
|
||||
generateId(type, originalId) {
|
||||
return `${type}_${originalId}`;
|
||||
}
|
||||
|
||||
// Extract original ID from prefixed ID
|
||||
extractOriginalId(prefixedId) {
|
||||
const parts = prefixedId.split("_");
|
||||
return parts.length > 1 ? parts.slice(1).join("_") : prefixedId;
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
async shutdown() {
|
||||
try {
|
||||
if (this.connection) {
|
||||
// Nano doesn't have an explicit close method, but we can mark as disconnected
|
||||
this.isConnected = false;
|
||||
console.log("CouchDB service shut down gracefully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during shutdown:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy compatibility methods
|
||||
async create(document) {
|
||||
return await this.createDocument(document);
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
return await this.getDocument(id);
|
||||
}
|
||||
|
||||
async update(id, document) {
|
||||
const existing = await this.getDocument(id);
|
||||
if (!existing) {
|
||||
throw new Error("Document not found for update");
|
||||
}
|
||||
const updatedDoc = { ...document, _id: id, _rev: existing._rev };
|
||||
return await this.updateDocument(updatedDoc);
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
const doc = await this.getDocument(id);
|
||||
if (!doc) return false;
|
||||
await this.deleteDocument(id, doc._rev);
|
||||
return true;
|
||||
}
|
||||
|
||||
// User-specific operations
|
||||
|
||||
Reference in New Issue
Block a user