UNPKG

@fizzyflow/suisql

Version:

SuiSQL is a library and set of tools for working with decentralized SQL databases on the Sui blockchain and Walrus protocol.

559 lines (443 loc) 16.5 kB
// import initSqlJs from 'sql.js'; import SuiSqlStatement from './SuiSqlStatement.js'; import SuiSqlSync from './SuiSqlSync.js'; import type { SuiSqlSyncToBlobckchainParams } from './SuiSqlSync.js'; // import type { Database, BindParams } from "sql.js"; import type { SuiClient } from '@mysten/sui/client'; import type { Signer } from '@mysten/sui/cryptography'; // import { getFieldsFromCreateTableSql } from './SuiSqlUtils'; import SuiSqlField from './SuiSqlField.js'; import SuiSqlLibrarian from './SuiSqlLibrarian.js'; import type { Database, BindParams } from './SuiSqlLibrarian.js'; import { CustomSignAndExecuteTransactionFunction } from "./SuiSqlBlockchain.js"; import SuiSqliteBinaryView from './SuiSqliteBinaryView.js'; import SuiSqlLog from './SuiSqlLog.js'; import type { SuiSqlWalrusWalrusClient } from './SuiSqlWalrus.js'; type SuiSqlParams = { debug?: boolean, id?: string, // id of the database, if not provided, name is used name?: string, // name of the database, if no id is provided, SuiSql will look up for the database by name network?: string, // 'testnet' | 'mainnet' suiClient: SuiClient, // always needed, pass the one connected to needed network RPC signer?: Signer, // for write operations (both for sui and walrus) signAndExecuteTransaction?: CustomSignAndExecuteTransactionFunction, // for dApp, wrap wallet adapter signAndExecuteTransaction into this currentWalletAddress?: string, // address of the connected wallet for write operations (both for sui and walrus) walrusClient?: SuiSqlWalrusWalrusClient, // optional, either walrusClient (to read) or walrusClient + signer (to read/write) publisherUrl?: string, // or publisherUrl + currentWalletAddress ( to write ) aggregatorUrl?: string, // better specify this always, as it's faster to read from aggregator than from walrusClient }; enum State { INITIALIZING = 'INITIALIZING', EMPTY = 'EMPTY', ERROR = 'ERROR', OK = 'OK', }; export default class SuiSql { public id?: string; public name?: string; private suiClient?: SuiClient; public suiSqlSync?: SuiSqlSync; public state: State = State.INITIALIZING; private statements: Array<SuiSqlStatement> = []; private _db: Database | null = null; // private _SQL: initSqlJs.SqlJsStatic | null = null; private librarian = new SuiSqlLibrarian(); private __initializationPromise: Promise<State> | null = null; private paramsCopy?: SuiSqlParams; public mostRecentWriteChangeTime?: number; // time at which the most recent write operation was done public binaryView?: SuiSqliteBinaryView; public initialBinaryView?: SuiSqliteBinaryView; constructor(params: SuiSqlParams) { // this._SQL = null; this.paramsCopy = {...params}; if (params.debug !== undefined) { SuiSqlLog.switch(params.debug); } if (params.id && params.name) { throw new Error('either id or name can be provided, not both'); } if (params.id) { this.id = params.id; } if (params.name) { this.name = params.name; } if (params.suiClient) { this.suiClient = params.suiClient; if (this.id || this.name) { this.suiSqlSync = new SuiSqlSync({ suiSql: this, id: this.id, name: this.name, suiClient: this.suiClient, signer: params.signer, signAndExecuteTransaction: params.signAndExecuteTransaction, currentWalletAddress: params.currentWalletAddress, network: params.network, walrusClient: params.walrusClient, publisherUrl: params.publisherUrl, aggregatorUrl: params.aggregatorUrl, }); } } else { throw new Error('SuiClient is required'); } } get network() { if (this.suiSqlSync) { return this.suiSqlSync.network; } return null; } /** * DB Base Walrus Blob ID ( in base64 format, the one for urls ) */ get walrusBlobId() { if (this.suiSqlSync) { return this.suiSqlSync.walrusBlobId; } return null; } get walrusEndEpoch() { if (this.suiSqlSync) { return this.suiSqlSync.walrusEndEpoch; } return null; } async hasWriteAccess() { if (this.suiSqlSync) { return await this.suiSqlSync.hasWriteAccess(); } return false; } hasUnsavedChanges() { if (this.suiSqlSync) { return this.suiSqlSync.hasUnsavedChanges(); } return false; } unsavedChangesCount() { if (this.suiSqlSync) { return this.suiSqlSync.unsavedChangesCount(); } return 0; } getBinaryView() { if (!this.binaryView || (this.binaryView && this.mostRecentWriteChangeTime && (!this.binaryView.createdAt || this.binaryView.createdAt < this.mostRecentWriteChangeTime) ) ) { const data = this.export(); if (data) { this.binaryView = new SuiSqliteBinaryView({ binary: data, }); } } if (this.binaryView) { return this.binaryView; } return null; } async getBinaryPatch(): Promise<Uint8Array | null> { const currentBinaryView = this.getBinaryView(); if (this.initialBinaryView && currentBinaryView) { const binaryPatch = await currentBinaryView.getBinaryPatch(this.initialBinaryView); return binaryPatch; } return null; } async getExpectedBlobId(): Promise<bigint | null> { const data = this.export(); if (data && this.suiSqlSync && this.suiSqlSync.walrus) { return await this.suiSqlSync.walrus.calculateBlobId(data); } return null; } async applyBinaryPatch(binaryPatch: Uint8Array) { const currentBinaryView = this.getBinaryView(); if (!currentBinaryView) { return false; } const patched = await currentBinaryView.getPatched(binaryPatch); this.replace(patched); return true; } async listDatabases(callback?: Function) { if (this.suiSqlSync && this.suiSqlSync.chain) { return await this.suiSqlSync.chain.listDatabases(callback); } return null; } /** * Initialize a database re-using configuration of the current one, so only the id or name is required * @param idOrName suiSql database id or name */ async database(idOrName: string): Promise<SuiSql | null> { if (!this.paramsCopy) { return null; } const paramsCopy = {...this.paramsCopy}; if (idOrName.startsWith('0x')) { paramsCopy.id = idOrName; delete paramsCopy.name; } else { paramsCopy.name = idOrName; delete paramsCopy.id; } const db = new SuiSql(paramsCopy); await db.initialize(); return db; } get db() { return this._db; } get writeExecutions() { const ret = []; for (const stmt of this.statements) { ret.push(...stmt.writeExecutions); } return ret; } replace(data: Uint8Array) { if (this.librarian.isReady) { this.binaryView = undefined; this.initialBinaryView = new SuiSqliteBinaryView({ binary: data, });; this._db = this.librarian.fromBinarySync(data); this.mostRecentWriteChangeTime = Date.now(); return true; } return false; } async initialize() { if (this.__initializationPromise) { return await this.__initializationPromise; } let __initializationPromiseResolver: Function = () => {}; this.__initializationPromise = new Promise((resolve) => { __initializationPromiseResolver = resolve; }); SuiSqlLog.log('initializing SuiSql database...', this.paramsCopy); try { this.state = State.EMPTY; this._db = await this.librarian.fromBinary(); try { if (this.suiSqlSync) { await this.suiSqlSync.syncFromBlockchain(); // that would also update this.state to OK in case there is something synced from the chain this.id = this.suiSqlSync.id; if (!this.id) { SuiSqlLog.log('error initilizing'); this.state = State.ERROR; } else { SuiSqlLog.log('db id', this.id); this.mostRecentWriteChangeTime = Date.now(); if (this.suiSqlSync.hasBeenCreated) { SuiSqlLog.log('database is freshly created'); this.state = State.EMPTY; } else { // SuiSqlLog.log('database is synced from the blockchain'); this.state = State.OK; } // make a binary view of the database to calculate binary patches later let data = this.export(); if (data) { if (data.length == 0) { await this.touch(); const redata = this.export(); if (redata) { data = redata; } } this.initialBinaryView = new SuiSqliteBinaryView({ binary: data, }); } } } } catch (e) { SuiSqlLog.log('error', e); this.state = State.ERROR; } } catch (e) { SuiSqlLog.log('error', e); this.state = State.ERROR; } __initializationPromiseResolver(this.state); return this.state; } async sync(params?: SuiSqlSyncToBlobckchainParams) { if (this.suiSqlSync) { const success = await this.suiSqlSync.syncToBlockchain(params); if (success) { this.binaryView = undefined; const data = this.export(); if (data) { this.initialBinaryView = new SuiSqliteBinaryView({ binary: data, });; } else { this.initialBinaryView = undefined; } } } else { throw new Error('not enough initialization params to sync'); } } async fillExpectedWalrus() { if (this.suiSqlSync) { await this.suiSqlSync.fillExpectedWalrus(); } else { throw new Error('not enough initialization params to sync'); } } async extendWalrus(extendedEpochs: number = 1) { await this.initialize(); if (this.suiSqlSync) { await this.suiSqlSync.extendWalrus(extendedEpochs); } else { throw new Error('not enough initialization params to sync'); } } markAsOk() { this.state = State.OK; } /** * Execute an SQL query, ignoring the rows it returns. */ async run(sql: string, params: BindParams) { SuiSqlLog.log('run', sql, params); await this.initialize(); const suiSqlStatement = new SuiSqlStatement({ suiSql: this, sql: sql, }); this.statements.push(suiSqlStatement); if (params != null) { suiSqlStatement.bind(params); } suiSqlStatement.run(); return true; } /** * Prepare an SQL statement * * @param {string} sql a string of SQL, that can contain placeholders (?, :VVV, :AAA, @AAA) * @param {array|object} params values to bind to placeholders */ async prepare(sql: string, params?: BindParams) { SuiSqlLog.log('prepare', sql, params); await this.initialize(); const suiSqlStatement = new SuiSqlStatement({ suiSql: this, sql: sql, }); if (params != null) { suiSqlStatement.bind(params); } this.statements.push(suiSqlStatement); return suiSqlStatement; } /** * Prepare an SQL statement and return all available results immediately * * @param {string} sql a string of SQL, that can contain placeholders (?, :VVV, :AAA, @AAA) * @param {array|object} params values to bind to placeholders */ async query(sql: string, params?: BindParams): Promise<Array<any>> { SuiSqlLog.log('query', sql, params); await this.initialize(); const prepared = await this.prepare(sql, params); const ret = []; while (prepared.step()) { ret.push(prepared.getAsObject()); } SuiSqlLog.log('query results', ret); return ret; } /** * Run an sql text containing many sql queries, one by one, ignoring return data. Returns the count of processed queries. */ async iterateStatements(sql: string): Promise<number> { SuiSqlLog.log('iterateStatements', sql); await this.initialize(); if (!this.db) { return 0; } let count = 0; for (let statement of this.db.iterateStatements(sql)) { const suiSqlStatement = new SuiSqlStatement({ suiSql: this, statement: statement, }); suiSqlStatement.step(); this.statements.push(suiSqlStatement); // do not call statement.free() manually, each statement is freed // before the next one is parsed count = count + 1; } return count; } async touch() { if (!this.db) { return false; } try { const rawStatement = this.db.prepare("VACUUM;"); rawStatement.step(); rawStatement.free(); } catch (e) { console.error(e); } return true; } async listTables() { SuiSqlLog.log('listTables'); await this.initialize(); const tables = []; const q = await this.prepare("SELECT name FROM sqlite_master WHERE type='table';"); while (q.step()) { const row = q.getAsObject(); if (row) { tables.push(row.name); } } q.free(); SuiSqlLog.log('listTables results', tables); return tables; } async describeTable(tableName: string) { SuiSqlLog.log('describeTable', tableName); await this.initialize(); const fields: Array<SuiSqlField> = []; try { const q = await this.prepare("select * from pragma_table_info(?) as tblInfo;", [tableName] ); await q.forEach((row: any) => { fields.push(new SuiSqlField({ suiSql: this, name: row.name, type: row.type, notnull: row.notnull, dfltValue: row.dflt_value, pk: row.pk, cid: row.cid, })); }); q.free(); } catch (e) { console.error(e); } SuiSqlLog.log('describeTable results', fields); return fields; } /** * Export the database as SqlLite binary representation */ export(): Uint8Array | null { if (this.db) { return this.db.export(); } return null; } }