Merge branch 'migrate-dexie-to-sqlite'

This commit is contained in:
2025-06-20 11:49:51 -06:00
27 changed files with 4782 additions and 965 deletions

75
src/assets/icons.json Normal file
View File

@@ -0,0 +1,75 @@
{
"warning": {
"fillRule": "evenodd",
"d": "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z",
"clipRule": "evenodd"
},
"spinner": {
"d": "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
},
"chart": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
},
"plus": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 4v16m8-8H4"
},
"settings": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
},
"settingsDot": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M15 12a3 3 0 11-6 0 3 3 0 016 0z"
},
"lock": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
},
"download": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
},
"check": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
},
"edit": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
},
"trash": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
},
"plusCircle": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 6v6m0 0v6m0-6h6m-6 0H6"
},
"info": {
"fillRule": "evenodd",
"d": "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z",
"clipRule": "evenodd"
}
}

View File

@@ -0,0 +1,90 @@
<template>
<svg
v-if="iconData"
:class="svgClass"
:fill="fill"
:stroke="stroke"
:viewBox="viewBox"
xmlns="http://www.w3.org/2000/svg"
>
<path v-for="(path, index) in iconData.paths" :key="index" v-bind="path" />
</svg>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import icons from "../assets/icons.json";
import { logger } from "../utils/logger";
/**
* Icon path interface
*/
interface IconPath {
d: string;
fillRule?: string;
clipRule?: string;
strokeLinecap?: string;
strokeLinejoin?: string;
strokeWidth?: string | number;
fill?: string;
stroke?: string;
}
/**
* Icon data interface
*/
interface IconData {
paths: IconPath[];
}
/**
* Icons JSON structure
*/
interface IconsJson {
[key: string]: IconPath | IconData;
}
/**
* Icon Renderer Component
*
* This component loads SVG icon definitions from a JSON file and renders them
* as SVG elements. It provides a clean way to use icons without cluttering
* templates with long SVG path definitions.
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2024
*/
@Component({
name: "IconRenderer",
})
export default class IconRenderer extends Vue {
@Prop({ required: true }) readonly iconName!: string;
@Prop({ default: "h-5 w-5" }) readonly svgClass!: string;
@Prop({ default: "none" }) readonly fill!: string;
@Prop({ default: "currentColor" }) readonly stroke!: string;
@Prop({ default: "0 0 24 24" }) readonly viewBox!: string;
/**
* Get the icon data for the specified icon name
*
* @returns {IconData | null} The icon data object or null if not found
*/
get iconData(): IconData | null {
const icon = (icons as IconsJson)[this.iconName];
if (!icon) {
logger.warn(`Icon "${this.iconName}" not found in icons.json`);
return null;
}
// Convert single path to array format for consistency
if ("d" in icon) {
return {
paths: [icon as IconPath],
};
}
return icon as IconData;
}
}
</script>

View File

@@ -1,4 +1,7 @@
import migrationService from "../services/migrationService";
import {
registerMigration,
runMigrations as runMigrationsService,
} from "../services/migrationService";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { arrayBufferToBase64 } from "@/libs/crypto";
@@ -123,16 +126,12 @@ const MIGRATIONS = [
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
*/
export async function runMigrations<T>(
sqlExec: (sql: string) => Promise<unknown>,
sqlQuery: (sql: string) => Promise<T>,
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
for (const migration of MIGRATIONS) {
migrationService.registerMigration(migration);
registerMigration(migration);
}
await migrationService.runMigrations(
sqlExec,
sqlQuery,
extractMigrationNames,
);
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
}

View File

@@ -227,10 +227,28 @@ export async function logConsoleAndDb(
}
/**
* Generates an SQL INSERT statement and parameters from a model object.
* @param model The model object containing fields to update
* @param tableName The name of the table to update
* @returns Object containing the SQL statement and parameters array
* Generates SQL INSERT statement and parameters from a model object
*
* This helper function creates a parameterized SQL INSERT statement
* from a JavaScript object. It filters out undefined values and
* creates the appropriate SQL syntax with placeholders.
*
* The function is used internally by the migration functions to
* safely insert data into the SQLite database.
*
* @function generateInsertStatement
* @param {Record<string, unknown>} model - The model object containing fields to insert
* @param {string} tableName - The name of the table to insert into
* @returns {Object} Object containing the SQL statement and parameters array
* @returns {string} returns.sql - The SQL INSERT statement
* @returns {unknown[]} returns.params - Array of parameter values
* @example
* ```typescript
* const contact = { did: 'did:example:123', name: 'John Doe' };
* const { sql, params } = generateInsertStatement(contact, 'contacts');
* // sql: "INSERT INTO contacts (did, name) VALUES (?, ?)"
* // params: ['did:example:123', 'John Doe']
* ```
*/
export function generateInsertStatement(
model: Record<string, unknown>,
@@ -248,12 +266,30 @@ export function generateInsertStatement(
}
/**
* Generates an SQL UPDATE statement and parameters from a model object.
* @param model The model object containing fields to update
* @param tableName The name of the table to update
* @param whereClause The WHERE clause for the update (e.g. "id = ?")
* @param whereParams Parameters for the WHERE clause
* @returns Object containing the SQL statement and parameters array
* Generates SQL UPDATE statement and parameters from a model object
*
* This helper function creates a parameterized SQL UPDATE statement
* from a JavaScript object. It filters out undefined values and
* creates the appropriate SQL syntax with placeholders.
*
* The function is used internally by the migration functions to
* safely update data in the SQLite database.
*
* @function generateUpdateStatement
* @param {Record<string, unknown>} model - The model object containing fields to update
* @param {string} tableName - The name of the table to update
* @param {string} whereClause - The WHERE clause for the update (e.g. "id = ?")
* @param {unknown[]} [whereParams=[]] - Parameters for the WHERE clause
* @returns {Object} Object containing the SQL statement and parameters array
* @returns {string} returns.sql - The SQL UPDATE statement
* @returns {unknown[]} returns.params - Array of parameter values
* @example
* ```typescript
* const contact = { name: 'Jane Doe' };
* const { sql, params } = generateUpdateStatement(contact, 'contacts', 'did = ?', ['did:example:123']);
* // sql: "UPDATE contacts SET name = ? WHERE did = ?"
* // params: ['Jane Doe', 'did:example:123']
* ```
*/
export function generateUpdateStatement(
model: Record<string, unknown>,

View File

@@ -1 +1 @@
Check the contact & settings export to see whether you want your new table to be included in it.
# Check the contact & settings export to see whether you want your new table to be included in it

View File

@@ -19,6 +19,10 @@ export interface Contact {
registered?: boolean; // cached value of the server setting
}
export type ContactWithJsonStrings = Contact & {
contactMethods?: string;
};
export const ContactSchema = {
contacts: "&did, name", // no need to key by other things
};

View File

@@ -64,6 +64,11 @@ export type Settings = {
webPushServer?: string; // Web Push server URL
};
// type of settings where the searchBoxes are JSON strings instead of objects
export type SettingsWithJsonStrings = Settings & {
searchBoxes: string;
};
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
}

View File

@@ -45,6 +45,7 @@ import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { sha256 } from "ethereum-cryptography/sha256";
import { IIdentifier } from "@veramo/core";
import { insertDidSpecificSettings, parseJsonField } from "../db/databaseUtil";
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
export interface GiverReceiverInputInfo {
did?: string;
@@ -998,3 +999,38 @@ export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
},
};
};
/**
* Imports an account from a mnemonic phrase
* @param mnemonic - The seed phrase to import from
* @param derivationPath - The derivation path to use (defaults to DEFAULT_ROOT_DERIVATION_PATH)
* @param shouldErase - Whether to erase existing accounts before importing
* @returns Promise that resolves when import is complete
* @throws Error if mnemonic is invalid or import fails
*/
export async function importFromMnemonic(
mnemonic: string,
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
shouldErase: boolean = false,
): Promise<void> {
const mne: string = mnemonic.trim().toLowerCase();
// Derive address and keys from mnemonic
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
// Create new identifier
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
// Handle erasures
if (shouldErase) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec("DELETE FROM accounts");
if (USE_DEXIE_DB) {
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.clear();
}
}
// Save the new identity
await saveNewIdentity(newId, mne, derivationPath);
}

View File

@@ -148,6 +148,11 @@ const routes: Array<RouteRecordRaw> = [
name: "logs",
component: () => import("../views/LogView.vue"),
},
{
path: "/database-migration",
name: "database-migration",
component: () => import("../views/DatabaseMigration.vue"),
},
{
path: "/new-activity",
name: "new-activity",

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,150 @@
/**
* Manage database migrations as people upgrade their app over time
*/
import { logger } from "../utils/logger";
/**
* Migration interface for database schema migrations
*/
interface Migration {
name: string;
sql: string;
}
export class MigrationService {
private static instance: MigrationService;
/**
* Migration registry to store and manage database migrations
*/
class MigrationRegistry {
private migrations: Migration[] = [];
private constructor() {}
static getInstance(): MigrationService {
if (!MigrationService.instance) {
MigrationService.instance = new MigrationService();
}
return MigrationService.instance;
}
registerMigration(migration: Migration) {
/**
* Register a migration with the registry
*
* @param migration - The migration to register
*/
registerMigration(migration: Migration): void {
this.migrations.push(migration);
logger.info(`[MigrationService] Registered migration: ${migration.name}`);
}
/**
* @param sqlExec - A function that executes a SQL statement and returns some update result
* @param sqlQuery - A function that executes a SQL query and returns the result in some format
* @param extractMigrationNames - A function that extracts the names (string array) from a "select name from migrations" query
* Get all registered migrations
*
* @returns Array of registered migrations
*/
async runMigrations<T>(
// note that this does not take parameters because the Capacitor SQLite 'execute' is different
sqlExec: (sql: string) => Promise<unknown>,
sqlQuery: (sql: string) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
// Create migrations table if it doesn't exist
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
getMigrations(): Migration[] {
return this.migrations;
}
// Get list of executed migrations
const result1: T = await sqlQuery("SELECT name FROM migrations;");
const executedMigrations = extractMigrationNames(result1);
// Run pending migrations in order
for (const migration of this.migrations) {
if (!executedMigrations.has(migration.name)) {
await sqlExec(migration.sql);
await sqlExec(
`INSERT INTO migrations (name) VALUES ('${migration.name}')`,
);
}
}
/**
* Clear all registered migrations
*/
clearMigrations(): void {
this.migrations = [];
logger.info("[MigrationService] Cleared all registered migrations");
}
}
export default MigrationService.getInstance();
// Create a singleton instance of the migration registry
const migrationRegistry = new MigrationRegistry();
/**
* Register a migration with the migration service
*
* This function is used by the migration system to register database
* schema migrations that need to be applied to the database.
*
* @param migration - The migration to register
*/
export function registerMigration(migration: Migration): void {
migrationRegistry.registerMigration(migration);
}
/**
* Run all registered migrations against the database
*
* This function executes all registered migrations in order, checking
* which ones have already been applied to avoid duplicate execution.
* It creates a migrations table if it doesn't exist to track applied
* migrations.
*
* @param sqlExec - Function to execute SQL statements
* @param sqlQuery - Function to query SQL data
* @param extractMigrationNames - Function to extract migration names from query results
* @returns Promise that resolves when all migrations are complete
*/
export async function runMigrations<T>(
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
try {
// Create migrations table if it doesn't exist
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY,
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of already applied migrations
const appliedMigrationsResult = await sqlQuery(
"SELECT name FROM migrations",
);
const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
logger.info(
`[MigrationService] Found ${appliedMigrations.size} applied migrations`,
);
// Get all registered migrations
const migrations = migrationRegistry.getMigrations();
if (migrations.length === 0) {
logger.warn("[MigrationService] No migrations registered");
return;
}
logger.info(
`[MigrationService] Running ${migrations.length} registered migrations`,
);
// Run each migration that hasn't been applied yet
for (const migration of migrations) {
if (appliedMigrations.has(migration.name)) {
logger.info(
`[MigrationService] Skipping already applied migration: ${migration.name}`,
);
continue;
}
logger.info(`[MigrationService] Applying migration: ${migration.name}`);
try {
// Execute the migration SQL
await sqlExec(migration.sql);
// Record that the migration was applied
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.info(
`[MigrationService] Successfully applied migration: ${migration.name}`,
);
} catch (error) {
logger.error(
`[MigrationService] Failed to apply migration ${migration.name}:`,
error,
);
throw new Error(`Migration ${migration.name} failed: ${error}`);
}
}
logger.info("[MigrationService] All migrations completed successfully");
} catch (error) {
logger.error("[MigrationService] Migration process failed:", error);
throw error;
}
}

View File

@@ -566,7 +566,7 @@ export default class ContactImportView extends Vue {
this.checkingImports = true;
try {
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {

File diff suppressed because it is too large Load Diff

View File

@@ -106,12 +106,12 @@ Raymer * @version 1.0.0 */
</button>
</div>
<UserNameDialog ref="userNameDialog" />
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
<div class="flex justify-end w-full">
<router-link
:to="{ name: 'start' }"
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
See all your options first
See advanced options
</router-link>
</div>
</div>

View File

@@ -51,7 +51,7 @@
<div v-if="numAccounts == 1" class="mt-4">
<input v-model="shouldErase" type="checkbox" class="mr-2" />
<label>Erase the previous identifier.</label>
<label>Erase previous identifiers.</label>
</div>
<div v-if="isNotProdServer()" class="mt-4 text-blue-500">
@@ -88,17 +88,9 @@ import { Router } from "vue-router";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
} from "../db/index";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "../libs/crypto";
import { retrieveAccountCount, saveNewIdentity } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
@@ -115,12 +107,9 @@ export default class ImportAccountView extends Vue {
$router!: Router;
apiServer = "";
address = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
mnemonic = "";
numAccounts = 0;
privateHex = "";
publicHex = "";
showAdvanced = false;
shouldErase = false;
@@ -143,33 +132,16 @@ export default class ImportAccountView extends Vue {
}
public async fromMnemonic() {
const mne: string = this.mnemonic.trim().toLowerCase();
try {
[this.address, this.privateHex, this.publicHex] = deriveAddress(
mne,
await importFromMnemonic(
this.mnemonic,
this.derivationPath,
this.shouldErase,
);
const newId = newIdentifier(
this.address,
this.publicHex,
this.privateHex,
this.derivationPath,
);
const accountsDB = await accountsDBPromise;
if (this.shouldErase) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec("DELETE FROM accounts");
if (USE_DEXIE_DB) {
await accountsDB.accounts.clear();
}
}
await saveNewIdentity(newId, mne, this.derivationPath);
this.$router.push({ name: "account" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error saving mnemonic & updating settings:", err);
logger.error("Error importing from mnemonic:", err);
if (err == "Error: invalid mnemonic") {
this.$notify(
{

View File

@@ -82,6 +82,18 @@
Derive new address from existing seed
</a>
</div>
<!-- Database Migration Section -->
<div class="mt-8 pt-6 border-t border-gray-200">
<div class="flex justify-center">
<router-link
:to="{ name: 'database-migration' }"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Migrate My Old Data
</router-link>
</div>
</div>
</div>
</div>
</section>

View File

@@ -182,6 +182,15 @@
>
Accounts
</button>
<button
class="text-sm text-blue-600 hover:text-blue-800 underline"
@click="
sqlQuery = 'SELECT * FROM contacts;';
executeSql();
"
>
Contacts
</button>
<button
class="text-sm text-blue-600 hover:text-blue-800 underline"
@click="