UNPKG

s3db.js

Version:

Use AWS S3, the world's most reliable document storage, as a database with this ORM.

494 lines (414 loc) 14.9 kB
import Plugin from "./plugin.class.js"; import tryFn from "../concerns/try-fn.js"; export class FullTextPlugin extends Plugin { constructor(options = {}) { super(); this.indexResource = null; this.config = { minWordLength: options.minWordLength || 3, maxResults: options.maxResults || 100, ...options }; this.indexes = new Map(); // In-memory index for simplicity } async setup(database) { this.database = database; // Create index resource if it doesn't exist const [ok, err, indexResource] = await tryFn(() => database.createResource({ name: 'fulltext_indexes', attributes: { id: 'string|required', resourceName: 'string|required', fieldName: 'string|required', word: 'string|required', recordIds: 'json|required', // Array of record IDs containing this word count: 'number|required', lastUpdated: 'string|required' } })); this.indexResource = ok ? indexResource : database.resources.fulltext_indexes; // Load existing indexes await this.loadIndexes(); // Use database hooks for automatic resource discovery this.installDatabaseHooks(); // Install hooks for existing resources this.installIndexingHooks(); } async start() { // Plugin is ready } async stop() { // Save indexes before stopping await this.saveIndexes(); // Remove database hooks this.removeDatabaseHooks(); } async loadIndexes() { if (!this.indexResource) return; const [ok, err, allIndexes] = await tryFn(() => this.indexResource.getAll()); if (ok) { for (const indexRecord of allIndexes) { const key = `${indexRecord.resourceName}:${indexRecord.fieldName}:${indexRecord.word}`; this.indexes.set(key, { recordIds: indexRecord.recordIds || [], count: indexRecord.count || 0 }); } } } async saveIndexes() { if (!this.indexResource) return; const [ok, err] = await tryFn(async () => { // Clear existing indexes const existingIndexes = await this.indexResource.getAll(); for (const index of existingIndexes) { await this.indexResource.delete(index.id); } // Save current indexes for (const [key, data] of this.indexes.entries()) { const [resourceName, fieldName, word] = key.split(':'); await this.indexResource.insert({ id: `index-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, resourceName, fieldName, word, recordIds: data.recordIds, count: data.count, lastUpdated: new Date().toISOString() }); } }); } installDatabaseHooks() { // Use the new database hooks system for automatic resource discovery this.database.addHook('afterCreateResource', (resource) => { if (resource.name !== 'fulltext_indexes') { this.installResourceHooks(resource); } }); } removeDatabaseHooks() { // Remove the hook we added this.database.removeHook('afterCreateResource', this.installResourceHooks.bind(this)); } installIndexingHooks() { // Register plugin with database if (!this.database.plugins) { this.database.plugins = {}; } this.database.plugins.fulltext = this; for (const resource of Object.values(this.database.resources)) { if (resource.name === 'fulltext_indexes') continue; this.installResourceHooks(resource); } // Hook into database proxy for new resources (check if already installed) if (!this.database._fulltextProxyInstalled) { // Store the previous createResource (could be another plugin's proxy) this.database._previousCreateResourceForFullText = this.database.createResource; this.database.createResource = async function (...args) { const resource = await this._previousCreateResourceForFullText(...args); if (this.plugins?.fulltext && resource.name !== 'fulltext_indexes') { this.plugins.fulltext.installResourceHooks(resource); } return resource; }; this.database._fulltextProxyInstalled = true; } // Ensure all existing resources have hooks (even if created before plugin setup) for (const resource of Object.values(this.database.resources)) { if (resource.name !== 'fulltext_indexes') { this.installResourceHooks(resource); } } } installResourceHooks(resource) { // Store original methods resource._insert = resource.insert; resource._update = resource.update; resource._delete = resource.delete; resource._deleteMany = resource.deleteMany; // Use wrapResourceMethod for all hooks so _pluginWrappers is set this.wrapResourceMethod(resource, 'insert', async (result, args, methodName) => { const [data] = args; // Index the new record this.indexRecord(resource.name, result.id, data).catch(() => {}); return result; }); this.wrapResourceMethod(resource, 'update', async (result, args, methodName) => { const [id, data] = args; // Remove old index entries this.removeRecordFromIndex(resource.name, id).catch(() => {}); // Index the updated record this.indexRecord(resource.name, id, result).catch(() => {}); return result; }); this.wrapResourceMethod(resource, 'delete', async (result, args, methodName) => { const [id] = args; // Remove from index this.removeRecordFromIndex(resource.name, id).catch(() => {}); return result; }); this.wrapResourceMethod(resource, 'deleteMany', async (result, args, methodName) => { const [ids] = args; // Remove from index for (const id of ids) { this.removeRecordFromIndex(resource.name, id).catch(() => {}); } return result; }); } async indexRecord(resourceName, recordId, data) { const indexedFields = this.getIndexedFields(resourceName); if (!indexedFields || indexedFields.length === 0) { return; } for (const fieldName of indexedFields) { const fieldValue = this.getFieldValue(data, fieldName); if (!fieldValue) { continue; } const words = this.tokenize(fieldValue); for (const word of words) { if (word.length < this.config.minWordLength) { continue; } const key = `${resourceName}:${fieldName}:${word.toLowerCase()}`; const existing = this.indexes.get(key) || { recordIds: [], count: 0 }; if (!existing.recordIds.includes(recordId)) { existing.recordIds.push(recordId); existing.count = existing.recordIds.length; } this.indexes.set(key, existing); } } } async removeRecordFromIndex(resourceName, recordId) { for (const [key, data] of this.indexes.entries()) { if (key.startsWith(`${resourceName}:`)) { const index = data.recordIds.indexOf(recordId); if (index > -1) { data.recordIds.splice(index, 1); data.count = data.recordIds.length; if (data.recordIds.length === 0) { this.indexes.delete(key); } else { this.indexes.set(key, data); } } } } } getFieldValue(data, fieldPath) { if (!fieldPath.includes('.')) { return data && data[fieldPath] !== undefined ? data[fieldPath] : null; } const keys = fieldPath.split('.'); let value = data; for (const key of keys) { if (value && typeof value === 'object' && key in value) { value = value[key]; } else { return null; } } return value; } tokenize(text) { if (!text) return []; // Convert to string and normalize const str = String(text).toLowerCase(); // Remove special characters but preserve accented characters return str .replace(/[^\w\s\u00C0-\u017F]/g, ' ') // Allow accented characters .split(/\s+/) .filter(word => word.length > 0); } getIndexedFields(resourceName) { // Use configured fields if available, otherwise fall back to defaults if (this.config.fields) { return this.config.fields; } // Default field mappings const fieldMappings = { users: ['name', 'email'], products: ['name', 'description'], articles: ['title', 'content'], // Add more mappings as needed }; return fieldMappings[resourceName] || []; } // Main search method async search(resourceName, query, options = {}) { const { fields = null, // Specific fields to search in limit = this.config.maxResults, offset = 0, exactMatch = false } = options; if (!query || query.trim().length === 0) { return []; } const searchWords = this.tokenize(query); const results = new Map(); // recordId -> score // Get fields to search in const searchFields = fields || this.getIndexedFields(resourceName); if (searchFields.length === 0) { return []; } // Search for each word for (const word of searchWords) { if (word.length < this.config.minWordLength) continue; for (const fieldName of searchFields) { if (exactMatch) { // Exact match - look for the exact word const key = `${resourceName}:${fieldName}:${word.toLowerCase()}`; const indexData = this.indexes.get(key); if (indexData) { for (const recordId of indexData.recordIds) { const currentScore = results.get(recordId) || 0; results.set(recordId, currentScore + 1); } } } else { // Partial match - look for words that start with the search term for (const [key, indexData] of this.indexes.entries()) { if (key.startsWith(`${resourceName}:${fieldName}:${word.toLowerCase()}`)) { for (const recordId of indexData.recordIds) { const currentScore = results.get(recordId) || 0; results.set(recordId, currentScore + 1); } } } } } } // Convert to sorted results const sortedResults = Array.from(results.entries()) .map(([recordId, score]) => ({ recordId, score })) .sort((a, b) => b.score - a.score) .slice(offset, offset + limit); return sortedResults; } // Search and return full records async searchRecords(resourceName, query, options = {}) { const searchResults = await this.search(resourceName, query, options); if (searchResults.length === 0) { return []; } const resource = this.database.resources[resourceName]; if (!resource) { throw new Error(`Resource '${resourceName}' not found`); } const recordIds = searchResults.map(result => result.recordId); const records = await resource.getMany(recordIds); // Filter out undefined/null records (in case getMany returns missing records) const result = records .filter(record => record && typeof record === 'object') .map(record => { const searchResult = searchResults.find(sr => sr.recordId === record.id); return { ...record, _searchScore: searchResult ? searchResult.score : 0 }; }) .sort((a, b) => b._searchScore - a._searchScore); return result; } // Utility methods async rebuildIndex(resourceName) { const resource = this.database.resources[resourceName]; if (!resource) { throw new Error(`Resource '${resourceName}' not found`); } // Clear existing indexes for this resource for (const [key] of this.indexes.entries()) { if (key.startsWith(`${resourceName}:`)) { this.indexes.delete(key); } } // Rebuild index in larger batches for better performance const allRecords = await resource.getAll(); const batchSize = 100; // Increased batch size for faster processing for (let i = 0; i < allRecords.length; i += batchSize) { const batch = allRecords.slice(i, i + batchSize); // Process batch sequentially to avoid overwhelming the system for (const record of batch) { const [ok, err] = await tryFn(() => this.indexRecord(resourceName, record.id, record)); if (!ok) { } } } // Save indexes await this.saveIndexes(); } async getIndexStats() { const stats = { totalIndexes: this.indexes.size, resources: {}, totalWords: 0 }; for (const [key, data] of this.indexes.entries()) { const [resourceName, fieldName] = key.split(':'); if (!stats.resources[resourceName]) { stats.resources[resourceName] = { fields: {}, totalRecords: new Set(), totalWords: 0 }; } if (!stats.resources[resourceName].fields[fieldName]) { stats.resources[resourceName].fields[fieldName] = { words: 0, totalOccurrences: 0 }; } stats.resources[resourceName].fields[fieldName].words++; stats.resources[resourceName].fields[fieldName].totalOccurrences += data.count; stats.resources[resourceName].totalWords++; for (const recordId of data.recordIds) { stats.resources[resourceName].totalRecords.add(recordId); } stats.totalWords++; } // Convert Sets to counts for (const resourceName in stats.resources) { stats.resources[resourceName].totalRecords = stats.resources[resourceName].totalRecords.size; } return stats; } async rebuildAllIndexes({ timeout } = {}) { if (timeout) { return Promise.race([ this._rebuildAllIndexesInternal(), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout)) ]); } return this._rebuildAllIndexesInternal(); } async _rebuildAllIndexesInternal() { const resourceNames = Object.keys(this.database.resources).filter(name => name !== 'fulltext_indexes'); // Process resources sequentially to avoid overwhelming the system for (const resourceName of resourceNames) { const [ok, err] = await tryFn(() => this.rebuildIndex(resourceName)); if (!ok) { } } } async clearIndex(resourceName) { // Clear indexes for specific resource for (const [key] of this.indexes.entries()) { if (key.startsWith(`${resourceName}:`)) { this.indexes.delete(key); } } // Save changes await this.saveIndexes(); } async clearAllIndexes() { // Clear all indexes this.indexes.clear(); // Save changes await this.saveIndexes(); } } export default FullTextPlugin;