diff --git a/CHANGELOG.md b/CHANGELOG.md index c03c1103e..f9b293467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Added wallet generation from seed & import from seed on web SDK (#710) * Add check for empty pay to id notes (#714). * [BREAKING] Refactored authentication out of the `Client` and added new separate authenticators (#718). +* Added import/export for web client db (#740). * Re-exported RemoteTransactionProver in `rust-client` (#752). * Moved error handling to the `TransactionRequestBuilder::build()` (#750). * [BREAKING] Added starting block number parameter to `CheckNullifiersByPrefix` and removed nullifiers from `SyncState` (#758). diff --git a/crates/rust-client/src/store/web_store/export/js_bindings.rs b/crates/rust-client/src/store/web_store/export/js_bindings.rs new file mode 100644 index 000000000..cfbb2b966 --- /dev/null +++ b/crates/rust-client/src/store/web_store/export/js_bindings.rs @@ -0,0 +1,8 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{js_sys, wasm_bindgen}; + +#[wasm_bindgen(module = "/src/store/web_store/js/export.js")] +extern "C" { + #[wasm_bindgen(js_name = exportStore)] + pub fn idxdb_export_store() -> js_sys::Promise; +} diff --git a/crates/rust-client/src/store/web_store/export/mod.rs b/crates/rust-client/src/store/web_store/export/mod.rs new file mode 100644 index 000000000..8fa688e84 --- /dev/null +++ b/crates/rust-client/src/store/web_store/export/mod.rs @@ -0,0 +1,17 @@ +use super::WebStore; +use crate::store::StoreError; + +mod js_bindings; +use js_bindings::idxdb_export_store; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; + +impl WebStore { + pub async fn export_store(&self) -> Result { + let promise = idxdb_export_store(); + let js_value = JsFuture::from(promise) + .await + .map_err(|err| StoreError::DatabaseError(format!("Failed to export store: {err:?}")))?; + Ok(js_value) + } +} diff --git a/crates/rust-client/src/store/web_store/import/js_bindings.rs b/crates/rust-client/src/store/web_store/import/js_bindings.rs new file mode 100644 index 000000000..285a1ca43 --- /dev/null +++ b/crates/rust-client/src/store/web_store/import/js_bindings.rs @@ -0,0 +1,9 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{js_sys, wasm_bindgen}; + +#[wasm_bindgen(module = "/src/store/web_store/js/import.js")] +extern "C" { + #[wasm_bindgen(js_name = forceImportStore)] + pub fn idxdb_force_import_store(store_dump: JsValue) -> js_sys::Promise; + +} diff --git a/crates/rust-client/src/store/web_store/import/mod.rs b/crates/rust-client/src/store/web_store/import/mod.rs new file mode 100644 index 000000000..25b7a1c9e --- /dev/null +++ b/crates/rust-client/src/store/web_store/import/mod.rs @@ -0,0 +1,17 @@ +use super::WebStore; +use crate::store::StoreError; + +mod js_bindings; +use js_bindings::idxdb_force_import_store; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; + +impl WebStore { + pub async fn force_import_store(&self, store_dump: JsValue) -> Result<(), StoreError> { + let promise = idxdb_force_import_store(store_dump); + JsFuture::from(promise) + .await + .map_err(|err| StoreError::DatabaseError(format!("Failed to import store: {err:?}")))?; + Ok(()) + } +} diff --git a/crates/rust-client/src/store/web_store/js/export.js b/crates/rust-client/src/store/web_store/js/export.js new file mode 100644 index 000000000..027eb4dc9 --- /dev/null +++ b/crates/rust-client/src/store/web_store/js/export.js @@ -0,0 +1,45 @@ +import { db } from "./schema.js"; + +async function recursivelyTransformForExport(obj) { + if (obj instanceof Blob) { + const blobBuffer = await obj.arrayBuffer(); + return { + __type: "Blob", + data: uint8ArrayToBase64(new Uint8Array(blobBuffer)), + }; + } + + if (Array.isArray(obj)) { + return await Promise.all(obj.map(recursivelyTransformForExport)); + } + + if (obj && typeof obj === "object") { + const entries = await Promise.all( + Object.entries(obj).map(async ([key, value]) => [ + key, + await recursivelyTransformForExport(value), + ]) + ); + return Object.fromEntries(entries); + } + + return obj; +} + +export async function exportStore() { + const db_json = {}; + for (const table of db.tables) { + const records = await table.toArray(); + + db_json[table.name] = await Promise.all( + records.map(recursivelyTransformForExport) + ); + } + + const stringified = JSON.stringify(db_json); + return stringified; +} + +function uint8ArrayToBase64(uint8Array) { + return btoa(String.fromCharCode(...uint8Array)); +} diff --git a/crates/rust-client/src/store/web_store/js/import.js b/crates/rust-client/src/store/web_store/js/import.js new file mode 100644 index 000000000..7472caa5a --- /dev/null +++ b/crates/rust-client/src/store/web_store/js/import.js @@ -0,0 +1,88 @@ +import { db, openDatabase } from "./schema.js"; + +async function recursivelyTransformForImport(obj) { + if (obj && typeof obj === "object") { + if (obj.__type === "Blob") { + return new Blob([base64ToUint8Array(obj.data)]); + } + + if (Array.isArray(obj)) { + return await Promise.all(obj.map(recursivelyTransformForImport)); + } + + const entries = await Promise.all( + Object.entries(obj).map(async ([key, value]) => [ + key, + await recursivelyTransformForImport(value), + ]) + ); + return Object.fromEntries(entries); + } + + return obj; // Return unchanged if it's neither Blob, Array, nor Object +} + +export async function forceImportStore(jsonStr) { + try { + if (!db.isOpen) { + await openDatabase(); + } + + let db_json = JSON.parse(jsonStr); + if (typeof db_json === "string") { + db_json = JSON.parse(db_json); + } + + const jsonTableNames = Object.keys(db_json); + const dbTableNames = db.tables.map((t) => t.name); + + if (jsonTableNames.length === 0) { + throw new Error("No tables found in the provided JSON."); + } + + // Wrap everything in a transaction + await db.transaction( + "rw", + ...dbTableNames.map((name) => db.table(name)), + async () => { + // Clear all tables in the database + await Promise.all(db.tables.map((t) => t.clear())); + + // Import data from JSON into matching tables + for (const tableName of jsonTableNames) { + const table = db.table(tableName); + + if (!dbTableNames.includes(tableName)) { + console.warn( + `Table "${tableName}" does not exist in the database schema. Skipping.` + ); + continue; // Skip tables not in the Dexie schema + } + + const records = db_json[tableName]; + + const transformedRecords = await Promise.all( + records.map(recursivelyTransformForImport) + ); + + await table.bulkPut(transformedRecords); + } + } + ); + + console.log("Store imported successfully."); + } catch (err) { + console.error("Failed to import store: ", err); + throw err; + } +} + +function base64ToUint8Array(base64) { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} diff --git a/crates/rust-client/src/store/web_store/mod.rs b/crates/rust-client/src/store/web_store/mod.rs index 05bbbc060..451c19cfb 100644 --- a/crates/rust-client/src/store/web_store/mod.rs +++ b/crates/rust-client/src/store/web_store/mod.rs @@ -36,6 +36,8 @@ compile_error!("The `idxdb` feature is only supported when targeting wasm32."); pub mod account; pub mod chain_data; +pub mod export; +pub mod import; pub mod note; pub mod sync; pub mod transaction; diff --git a/crates/web-client/package.json b/crates/web-client/package.json index b44486e12..eab91ff49 100644 --- a/crates/web-client/package.json +++ b/crates/web-client/package.json @@ -1,6 +1,6 @@ { "name": "@demox-labs/miden-sdk", - "version": "0.6.1-next.5", + "version": "0.6.1-next.6", "description": "Polygon Miden Wasm SDK", "collaborators": [ "Polygon Miden", diff --git a/crates/web-client/src/export.rs b/crates/web-client/src/export.rs index f6e1422ba..a6928c4b6 100644 --- a/crates/web-client/src/export.rs +++ b/crates/web-client/src/export.rs @@ -69,4 +69,15 @@ impl WebClient { Err(JsValue::from_str("Client not initialized")) } } + + /// Retrieves the entire underlying web store and returns it as a JsValue + /// + /// Meant to be used in conjunction with the force_import_store method + pub async fn export_store(&mut self) -> Result { + let store = self.store.as_ref().ok_or(JsValue::from_str("Store not initialized"))?; + let export = + store.export_store().await.map_err(|err| JsValue::from_str(&format!("{err}")))?; + + Ok(export) + } } diff --git a/crates/web-client/src/import.rs b/crates/web-client/src/import.rs index 8c4b73643..3c3e49413 100644 --- a/crates/web-client/src/import.rs +++ b/crates/web-client/src/import.rs @@ -77,4 +77,17 @@ impl WebClient { Err(JsValue::from_str("Client not initialized")) } } + + // Destructive operation, will fully overwrite the current web store + // + // The input to this function should be the result of a call to `export_store` + pub async fn force_import_store(&mut self, store_dump: JsValue) -> Result { + let store = self.store.as_ref().ok_or(JsValue::from_str("Store not initialized"))?; + store + .force_import_store(store_dump) + .await + .map_err(|err| JsValue::from_str(&format!("{err}")))?; + + Ok(JsValue::from_str("Store imported successfully")) + } } diff --git a/crates/web-client/test/import_export.test.ts b/crates/web-client/test/import_export.test.ts new file mode 100644 index 000000000..26a76bcdb --- /dev/null +++ b/crates/web-client/test/import_export.test.ts @@ -0,0 +1,49 @@ +// TODO: Rename this / figure out rebasing with the other featuer which has import tests + +import { expect } from "chai"; +import { testingPage } from "./mocha.global.setup.mjs"; +import { clearStore, setupWalletAndFaucet } from "./webClientTestUtils"; + +const exportDb = async () => { + return await testingPage.evaluate(async () => { + const client = window.client; + const db = await client.export_store(); + const serialized = JSON.stringify(db); + return serialized; + }); +}; + +const importDb = async (db: any) => { + return await testingPage.evaluate(async (_db) => { + const client = window.client; + await client.force_import_store(_db); + }, db); +}; + +const getAccount = async (accountId: string) => { + return await testingPage.evaluate(async (_accountId) => { + const client = window.client; + const accountId = window.AccountId.from_hex(_accountId); + const account = await client.get_account(accountId); + return { + accountId: account?.id().to_string(), + accountHash: account?.hash().to_hex(), + }; + }, accountId); +}; + +describe("export and import the db", () => { + it("export db with an account, find the account when re-importing", async () => { + const { accountHash: initialAccountHash, accountId } = + await setupWalletAndFaucet(); + const dbDump = await exportDb(); + + await clearStore(); + + await importDb(dbDump); + + const { accountHash } = await getAccount(accountId); + + expect(accountHash).to.equal(initialAccountHash); + }); +}); diff --git a/crates/web-client/test/webClientTestUtils.ts b/crates/web-client/test/webClientTestUtils.ts index 82a6cd4c7..773e66017 100644 --- a/crates/web-client/test/webClientTestUtils.ts +++ b/crates/web-client/test/webClientTestUtils.ts @@ -342,6 +342,7 @@ export const consumeTransaction = async ( interface SetupWalletFaucetResult { accountId: string; faucetId: string; + accountHash: string; } export const setupWalletAndFaucet = @@ -363,6 +364,7 @@ export const setupWalletAndFaucet = return { accountId: account.id().to_string(), + accountHash: account.hash().to_hex(), faucetId: faucetAccount.id().to_string(), }; }); diff --git a/crates/web-client/yarn.lock b/crates/web-client/yarn.lock index 4d4615f93..3b8ad8b97 100644 --- a/crates/web-client/yarn.lock +++ b/crates/web-client/yarn.lock @@ -432,7 +432,7 @@ chai-as-promised@^8.0.0: dependencies: check-error "^2.0.0" -chai@^5.1.1, "chai@>= 2.1.2 < 6": +chai@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz" integrity sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA== @@ -526,16 +526,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - color-name@1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" @@ -597,6 +597,13 @@ data-uri-to-buffer@^6.0.2: resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz" integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw== +debug@4, debug@^4.1.1, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6: + version "4.3.6" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + debug@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" @@ -604,13 +611,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.1, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@4: - version "4.3.6" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" - decamelize@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" @@ -644,7 +644,7 @@ degenerator@^5.0.0: escodegen "^2.1.0" esprima "^4.0.1" -devtools-protocol@*, devtools-protocol@0.0.1330662: +devtools-protocol@0.0.1330662: version "0.0.1330662" resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1330662.tgz" integrity sha512-pzh6YQ8zZfz3iKlCvgzVCu22NdpZ8hNmwU6WnQjNVquh0A9iVosPtNLWDwaWVGyrntQlltPFztTMK5Cg6lfCuw== @@ -934,18 +934,7 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^8.0.3: - version "8.1.0" - resolved "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz" - integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^5.0.1" - once "^1.3.0" - -glob@^8.1.0: +glob@^8.0.3, glob@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz" integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== @@ -1327,16 +1316,16 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== - minipass@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz" integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + minizlib@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz" @@ -1388,16 +1377,16 @@ mocha@^10.7.3: yargs-parser "^20.2.9" yargs-unparser "^2.0.0" -ms@^2.1.1, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + netmask@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz" @@ -1683,14 +1672,14 @@ rimraf@^6.0.1: glob "^11.0.0" package-json-from-dist "^1.0.0" -rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^2.68.0||^3.0.0||^4.0.0, rollup@^2.78.0||^3.0.0||^4.0.0, rollup@^3.27.2: +rollup@^3.27.2: version "3.29.4" resolved "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz" integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== optionalDependencies: fsevents "~2.3.2" -safe-buffer@^5.1.0, safe-buffer@5.1.2: +safe-buffer@5.1.2, safe-buffer@^5.1.0: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -1963,7 +1952,7 @@ typed-query-selector@^2.12.0: resolved "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz" integrity sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg== -typescript@^5.5.4, typescript@>=2.7, typescript@>=4.9.5: +typescript@^5.5.4: version "5.5.4" resolved "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz" integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==