import nano from 'nano';
import {NoteRepository} from './note-repository.js';
import {Note} from '../models/note.js';
/**
* CouchDB implementation of the NoteRepository interface.
* Provides persistent storage for notes using Apache CouchDB as the backend.
*
* @class
* @extends NoteRepository
* @example
* const repository = new CouchDbNoteRepository('http://admin:password@localhost:5984', 'notes_db');
* await repository.init();
* const notes = await repository.findAll();
*/
export class CouchDbNoteRepository extends NoteRepository {
/**
* Create a new CouchDbNoteRepository
* @param {string} url - CouchDB connection URL (including authentication if required)
* @param {string} dbName - Database name to use for storing notes
* @returns {CouchDbNoteRepository} New CouchDbNoteRepository instance
* @example
* // With authentication
* const repo = new CouchDbNoteRepository('http://user:pass@localhost:5984', 'my_notes');
*
* // Without authentication
* const repo = new CouchDbNoteRepository('http://localhost:5984', 'my_notes');
*/
constructor(url, dbName) {
super();
this.client = nano(url);
this.dbName = dbName;
this.db = null;
}
/**
* Initialize the repository by ensuring the database exists and creating necessary design documents.
* This method must be called before using any other repository methods.
*
* @returns {Promise<void>}
* @throws {Error} When CouchDB is unreachable or database creation fails
* @throws {Error} When design document creation fails
* @example
* const repository = new CouchDbNoteRepository(url, dbName);
* await repository.init(); // Creates database and design documents if needed
*/
async init() {
try {
// Check if a database exists, if not, create it
const dbList = await this.client.db.list();
if (!dbList.includes(this.dbName)) {
await this.client.db.create(this.dbName);
}
this.db = this.client.use(this.dbName);
// Create or update design document for views
const expectedDesignDoc = {
_id: '_design/notes',
views: {
all: {
map: "function(doc) { if (doc.type === 'note') { emit(doc._id, null); } }"
},
active: {
map: "function(doc) { if (doc.type === 'note' && doc.deletedAt === null) { emit(doc.updatedAt, null); } }"
},
deleted: {
map: "function(doc) { if (doc.type === 'note' && doc.deletedAt !== null) { emit(doc.deletedAt, null); } }"
}
},
language: 'javascript'
};
try {
const existingDoc = await this.db.get('_design/notes');
// Check if the existing design doc has all the required views
const hasAllViews = existingDoc.views &&
existingDoc.views.all &&
existingDoc.views.active &&
existingDoc.views.deleted;
if (!hasAllViews) {
// Update the existing design doc with all views
expectedDesignDoc._rev = existingDoc._rev;
await this.db.insert(expectedDesignDoc);
console.log('Updated design document with all views');
}
} catch (error) {
if (error.statusCode === 404) {
// Design document doesn't exist, create it
await this.db.insert(expectedDesignDoc);
console.log('Created design document with all views');
} else {
throw error;
}
}
} catch (error) {
console.error('Failed to initialize CouchDB repository:', error);
throw error;
}
}
/**
* Find all active notes (not deleted)
* @returns {Promise<Note[]>} Promise resolving to an array of active Note objects
* @throws {Error} When database query fails or CouchDB is unreachable
* @example
* const activeNotes = await repository.findAll();
* console.log(`Found ${activeNotes.length} active notes`);
*/
async findAll() {
try {
const result = await this.db.view('notes', 'active', {
update: true,
include_docs: true,
descending: true // Sort by date descending (newest first)
});
if (!result.rows) {
console.log('No rows in view result, returning empty array');
return [];
}
const notes = result.rows.map(row => {
if (!row.doc) {
console.log('Row without doc:', row);
return null;
}
const doc = row.doc;
return Note.fromObject({
id: doc._id,
title: doc.title,
content: doc.content,
deletedAt: doc.deletedAt,
createdAt: new Date(doc.createdAt),
updatedAt: new Date(doc.updatedAt)
});
}).filter(note => note !== null);
return notes;
} catch (error) {
console.error('Failed to find all active notes:', error);
throw error;
}
}
/**
* Find all deleted notes (in recycle bin)
* @returns {Promise<Note[]>} Promise resolving to an array of deleted Note objects
* @throws {Error} When database query fails or CouchDB is unreachable
* @example
* const deletedNotes = await repository.findDeleted();
* console.log(`Found ${deletedNotes.length} notes in recycle bin`);
*/
async findDeleted() {
try {
const result = await this.db.view('notes', 'deleted', {
update: true,
include_docs: true,
descending: true // Sort by deleted date descending (most recently deleted first)
});
return this._mapResult(result);
} catch (error) {
console.error('Failed to find deleted notes:', error);
throw error;
}
}
/**
* Find all notes regardless of deletion status
* @returns {Promise<Note[]>} Promise resolving to an array of all Note objects
* @throws {Error} When database query fails or CouchDB is unreachable
*/
async findAllIncludingDeleted() {
try {
const result = await this.db.view('notes', 'all', {
update: true,
include_docs: true,
descending: true
});
return this._mapResult(result);
} catch (error) {
console.error('Failed to find all notes including deleted:', error);
throw error;
}
}
/**
* Find a note by its unique identifier
* @param {string} id - The unique ID of the note to retrieve
* @returns {Promise<Note|null>} Promise resolving to a Note object or null if not found
* @throws {Error} When database query fails (except for 404 not found)
* @example
* const note = await repository.findById('note_123');
* if (note) {
* console.log(`Found note: ${note.title}`);
* } else {
* console.log('Note not found');
* }
*/
async findById(id) {
try {
const doc = await this.db.get(id);
return Note.fromObject({
id: doc._id,
title: doc.title,
content: doc.content,
deletedAt: doc.deletedAt ? new Date(doc.deletedAt) : null,
createdAt: new Date(doc.createdAt),
updatedAt: new Date(doc.updatedAt)
});
} catch (error) {
if (error.statusCode === 404) {
return null;
}
console.error(`Failed to find note with ID ${id}:`, error);
throw error;
}
}
/**
* Create a new note in the database
* @param {Object} note - The note data to create
* @param {string} note.title - The title of the note
* @param {string} note.content - The content of the note
* @returns {Promise<Note>} Promise resolving to the created Note object with assigned ID
* @throws {Error} When note creation fails or database is unreachable
* @example
* const newNote = await repository.create({
* title: 'My New Note',
* content: 'This is the content of my note'
* });
* console.log(`Created note with ID: ${newNote.id}`);
*/
async create(note) {
try {
const now = new Date();
const doc = {
type: 'note',
title: note.title,
content: note.content,
deletedAt: null,
createdAt: now.toISOString(),
updatedAt: now.toISOString()
};
const result = await this.db.insert(doc);
return Note.fromObject({
id: result.id,
title: doc.title,
content: doc.content,
deletedAt: doc.deletedAt,
createdAt: new Date(doc.createdAt),
updatedAt: new Date(doc.updatedAt)
});
} catch (error) {
console.error('Failed to create note:', error);
throw error;
}
}
/**
* Update an existing note in the database
* @param {string} id - The ID of the note to update
* @param {Object} note - The updated note data
* @param {string} note.title - The updated title of the note
* @param {string} note.content - The updated content of the note
* @returns {Promise<Note|null>} Promise resolving to the updated Note object or null if not found
* @throws {Error} When update fails due to database issues
* @throws {Error} When document was modified concurrently (409 conflict)
* @example
* const updatedNote = await repository.update('note_123', {
* title: 'Updated Title',
* content: 'Updated content'
* });
* if (updatedNote) {
* console.log('Note updated successfully');
* } else {
* console.log('Note not found');
* }
*/
async update(id, note) {
try {
// Get the current document
let currentDoc;
try {
currentDoc = await this.db.get(id);
console.log('Current document:', currentDoc);
} catch (error) {
if (error.statusCode === 404) {
console.log(`Note ${id} not found`);
return null;
}
throw error;
}
const now = new Date();
const updatedDoc = {
_id: id,
_rev: currentDoc._rev,
type: 'note',
title: note.title,
content: note.content,
deletedAt: currentDoc.deletedAt || null,
createdAt: currentDoc.createdAt,
updatedAt: now.toISOString()
};
const result = await this.db.insert(updatedDoc);
return Note.fromObject({
id: id,
title: note.title,
content: note.content,
deletedAt: updatedDoc.deletedAt ? new Date(updatedDoc.deletedAt) : null,
createdAt: new Date(currentDoc.createdAt),
updatedAt: new Date(updatedDoc.updatedAt)
});
} catch (error) {
console.error(`Failed to update note with ID ${id}:`, error);
if (error.statusCode === 409) {
throw new Error('Document was modified concurrently. Please try again.');
}
throw error;
}
}
/**
* Move a note to recycle bin (soft delete)
* @param {string} id - The ID of the note to move to recycle bin
* @returns {Promise<boolean>} Promise resolving to true if moved to recycle bin, false if not found
* @throws {Error} When operation fails due to database issues
* @example
* const moved = await repository.moveToRecycleBin('note_123');
* if (moved) {
* console.log('Note moved to recycle bin successfully');
* } else {
* console.log('Note not found');
* }
*/
async moveToRecycleBin(id) {
try {
// Get the current document
let currentDoc;
try {
currentDoc = await this.db.get(id);
} catch (error) {
if (error.statusCode === 404) {
return false;
}
throw error;
}
const now = new Date();
const updatedDoc = {
...currentDoc,
deletedAt: now.toISOString(),
updatedAt: now.toISOString()
};
await this.db.insert(updatedDoc);
return true;
} catch (error) {
console.error(`Failed to move note to recycle bin with ID ${id}:`, error);
if (error.statusCode === 404) {
return false;
}
throw error;
}
}
/**
* Restore a note from recycle bin
* @param {string} id - The ID of the note to restore
* @returns {Promise<boolean>} Promise resolving to true if restored, false if not found
* @throws {Error} When operation fails due to database issues
* @example
* const restored = await repository.restore('note_123');
* if (restored) {
* console.log('Note restored successfully');
* } else {
* console.log('Note not found');
* }
*/
async restore(id) {
try {
// Get the current document
let currentDoc;
try {
currentDoc = await this.db.get(id);
} catch (error) {
if (error.statusCode === 404) {
return false;
}
throw error;
}
const now = new Date();
const updatedDoc = {
...currentDoc,
deletedAt: null,
updatedAt: now.toISOString()
};
await this.db.insert(updatedDoc);
return true;
} catch (error) {
console.error(`Failed to restore note with ID ${id}:`, error);
if (error.statusCode === 404) {
return false;
}
throw error;
}
}
/**
* Permanently delete a note from the database
* @param {string} id - The ID of the note to permanently delete
* @returns {Promise<boolean>} Promise resolving to true if deleted, false if not found
* @throws {Error} When deletion fails due to database issues
* @example
* const deleted = await repository.permanentDelete('note_123');
* if (deleted) {
* console.log('Note permanently deleted');
* } else {
* console.log('Note not found');
* }
*/
async permanentDelete(id) {
try {
// Get the current document
let doc;
try {
doc = await this.db.get(id);
} catch (error) {
if (error.statusCode === 404) {
return false;
}
throw error;
}
await this.db.destroy(id, doc._rev);
return true;
} catch (error) {
if (error.statusCode === 404) {
return false;
}
console.error(`Failed to permanently delete note with ID ${id}:`, error);
throw error;
}
}
/**
* Empty the recycle bin by permanently deleting all deleted notes
* @returns {Promise<number>} Promise resolving to the number of notes permanently deleted
* @throws {Error} When operation fails due to database issues
* @example
* const deletedCount = await repository.emptyRecycleBin();
* console.log(`Permanently deleted ${deletedCount} notes from recycle bin`);
*/
async emptyRecycleBin() {
try {
// Get all deleted notes
const deletedNotes = await this.findDeleted();
let deleteCount = 0;
// Permanently delete each one
for (const note of deletedNotes) {
const deleted = await this.permanentDelete(note.id);
if (deleted) {
deleteCount++;
}
}
return deleteCount;
} catch (error) {
console.error('Failed to empty recycle bin:', error);
throw error;
}
}
/**
* Restore all notes from recycle bin
* @returns {Promise<number>} Promise resolving to the number of notes restored
* @throws {Error} When operation fails due to database issues
* @example
* const restoredCount = await repository.restoreAll();
* console.log(`Restored ${restoredCount} notes from recycle bin`);
*/
async restoreAll() {
try {
// Get all deleted notes
const deletedNotes = await this.findDeleted();
let restoreCount = 0;
// Restore each one
for (const note of deletedNotes) {
const restored = await this.restore(note.id);
if (restored) {
restoreCount++;
}
}
return restoreCount;
} catch (error) {
console.error('Failed to restore all notes from recycle bin:', error);
throw error;
}
}
/**
* Count the number of deleted notes in recycle bin
* @returns {Promise<number>} Promise resolving to the count of deleted notes
* @throws {Error} When database query fails or CouchDB is unreachable
* @example
* const count = await repository.countDeleted();
* console.log(`Recycle bin contains ${count} notes`);
*/
async countDeleted() {
try {
const result = await this.db.view('notes', 'deleted', {
update: true
});
return result.rows ? result.rows.length : 0;
} catch (error) {
console.error('Failed to count deleted notes:', error);
throw error;
}
}
_mapResult(result) {
if (!result || !result.rows) {
console.log('No rows in view result, returning empty array');
return [];
}
return result.rows.map(row => {
if (!row.doc) {
console.log('Row without doc:', row);
return null;
}
const doc = row.doc;
return Note.fromObject({
id: doc._id,
title: doc.title,
content: doc.content,
deletedAt: doc.deletedAt ? new Date(doc.deletedAt) : null,
createdAt: new Date(doc.createdAt),
updatedAt: new Date(doc.updatedAt)
});
}).filter(note => note !== null);
}
}