@shaxpir/sharedb-storage-sqlite
Version:
Shared SQLite storage components for ShareDB adapters
519 lines (449 loc) • 16.7 kB
JavaScript
var BaseSchemaStrategy = require('./base-schema-strategy');
var Formatted = require('../utils/formatted');
/**
* Default schema strategy that implements the original ShareDB storage pattern:
* - Single 'docs' table for all document collections
* - Single 'meta' table for inventory and metadata
* - All-or-nothing encryption (entire payload encrypted)
*/
module.exports = DefaultSchemaStrategy;
function DefaultSchemaStrategy(options) {
BaseSchemaStrategy.call(this, options);
options = options || {};
this.useEncryption = options.useEncryption || false;
this.encryptionCallback = options.encryptionCallback;
this.decryptionCallback = options.decryptionCallback;
// schemaPrefix is optional - use empty string if not provided
this.schemaPrefix = options.schemaPrefix ? options.schemaPrefix : '';
this.collectionMapping = options.collectionMapping;
// Copy any additional options as properties for testing and extensibility
var knownOptions = ['useEncryption', 'encryptionCallback', 'decryptionCallback', 'schemaPrefix', 'collectionMapping', 'debug'];
for (var key in options) {
if (options.hasOwnProperty(key) && !knownOptions.includes(key)) {
this[key] = options[key];
}
}
}
// Inherit from BaseSchemaStrategy
DefaultSchemaStrategy.prototype = Object.create(BaseSchemaStrategy.prototype);
DefaultSchemaStrategy.prototype.constructor = DefaultSchemaStrategy;
/**
* Helper to get the table name with schema prefix if applicable
*/
DefaultSchemaStrategy.prototype.getPrefixedTableName = function(tableName) {
return this.schemaPrefix ? this.schemaPrefix + '.' + tableName : tableName;
};
/**
* Initialize the default schema with 'docs' and 'meta' tables
*/
DefaultSchemaStrategy.prototype.initializeSchema = function(db, callback) {
var strategy = this;
var promises = [];
// Use getTableName to get the correct table names (with mapping if configured)
// When collectionMapping is used, it expects 'docs' and 'meta' as inputs
let docsTable, metaTable;
if (this.collectionMapping && typeof this.collectionMapping === 'function') {
// When mapping is provided, call it directly with 'docs' and 'meta'
docsTable = this.collectionMapping('docs');
metaTable = this.collectionMapping('meta');
// Add prefix if the mapped names don't already include it
if (!docsTable.includes('.')) docsTable = this.getPrefixedTableName(docsTable);
if (!metaTable.includes('.')) metaTable = this.getPrefixedTableName(metaTable);
} else {
// Use standard table names with prefix
docsTable = this.getPrefixedTableName('docs');
metaTable = this.getPrefixedTableName('meta');
}
// Create docs table
promises.push(db.runAsync(
'CREATE TABLE IF NOT EXISTS ' + docsTable + ' (' +
'id TEXT PRIMARY KEY, ' +
'data JSON' +
')',
));
// Create meta table
promises.push(db.runAsync(
'CREATE TABLE IF NOT EXISTS ' + metaTable + ' (' +
'id TEXT PRIMARY KEY, ' +
'data JSON' +
')',
));
Promise.all(promises).then(function() {
strategy.debug && console.log('[DefaultSchemaStrategy] Schema initialized');
callback && callback();
}).catch(function(error) {
callback && callback(error);
});
};
/**
* Validate that the schema exists
*/
DefaultSchemaStrategy.prototype.validateSchema = function(db, callback) {
var promises = [];
// Check if tables exist
promises.push(db.getFirstAsync(
'SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'docs\'',
));
promises.push(db.getFirstAsync(
'SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'meta\'',
));
Promise.all(promises).then(function(results) {
var isValid = results[0] && results[1];
callback && callback(null, isValid);
}).catch(function(error) {
callback && callback(error, false);
});
};
/**
* Get table name - always 'docs' for documents, 'meta' for metadata
*/
DefaultSchemaStrategy.prototype.getTableName = function(collection) {
// If collectionMapping is provided, use it
if (this.collectionMapping && typeof this.collectionMapping === 'function') {
// Map the collection name
var mappedName = this.collectionMapping(collection === '__meta__' ? 'meta' : collection);
// Return as-is if it already includes a schema prefix (contains a dot)
return mappedName.includes('.') ? mappedName : this.getPrefixedTableName(mappedName);
}
// Otherwise use default strategy: all docs go in 'docs' table regardless of collection
var baseTableName = collection === '__meta__' ? 'meta' : 'docs';
return this.getPrefixedTableName(baseTableName);
};
/**
* Validate and sanitize table name to prevent SQL injection
*/
DefaultSchemaStrategy.prototype.validateTableName = function(tableName) {
if (tableName !== 'docs' && tableName !== 'meta') {
throw new Error('Invalid table name: ' + tableName + '. Must be "docs" or "meta"');
}
return tableName;
};
/**
* Write records using the default schema
*/
DefaultSchemaStrategy.prototype.writeRecords = function(db, recordsByType, callback) {
var strategy = this;
var promises = [];
let totalCount = 0;
// Process docs records
if (recordsByType.docs) {
var docsRecords = Array.isArray(recordsByType.docs) ? recordsByType.docs : [recordsByType.docs];
for (let i = 0; i < docsRecords.length; i++) {
let record = docsRecords[i];
// Validate that we have a proper compound ID
var compoundId = Formatted.asCompoundKey(record.id);
record = strategy.maybeEncryptRecord(record);
promises.push(db.runAsync(
'INSERT OR REPLACE INTO docs (id, data) VALUES (?, ?)',
[compoundId, JSON.stringify(record)],
));
totalCount++;
}
}
// Process meta records
if (recordsByType.meta) {
var metaRecords = Array.isArray(recordsByType.meta) ? recordsByType.meta : [recordsByType.meta];
for (let j = 0; j < metaRecords.length; j++) {
var metaRecord = metaRecords[j];
// Validate that we have a proper compound ID
var compoundId = Formatted.asCompoundKey(metaRecord.id);
// Meta records are not encrypted in the default strategy
promises.push(db.runAsync(
'INSERT OR REPLACE INTO meta (id, data) VALUES (?, ?)',
[compoundId, JSON.stringify(metaRecord.payload)],
));
totalCount++;
}
}
Promise.all(promises).then(function() {
strategy.debug && console.log('DefaultSchemaStrategy: Wrote ' + totalCount + ' records');
callback && callback(null);
}).catch(function(error) {
callback && callback(error);
});
};
/**
* Read a single record
*/
DefaultSchemaStrategy.prototype.readRecord = function(db, type, collection, id, callback) {
var strategy = this;
var tableName = this.getTableName(type === 'meta' ? '__meta__' : collection);
// Validate and use the compound ID format
var compoundId = Formatted.asCompoundKey(id);
db.getFirstAsync(
'SELECT data FROM ' + tableName + ' WHERE id = ?',
[compoundId],
).then(function(row) {
if (!row) {
callback && callback(null, null);
return;
}
let record = JSON.parse(row.data);
// Decrypt if needed (only for docs, not meta)
if (type === 'docs' && strategy.useEncryption && record.encrypted_payload) {
record = strategy.maybeDecryptRecord(record);
}
callback && callback(null, record);
}).catch(function(error) {
// If the table doesn't exist (e.g., after deleteDatabase), treat it as "record not found"
if (error && error.code === 'SQLITE_ERROR' && error.message && error.message.includes('no such table')) {
callback && callback(null, null);
} else {
callback && callback(error, null);
}
});
};
/**
* Read all records of a type
*/
DefaultSchemaStrategy.prototype.readAllRecords = function(db, type, collection, callback) {
var strategy = this;
var tableName = this.getTableName(type === 'meta' ? '__meta__' : collection);
db.getAllAsync(
'SELECT id, data FROM ' + tableName,
).then(function(rows) {
var records = [];
for (let i = 0; i < rows.length; i++) {
let record = JSON.parse(rows[i].data);
// Decrypt if needed (only for docs, not meta)
if (type === 'docs' && strategy.useEncryption && record.encrypted_payload) {
record = strategy.maybeDecryptRecord(record);
}
records.push({
id: rows[i].id,
payload: record.payload || record,
});
}
callback && callback(null, records);
}).catch(function(error) {
callback && callback(error, null);
});
};
/**
* Read multiple records by ID in a single SQL query (bulk operation)
*/
DefaultSchemaStrategy.prototype.readRecordsBulk = function(db, type, collection, ids, callback) {
if (!Array.isArray(ids) || ids.length === 0) {
return callback && callback(null, []);
}
var strategy = this;
var tableName = this.getTableName(type === 'meta' ? '__meta__' : collection);
// Validate all IDs are proper compound keys
var validatedIds = ids.map(function(id) {
return Formatted.asCompoundKey(id);
});
// Create placeholders for the IN clause (?, ?, ?, ...)
var placeholders = validatedIds.map(function() {
return '?';
}).join(', ');
var sql = 'SELECT id, data FROM ' + tableName + ' WHERE id IN (' + placeholders + ')';
db.getAllAsync(sql, validatedIds).then(function(rows) {
var records = [];
for (let i = 0; i < rows.length; i++) {
let record = JSON.parse(rows[i].data);
// Decrypt if needed (only for docs, not meta)
if (type === 'docs' && strategy.useEncryption && record.encrypted_payload) {
record = strategy.maybeDecryptRecord(record);
}
records.push({
id: rows[i].id,
payload: record.payload || record,
});
}
strategy.debug && console.log('DefaultSchemaStrategy: Bulk read ' + records.length + '/' + ids.length + ' records from ' + tableName);
callback && callback(null, records);
}).catch(function(error) {
strategy.debug && console.error('DefaultSchemaStrategy: Error in bulk read from ' + tableName + ': ' + error);
callback && callback(error, null);
});
};
/**
* Delete a record
*/
DefaultSchemaStrategy.prototype.deleteRecord = function(db, type, collection, id, callback) {
var strategy = this;
var tableName = this.getTableName(type === 'meta' ? '__meta__' : collection);
// Validate and use the compound ID format
var compoundId = Formatted.asCompoundKey(id);
db.runAsync(
'DELETE FROM ' + tableName + ' WHERE id = ?',
[compoundId],
).then(function() {
strategy.debug && console.log('DefaultSchemaStrategy: Deleted record ' + id + ' from ' + tableName);
callback && callback();
}).catch(function(error) {
callback && callback(error);
});
};
/**
* Helper to encrypt a record if encryption is enabled
*/
DefaultSchemaStrategy.prototype.maybeEncryptRecord = function(record) {
if (!this.useEncryption || !this.encryptionCallback) {
return record;
}
return {
id: record.id,
encrypted_payload: this.encryptionCallback(JSON.stringify(record.payload)),
};
};
/**
* Helper to decrypt a record if it's encrypted
*/
DefaultSchemaStrategy.prototype.maybeDecryptRecord = function(record) {
if (!this.useEncryption || !this.decryptionCallback || !record.encrypted_payload) {
return record;
}
return {
id: record.id,
payload: JSON.parse(this.decryptionCallback(record.encrypted_payload)),
};
};
/**
* Get inventory type - JSON for default strategy
*/
DefaultSchemaStrategy.prototype.getInventoryType = function() {
return this.schemaPrefix ? this.schemaPrefix + '-json' : 'json';
};
/**
* Initialize inventory as a single JSON document in meta table
*/
DefaultSchemaStrategy.prototype.initializeInventory = function(db, callback) {
var strategy = this;
var inventory = {
id: 'inventory',
payload: {
collections: {},
},
};
// Get the meta table name (with mapping if configured)
var metaTable = this.collectionMapping && typeof this.collectionMapping === 'function'
? this.collectionMapping('meta')
: this.getPrefixedTableName('meta');
// Check if inventory already exists
db.getFirstAsync(
'SELECT data FROM ' + metaTable + ' WHERE id = ?',
['inventory'],
).then(function(row) {
if (row) {
// Inventory exists, return it
var existing = JSON.parse(row.data);
callback && callback(null, {
id: 'inventory',
payload: existing,
});
} else {
// Create new inventory
return db.runAsync(
'INSERT INTO ' + metaTable + ' (id, data) VALUES (?, ?)',
['inventory', JSON.stringify(inventory.payload)],
).then(function() {
callback && callback(null, inventory);
});
}
}).catch(function(error) {
callback && callback(error, null);
});
};
/**
* Read the entire inventory from the JSON document
*/
DefaultSchemaStrategy.prototype.readInventory = function(db, callback) {
var strategy = this;
// Get the meta table name (with mapping if configured)
var metaTable = this.collectionMapping && typeof this.collectionMapping === 'function'
? this.collectionMapping('meta')
: this.getPrefixedTableName('meta');
db.getFirstAsync(
'SELECT data FROM ' + metaTable + ' WHERE id = ?',
['inventory'],
).then(function(row) {
if (!row) {
callback && callback(null, {
id: 'inventory',
payload: {collections: {}},
});
return;
}
var inventory = JSON.parse(row.data);
callback && callback(null, {
id: 'inventory',
payload: inventory,
});
}).catch(function(error) {
callback && callback(error, null);
});
};
/**
* Update inventory by modifying the JSON document
*/
DefaultSchemaStrategy.prototype.updateInventoryItem = function(db, collection, docId, version, operation, callback) {
var strategy = this;
// Validate collection name and document ID
collection = Formatted.asCollectionName(collection);
docId = Formatted.asDocId(docId);
// Read current inventory
this.readInventory(db, function(error, inventory) {
if (error) {
callback && callback(error);
return;
}
var payload = inventory.payload || {collections: {}};
// Ensure collection exists
if (!payload.collections[collection]) {
payload.collections[collection] = {};
}
// Update based on operation
if (operation === 'add' || operation === 'update') {
payload.collections[collection][docId] = version;
} else if (operation === 'remove') {
delete payload.collections[collection][docId];
// Clean up empty collections
if (Object.keys(payload.collections[collection]).length === 0) {
delete payload.collections[collection];
}
}
// Get the meta table name (with mapping if configured)
var metaTable = strategy.collectionMapping && typeof strategy.collectionMapping === 'function'
? strategy.collectionMapping('meta')
: strategy.getPrefixedTableName('meta');
// Write updated inventory back
db.runAsync(
'UPDATE ' + metaTable + ' SET data = ? WHERE id = ?',
[JSON.stringify(payload), 'inventory'],
).then(function() {
strategy.debug && console.log('DefaultSchemaStrategy: Updated inventory for ' + collection + '/' + docId);
callback && callback(null);
}).catch(function(err) {
callback && callback(err);
});
});
};
/**
* Delete all tables created by this schema strategy
*/
DefaultSchemaStrategy.prototype.deleteAllTables = function(db, callback) {
var strategy = this;
var promises = [];
// Get table names (with mapping if configured)
let docsTable, metaTable;
if (this.collectionMapping && typeof this.collectionMapping === 'function') {
docsTable = this.collectionMapping('docs');
metaTable = this.collectionMapping('meta');
} else {
docsTable = this.getPrefixedTableName('docs');
metaTable = this.getPrefixedTableName('meta');
}
// Drop the standard tables used by DefaultSchemaStrategy
promises.push(db.runAsync('DROP TABLE IF EXISTS ' + metaTable));
promises.push(db.runAsync('DROP TABLE IF EXISTS ' + docsTable));
promises.push(db.runAsync('DROP TABLE IF EXISTS ' + this.getPrefixedTableName('inventory')));
Promise.all(promises)
.then(function() {
strategy.debug && console.log('DefaultSchemaStrategy: Deleted all tables');
callback && callback();
})
.catch(function(err) {
callback && callback(err);
});
};