UNPKG

@shaxpir/sharedb-storage-sqlite

Version:

Shared SQLite storage components for ShareDB adapters

1,184 lines (1,027 loc) 39.3 kB
/** * Schema strategy that creates a separate table for each collection. * This allows for: * - Collection-specific indexes * - Field-level encryption configuration per collection * - Optimized queries per collection * - Better performance for large collections * - Relational projections for array fields */ var BaseSchemaStrategy = require('./base-schema-strategy'); var Formatted = require('../utils/formatted'); module.exports = CollectionPerTableStrategy; function CollectionPerTableStrategy(options) { options = options || {}; BaseSchemaStrategy.call(this, options); this.useEncryption = options.useEncryption || false; this.encryptionCallback = options.encryptionCallback; this.decryptionCallback = options.decryptionCallback; this.collectionConfig = options.collectionConfig || {}; this.createdTables = {}; this.projectionsByCollection = this.parseProjections(this.collectionConfig); this.disableTransactions = options.disableTransactions; } // Inherit from BaseSchemaStrategy CollectionPerTableStrategy.prototype = Object.create(BaseSchemaStrategy.prototype); CollectionPerTableStrategy.prototype.constructor = CollectionPerTableStrategy; /** * Parse projections from collection configuration */ CollectionPerTableStrategy.prototype.parseProjections = function(collectionConfig) { var projectionsByCollection = {}; // console.log('[CollectionPerTableStrategy.parseProjections] Parsing projections for collections:', Object.keys(collectionConfig)); for (var collection in collectionConfig) { var config = collectionConfig[collection]; // console.log('[CollectionPerTableStrategy.parseProjections] Collection:', collection, 'has projections:', !!config.projections); if (config.projections && Array.isArray(config.projections)) { projectionsByCollection[collection] = config.projections.map(function(projection) { // Validate projection configuration if (!projection.type || projection.type !== 'array_expansion') { throw new Error('Only array_expansion projection type is currently supported'); } if (!projection.targetTable) { throw new Error('Projection requires targetTable'); } if (!projection.mapping) { throw new Error('Projection requires mapping'); } if (!projection.arrayPath) { throw new Error('Array expansion projection requires arrayPath'); } if (!projection.primaryKey || !Array.isArray(projection.primaryKey)) { throw new Error('Projection requires primaryKey array'); } return projection; }); } } return projectionsByCollection; }; /** * Initialize the schema - creates inventory table and any pre-configured collection tables */ CollectionPerTableStrategy.prototype.initializeSchema = function(db, callback) { var self = this; return Promise.resolve() .then(function() { // Create inventory table with support for both numeric and string versions return self.runAsync(db, 'CREATE TABLE IF NOT EXISTS sharedb_inventory (' + 'collection TEXT NOT NULL, ' + 'doc_id TEXT NOT NULL, ' + 'version_num REAL, ' + // For numeric versions 'version_str TEXT, ' + // For string versions (timestamps) 'has_pending INTEGER NOT NULL DEFAULT 0, ' + 'updated_at INTEGER, ' + 'PRIMARY KEY (collection, doc_id)' + ')' ); }) .then(function() { // Create indexes for inventory table return self.runAsync(db, 'CREATE INDEX IF NOT EXISTS idx_inventory_collection ON sharedb_inventory (collection)'); }) .then(function() { return self.runAsync(db, 'CREATE INDEX IF NOT EXISTS idx_inventory_updated ON sharedb_inventory (updated_at)'); }) .then(function() { // Create tables for any pre-configured collections var collections = Object.keys(self.collectionConfig); var promises = []; for (var i = 0; i < collections.length; i++) { promises.push(self.createCollectionTable(db, collections[i])); } return Promise.all(promises); }) .then(function() { // Create projection tables var collections = Object.keys(self.collectionConfig); var promises = []; for (var i = 0; i < collections.length; i++) { promises.push(self.createProjectionTables(db, collections[i])); } return Promise.all(promises); }) .then(function() { self.debug && console.log('[CollectionPerTableStrategy] Schema initialized'); if (callback) callback(null); }) .catch(function(error) { console.error('[CollectionPerTableStrategy] Schema initialization error:', error); if (callback) callback(error); throw error; }); }; /** * Create a table for a specific collection with its indexes */ CollectionPerTableStrategy.prototype.createCollectionTable = function(db, collection) { // Validate collection name collection = Formatted.asCollectionName(collection); var self = this; var tableName = this.getTableName(collection); var config = this.collectionConfig[collection] || {}; return this.runAsync(db, 'CREATE TABLE IF NOT EXISTS ' + tableName + ' (' + 'id TEXT PRIMARY KEY, ' + 'collection TEXT, ' + 'data JSON' + ')' ).then(function() { // Create indexes sequentially after table is created if (config.indexes && config.indexes.length > 0) { var promises = []; for (var i = 0; i < config.indexes.length; i++) { var field = config.indexes[i]; // Sanitize field name for index name (replace dots with underscores) var sanitizedField = field.replace(/\./g, '_'); // Use idx_ prefix for consistency with inventory indexes var indexName = 'idx_' + tableName + '_' + sanitizedField; // Use single quotes for JSON path in SQLite promises.push(self.runAsync(db, 'CREATE INDEX IF NOT EXISTS ' + indexName + ' ON ' + tableName + ' (json_extract(data, \'$.' + field + '\'))' )); } return Promise.all(promises); } }).then(function() { self.createdTables[collection] = true; self.debug && console.log('[CollectionPerTableStrategy] Created table for collection:', collection); }); }; /** * Create projection tables for a collection */ CollectionPerTableStrategy.prototype.createProjectionTables = function(db, collection) { // Validate collection name collection = Formatted.asCollectionName(collection); var self = this; var projections = this.projectionsByCollection[collection]; if (!projections || projections.length === 0) { return Promise.resolve(); } var promises = []; for (var i = 0; i < projections.length; i++) { promises.push(this.createProjectionTable(db, projections[i])); } return Promise.all(promises); }; /** * Create a single projection table */ CollectionPerTableStrategy.prototype.createProjectionTable = function(db, projection) { var self = this; // Build CREATE TABLE statement var columns = []; for (var targetColumn in projection.mapping) { var mappingConfig = projection.mapping[targetColumn]; // Determine SQL datatype var dataType = 'TEXT'; // default if (typeof mappingConfig === 'object' && mappingConfig.dataType) { dataType = mappingConfig.dataType; } columns.push(targetColumn + ' ' + dataType); } columns.push('created_at INTEGER'); // Add PRIMARY KEY constraint var primaryKeyClause = 'PRIMARY KEY (' + projection.primaryKey.join(', ') + ')'; // Use the projection table name directly without prefix var projectionTableName = projection.targetTable; var createTableSQL = 'CREATE TABLE IF NOT EXISTS ' + projectionTableName + ' (' + columns.join(', ') + ', ' + primaryKeyClause + ')'; return this.runAsync(db, createTableSQL) .then(function() { // Create indexes for primary key columns var promises = []; for (var i = 0; i < projection.primaryKey.length; i++) { var column = projection.primaryKey[i]; var indexName = 'idx_' + projection.targetTable + '_' + column; promises.push(self.runAsync(db, 'CREATE INDEX IF NOT EXISTS ' + indexName + ' ON ' + projectionTableName + '(' + column + ')' )); } return Promise.all(promises); }) .then(function() { // Create any additional custom indexes if (projection.indexes) { var promises = []; for (var i = 0; i < projection.indexes.length; i++) { var indexConfig = projection.indexes[i]; var indexName = indexConfig.name || 'idx_' + projection.targetTable + '_' + indexConfig.columns.join('_'); var uniqueClause = indexConfig.unique ? 'UNIQUE ' : ''; promises.push(self.runAsync(db, 'CREATE ' + uniqueClause + 'INDEX IF NOT EXISTS ' + indexName + ' ON ' + projectionTableName + '(' + indexConfig.columns.join(', ') + ')' )); } return Promise.all(promises); } }) .then(function() { self.debug && console.log('[CollectionPerTableStrategy] Created projection table', projectionTableName); }); }; /** * Helper to run SQL with proper promise handling */ CollectionPerTableStrategy.prototype.runAsync = function(db, sql, params) { return db.runAsync(sql, params); }; /** * Helper to get first result with proper promise handling */ CollectionPerTableStrategy.prototype.getFirstAsync = function(db, sql, params) { return db.getFirstAsync(sql, params); }; /** * Helper to get all results with proper promise handling */ CollectionPerTableStrategy.prototype.getAllAsync = function(db, sql, params) { return db.getAllAsync(sql, params); }; /** * Get the table name for a collection */ CollectionPerTableStrategy.prototype.getTableName = function(collection) { // Validate collection name collection = Formatted.asCollectionName(collection); // console.log('[CollectionPerTableStrategy.getTableName] Called for collection:', collection, 'Strategy:', this.constructor.name); if (collection === '__inventory__') { return 'sharedb_inventory'; } // Just use the collection name directly without prefix // The attachment provides namespace separation when needed return collection.replace(/[^a-zA-Z0-9_]/g, '_'); }; /** * Validate that the schema exists */ CollectionPerTableStrategy.prototype.validateSchema = function(db, callback) { var self = this; this.getFirstAsync(db, "SELECT name FROM sqlite_master WHERE type='table' AND name='sharedb_inventory'") .then(function(result) { var isValid = !!result; if (callback) callback(null, isValid); return isValid; }).catch(function(error) { if (callback) callback(error, false); throw error; }); }; /** * Write records to the database */ CollectionPerTableStrategy.prototype.writeRecords = function(db, recordsByType, callback) { var self = this; var promises = []; var totalCount = 0; // Process docs records by collection // Debug logging removed - compound key handling is now working correctly if (recordsByType.docs) { var docsToWrite = Array.isArray(recordsByType.docs) ? recordsByType.docs : [recordsByType.docs]; // Group documents by collection var docsByCollection = {}; for (var i = 0; i < docsToWrite.length; i++) { var doc = docsToWrite[i]; var collection = doc.payload && doc.payload.collection; if (!collection) { console.warn('[CollectionPerTableStrategy] Document missing collection:', doc.id); continue; } if (!docsByCollection[collection]) { docsByCollection[collection] = []; } docsByCollection[collection].push(doc); } // Write documents for each collection for (var collection in docsByCollection) { promises.push(this.writeCollectionRecords(db, collection, docsByCollection[collection])); totalCount += docsByCollection[collection].length; } } // Process meta records - inventory is the only "meta" record we handle if (recordsByType.meta) { var metaRecord = Array.isArray(recordsByType.meta) ? recordsByType.meta[0] : recordsByType.meta; // Check if this is the inventory record if (metaRecord && metaRecord.id === 'inventory' && metaRecord.payload && metaRecord.payload.collections) { // Write inventory to the inventory table promises.push(this.writeInventoryFromMeta(db, metaRecord)); totalCount++; } // Ignore any other meta records since we don't have a meta table } Promise.all(promises) .then(function() { self.debug && console.log('[CollectionPerTableStrategy] Wrote ' + totalCount + ' records'); if (callback) callback(null); }) .catch(function(error) { console.error('[CollectionPerTableStrategy] Write error:', error); if (callback) { callback(error); } else { throw error; } }); }; /** * Write records for a specific collection */ CollectionPerTableStrategy.prototype.writeCollectionRecords = function(db, collection, records) { // Validate collection name collection = Formatted.asCollectionName(collection); var self = this; // Ensure table exists return this.ensureCollectionTable(db, collection) .then(function() { var promises = []; for (var i = 0; i < records.length; i++) { var record = records[i]; promises.push(self.writeCollectionRecord(db, collection, record)); } return Promise.all(promises); }); }; /** * Write a single collection record */ CollectionPerTableStrategy.prototype.writeCollectionRecord = function(db, collection, record) { // Validate collection name collection = Formatted.asCollectionName(collection); var self = this; var tableName = this.getTableName(collection); // console.log('[CollectionPerTableStrategy.writeCollectionRecord] Writing to table:', tableName, 'for collection:', collection, 'record id:', record.id); // Extract the simple document ID from compound key (collection/id -> id) // ShareDB sends compound IDs in the format "collection/docId" // We need to store only the docId part in inventory var simpleDocId = record.id; if (record.id && record.id.indexOf('/') !== -1) { // Validate it's a proper compound key and extract parts var parts = Formatted.split(record.id); // Verify the collection matches if (parts.collection !== collection) { throw new Error('Collection mismatch in compound key. Expected "' + collection + '/..." but got "' + record.id + '"'); } simpleDocId = parts.docId; } // Check for existing record first (for projections) - use simple ID return this.getFirstAsync(db, 'SELECT data FROM ' + tableName + ' WHERE id = ?', [simpleDocId]) .then(function(oldRow) { var oldRecord = oldRow ? JSON.parse(oldRow.data) : null; // Apply encryption if needed var recordToStore = self.maybeEncryptRecord(record, collection); // Store with simple ID, but keep compound ID in the data return self.runAsync(db, 'INSERT OR REPLACE INTO ' + tableName + ' (id, collection, data) VALUES (?, ?, ?)', [simpleDocId, collection, JSON.stringify(recordToStore)] ).then(function() { // Update projections if configured return self.updateProjections(db, collection, record, oldRecord); }); }) .then(function() { // Update inventory with simple ID var version = record.payload.v; var hasPending = record.payload.pendingOps && record.payload.pendingOps.length > 0; return self.updateInventoryForRecord(db, collection, simpleDocId, version, hasPending); }); }; /** * Update projections when a record is written */ CollectionPerTableStrategy.prototype.updateProjections = function(db, collection, newRecord, oldRecord) { // Validate collection name collection = Formatted.asCollectionName(collection); var self = this; var projections = this.projectionsByCollection[collection]; // console.log('[CollectionPerTableStrategy.updateProjections] Called for collection:', collection); // console.log('[CollectionPerTableStrategy.updateProjections] Projections configured:', projections ? projections.length : 0); // if (projections && projections.length > 0) { // console.log('[CollectionPerTableStrategy.updateProjections] Projection details:', JSON.stringify(projections, null, 2)); // } if (!projections || projections.length === 0) { return Promise.resolve(); } var promises = []; for (var i = 0; i < projections.length; i++) { var projection = projections[i]; // console.log('[CollectionPerTableStrategy.updateProjections] Processing projection:', projection.tableName); // console.log('[CollectionPerTableStrategy.updateProjections] Projection type:', projection.type); // console.log('[CollectionPerTableStrategy.updateProjections] Array path:', projection.arrayPath); var arrayValue = this.getValueAtPath(newRecord, projection.arrayPath); // console.log('[CollectionPerTableStrategy.updateProjections] Array value:', JSON.stringify(arrayValue)); if (projection.type === 'array_expansion') { promises.push(this.updateArrayExpansionProjection(db, projection, newRecord, oldRecord)); } } return Promise.all(promises); }; /** * Update an array expansion projection */ CollectionPerTableStrategy.prototype.updateArrayExpansionProjection = function(db, projection, newRecord, oldRecord) { var self = this; var recordId = newRecord.id; var projectionTableName = projection.targetTable; // Delete existing projections for this record var deleteColumns = []; var deleteValues = []; for (var targetColumn in projection.mapping) { var mappingConfig = projection.mapping[targetColumn]; // Extract source path from mapping config var sourcePath; if (typeof mappingConfig === 'string') { sourcePath = mappingConfig; } else { sourcePath = mappingConfig.sourcePath || mappingConfig.source; } // If source is from root document (not array element), use it as delete condition if (sourcePath && sourcePath !== '@element') { var value; if (sourcePath === 'id') { // Special case for document id value = recordId; } else if (sourcePath.includes('.')) { // Path with dots - treat as path from document root value = this.getValueAtPath(newRecord, sourcePath); } else { // Simple identifier without dots - treat as literal value or special case value = sourcePath; } if (value !== undefined) { deleteColumns.push(targetColumn + ' = ?'); deleteValues.push(value); } } } var deletePromise; if (deleteColumns.length > 0) { var deleteSQL = 'DELETE FROM ' + projectionTableName + ' WHERE ' + deleteColumns.join(' AND '); deletePromise = this.runAsync(db, deleteSQL, deleteValues); } else { deletePromise = Promise.resolve(); } return deletePromise.then(function() { // Get array from record var array = self.getValueAtPath(newRecord, projection.arrayPath); if (!Array.isArray(array) || array.length === 0) { return; } // Insert new projections var promises = []; for (var i = 0; i < array.length; i++) { var arrayItem = array[i]; // Build row for projection table var row = {}; for (var targetColumn in projection.mapping) { var mappingConfig = projection.mapping[targetColumn]; // Extract source path and get value var sourcePath; if (typeof mappingConfig === 'string') { sourcePath = mappingConfig; } else { sourcePath = mappingConfig.sourcePath || mappingConfig.source; } var value; if (sourcePath === '@element') { // Use the array item itself value = arrayItem; } else if (sourcePath === 'id') { // Special case for document id value = recordId; } else if (sourcePath.includes('.')) { // Path with dots - treat as path from document root value = self.getValueAtPath(newRecord, sourcePath); } else { value = sourcePath; // Literal value } row[targetColumn] = value; } row.created_at = Date.now(); // Insert row var columns = Object.keys(row); var placeholders = columns.map(function() { return '?'; }).join(', '); var values = columns.map(function(col) { return row[col]; }); promises.push(self.runAsync(db, 'INSERT OR REPLACE INTO ' + projectionTableName + ' (' + columns.join(', ') + ') VALUES (' + placeholders + ')', values )); } return Promise.all(promises); }); }; /** * Get value at a path in an object */ CollectionPerTableStrategy.prototype.getValueAtPath = function(obj, path) { var parts = path.split('.'); var current = obj; for (var i = 0; i < parts.length; i++) { if (current === null || current === undefined) { return undefined; } current = current[parts[i]]; } return current; }; /** * Write inventory data from the meta format to the inventory table */ CollectionPerTableStrategy.prototype.writeInventoryFromMeta = function(db, inventoryMeta) { var self = this; var promises = []; // Update inventory items incrementally (don't clear the entire table) var collections = inventoryMeta.payload.collections; for (var collection in collections) { for (var docId in collections[collection]) { var item = collections[collection][docId]; var versionNum = typeof item.v === 'number' ? item.v : null; var versionStr = typeof item.v === 'string' ? item.v : null; var hasPending = item.p || false; // Use INSERT OR REPLACE to update existing entries or add new ones promises.push(self.runAsync(db, 'INSERT OR REPLACE INTO sharedb_inventory (collection, doc_id, version_num, version_str, has_pending, updated_at) ' + 'VALUES (?, ?, ?, ?, ?, ?)', [collection, docId, versionNum, versionStr, hasPending ? 1 : 0, Date.now()] )); } } return Promise.all(promises); }; /** * Ensure a collection table exists */ CollectionPerTableStrategy.prototype.ensureCollectionTable = function(db, collection) { // Validate collection name collection = Formatted.asCollectionName(collection); if (this.createdTables[collection]) { return Promise.resolve(); } return this.createCollectionTable(db, collection) .then(this.createProjectionTables(db, collection)); }; /** * Update inventory for a specific record */ CollectionPerTableStrategy.prototype.updateInventoryForRecord = function(db, collection, docId, version, hasPending) { // Validate and normalize formats collection = Formatted.asCollectionName(collection); docId = Formatted.asDocId(docId); var versionNum = null; var versionStr = null; if (typeof version === 'number') { versionNum = version; } else if (typeof version === 'string') { versionStr = version; } return this.runAsync(db, 'INSERT OR REPLACE INTO sharedb_inventory (collection, doc_id, version_num, version_str, has_pending, updated_at) ' + 'VALUES (?, ?, ?, ?, ?, ?)', [collection, docId, versionNum, versionStr, hasPending ? 1 : 0, Date.now()] ); }; /** * Read a single record from the database */ CollectionPerTableStrategy.prototype.readRecord = function(db, type, collection, id, callback) { var self = this; var promise; if (type === 'meta') { // DurableStore calls readRecord('meta', 'inventory') to get the inventory // We need to reconstruct the full inventory from our sharedb_inventory table if (id === 'inventory') { promise = this.readInventory(db).then(function(inventoryRecord) { // readInventory already returns { id: 'inventory', payload: { collections: ... } } // Return the full record - SqliteStorage will extract the payload return inventoryRecord; }).catch(function(error) { throw error; }); } else { // No other meta records in CollectionPerTableStrategy promise = Promise.resolve(null); } } else { // Validate collection name and document ID for non-meta records collection = Formatted.asCollectionName(collection); id = Formatted.asDocId(id); var tableName = this.getTableName(collection); console.log('[CollectionPerTableStrategy.readRecord] Reading from table:', tableName, 'id:', id); // Use simple ID for querying promise = this.getFirstAsync(db, 'SELECT data FROM ' + tableName + ' WHERE id = ?', [id]) .then(function(row) { console.log('[CollectionPerTableStrategy.readRecord] Query result:', row ? 'found' : 'not found'); if (!row) return null; var record = JSON.parse(row.data); console.log('[CollectionPerTableStrategy.readRecord] Parsed record has keys:', Object.keys(record)); console.log('[CollectionPerTableStrategy.readRecord] Has payload:', !!record.payload); // IMPORTANT: The record structure in the database is: // { id: "collection/docId", payload: { ... ShareDB document ... } } // We need to return this full structure to SqliteStorage console.log('[CollectionPerTableStrategy.readRecord] Returning record with id:', record.id); // Decrypt if needed if (self.useEncryption && record.encrypted_payload) { record = self.maybeDecryptRecord(record, collection); } console.log('[CollectionPerTableStrategy.readRecord] About to return record, checking structure:', { hasRecord: !!record, hasPayload: !!(record && record.payload), payloadType: record && record.payload ? typeof record.payload : 'N/A' }); // Log the full record for debugging console.log('[CollectionPerTableStrategy.readRecord] Full record being returned:', JSON.stringify(record, null, 2)); return record; }) .catch(function(error) { // Table might not exist yet if (error.message && error.message.includes('no such table')) { return null; } throw error; }); } if (callback) { promise .then(function(result) { callback(null, result); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Read all records of a given type */ CollectionPerTableStrategy.prototype.readAllRecords = function(db, type, collection, callback) { var self = this; // Validate collection name collection = Formatted.asCollectionName(collection); var promise; if (type === 'meta') { // No meta table - return empty array for meta reads promise = Promise.resolve([]); } else if (collection) { var tableName = this.getTableName(collection); promise = this.getAllAsync(db, 'SELECT id, data FROM ' + tableName) .then(function(rows) { return rows.map(function(row) { var record = JSON.parse(row.data); // Decrypt if needed if (self.useEncryption && record.encrypted_payload) { record = self.maybeDecryptRecord(record, collection); } return record; }); }) .catch(function(error) { // Table might not exist yet if (error.message && error.message.includes('no such table')) { return []; } throw error; }); } else { // Read from all collection tables (use configured collections as source of truth) var configuredCollections = Object.keys(self.collectionConfig); var promises = configuredCollections.map(function(collection) { var tableName = self.getTableName(collection); return self.getAllAsync(db, 'SELECT id, data FROM ' + tableName) .catch(function(err) { // Table might not exist yet, return empty array return []; }); }); promise = Promise.all(promises) .then(function(allResults) { var records = []; for (var i = 0; i < allResults.length; i++) { var rows = allResults[i]; for (var j = 0; j < rows.length; j++) { var record = JSON.parse(rows[j].data); // Decrypt if needed if (self.useEncryption && record.encrypted_payload) { var recordCollection = record.payload && record.payload.collection; record = self.maybeDecryptRecord(record, recordCollection); } records.push(record); } } return records; }); } if (callback) { promise .then(function(result) { callback(null, result); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Read multiple records by ID (bulk operation) */ CollectionPerTableStrategy.prototype.readRecordsBulk = function(db, type, collection, ids, callback) { var self = this; // Validate collection name collection = Formatted.asCollectionName(collection); if (!ids || ids.length === 0) { if (callback) callback(null, []); return Promise.resolve([]); } // Validate all document IDs ids = ids.map(function(id) { return Formatted.asDocId(id); }); var placeholders = ids.map(function() { return '?'; }).join(', '); var promise; if (type === 'meta') { // No meta table - return empty array for meta reads promise = Promise.resolve([]); } else { var tableName = this.getTableName(collection); var sql = 'SELECT id, data FROM ' + tableName + ' WHERE id IN (' + placeholders + ')'; promise = this.getAllAsync(db, sql, ids) .then(function(rows) { return rows.map(function(row) { var record = JSON.parse(row.data); // Decrypt if needed if (self.useEncryption && record.encrypted_payload) { record = self.maybeDecryptRecord(record, collection); } return record; }); }) .catch(function(error) { // Table might not exist yet if (error.message && error.message.includes('no such table')) { return []; } throw error; }); } if (callback) { promise .then(function(result) { callback(null, result); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Delete a record from the database */ CollectionPerTableStrategy.prototype.deleteRecord = function(db, type, collection, id, callback) { var self = this; var promise; // Validate collection name and document ID collection = Formatted.asCollectionName(collection); id = Formatted.asDocId(id); if (type === 'meta') { // No meta table - just resolve for meta deletes promise = Promise.resolve(); } else { var tableName = this.getTableName(collection); // Use simple ID for deletion promise = this.runAsync(db, 'DELETE FROM ' + tableName + ' WHERE id = ?', [id]) .then(function() { // Delete from projections return self.deleteFromProjections(db, collection, id); }) .then(function() { // Remove from inventory - use simple ID return self.runAsync(db, 'DELETE FROM sharedb_inventory WHERE collection = ? AND doc_id = ?', [collection, id] ); }) .catch(function(error) { // Table might not exist yet if (error.message && error.message.includes('no such table')) { return; } throw error; }); } if (callback) { promise .then(function() { callback(null); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Delete record from projection tables */ CollectionPerTableStrategy.prototype.deleteFromProjections = function(db, collection, recordId) { var self = this; // Validate collection name and document ID collection = Formatted.asCollectionName(collection); recordId = Formatted.asDocId(recordId); var projections = this.projectionsByCollection[collection]; if (!projections || projections.length === 0) { return Promise.resolve(); } // For now, we can't easily delete from projections without knowing the original record // This would require storing the record ID in projection tables // TODO: Consider adding a source_record_id column to projection tables return Promise.resolve(); }; /** * Helper to encrypt a record if encryption is enabled */ CollectionPerTableStrategy.prototype.maybeEncryptRecord = function(record, collection) { if (!this.useEncryption || !this.encryptionCallback) { return record; } var config = this.collectionConfig[collection] || {}; // Field-level encryption if (config.encryptedFields && config.encryptedFields.length > 0) { // TODO: Implement field-level encryption // For now, fall back to full encryption } // Full record encryption return { id: record.id, collection: collection, encrypted_payload: this.encryptionCallback(JSON.stringify(record.payload)) }; }; /** * Helper to decrypt a record if it's encrypted */ CollectionPerTableStrategy.prototype.maybeDecryptRecord = function(record, collection) { if (!this.useEncryption || !this.decryptionCallback || !record.encrypted_payload) { return record; } return { id: record.id, payload: JSON.parse(this.decryptionCallback(record.encrypted_payload)) }; }; /** * Initialize inventory */ CollectionPerTableStrategy.prototype.initializeInventory = function(db, callback) { var promise = this.readInventory(db); if (callback) { promise .then(function(result) { callback(null, result); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Read the entire inventory */ CollectionPerTableStrategy.prototype.readInventory = function(db, callback) { var promise = this.getAllAsync(db, 'SELECT collection, doc_id, version_num, version_str, has_pending FROM sharedb_inventory' ).then(function(rows) { var collections = {}; for (var i = 0; i < rows.length; i++) { var row = rows[i]; if (!collections[row.collection]) { collections[row.collection] = {}; } // Store as object with version and pending flag var inventoryItem = { v: row.version_num !== null ? row.version_num : row.version_str, p: row.has_pending === 1 }; collections[row.collection][row.doc_id] = inventoryItem; } return { id: 'inventory', payload: { collections: collections } }; }).catch(function(error) { // If table doesn't exist, return empty inventory if (error.message && error.message.includes('no such table')) { return { id: 'inventory', payload: { collections: {} } }; } throw error; }); if (callback) { promise .then(function(result) { callback(null, result); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Update inventory for a specific collection/document */ CollectionPerTableStrategy.prototype.updateInventoryItem = function(db, collection, docId, version, operation, callback) { // Validate and normalize formats try { collection = Formatted.asCollectionName(collection); docId = Formatted.asDocId(docId); } catch (error) { if (callback) { callback(error); return; } return Promise.reject(error); } var self = this; var promise; if (operation === 'add' || operation === 'update') { promise = this.getFirstAsync(db, 'SELECT version_num, version_str FROM sharedb_inventory WHERE collection = ? AND doc_id = ?', [collection, docId] ).then(function(existing) { // Check for version regression if (existing) { var existingVersion = existing.version_num !== null ? existing.version_num : existing.version_str; // Check version type compatibility if (typeof version !== typeof existingVersion) { throw new Error('Version type mismatch for ' + collection + '/' + docId + ': trying to use ' + typeof version + ' version ' + version + ' but existing version is ' + typeof existingVersion + ' ' + existingVersion); } // Check for regression if (typeof version === 'number' && version < existingVersion) { throw new Error('Version regression detected for ' + collection + '/' + docId + ': trying to update to version ' + version + ' but current version is ' + existingVersion); } else if (typeof version === 'string' && version < existingVersion) { throw new Error('Version regression detected for ' + collection + '/' + docId + ': trying to update to version ' + version + ' but current version is ' + existingVersion); } } // Update inventory return self.updateInventoryForRecord(db, collection, docId, version, false); }); } else if (operation === 'remove') { promise = this.runAsync(db, 'DELETE FROM sharedb_inventory WHERE collection = ? AND doc_id = ?', [collection, docId] ); } else { promise = Promise.reject(new Error('Unknown inventory operation: ' + operation)); } if (callback) { promise .then(function() { callback(null); }) .catch(function(error) { callback(error); }); } return promise; }; /** * Add or update inventory item (upsert) */ CollectionPerTableStrategy.prototype.upsertInventoryItem = function(db, collection, docId, version, callback) { // Validate collection name and document ID collection = Formatted.asCollectionName(collection); docId = Formatted.asDocId(docId); return this.updateInventoryItem(db, collection, docId, version, 'add', callback); }; /** * Delete inventory item */ CollectionPerTableStrategy.prototype.deleteInventoryItem = function(db, collection, docId, callback) { // Validate collection name and document ID collection = Formatted.asCollectionName(collection); docId = Formatted.asDocId(docId); return this.updateInventoryItem(db, collection, docId, null, 'remove', callback); }; /** * Get inventory type */ CollectionPerTableStrategy.prototype.getInventoryType = function() { return 'table'; }; /** * Delete all tables created by this schema strategy */ CollectionPerTableStrategy.prototype.deleteAllTables = function(db, callback) { var self = this; var promise = this.getAllAsync(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" ).then(function(tables) { var promises = []; for (var i = 0; i < tables.length; i++) { promises.push(self.runAsync(db, 'DROP TABLE IF EXISTS ' + tables[i].name)); } return Promise.all(promises); }).then(function() { // Clear our created tables cache self.createdTables = {}; self.debug && console.log('[CollectionPerTableStrategy] Deleted all tables'); }); if (callback) { promise .then(function() { callback(null); }) .catch(function(error) { callback(error); }); } return promise; };