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:
William Valentin
2025-11-01 13:05:18 -07:00
parent e74de09605
commit 2961107136
7 changed files with 1862 additions and 608 deletions

View File

@@ -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