@sqlitecloud/drivers
Version:
SQLiteCloud drivers for Typescript/Javascript in edge, web and node clients
329 lines (328 loc) • 17 kB
JavaScript
;
//
// utilities.ts - utility methods to manipulate SQL statements
//
Object.defineProperty(exports, "__esModule", { value: true });
exports.isNode = exports.isBrowser = void 0;
exports.anonimizeCommand = anonimizeCommand;
exports.anonimizeError = anonimizeError;
exports.getInitializationCommands = getInitializationCommands;
exports.sanitizeSQLiteIdentifier = sanitizeSQLiteIdentifier;
exports.getUpdateResults = getUpdateResults;
exports.popCallback = popCallback;
exports.validateConfiguration = validateConfiguration;
exports.parseconnectionstring = parseconnectionstring;
exports.parseBoolean = parseBoolean;
exports.parseBooleanToZeroOne = parseBooleanToZeroOne;
exports.parseSafeIntegerMode = parseSafeIntegerMode;
exports.parseWebsocketBlobTransferFormat = parseWebsocketBlobTransferFormat;
exports.parseWebsocketMaxAttachments = parseWebsocketMaxAttachments;
exports.encodeBigIntMarkers = encodeBigIntMarkers;
exports.decodeBigIntMarkers = decodeBigIntMarkers;
exports.decodeWebsocketRowsetData = decodeWebsocketRowsetData;
const types_1 = require("./types");
const safe_imports_1 = require("./safe-imports");
const buffer_1 = require("buffer");
// explicitly importing these libraries to allow cross-platform support by replacing them
// In React Native: Metro resolves 'whatwg-url' to 'react-native-url-polyfill' via package.json react-native field
// In Web/Node: Uses standard whatwg-url package
const URL = (0, safe_imports_1.getSafeURL)();
//
// determining running environment, thanks to browser-or-node
// https://www.npmjs.com/package/browser-or-node
//
exports.isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
exports.isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
//
// utility methods
//
/** Messages going to the server are sometimes logged when error conditions occour and need to be stripped of user credentials */
function anonimizeCommand(message) {
// hide password in AUTH command if needed
message = message.replace(/USER \S+/, 'USER ******');
message = message.replace(/PASSWORD \S+?(?=;)/, 'PASSWORD ******');
message = message.replace(/HASH \S+?(?=;)/, 'HASH ******');
return message;
}
/** Strip message code in error of user credentials */
function anonimizeError(error) {
if (error === null || error === void 0 ? void 0 : error.message) {
error.message = anonimizeCommand(error.message);
}
return error;
}
/** Initialization commands sent to database when connection is established */
function getInitializationCommands(config) {
// we check the credentials using non linearizable so we're quicker
// then we bring back linearizability unless specified otherwise
let commands = 'SET CLIENT KEY NONLINEARIZABLE TO 1;';
// first user authentication, then all other commands
if (config.apikey) {
commands += `AUTH APIKEY ${config.apikey};`;
}
else if (config.token) {
commands += `AUTH TOKEN ${config.token};`;
}
else {
commands += `AUTH USER ${config.username || ''} ${config.password_hashed ? 'HASH' : 'PASSWORD'} ${config.password || ''};`;
}
if (config.compression) {
commands += 'SET CLIENT KEY COMPRESSION TO 1;';
}
if (config.zerotext) {
commands += 'SET CLIENT KEY ZEROTEXT TO 1;';
}
if (config.noblob) {
commands += 'SET CLIENT KEY NOBLOB TO 1;';
}
if (config.maxdata) {
commands += `SET CLIENT KEY MAXDATA TO ${config.maxdata};`;
}
if (config.maxrows) {
commands += `SET CLIENT KEY MAXROWS TO ${config.maxrows};`;
}
if (config.maxrowset) {
commands += `SET CLIENT KEY MAXROWSET TO ${config.maxrowset};`;
}
// we ALWAYS set non linearizable to 1 when we start so we can be quicker on login
// but then we need to put it back to its default value if "linearizable" unless set
if (!config.non_linearizable) {
commands += 'SET CLIENT KEY NONLINEARIZABLE TO 0;';
}
if (config.database) {
if (config.create && !config.memory) {
commands += `CREATE DATABASE ${config.database} IF NOT EXISTS;`;
}
commands += `USE DATABASE ${config.database};`;
}
return commands;
}
/** Sanitizes an SQLite identifier (e.g., table name, column name). */
function sanitizeSQLiteIdentifier(identifier) {
const trimmed = identifier.trim();
// it's not empty
if (trimmed.length === 0) {
throw new Error('Identifier cannot be empty.');
}
// escape double quotes
const escaped = trimmed.replace(/"/g, '""');
// Wrap in double quotes for safety
return `"${escaped}"`;
}
/** Converts results of an update or insert call into a more meaning full result set */
function getUpdateResults(results) {
if (results) {
if (Array.isArray(results) && results.length > 0) {
switch (results[0]) {
case types_1.SQLiteCloudArrayType.ARRAY_TYPE_SQLITE_EXEC:
return {
type: Number(results[0]),
index: Number(results[1]),
lastID: results[2], // ROWID (sqlite3_last_insert_rowid)
changes: results[3], // CHANGES(sqlite3_changes)
totalChanges: results[4], // TOTAL_CHANGES (sqlite3_total_changes)
finalized: Number(results[5]), // FINALIZED
rowId: results[2] // same as lastId
};
}
}
}
return undefined;
}
/**
* Many of the methods in our API may contain a callback as their last argument.
* This method will take the arguments array passed to the method and return an object
* containing the arguments array with the callbacks removed (if any), and the callback itself.
* If there are multiple callbacks, the first one is returned as 'callback' and the last one
* as 'completeCallback'.
*
* @returns args is a simple list of SQLiteCloudDataTypes, we flat them into a single array
*/
function popCallback(args) {
const remaining = args;
// at least 1 callback?
if (args && args.length > 0 && typeof args[args.length - 1] === 'function') {
// at least 2 callbacks?
if (args.length > 1 && typeof args[args.length - 2] === 'function') {
return { args: remaining.slice(0, -2).flat(), callback: args[args.length - 2], complete: args[args.length - 1] };
}
return { args: remaining.slice(0, -1).flat(), callback: args[args.length - 1] };
}
return { args: remaining.flat() };
}
//
// configuration validation
//
/** Validate configuration, apply defaults, throw if something is missing or misconfigured */
function validateConfiguration(config) {
console.assert(config, 'SQLiteCloudConnection.validateConfiguration - missing config');
if (config.connectionstring) {
const configOverrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
const connectionStringConfig = parseconnectionstring(config.connectionstring);
config = Object.assign(Object.assign(Object.assign({}, connectionStringConfig), configOverrides), { connectionstring: config.connectionstring // keep original connection string
});
}
// apply defaults where needed
config.port || (config.port = types_1.DEFAULT_PORT);
config.timeout = config.timeout && config.timeout > 0 ? config.timeout : types_1.DEFAULT_TIMEOUT;
config.clientid || (config.clientid = 'SQLiteCloud');
config.verbose = parseBoolean(config.verbose);
config.noblob = parseBoolean(config.noblob);
config.compression = config.compression != undefined && config.compression != null ? parseBoolean(config.compression) : true; // default: true
config.safe_integer_mode = parseSafeIntegerMode(config.safe_integer_mode || types_1.SAFE_INTEGER_MODE);
config.websocketBlobFormat = parseWebsocketBlobTransferFormat(config.websocketBlobFormat);
config.websocketMaxAttachments = parseWebsocketMaxAttachments(config.websocketMaxAttachments);
config.create = parseBoolean(config.create);
config.non_linearizable = parseBoolean(config.non_linearizable);
config.insecure = parseBoolean(config.insecure);
const hasCredentials = (config.username && config.password) || config.apikey || config.token;
if (!config.host || !hasCredentials) {
console.error('SQLiteCloudConnection.validateConfiguration - missing arguments', config);
throw new types_1.SQLiteCloudError('The user, password and host arguments, the ?apikey= or the ?token= must be specified.', { errorCode: 'ERR_MISSING_ARGS' });
}
if (!config.connectionstring) {
// build connection string from configuration, values are already validated
config.connectionstring = `sqlitecloud://${config.host}:${config.port}/${config.database || ''}`;
if (config.apikey) {
config.connectionstring += `?apikey=${config.apikey}`;
}
else if (config.token) {
config.connectionstring += `?token=${config.token}`;
}
else {
config.connectionstring = `sqlitecloud://${encodeURIComponent(config.username || '')}:${encodeURIComponent(config.password || '')}@${config.host}:${config.port}/${config.database}`;
}
}
return config;
}
/**
* Parse connectionstring like sqlitecloud://username:password@host:port/database?option1=xxx&option2=xxx
* or sqlitecloud://host.sqlite.cloud:8860/chinook.sqlite?apikey=mIiLARzKm9XBVllbAzkB1wqrgijJ3Gx0X5z1Agm3xBo
* into its basic components.
*/
function parseconnectionstring(connectionstring) {
try {
// The URL constructor throws a TypeError if the URL is not valid.
// in spite of having the same structure as a regular url
// protocol://username:password@host:port/database?option1=xxx&option2=xxx)
// the sqlitecloud: protocol is not recognized by the URL constructor in browsers
// so we need to replace it with https: to make it work
const knownProtocolUrl = connectionstring.replace('sqlitecloud:', 'https:');
const url = new URL(knownProtocolUrl);
// all lowecase options
const options = {};
url.searchParams.forEach((value, key) => {
options[key.toLowerCase().replace(/-/g, '_')] = value.trim();
});
const config = Object.assign(Object.assign({}, options), { username: url.username ? decodeURIComponent(url.username) : undefined, password: url.password ? decodeURIComponent(url.password) : undefined, password_hashed: options.password_hashed ? parseBoolean(options.password_hashed) : undefined, host: url.hostname,
// type cast values
port: url.port ? parseInt(url.port) : undefined, insecure: options.insecure ? parseBoolean(options.insecure) : undefined, timeout: options.timeout ? parseInt(options.timeout) : undefined, zerotext: options.zerotext ? parseBoolean(options.zerotext) : undefined, create: options.create ? parseBoolean(options.create) : undefined, memory: options.memory ? parseBoolean(options.memory) : undefined, compression: options.compression ? parseBoolean(options.compression) : undefined, non_linearizable: options.non_linearizable ? parseBoolean(options.non_linearizable) : undefined, noblob: options.noblob ? parseBoolean(options.noblob) : undefined, maxdata: options.maxdata ? parseInt(options.maxdata) : undefined, maxrows: options.maxrows ? parseInt(options.maxrows) : undefined, maxrowset: options.maxrowset ? parseInt(options.maxrowset) : undefined, safe_integer_mode: options.safe_integer_mode ? parseSafeIntegerMode(options.safe_integer_mode) : undefined, usewebsocket: options.usewebsocket ? parseBoolean(options.usewebsocket) : undefined, websocketBlobFormat: options.websocket_blob_format ? parseWebsocketBlobTransferFormat(options.websocket_blob_format, undefined) : undefined, websocketMaxAttachments: options.websocket_max_attachments ? parseWebsocketMaxAttachments(options.websocket_max_attachments) : undefined, verbose: options.verbose ? parseBoolean(options.verbose) : undefined });
// either you use an apikey, token or username and password
if (Number(!!config.apikey) + Number(!!config.token) + Number(!!(config.username || config.password)) > 1) {
console.error('SQLiteCloudConnection.parseconnectionstring - choose between apikey, token or username/password');
throw new types_1.SQLiteCloudError('Choose between apikey, token or username/password');
}
const database = url.pathname.replace('/', ''); // pathname is database name, remove the leading slash
if (database) {
config.database = database;
}
return config;
}
catch (error) {
throw new types_1.SQLiteCloudError(`Invalid connection string: ${connectionstring} - error: ${error}`);
}
}
/** Returns true if value is 1 or true */
function parseBoolean(value) {
if (typeof value === 'string') {
return value.toLowerCase() === 'true' || value === '1';
}
return value ? true : false;
}
/** Returns true if value is 1 or true */
function parseBooleanToZeroOne(value) {
if (typeof value === 'string') {
return value.toLowerCase() === 'true' || value === '1' ? 1 : 0;
}
return value ? 1 : 0;
}
/** Parse 64-bit integer handling mode */
function parseSafeIntegerMode(value) {
const mode = value === null || value === void 0 ? void 0 : value.toLowerCase();
if (mode === 'number' || mode === 'bigint' || mode === 'mixed') {
return mode;
}
return 'number';
}
/** Parse websocket BLOB transport format, falling back to the driver default for new websocket clients. */
function parseWebsocketBlobTransferFormat(value, fallback = types_1.DEFAULT_WEBSOCKET_BLOB_TRANSFER_FORMAT) {
const format = value === null || value === void 0 ? void 0 : value.toLowerCase();
if (format === 'base64-blobs-v1' || format === 'socketio-blobs-v1') {
return format;
}
return fallback;
}
/** Parse the maximum number of socket.io binary attachments allowed for a websocket response. */
function parseWebsocketMaxAttachments(value) {
const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value;
if (Number.isSafeInteger(parsed) && parsed > 0) {
return parsed;
}
return types_1.DEFAULT_WEBSOCKET_MAX_ATTACHMENTS;
}
const BIGINT_MARKER_RE = /^-?\d+n$/;
const BLOB_COLUMN_TYPE_RE = /\bblob\b/i;
/** Convert values that JSON cannot represent losslessly into sqlitecloud-js bigint markers. */
function encodeBigIntMarkers(value) {
if (typeof value === 'bigint') {
return `${value.toString()}n`;
}
if (Array.isArray(value)) {
return value.map(item => encodeBigIntMarkers(item));
}
if (value && typeof value === 'object' && !buffer_1.Buffer.isBuffer(value)) {
const result = {};
Object.entries(value).forEach(([key, item]) => {
result[key] = encodeBigIntMarkers(item);
});
return result;
}
return value;
}
/** Convert sqlitecloud-js bigint markers back into BigInt values for lossless integer modes. */
function decodeBigIntMarkers(value, safeIntegerMode) {
if (safeIntegerMode !== 'bigint' && safeIntegerMode !== 'mixed') {
return value;
}
if (typeof value === 'string' && BIGINT_MARKER_RE.test(value)) {
return BigInt(value.slice(0, -1));
}
if (Array.isArray(value)) {
return value.map(item => decodeBigIntMarkers(item, safeIntegerMode));
}
if (value && typeof value === 'object' && !buffer_1.Buffer.isBuffer(value)) {
const result = {};
Object.entries(value).forEach(([key, item]) => {
result[key] = decodeBigIntMarkers(item, safeIntegerMode);
});
return result;
}
return value;
}
/** Decode websocket rowset cells using metadata-aware rules for bigint markers and negotiated BLOB transport. */
function decodeWebsocketRowsetData(data, metadata, safeIntegerMode, blobTransferFormat) {
if (!Array.isArray(data)) {
return decodeBigIntMarkers(data, safeIntegerMode);
}
const blobColumnIndexes = new Set(metadata.columns.flatMap((column, index) => (column.type && BLOB_COLUMN_TYPE_RE.test(column.type) ? [index] : [])));
const decodeCell = (value, columnIndex) => {
if (blobTransferFormat === 'base64-blobs-v1' && blobColumnIndexes.has(columnIndex) && typeof value === 'string') {
return buffer_1.Buffer.from(value, 'base64');
}
return decodeBigIntMarkers(value, safeIntegerMode);
};
if (data.every(row => !Array.isArray(row))) {
return data.map((value, index) => decodeCell(value, index % metadata.numberOfColumns));
}
return data.map(row => (Array.isArray(row) ? row.map((value, columnIndex) => decodeCell(value, columnIndex)) : decodeBigIntMarkers(row, safeIntegerMode)));
}