feat(runtime): add LocalSQL adapter for CloudStore interface

TASK-007A: LocalSQL Adapter (Phase 5/Phase 11 shared foundation)

- Add LocalSQLAdapter implementing CloudStore interface with SQLite backend
- Add QueryBuilder for Parse-style query to SQL translation
- Add SchemaManager for table creation, migrations, and exports
- Support all CloudStore methods: query, fetch, create, save, delete
- Support aggregate, distinct, increment, count operations
- Support relations via junction tables
- Add schema export to PostgreSQL and Supabase formats
- Add comprehensive unit tests for QueryBuilder

This adapter enables:
- Local offline database for development
- Foundation for Phase 11 execution history storage
- Schema migration path to production databases
This commit is contained in:
Richard Osborne
2026-01-15 16:04:24 +01:00
parent dd3ac95299
commit 72c9989a68
8 changed files with 2752 additions and 0 deletions

View File

@@ -0,0 +1,587 @@
/**
* LocalSQLAdapter - SQLite-based CloudStore adapter
*
* Implements the CloudStore interface using SQLite for local storage.
* Provides the same API as the Parse-based CloudStore but stores
* data locally using better-sqlite3.
*
* @module adapters/local-sql/LocalSQLAdapter
*/
const EventEmitter = require('../../../events');
const QueryBuilder = require('./QueryBuilder');
const SchemaManager = require('./SchemaManager');
/**
* Generate a unique object ID (similar to Parse objectId)
*
* @returns {string} 10-character alphanumeric ID
*/
function generateObjectId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 10; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length));
}
return id;
}
/**
* LocalSQLAdapter class
*
* Implements the same interface as CloudStore but uses SQLite
*/
class LocalSQLAdapter {
/**
* @param {string} dbPath - Path to SQLite database file
* @param {Object} [options] - Configuration options
* @param {boolean} [options.autoCreateTables=true] - Auto-create tables on first access
* @param {Object} [options.collections] - Collection schemas (same as dbCollections metadata)
*/
constructor(dbPath, options = {}) {
this.dbPath = dbPath;
this.options = {
autoCreateTables: true,
...options
};
this.db = null;
this.schemaManager = null;
this.events = new EventEmitter();
this.events.setMaxListeners(10000);
// Collection schemas (like CloudStore._collections)
this._collections = options.collections || {};
}
/**
* Connect to the database
*
* @returns {Promise<void>}
*/
async connect() {
if (this.db) {
return; // Already connected
}
// Dynamic import of better-sqlite3 (Node.js only)
// This allows the module to be loaded in browser environments
// where better-sqlite3 is not available
try {
const Database = require('better-sqlite3');
this.db = new Database(this.dbPath);
// Enable WAL mode for better concurrent access
this.db.pragma('journal_mode = WAL');
// Initialize schema manager
this.schemaManager = new SchemaManager(this.db);
this.schemaManager.ensureSchemaTable();
// Create tables from collections if provided
if (this.options.autoCreateTables && this._collections) {
for (const [name, collection] of Object.entries(this._collections)) {
if (collection.schema) {
this.schemaManager.createTable({
name,
columns: Object.entries(collection.schema.properties || {}).map(([colName, colDef]) => ({
name: colName,
type: colDef.type,
required: colDef.required,
targetClass: colDef.targetClass
}))
});
}
}
}
} catch (e) {
throw new Error(`Failed to connect to SQLite database: ${e.message}`);
}
}
/**
* Disconnect from the database
*
* @returns {Promise<void>}
*/
async disconnect() {
if (this.db) {
this.db.close();
this.db = null;
this.schemaManager = null;
}
}
/**
* Ensure table exists (auto-create if needed)
*
* @private
* @param {string} collection - Collection name
*/
_ensureTable(collection) {
if (!this.schemaManager) {
throw new Error('Database not connected');
}
// Check if table exists
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(collection);
if (!exists && this.options.autoCreateTables) {
// Auto-create table with basic schema
this.schemaManager.createTable({
name: collection,
columns: []
});
}
}
/**
* Get schema for a collection
*
* @private
* @param {string} collection - Collection name
* @returns {Object|null}
*/
_getSchema(collection) {
if (this._collections[collection]) {
return this._collections[collection].schema;
}
return this.schemaManager?.getTableSchema(collection);
}
/**
* Convert a database row to a record object
*
* @private
* @param {Object} row - Database row
* @param {string} collection - Collection name
* @returns {Object}
*/
_rowToRecord(row, collection) {
if (!row) return null;
const schema = this._getSchema(collection);
const record = {};
for (const [key, value] of Object.entries(row)) {
const colType = schema?.properties?.[key]?.type;
record[key] = QueryBuilder.deserializeValue(value, colType);
}
return record;
}
// =========================================================================
// CloudStore Interface Methods
// =========================================================================
/**
* Subscribe to events
*
* @param {string} event - Event name
* @param {Function} handler - Event handler
* @param {Object} [context] - Context for handler
*/
on(event, handler, context) {
this.events.on(event, handler, context);
}
/**
* Unsubscribe from events
*
* @param {string} [event] - Event name (optional - removes all if not provided)
* @param {Function} [handler] - Event handler
* @param {Object} [context] - Context
*/
off(event, handler, context) {
if (event) {
this.events.off(event, handler, context);
} else {
this.events.removeAllListeners();
}
}
/**
* Query records
*
* @param {Object} options - Query options
*/
query(options) {
try {
this._ensureTable(options.collection);
const schema = this._getSchema(options.collection);
const { sql, params } = QueryBuilder.buildSelect(options, schema);
const rows = this.db.prepare(sql).all(...params);
const results = rows.map((row) => this._rowToRecord(row, options.collection));
// Handle count if requested
let count;
if (options.count) {
const { sql: countSQL, params: countParams } = QueryBuilder.buildCount(options, schema);
const countRow = this.db.prepare(countSQL).get(...countParams);
count = countRow?.count || 0;
}
options.success(results, count);
} catch (e) {
console.error('LocalSQLAdapter.query error:', e);
options.error(e.message);
}
}
/**
* Fetch a single record
*
* @param {Object} options - Fetch options
*/
fetch(options) {
try {
this._ensureTable(options.collection);
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`;
const row = this.db.prepare(sql).get(options.objectId);
if (!row) {
options.error('Object not found');
return;
}
const record = this._rowToRecord(row, options.collection);
options.success(record);
this.events.emit('fetch', {
type: 'fetch',
objectId: options.objectId,
object: record,
collection: options.collection
});
} catch (e) {
console.error('LocalSQLAdapter.fetch error:', e);
options.error(e.message);
}
}
/**
* Create a new record
*
* @param {Object} options - Create options
*/
create(options) {
try {
this._ensureTable(options.collection);
// Auto-add columns for new fields
if (this.options.autoCreateTables && this.schemaManager) {
for (const [key, value] of Object.entries(options.data)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
const type = this._inferType(value);
this.schemaManager.addColumn(options.collection, { name: key, type });
}
}
}
const objectId = generateObjectId();
const { sql, params } = QueryBuilder.buildInsert(options, objectId);
this.db.prepare(sql).run(...params);
// Fetch the created record to get all fields
const createdRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
.get(objectId);
const record = this._rowToRecord(createdRow, options.collection);
options.success(record);
this.events.emit('create', {
type: 'create',
objectId,
object: record,
collection: options.collection
});
} catch (e) {
console.error('LocalSQLAdapter.create error:', e);
options.error(e.message);
}
}
/**
* Save (update) an existing record
*
* @param {Object} options - Save options
*/
save(options) {
try {
this._ensureTable(options.collection);
// Auto-add columns for new fields
if (this.options.autoCreateTables && this.schemaManager) {
for (const [key, value] of Object.entries(options.data)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
const type = this._inferType(value);
this.schemaManager.addColumn(options.collection, { name: key, type });
}
}
}
const { sql, params } = QueryBuilder.buildUpdate(options);
this.db.prepare(sql).run(...params);
// Fetch the updated record
const updatedRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
.get(options.objectId);
const record = this._rowToRecord(updatedRow, options.collection);
options.success(record);
this.events.emit('save', {
type: 'save',
objectId: options.objectId,
object: record,
collection: options.collection
});
} catch (e) {
console.error('LocalSQLAdapter.save error:', e);
options.error(e.message);
}
}
/**
* Delete a record
*
* @param {Object} options - Delete options
*/
delete(options) {
try {
this._ensureTable(options.collection);
const { sql, params } = QueryBuilder.buildDelete(options);
this.db.prepare(sql).run(...params);
options.success();
this.events.emit('delete', {
type: 'delete',
objectId: options.objectId,
collection: options.collection
});
} catch (e) {
console.error('LocalSQLAdapter.delete error:', e);
options.error(e.message);
}
}
/**
* Count records
*
* @param {Object} options - Count options
*/
count(options) {
try {
this._ensureTable(options.collection);
const schema = this._getSchema(options.collection);
const { sql, params } = QueryBuilder.buildCount(options, schema);
const row = this.db.prepare(sql).get(...params);
options.success(row?.count || 0);
} catch (e) {
console.error('LocalSQLAdapter.count error:', e);
options.error(e.message);
}
}
/**
* Aggregate records
*
* @param {Object} options - Aggregate options
*/
aggregate(options) {
try {
this._ensureTable(options.collection);
const { sql, params } = QueryBuilder.buildAggregate(options);
const row = this.db.prepare(sql).get(...params);
// Format result like Parse Server
const result = {};
if (row) {
for (const key of Object.keys(options.group)) {
result[key] = row[key];
}
}
options.success(result);
} catch (e) {
console.error('LocalSQLAdapter.aggregate error:', e);
options.error(e.message);
}
}
/**
* Get distinct values
*
* @param {Object} options - Distinct options
*/
distinct(options) {
try {
this._ensureTable(options.collection);
const { sql, params } = QueryBuilder.buildDistinct(options);
const rows = this.db.prepare(sql).all(...params);
const results = rows.map((r) => r[options.property]);
options.success(results);
} catch (e) {
console.error('LocalSQLAdapter.distinct error:', e);
options.error(e.message);
}
}
/**
* Increment properties
*
* @param {Object} options - Increment options
*/
increment(options) {
try {
this._ensureTable(options.collection);
const { sql, params } = QueryBuilder.buildIncrement(options);
this.db.prepare(sql).run(...params);
// Fetch the updated record
const updatedRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
.get(options.objectId);
const record = this._rowToRecord(updatedRow, options.collection);
options.success(record);
} catch (e) {
console.error('LocalSQLAdapter.increment error:', e);
options.error(e.message);
}
}
/**
* Add a relation
*
* @param {Object} options - Relation options
*/
addRelation(options) {
try {
this.schemaManager.addRelation(options.collection, options.objectId, options.key, options.targetObjectId);
options.success({});
} catch (e) {
console.error('LocalSQLAdapter.addRelation error:', e);
options.error(e.message);
}
}
/**
* Remove a relation
*
* @param {Object} options - Relation options
*/
removeRelation(options) {
try {
this.schemaManager.removeRelation(options.collection, options.objectId, options.key, options.targetObjectId);
options.success({});
} catch (e) {
console.error('LocalSQLAdapter.removeRelation error:', e);
options.error(e.message);
}
}
// =========================================================================
// Additional LocalSQL-specific methods
// =========================================================================
/**
* Get the schema manager instance
*
* @returns {SchemaManager}
*/
getSchemaManager() {
return this.schemaManager;
}
/**
* Get the raw database instance
*
* @returns {import('better-sqlite3').Database}
*/
getDatabase() {
return this.db;
}
/**
* Execute raw SQL (use with caution!)
*
* @param {string} sql - SQL statement
* @param {Array} [params] - Parameters
* @returns {any}
*/
exec(sql, params = []) {
if (params.length > 0) {
return this.db.prepare(sql).all(...params);
}
return this.db.exec(sql);
}
/**
* Run a transaction
*
* @param {Function} fn - Function to run in transaction
* @returns {any}
*/
transaction(fn) {
return this.db.transaction(fn)();
}
/**
* Infer type from a JavaScript value
*
* @private
* @param {*} value
* @returns {string}
*/
_inferType(value) {
if (value === null || value === undefined) {
return 'String';
}
if (typeof value === 'string') {
return 'String';
}
if (typeof value === 'number') {
return 'Number';
}
if (typeof value === 'boolean') {
return 'Boolean';
}
if (value instanceof Date) {
return 'Date';
}
if (Array.isArray(value)) {
return 'Array';
}
if (typeof value === 'object') {
if (value.__type === 'Date') return 'Date';
if (value.__type === 'Pointer') return 'Pointer';
if (value.__type === 'File') return 'File';
if (value.__type === 'GeoPoint') return 'GeoPoint';
return 'Object';
}
return 'String';
}
}
module.exports = LocalSQLAdapter;

View File

@@ -0,0 +1,705 @@
/**
* QueryBuilder - Translates Parse-style queries to SQLite SQL
*
* Parse uses operators like $eq, $ne, $gt, $lt, $in, etc.
* This translates them to SQL WHERE clauses.
*
* @module adapters/local-sql/QueryBuilder
*/
/**
* Reserved SQLite keywords that need to be escaped
*/
const RESERVED_WORDS = new Set([
'order',
'group',
'select',
'from',
'where',
'index',
'table',
'create',
'drop',
'alter',
'delete',
'insert',
'update',
'key',
'primary',
'foreign',
'references',
'null',
'not',
'and',
'or',
'in',
'like',
'between',
'is',
'exists',
'case',
'when',
'then',
'else',
'end',
'join',
'inner',
'outer',
'left',
'right',
'on',
'as',
'asc',
'desc',
'limit',
'offset',
'union',
'distinct',
'all',
'any',
'some',
'true',
'false',
'default',
'values',
'set',
'into',
'by',
'having',
'count',
'sum',
'avg',
'min',
'max'
]);
/**
* Escape a table name for SQL
* @param {string} name
* @returns {string}
*/
function escapeTable(name) {
// Sanitize: only allow alphanumeric and underscore
const sanitized = name.replace(/[^a-zA-Z0-9_]/g, '');
return `"${sanitized}"`;
}
/**
* Escape a column name for SQL
* @param {string} name
* @returns {string}
*/
function escapeColumn(name) {
// Sanitize: only allow alphanumeric and underscore
const sanitized = name.replace(/[^a-zA-Z0-9_]/g, '');
// Always quote to handle reserved words
return `"${sanitized}"`;
}
/**
* Convert a Parse Date object to ISO string for SQLite
* @param {Object|Date|string} value
* @returns {string}
*/
function convertDateValue(value) {
if (value && value.__type === 'Date' && value.iso) {
return value.iso;
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
}
/**
* Convert a Parse Pointer to its objectId
* @param {Object|string} value
* @returns {string}
*/
function convertPointerValue(value) {
if (value && value.__type === 'Pointer' && value.objectId) {
return value.objectId;
}
return value;
}
/**
* Build a WHERE clause from a Parse-style query
*
* @param {Object} where - Parse-style query object
* @param {Array} params - Array to push parameter values to
* @param {Object} [schema] - Optional schema for type-aware conversion
* @returns {string} SQL WHERE clause (without "WHERE" keyword)
*/
function buildWhereClause(where, params, schema) {
if (!where || Object.keys(where).length === 0) {
return '';
}
const conditions = [];
for (const [key, condition] of Object.entries(where)) {
// Handle logical operators
if (key === '$and' && Array.isArray(condition)) {
const subConditions = condition.map((sub) => buildWhereClause(sub, params, schema)).filter((c) => c);
if (subConditions.length > 0) {
conditions.push(`(${subConditions.join(' AND ')})`);
}
continue;
}
if (key === '$or' && Array.isArray(condition)) {
const subConditions = condition.map((sub) => buildWhereClause(sub, params, schema)).filter((c) => c);
if (subConditions.length > 0) {
conditions.push(`(${subConditions.join(' OR ')})`);
}
continue;
}
// Handle $relatedTo - this is tricky with SQLite
if (key === '$relatedTo') {
// For relations, we need a subquery on the junction table
const { object, key: relationKey } = condition;
if (object && object.objectId && object.className && relationKey) {
const junctionTable = `_Join_${relationKey}_${object.className}`;
conditions.push(`"objectId" IN (SELECT "relatedId" FROM ${escapeTable(junctionTable)} WHERE "owningId" = ?)`);
params.push(object.objectId);
}
continue;
}
// Handle field conditions
const col = escapeColumn(key);
if (typeof condition !== 'object' || condition === null) {
// Direct equality
conditions.push(`${col} = ?`);
params.push(convertDateValue(convertPointerValue(condition)));
continue;
}
// Handle Parse operators
for (const [op, value] of Object.entries(condition)) {
const sqlCondition = translateOperator(col, op, value, params, schema);
if (sqlCondition) {
conditions.push(sqlCondition);
}
}
}
return conditions.join(' AND ');
}
/**
* Translate a single Parse operator to SQL
*
* @param {string} col - Escaped column name
* @param {string} op - Parse operator ($eq, $ne, etc.)
* @param {*} value - Comparison value
* @param {Array} params - Parameters array to push values to
* @param {Object} [schema] - Optional schema
* @returns {string|null} SQL condition or null
*/
function translateOperator(col, op, value, params, schema) {
// Convert special types
const convertedValue = convertDateValue(convertPointerValue(value));
switch (op) {
case '$eq':
if (convertedValue === null) {
return `${col} IS NULL`;
}
params.push(convertedValue);
return `${col} = ?`;
case '$ne':
if (convertedValue === null) {
return `${col} IS NOT NULL`;
}
params.push(convertedValue);
return `${col} != ?`;
case '$gt':
params.push(convertedValue);
return `${col} > ?`;
case '$gte':
params.push(convertedValue);
return `${col} >= ?`;
case '$lt':
params.push(convertedValue);
return `${col} < ?`;
case '$lte':
params.push(convertedValue);
return `${col} <= ?`;
case '$in':
if (!Array.isArray(value) || value.length === 0) {
return '0'; // Always false
}
const inValues = value.map((v) => convertDateValue(convertPointerValue(v)));
const placeholders = inValues.map(() => '?').join(', ');
params.push(...inValues);
return `${col} IN (${placeholders})`;
case '$nin':
if (!Array.isArray(value) || value.length === 0) {
return '1'; // Always true (not in empty set)
}
const ninValues = value.map((v) => convertDateValue(convertPointerValue(v)));
const ninPlaceholders = ninValues.map(() => '?').join(', ');
params.push(...ninValues);
return `${col} NOT IN (${ninPlaceholders})`;
case '$exists':
return value ? `${col} IS NOT NULL` : `${col} IS NULL`;
case '$regex':
// SQLite doesn't have native regex, use LIKE with % wildcards
// This is a simplification - only handles basic patterns
params.push(`%${value}%`);
return `${col} LIKE ?`;
case '$options':
// This is used with $regex, ignore here
return null;
case '$text':
// Full text search - convert to LIKE
if (value && value.$search) {
const term = typeof value.$search === 'string' ? value.$search : value.$search.$term || '';
params.push(`%${term}%`);
return `${col} LIKE ?`;
}
return null;
// Geo queries - not fully supported in SQLite without extensions
case '$nearSphere':
case '$within':
case '$geoWithin':
console.warn(`Geo query operator ${op} not supported in SQLite adapter`);
return null;
default:
console.warn(`Unknown query operator: ${op}`);
return null;
}
}
/**
* Build ORDER BY clause from Parse-style sort
*
* @param {string|string[]} sort - Sort specification (e.g., 'name' or '-createdAt' for desc)
* @returns {string} SQL ORDER BY clause (without "ORDER BY" keyword)
*/
function buildOrderClause(sort) {
if (!sort) {
return '';
}
const sortArray = Array.isArray(sort) ? sort : sort.split(',');
const orders = sortArray.map((s) => {
const trimmed = s.trim();
if (trimmed.startsWith('-')) {
return `${escapeColumn(trimmed.substring(1))} DESC`;
}
return `${escapeColumn(trimmed)} ASC`;
});
return orders.join(', ');
}
/**
* Build a SELECT query
*
* @param {Object} options - Query options
* @param {string} options.collection - Collection name
* @param {Object} [options.where] - Parse-style query filter
* @param {string|string[]} [options.select] - Fields to select
* @param {string|string[]} [options.sort] - Sort order
* @param {number} [options.limit] - Max records
* @param {number} [options.skip] - Records to skip
* @param {Object} [schema] - Optional schema
* @returns {{ sql: string, params: Array }}
*/
function buildSelect(options, schema) {
const params = [];
const table = escapeTable(options.collection);
// Build SELECT clause
let selectClause = '*';
if (options.select) {
const selectArray = Array.isArray(options.select) ? options.select : options.select.split(',');
// Always include objectId
const fields = new Set(['objectId', ...selectArray.map((s) => s.trim())]);
selectClause = Array.from(fields)
.map((f) => escapeColumn(f))
.join(', ');
}
let sql = `SELECT ${selectClause} FROM ${table}`;
// Build WHERE clause
if (options.where) {
const whereClause = buildWhereClause(options.where, params, schema);
if (whereClause) {
sql += ` WHERE ${whereClause}`;
}
}
// Build ORDER BY clause
if (options.sort) {
const orderClause = buildOrderClause(options.sort);
if (orderClause) {
sql += ` ORDER BY ${orderClause}`;
}
}
// Build LIMIT/OFFSET
if (options.limit !== undefined) {
sql += ' LIMIT ?';
params.push(options.limit);
}
if (options.skip !== undefined && options.skip > 0) {
sql += ' OFFSET ?';
params.push(options.skip);
}
return { sql, params };
}
/**
* Build a COUNT query
*
* @param {Object} options
* @param {string} options.collection
* @param {Object} [options.where]
* @param {Object} [schema]
* @returns {{ sql: string, params: Array }}
*/
function buildCount(options, schema) {
const params = [];
const table = escapeTable(options.collection);
let sql = `SELECT COUNT(*) as count FROM ${table}`;
if (options.where) {
const whereClause = buildWhereClause(options.where, params, schema);
if (whereClause) {
sql += ` WHERE ${whereClause}`;
}
}
return { sql, params };
}
/**
* Build an INSERT query
*
* @param {Object} options
* @param {string} options.collection
* @param {Object} options.data
* @param {string} objectId
* @returns {{ sql: string, params: Array }}
*/
function buildInsert(options, objectId) {
const params = [];
const table = escapeTable(options.collection);
const now = new Date().toISOString();
const data = {
objectId,
createdAt: now,
updatedAt: now,
...options.data
};
// Remove protected fields
delete data._createdAt;
delete data._updatedAt;
const columns = [];
const placeholders = [];
for (const [key, value] of Object.entries(data)) {
columns.push(escapeColumn(key));
placeholders.push('?');
params.push(serializeValue(value));
}
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`;
return { sql, params };
}
/**
* Build an UPDATE query
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.objectId
* @param {Object} options.data
* @returns {{ sql: string, params: Array }}
*/
function buildUpdate(options) {
const params = [];
const table = escapeTable(options.collection);
const data = { ...options.data };
// Add updatedAt
data.updatedAt = new Date().toISOString();
// Remove protected fields
delete data.objectId;
delete data.createdAt;
delete data._createdAt;
delete data._updatedAt;
const setClause = [];
for (const [key, value] of Object.entries(data)) {
setClause.push(`${escapeColumn(key)} = ?`);
params.push(serializeValue(value));
}
params.push(options.objectId);
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
return { sql, params };
}
/**
* Build a DELETE query
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.objectId
* @returns {{ sql: string, params: Array }}
*/
function buildDelete(options) {
const table = escapeTable(options.collection);
const sql = `DELETE FROM ${table} WHERE "objectId" = ?`;
return { sql, params: [options.objectId] };
}
/**
* Build an INCREMENT query
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.objectId
* @param {Object<string, number>} options.properties
* @returns {{ sql: string, params: Array }}
*/
function buildIncrement(options) {
const params = [];
const table = escapeTable(options.collection);
const setClause = [];
for (const [key, amount] of Object.entries(options.properties)) {
const col = escapeColumn(key);
setClause.push(`${col} = COALESCE(${col}, 0) + ?`);
params.push(amount);
}
// Add updatedAt
setClause.push('"updatedAt" = ?');
params.push(new Date().toISOString());
params.push(options.objectId);
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
return { sql, params };
}
/**
* Build a DISTINCT query
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.property
* @param {Object} [options.where]
* @returns {{ sql: string, params: Array }}
*/
function buildDistinct(options) {
const params = [];
const table = escapeTable(options.collection);
const col = escapeColumn(options.property);
let sql = `SELECT DISTINCT ${col} FROM ${table}`;
if (options.where) {
const whereClause = buildWhereClause(options.where, params);
if (whereClause) {
sql += ` WHERE ${whereClause}`;
}
}
return { sql, params };
}
/**
* Build an AGGREGATE query
*
* @param {Object} options
* @param {string} options.collection
* @param {Object} [options.where]
* @param {Object} options.group - Grouping config with avg/sum/max/min/distinct
* @param {number} [options.limit]
* @param {number} [options.skip]
* @returns {{ sql: string, params: Array }}
*/
function buildAggregate(options) {
const params = [];
const table = escapeTable(options.collection);
const selectParts = [];
for (const [alias, groupConfig] of Object.entries(options.group)) {
if (groupConfig.avg !== undefined) {
selectParts.push(`AVG(${escapeColumn(groupConfig.avg)}) as ${escapeColumn(alias)}`);
} else if (groupConfig.sum !== undefined) {
selectParts.push(`SUM(${escapeColumn(groupConfig.sum)}) as ${escapeColumn(alias)}`);
} else if (groupConfig.max !== undefined) {
selectParts.push(`MAX(${escapeColumn(groupConfig.max)}) as ${escapeColumn(alias)}`);
} else if (groupConfig.min !== undefined) {
selectParts.push(`MIN(${escapeColumn(groupConfig.min)}) as ${escapeColumn(alias)}`);
} else if (groupConfig.distinct !== undefined) {
// COUNT DISTINCT as alternative to $addToSet
selectParts.push(`COUNT(DISTINCT ${escapeColumn(groupConfig.distinct)}) as ${escapeColumn(alias)}`);
}
}
if (selectParts.length === 0) {
return { sql: `SELECT COUNT(*) as count FROM ${table}`, params: [] };
}
let sql = `SELECT ${selectParts.join(', ')} FROM ${table}`;
if (options.where) {
const whereClause = buildWhereClause(options.where, params);
if (whereClause) {
sql += ` WHERE ${whereClause}`;
}
}
return { sql, params };
}
/**
* Serialize a JavaScript value for SQLite storage
*
* @param {*} value
* @returns {*}
*/
function serializeValue(value) {
if (value === null || value === undefined) {
return null;
}
// Handle Parse types
if (value && typeof value === 'object') {
// Date type
if (value.__type === 'Date' && value.iso) {
return value.iso;
}
// Pointer type - store just the objectId
if (value.__type === 'Pointer' && value.objectId) {
return value.objectId;
}
// File type - store as JSON
if (value.__type === 'File') {
return JSON.stringify(value);
}
// GeoPoint type - store as JSON
if (value.__type === 'GeoPoint') {
return JSON.stringify(value);
}
// Arrays and objects - store as JSON
if (Array.isArray(value) || Object.keys(value).length > 0) {
return JSON.stringify(value);
}
}
// Handle Date objects
if (value instanceof Date) {
return value.toISOString();
}
// Handle booleans - SQLite uses 0/1
if (typeof value === 'boolean') {
return value ? 1 : 0;
}
return value;
}
/**
* Deserialize a SQLite value back to JavaScript
*
* @param {*} value
* @param {string} [type] - Expected type from schema
* @returns {*}
*/
function deserializeValue(value, type) {
if (value === null || value === undefined) {
return null;
}
// Handle type-based deserialization
if (type === 'Boolean') {
return Boolean(value);
}
if (type === 'Date') {
return value; // Keep as ISO string, let CloudStore handle Date objects
}
if (type === 'Object' || type === 'Array' || type === 'GeoPoint' || type === 'File') {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (e) {
return value;
}
}
}
// Try to parse JSON strings that look like objects/arrays
if (typeof value === 'string') {
if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) {
try {
return JSON.parse(value);
} catch (e) {
// Not valid JSON, return as-is
}
}
}
return value;
}
module.exports = {
escapeTable,
escapeColumn,
buildWhereClause,
buildOrderClause,
buildSelect,
buildCount,
buildInsert,
buildUpdate,
buildDelete,
buildIncrement,
buildDistinct,
buildAggregate,
serializeValue,
deserializeValue
};

View File

@@ -0,0 +1,511 @@
/**
* SchemaManager - Handles SQLite schema creation, migration, and export
*
* Manages table creation based on Noodl collection schemas,
* handles migrations when schema changes, and can export
* schemas to other database formats (Postgres, Supabase, etc.)
*
* @module adapters/local-sql/SchemaManager
*/
const { escapeTable, escapeColumn } = require('./QueryBuilder');
/**
* Map Noodl/Parse types to SQLite types
*
* @type {Object<string, string>}
*/
const TYPE_MAP = {
String: 'TEXT',
Number: 'REAL',
Boolean: 'INTEGER', // SQLite uses 0/1
Date: 'TEXT', // ISO8601 string
Object: 'TEXT', // JSON string
Array: 'TEXT', // JSON string
Pointer: 'TEXT', // objectId reference
Relation: null, // Handled via junction tables
GeoPoint: 'TEXT', // JSON string
File: 'TEXT' // JSON string with url/name
};
/**
* Map Noodl types to PostgreSQL types (for export)
*
* @type {Object<string, string>}
*/
const POSTGRES_TYPE_MAP = {
String: 'TEXT',
Number: 'NUMERIC',
Boolean: 'BOOLEAN',
Date: 'TIMESTAMPTZ',
Object: 'JSONB',
Array: 'JSONB',
Pointer: 'TEXT', // or UUID with FK
Relation: null,
GeoPoint: 'POINT', // or use PostGIS
File: 'JSONB'
};
/**
* SchemaManager class
*/
class SchemaManager {
/**
* @param {import('better-sqlite3').Database} db - SQLite database instance
*/
constructor(db) {
this.db = db;
this._schemaCache = new Map();
}
/**
* Ensure the internal schema tracking table exists
*/
ensureSchemaTable() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS "_Schema" (
"name" TEXT PRIMARY KEY,
"schema" TEXT NOT NULL,
"createdAt" TEXT DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
}
/**
* Create a table from a schema definition
*
* @param {Object} schema - Table schema
* @param {string} schema.name - Table name
* @param {Array<Object>} schema.columns - Column definitions
* @returns {boolean} Whether table was created (false if already existed)
*/
createTable(schema) {
const tableName = schema.name;
// Check if table exists
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(tableName);
if (exists) {
return false;
}
// Build column definitions
const columnDefs = [
'"objectId" TEXT PRIMARY KEY',
'"createdAt" TEXT DEFAULT CURRENT_TIMESTAMP',
'"updatedAt" TEXT DEFAULT CURRENT_TIMESTAMP',
'"ACL" TEXT' // Access control list as JSON
];
// Add user-defined columns
for (const col of schema.columns || []) {
const colDef = this._columnToSQL(col);
if (colDef) {
columnDefs.push(colDef);
}
}
// Create main table
const createSQL = `CREATE TABLE ${escapeTable(tableName)} (${columnDefs.join(', ')})`;
this.db.exec(createSQL);
// Create standard indexes
this.db.exec(`CREATE INDEX IF NOT EXISTS "idx_${tableName}_createdAt" ON ${escapeTable(tableName)}("createdAt")`);
this.db.exec(`CREATE INDEX IF NOT EXISTS "idx_${tableName}_updatedAt" ON ${escapeTable(tableName)}("updatedAt")`);
// Create junction tables for relations
for (const col of schema.columns || []) {
if (col.type === 'Relation' && col.targetClass) {
this._createJunctionTable(tableName, col.name, col.targetClass);
}
}
// Store schema in tracking table
this.ensureSchemaTable();
this.db
.prepare(
`INSERT OR REPLACE INTO "_Schema" ("name", "schema", "updatedAt")
VALUES (?, ?, CURRENT_TIMESTAMP)`
)
.run(tableName, JSON.stringify(schema));
this._schemaCache.set(tableName, schema);
return true;
}
/**
* Add a column to an existing table
*
* @param {string} tableName - Table name
* @param {Object} column - Column definition
*/
addColumn(tableName, column) {
const colDef = this._columnToSQL(column);
if (!colDef) {
return;
}
try {
this.db.exec(`ALTER TABLE ${escapeTable(tableName)} ADD COLUMN ${colDef}`);
// Update schema tracking
const schema = this.getTableSchema(tableName);
if (schema) {
schema.columns = schema.columns || [];
schema.columns.push(column);
this.db
.prepare(`UPDATE "_Schema" SET "schema" = ?, "updatedAt" = CURRENT_TIMESTAMP WHERE "name" = ?`)
.run(JSON.stringify(schema), tableName);
this._schemaCache.set(tableName, schema);
}
} catch (e) {
// Column may already exist
if (!e.message.includes('duplicate column name')) {
throw e;
}
}
}
/**
* Get schema for a table
*
* @param {string} tableName - Table name
* @returns {Object|null} Schema definition or null
*/
getTableSchema(tableName) {
if (this._schemaCache.has(tableName)) {
return this._schemaCache.get(tableName);
}
this.ensureSchemaTable();
const row = this.db.prepare('SELECT "schema" FROM "_Schema" WHERE "name" = ?').get(tableName);
if (row) {
const schema = JSON.parse(row.schema);
this._schemaCache.set(tableName, schema);
return schema;
}
return null;
}
/**
* List all tables
*
* @returns {string[]} Table names
*/
listTables() {
const rows = this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_%'")
.all();
return rows.map((r) => r.name);
}
/**
* Export all schemas
*
* @returns {Object[]} Array of schema definitions
*/
exportSchemas() {
this.ensureSchemaTable();
const rows = this.db.prepare('SELECT "name", "schema" FROM "_Schema" WHERE "name" NOT LIKE \'_%\'').all();
return rows.map((r) => JSON.parse(r.schema));
}
/**
* Generate PostgreSQL-compatible SQL for migration
*
* @returns {string} SQL statements
*/
generatePostgresSQL() {
const schemas = this.exportSchemas();
const statements = [];
statements.push('-- Generated by Noodl LocalSQL Export');
statements.push('-- PostgreSQL Schema');
statements.push('');
for (const schema of schemas) {
statements.push(`-- Table: ${schema.name}`);
const columnDefs = [
'"objectId" TEXT PRIMARY KEY',
'"createdAt" TIMESTAMPTZ DEFAULT NOW()',
'"updatedAt" TIMESTAMPTZ DEFAULT NOW()',
'"ACL" JSONB'
];
for (const col of schema.columns || []) {
const pgType = POSTGRES_TYPE_MAP[col.type];
if (pgType) {
let def = `"${col.name}" ${pgType}`;
if (col.required) def += ' NOT NULL';
columnDefs.push(def);
}
}
statements.push(`CREATE TABLE IF NOT EXISTS "${schema.name}" (`);
statements.push(` ${columnDefs.join(',\n ')}`);
statements.push(');');
statements.push('');
// Indexes
statements.push(`CREATE INDEX IF NOT EXISTS "idx_${schema.name}_createdAt" ON "${schema.name}"("createdAt");`);
statements.push(`CREATE INDEX IF NOT EXISTS "idx_${schema.name}_updatedAt" ON "${schema.name}"("updatedAt");`);
statements.push('');
// Add updatedAt trigger
statements.push(`-- Trigger for auto-updating updatedAt`);
statements.push(`CREATE OR REPLACE FUNCTION update_updated_at_column()`);
statements.push(`RETURNS TRIGGER AS $$`);
statements.push(`BEGIN`);
statements.push(` NEW."updatedAt" = NOW();`);
statements.push(` RETURN NEW;`);
statements.push(`END;`);
statements.push(`$$ language 'plpgsql';`);
statements.push('');
statements.push(`DROP TRIGGER IF EXISTS "update_${schema.name}_updated_at" ON "${schema.name}";`);
statements.push(`CREATE TRIGGER "update_${schema.name}_updated_at" BEFORE UPDATE ON "${schema.name}"`);
statements.push(` FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();`);
statements.push('');
}
return statements.join('\n');
}
/**
* Generate Supabase-compatible SQL (includes RLS policies)
*
* @returns {string} SQL statements
*/
generateSupabaseSQL() {
const baseSQL = this.generatePostgresSQL();
const schemas = this.exportSchemas();
const rlsStatements = [];
rlsStatements.push('');
rlsStatements.push('-- Row Level Security Policies');
rlsStatements.push('');
for (const schema of schemas) {
const tableName = schema.name;
rlsStatements.push(`-- RLS for ${tableName}`);
rlsStatements.push(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`);
rlsStatements.push('');
// Default policy: allow authenticated users
rlsStatements.push(`-- Allow authenticated users to read all records`);
rlsStatements.push(
`CREATE POLICY "Allow authenticated read" ON "${tableName}" FOR SELECT TO authenticated USING (true);`
);
rlsStatements.push('');
rlsStatements.push(`-- Allow users to insert their own records`);
rlsStatements.push(
`CREATE POLICY "Allow insert" ON "${tableName}" FOR INSERT TO authenticated WITH CHECK (true);`
);
rlsStatements.push('');
rlsStatements.push(`-- Allow users to update their own records (customize based on ACL)`);
rlsStatements.push(
`CREATE POLICY "Allow update" ON "${tableName}" FOR UPDATE TO authenticated USING (true) WITH CHECK (true);`
);
rlsStatements.push('');
rlsStatements.push(`-- Allow users to delete their own records (customize based on ACL)`);
rlsStatements.push(`CREATE POLICY "Allow delete" ON "${tableName}" FOR DELETE TO authenticated USING (true);`);
rlsStatements.push('');
}
return baseSQL + rlsStatements.join('\n');
}
/**
* Generate JSON schema export
*
* @returns {Object} JSON schema definition
*/
exportAsJSON() {
return {
version: '1.0',
exportedAt: new Date().toISOString(),
tables: this.exportSchemas()
};
}
/**
* Import schema from JSON
*
* @param {Object} jsonSchema - Schema definition from exportAsJSON
*/
importFromJSON(jsonSchema) {
const tables = jsonSchema.tables || [];
for (const tableSchema of tables) {
this.createTable(tableSchema);
}
}
/**
* Check if a table needs migration (schema changed)
*
* @param {string} tableName - Table name
* @param {Object} newSchema - New schema definition
* @returns {Object} Migration info with added/removed columns
*/
checkMigration(tableName, newSchema) {
const currentSchema = this.getTableSchema(tableName);
if (!currentSchema) {
return { needsMigration: false, tableExists: false };
}
const currentCols = new Set((currentSchema.columns || []).map((c) => c.name));
const newCols = new Set((newSchema.columns || []).map((c) => c.name));
const added = [...newCols].filter((c) => !currentCols.has(c));
const removed = [...currentCols].filter((c) => !newCols.has(c));
return {
needsMigration: added.length > 0 || removed.length > 0,
tableExists: true,
added,
removed
};
}
/**
* Convert column definition to SQL
*
* @private
* @param {Object} col - Column definition
* @returns {string|null} SQL column definition
*/
_columnToSQL(col) {
const sqlType = TYPE_MAP[col.type];
if (!sqlType) {
return null; // Relations handled separately
}
let def = `${escapeColumn(col.name)} ${sqlType}`;
if (col.required) {
def += ' NOT NULL';
}
if (col.defaultValue !== undefined) {
if (typeof col.defaultValue === 'string') {
def += ` DEFAULT '${col.defaultValue}'`;
} else if (typeof col.defaultValue === 'boolean') {
def += ` DEFAULT ${col.defaultValue ? 1 : 0}`;
} else {
def += ` DEFAULT ${col.defaultValue}`;
}
}
return def;
}
/**
* Create a junction table for many-to-many relations
*
* @private
* @param {string} owningClass - Source class name
* @param {string} relationName - Relation field name
* @param {string} targetClass - Target class name
*/
_createJunctionTable(owningClass, relationName, targetClass) {
const junctionTable = `_Join_${relationName}_${owningClass}`;
this.db.exec(`
CREATE TABLE IF NOT EXISTS ${escapeTable(junctionTable)} (
"owningId" TEXT NOT NULL,
"relatedId" TEXT NOT NULL,
PRIMARY KEY ("owningId", "relatedId")
)
`);
// Create indexes for efficient lookups
this.db.exec(
`CREATE INDEX IF NOT EXISTS "idx_${junctionTable}_owning" ON ${escapeTable(junctionTable)}("owningId")`
);
this.db.exec(
`CREATE INDEX IF NOT EXISTS "idx_${junctionTable}_related" ON ${escapeTable(junctionTable)}("relatedId")`
);
}
/**
* Add to a relation (junction table)
*
* @param {string} owningClass - Source class name
* @param {string} owningId - Source record ID
* @param {string} relationName - Relation field name
* @param {string} targetId - Target record ID
*/
addRelation(owningClass, owningId, relationName, targetId) {
const junctionTable = `_Join_${relationName}_${owningClass}`;
try {
this.db
.prepare(`INSERT OR IGNORE INTO ${escapeTable(junctionTable)} ("owningId", "relatedId") VALUES (?, ?)`)
.run(owningId, targetId);
} catch (e) {
// Table might not exist
if (e.message.includes('no such table')) {
this._createJunctionTable(owningClass, relationName, 'Unknown');
this.db
.prepare(`INSERT OR IGNORE INTO ${escapeTable(junctionTable)} ("owningId", "relatedId") VALUES (?, ?)`)
.run(owningId, targetId);
} else {
throw e;
}
}
}
/**
* Remove from a relation (junction table)
*
* @param {string} owningClass - Source class name
* @param {string} owningId - Source record ID
* @param {string} relationName - Relation field name
* @param {string} targetId - Target record ID
*/
removeRelation(owningClass, owningId, relationName, targetId) {
const junctionTable = `_Join_${relationName}_${owningClass}`;
this.db
.prepare(`DELETE FROM ${escapeTable(junctionTable)} WHERE "owningId" = ? AND "relatedId" = ?`)
.run(owningId, targetId);
}
/**
* Get related IDs from a relation
*
* @param {string} owningClass - Source class name
* @param {string} owningId - Source record ID
* @param {string} relationName - Relation field name
* @returns {string[]} Related record IDs
*/
getRelatedIds(owningClass, owningId, relationName) {
const junctionTable = `_Join_${relationName}_${owningClass}`;
try {
const rows = this.db
.prepare(`SELECT "relatedId" FROM ${escapeTable(junctionTable)} WHERE "owningId" = ?`)
.all(owningId);
return rows.map((r) => r.relatedId);
} catch (e) {
if (e.message.includes('no such table')) {
return [];
}
throw e;
}
}
}
module.exports = SchemaManager;

View File

@@ -0,0 +1,18 @@
/**
* LocalSQL Adapter Module
*
* SQLite-based implementation of the CloudStore adapter interface.
* Provides local storage with the same API as Parse-based CloudStore.
*
* @module adapters/local-sql
*/
const LocalSQLAdapter = require('./LocalSQLAdapter');
const QueryBuilder = require('./QueryBuilder');
const SchemaManager = require('./SchemaManager');
module.exports = {
LocalSQLAdapter,
QueryBuilder,
SchemaManager
};