forked from jsnbuchanan/crowd-funder-for-time-pwa
feat: integrate importFromMnemonic utility into migration service and UI
- Add account migration support to migrationService with importFromMnemonic integration - Extend DataComparison and MigrationResult interfaces to include accounts - Add getDexieAccounts() and getSqliteAccounts() functions for account retrieval - Implement compareAccounts() and migrateAccounts() functions with proper error handling - Update DatabaseMigration.vue UI to support account migration: - Add "Migrate Accounts" button with lock icon - Extend summary cards grid to show Dexie/SQLite account counts - Add Account Differences section with added/modified/missing indicators - Update success message to include account migration counts - Enhance grid layouts to accommodate 6 summary cards and 3 difference sections The migration service now provides complete data migration capabilities for contacts, settings, and accounts, with enhanced reliability through the importFromMnemonic utility for mnemonic-based account handling.
This commit is contained in:
@@ -22,12 +22,14 @@
|
||||
*/
|
||||
|
||||
import { PlatformServiceFactory } from "./PlatformServiceFactory";
|
||||
import { db } from "../db/index";
|
||||
import { db, accountsDBPromise } from "../db/index";
|
||||
import { Contact, ContactMethod } from "../db/tables/contacts";
|
||||
import { Settings } from "../db/tables/settings";
|
||||
import { Account } from "../db/tables/accounts";
|
||||
import { logger } from "../utils/logger";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { USE_DEXIE_DB } from "../constants/app";
|
||||
import { importFromMnemonic } from "../libs/util";
|
||||
|
||||
/**
|
||||
* Interface for data comparison results between Dexie and SQLite databases
|
||||
@@ -41,6 +43,8 @@ import { USE_DEXIE_DB } from "../constants/app";
|
||||
* @property {Contact[]} sqliteContacts - All contacts from SQLite database
|
||||
* @property {Settings[]} dexieSettings - All settings from Dexie database
|
||||
* @property {Settings[]} sqliteSettings - All settings from SQLite database
|
||||
* @property {Account[]} dexieAccounts - All accounts from Dexie database
|
||||
* @property {Account[]} sqliteAccounts - All accounts from SQLite database
|
||||
* @property {Object} differences - Detailed differences between databases
|
||||
* @property {Object} differences.contacts - Contact-specific differences
|
||||
* @property {Contact[]} differences.contacts.added - Contacts in Dexie but not SQLite
|
||||
@@ -50,12 +54,18 @@ import { USE_DEXIE_DB } from "../constants/app";
|
||||
* @property {Settings[]} differences.settings.added - Settings in Dexie but not SQLite
|
||||
* @property {Settings[]} differences.settings.modified - Settings that differ between databases
|
||||
* @property {Settings[]} differences.settings.missing - Settings in SQLite but not Dexie
|
||||
* @property {Object} differences.accounts - Account-specific differences
|
||||
* @property {Account[]} differences.accounts.added - Accounts in Dexie but not SQLite
|
||||
* @property {Account[]} differences.accounts.modified - Accounts that differ between databases
|
||||
* @property {Account[]} differences.accounts.missing - Accounts in SQLite but not Dexie
|
||||
*/
|
||||
export interface DataComparison {
|
||||
dexieContacts: Contact[];
|
||||
sqliteContacts: Contact[];
|
||||
dexieSettings: Settings[];
|
||||
sqliteSettings: Settings[];
|
||||
dexieAccounts: Account[];
|
||||
sqliteAccounts: Account[];
|
||||
differences: {
|
||||
contacts: {
|
||||
added: Contact[];
|
||||
@@ -67,6 +77,11 @@ export interface DataComparison {
|
||||
modified: Settings[];
|
||||
missing: Settings[];
|
||||
};
|
||||
accounts: {
|
||||
added: Account[];
|
||||
modified: Account[];
|
||||
missing: Account[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +96,7 @@ export interface DataComparison {
|
||||
* @property {boolean} success - Whether the migration operation completed successfully
|
||||
* @property {number} contactsMigrated - Number of contacts successfully migrated
|
||||
* @property {number} settingsMigrated - Number of settings successfully migrated
|
||||
* @property {number} accountsMigrated - Number of accounts successfully migrated
|
||||
* @property {string[]} errors - Array of error messages encountered during migration
|
||||
* @property {string[]} warnings - Array of warning messages (non-fatal issues)
|
||||
*/
|
||||
@@ -88,6 +104,7 @@ export interface MigrationResult {
|
||||
success: boolean;
|
||||
contactsMigrated: number;
|
||||
settingsMigrated: number;
|
||||
accountsMigrated: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -315,6 +332,105 @@ export async function getSqliteSettings(): Promise<Settings[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all accounts from the SQLite database
|
||||
*
|
||||
* This function uses the platform service to query the SQLite database
|
||||
* and retrieve all account records. It handles the conversion of raw
|
||||
* database results into properly typed Account objects.
|
||||
*
|
||||
* The function also handles JSON parsing for complex fields like
|
||||
* identity, ensuring proper type conversion.
|
||||
*
|
||||
* @async
|
||||
* @function getSqliteAccounts
|
||||
* @returns {Promise<Account[]>} Array of all accounts from SQLite database
|
||||
* @throws {Error} If database query fails or data conversion fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const accounts = await getSqliteAccounts();
|
||||
* console.log(`Retrieved ${accounts.length} accounts from SQLite`);
|
||||
* } catch (error) {
|
||||
* console.error('Failed to retrieve SQLite accounts:', error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getSqliteAccounts(): Promise<Account[]> {
|
||||
try {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery("SELECT * FROM accounts");
|
||||
|
||||
if (!result?.values?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accounts = result.values.map((row) => {
|
||||
const account = parseJsonField(row, {}) as any;
|
||||
return {
|
||||
id: account.id,
|
||||
dateCreated: account.dateCreated || "",
|
||||
derivationPath: account.derivationPath || "",
|
||||
did: account.did || "",
|
||||
identity: account.identity || "",
|
||||
mnemonic: account.mnemonic || "",
|
||||
passkeyCredIdHex: account.passkeyCredIdHex || "",
|
||||
publicKeyHex: account.publicKeyHex || "",
|
||||
} as Account;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[MigrationService] Retrieved ${accounts.length} accounts from SQLite`,
|
||||
);
|
||||
return accounts;
|
||||
} catch (error) {
|
||||
logger.error("[MigrationService] Error retrieving SQLite accounts:", error);
|
||||
throw new Error(`Failed to retrieve SQLite accounts: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all accounts from the Dexie (IndexedDB) database
|
||||
*
|
||||
* This function connects to the Dexie database and retrieves all account
|
||||
* records. It requires that USE_DEXIE_DB is enabled in the app constants.
|
||||
*
|
||||
* The function handles database opening and error conditions, providing
|
||||
* detailed logging for debugging purposes.
|
||||
*
|
||||
* @async
|
||||
* @function getDexieAccounts
|
||||
* @returns {Promise<Account[]>} Array of all accounts from Dexie database
|
||||
* @throws {Error} If Dexie database is not enabled or if database access fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const accounts = await getDexieAccounts();
|
||||
* console.log(`Retrieved ${accounts.length} accounts from Dexie`);
|
||||
* } catch (error) {
|
||||
* console.error('Failed to retrieve Dexie accounts:', error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getDexieAccounts(): Promise<Account[]> {
|
||||
if (!USE_DEXIE_DB) {
|
||||
throw new Error("Dexie database is not enabled");
|
||||
}
|
||||
|
||||
try {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
logger.info(
|
||||
`[MigrationService] Retrieved ${accounts.length} accounts from Dexie`,
|
||||
);
|
||||
return accounts;
|
||||
} catch (error) {
|
||||
logger.error("[MigrationService] Error retrieving Dexie accounts:", error);
|
||||
throw new Error(`Failed to retrieve Dexie accounts: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares data between Dexie and SQLite databases
|
||||
*
|
||||
@@ -346,13 +462,21 @@ export async function getSqliteSettings(): Promise<Settings[]> {
|
||||
export async function compareDatabases(): Promise<DataComparison> {
|
||||
logger.info("[MigrationService] Starting database comparison");
|
||||
|
||||
const [dexieContacts, sqliteContacts, dexieSettings, sqliteSettings] =
|
||||
await Promise.all([
|
||||
getDexieContacts(),
|
||||
getSqliteContacts(),
|
||||
getDexieSettings(),
|
||||
getSqliteSettings(),
|
||||
]);
|
||||
const [
|
||||
dexieContacts,
|
||||
sqliteContacts,
|
||||
dexieSettings,
|
||||
sqliteSettings,
|
||||
dexieAccounts,
|
||||
sqliteAccounts,
|
||||
] = await Promise.all([
|
||||
getDexieContacts(),
|
||||
getSqliteContacts(),
|
||||
getDexieSettings(),
|
||||
getSqliteSettings(),
|
||||
getDexieAccounts(),
|
||||
getSqliteAccounts(),
|
||||
]);
|
||||
|
||||
// Compare contacts
|
||||
const contactDifferences = compareContacts(dexieContacts, sqliteContacts);
|
||||
@@ -360,14 +484,20 @@ export async function compareDatabases(): Promise<DataComparison> {
|
||||
// Compare settings
|
||||
const settingsDifferences = compareSettings(dexieSettings, sqliteSettings);
|
||||
|
||||
// Compare accounts
|
||||
const accountDifferences = compareAccounts(dexieAccounts, sqliteAccounts);
|
||||
|
||||
const comparison: DataComparison = {
|
||||
dexieContacts,
|
||||
sqliteContacts,
|
||||
dexieSettings,
|
||||
sqliteSettings,
|
||||
dexieAccounts,
|
||||
sqliteAccounts,
|
||||
differences: {
|
||||
contacts: contactDifferences,
|
||||
settings: settingsDifferences,
|
||||
accounts: accountDifferences,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -376,8 +506,11 @@ export async function compareDatabases(): Promise<DataComparison> {
|
||||
sqliteContacts: sqliteContacts.length,
|
||||
dexieSettings: dexieSettings.length,
|
||||
sqliteSettings: sqliteSettings.length,
|
||||
dexieAccounts: dexieAccounts.length,
|
||||
sqliteAccounts: sqliteAccounts.length,
|
||||
contactDifferences: contactDifferences,
|
||||
settingsDifferences: settingsDifferences,
|
||||
accountDifferences: accountDifferences,
|
||||
});
|
||||
|
||||
return comparison;
|
||||
@@ -491,6 +624,57 @@ function compareSettings(
|
||||
return { added, modified, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares accounts between Dexie and SQLite databases
|
||||
*
|
||||
* This helper function analyzes two arrays of accounts and identifies
|
||||
* which accounts are added (in Dexie but not SQLite), modified
|
||||
* (different between databases), or missing (in SQLite but not Dexie).
|
||||
*
|
||||
* The comparison is based on the account's ID as the primary key,
|
||||
* with detailed field-by-field comparison for modified accounts.
|
||||
*
|
||||
* @function compareAccounts
|
||||
* @param {Account[]} dexieAccounts - Accounts from Dexie database
|
||||
* @param {Account[]} sqliteAccounts - Accounts from SQLite database
|
||||
* @returns {Object} Object containing added, modified, and missing accounts
|
||||
* @returns {Account[]} returns.added - Accounts in Dexie but not SQLite
|
||||
* @returns {Account[]} returns.modified - Accounts that differ between databases
|
||||
* @returns {Account[]} returns.missing - Accounts in SQLite but not Dexie
|
||||
* @example
|
||||
* ```typescript
|
||||
* const differences = compareAccounts(dexieAccounts, sqliteAccounts);
|
||||
* console.log(`Added: ${differences.added.length}`);
|
||||
* console.log(`Modified: ${differences.modified.length}`);
|
||||
* console.log(`Missing: ${differences.missing.length}`);
|
||||
* ```
|
||||
*/
|
||||
function compareAccounts(dexieAccounts: Account[], sqliteAccounts: Account[]) {
|
||||
const added: Account[] = [];
|
||||
const modified: Account[] = [];
|
||||
const missing: Account[] = [];
|
||||
|
||||
// Find accounts that exist in Dexie but not in SQLite
|
||||
for (const dexieAccount of dexieAccounts) {
|
||||
const sqliteAccount = sqliteAccounts.find((a) => a.id === dexieAccount.id);
|
||||
if (!sqliteAccount) {
|
||||
added.push(dexieAccount);
|
||||
} else if (!accountsEqual(dexieAccount, sqliteAccount)) {
|
||||
modified.push(dexieAccount);
|
||||
}
|
||||
}
|
||||
|
||||
// Find accounts that exist in SQLite but not in Dexie
|
||||
for (const sqliteAccount of sqliteAccounts) {
|
||||
const dexieAccount = dexieAccounts.find((a) => a.id === sqliteAccount.id);
|
||||
if (!dexieAccount) {
|
||||
missing.push(sqliteAccount);
|
||||
}
|
||||
}
|
||||
|
||||
return { added, modified, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two contacts for equality
|
||||
*
|
||||
@@ -592,6 +776,43 @@ function settingsEqual(settings1: Settings, settings2: Settings): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two accounts for equality
|
||||
*
|
||||
* This helper function performs a deep comparison of two Account objects
|
||||
* to determine if they are identical. The comparison includes all
|
||||
* relevant fields including complex objects like identity.
|
||||
*
|
||||
* For identity, the function uses JSON.stringify to compare
|
||||
* the objects, ensuring that both structure and content are identical.
|
||||
*
|
||||
* @function accountsEqual
|
||||
* @param {Account} account1 - First account to compare
|
||||
* @param {Account} account2 - Second account to compare
|
||||
* @returns {boolean} True if accounts are identical, false otherwise
|
||||
* @example
|
||||
* ```typescript
|
||||
* const areEqual = accountsEqual(account1, account2);
|
||||
* if (areEqual) {
|
||||
* console.log('Accounts are identical');
|
||||
* } else {
|
||||
* console.log('Accounts differ');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
function accountsEqual(account1: Account, account2: Account): boolean {
|
||||
return (
|
||||
account1.id === account2.id &&
|
||||
account1.dateCreated === account2.dateCreated &&
|
||||
account1.derivationPath === account2.derivationPath &&
|
||||
account1.did === account2.did &&
|
||||
account1.identity === account2.identity &&
|
||||
account1.mnemonic === account2.mnemonic &&
|
||||
account1.passkeyCredIdHex === account2.passkeyCredIdHex &&
|
||||
account1.publicKeyHex === account2.publicKeyHex
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates YAML-formatted comparison data
|
||||
*
|
||||
@@ -622,6 +843,8 @@ export function generateComparisonYaml(comparison: DataComparison): string {
|
||||
sqliteContacts: comparison.sqliteContacts.length,
|
||||
dexieSettings: comparison.dexieSettings.length,
|
||||
sqliteSettings: comparison.sqliteSettings.length,
|
||||
dexieAccounts: comparison.dexieAccounts.length,
|
||||
sqliteAccounts: comparison.sqliteAccounts.length,
|
||||
},
|
||||
differences: {
|
||||
contacts: {
|
||||
@@ -634,6 +857,11 @@ export function generateComparisonYaml(comparison: DataComparison): string {
|
||||
modified: comparison.differences.settings.modified.length,
|
||||
missing: comparison.differences.settings.missing.length,
|
||||
},
|
||||
accounts: {
|
||||
added: comparison.differences.accounts.added.length,
|
||||
modified: comparison.differences.accounts.modified.length,
|
||||
missing: comparison.differences.accounts.missing.length,
|
||||
},
|
||||
},
|
||||
contacts: {
|
||||
dexie: comparison.dexieContacts.map((c) => ({
|
||||
@@ -677,6 +905,28 @@ export function generateComparisonYaml(comparison: DataComparison): string {
|
||||
searchBoxes: s.searchBoxes,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
dexie: comparison.dexieAccounts.map((a) => ({
|
||||
id: a.id,
|
||||
dateCreated: a.dateCreated,
|
||||
derivationPath: a.derivationPath,
|
||||
did: a.did,
|
||||
identity: a.identity,
|
||||
mnemonic: a.mnemonic,
|
||||
passkeyCredIdHex: a.passkeyCredIdHex,
|
||||
publicKeyHex: a.publicKeyHex,
|
||||
})),
|
||||
sqlite: comparison.sqliteAccounts.map((a) => ({
|
||||
id: a.id,
|
||||
dateCreated: a.dateCreated,
|
||||
derivationPath: a.derivationPath,
|
||||
did: a.did,
|
||||
identity: a.identity,
|
||||
mnemonic: a.mnemonic,
|
||||
passkeyCredIdHex: a.passkeyCredIdHex,
|
||||
publicKeyHex: a.publicKeyHex,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -725,6 +975,7 @@ export async function migrateContacts(
|
||||
success: true,
|
||||
contactsMigrated: 0,
|
||||
settingsMigrated: 0,
|
||||
accountsMigrated: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
@@ -834,6 +1085,7 @@ export async function migrateSettings(
|
||||
success: true,
|
||||
contactsMigrated: 0,
|
||||
settingsMigrated: 0,
|
||||
accountsMigrated: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
@@ -921,6 +1173,144 @@ export async function migrateSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates accounts from Dexie to SQLite database
|
||||
*
|
||||
* This function transfers all accounts from the Dexie database to the
|
||||
* SQLite database. It handles both new accounts (INSERT) and existing
|
||||
* accounts (UPDATE) based on the overwriteExisting parameter.
|
||||
*
|
||||
* For accounts with mnemonic data, the function uses importFromMnemonic
|
||||
* to ensure proper key derivation and identity creation during migration.
|
||||
*
|
||||
* The function processes accounts one by one to ensure data integrity
|
||||
* and provides detailed logging of the migration process. It returns
|
||||
* comprehensive results including success status, counts, and any
|
||||
* errors or warnings encountered.
|
||||
*
|
||||
* @async
|
||||
* @function migrateAccounts
|
||||
* @param {boolean} [overwriteExisting=false] - Whether to overwrite existing accounts in SQLite
|
||||
* @returns {Promise<MigrationResult>} Detailed results of the migration operation
|
||||
* @throws {Error} If the migration process fails completely
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const result = await migrateAccounts(true); // Overwrite existing
|
||||
* if (result.success) {
|
||||
* console.log(`Successfully migrated ${result.accountsMigrated} accounts`);
|
||||
* } else {
|
||||
* console.error('Migration failed:', result.errors);
|
||||
* }
|
||||
* } catch (error) {
|
||||
* console.error('Migration process failed:', error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function migrateAccounts(
|
||||
overwriteExisting: boolean = false,
|
||||
): Promise<MigrationResult> {
|
||||
logger.info("[MigrationService] Starting account migration", {
|
||||
overwriteExisting,
|
||||
});
|
||||
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
contactsMigrated: 0,
|
||||
settingsMigrated: 0,
|
||||
accountsMigrated: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const dexieAccounts = await getDexieAccounts();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
for (const account of dexieAccounts) {
|
||||
try {
|
||||
// Check if account already exists
|
||||
const existingResult = await platformService.dbQuery(
|
||||
"SELECT id FROM accounts WHERE id = ?",
|
||||
[account.id],
|
||||
);
|
||||
|
||||
if (existingResult?.values?.length) {
|
||||
if (overwriteExisting) {
|
||||
// Update existing account
|
||||
const { sql, params } = generateUpdateStatement(
|
||||
account as unknown as Record<string, unknown>,
|
||||
"accounts",
|
||||
"id = ?",
|
||||
[account.id],
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
result.accountsMigrated++;
|
||||
logger.info(`[MigrationService] Updated account: ${account.id}`);
|
||||
} else {
|
||||
result.warnings.push(
|
||||
`Account ${account.id} already exists, skipping`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For new accounts with mnemonic, use importFromMnemonic for proper key derivation
|
||||
if (account.mnemonic && account.derivationPath) {
|
||||
try {
|
||||
// Use importFromMnemonic to ensure proper key derivation and identity creation
|
||||
await importFromMnemonic(
|
||||
account.mnemonic,
|
||||
account.derivationPath,
|
||||
false, // Don't erase existing accounts during migration
|
||||
);
|
||||
logger.info(
|
||||
`[MigrationService] Imported account with mnemonic: ${account.id}`,
|
||||
);
|
||||
} catch (importError) {
|
||||
// Fall back to direct insertion if importFromMnemonic fails
|
||||
logger.warn(
|
||||
`[MigrationService] importFromMnemonic failed for account ${account.id}, falling back to direct insertion: ${importError}`,
|
||||
);
|
||||
const { sql, params } = generateInsertStatement(
|
||||
account as unknown as Record<string, unknown>,
|
||||
"accounts",
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
}
|
||||
} else {
|
||||
// Insert new account without mnemonic
|
||||
const { sql, params } = generateInsertStatement(
|
||||
account as unknown as Record<string, unknown>,
|
||||
"accounts",
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
}
|
||||
result.accountsMigrated++;
|
||||
logger.info(`[MigrationService] Added account: ${account.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to migrate account ${account.id}: ${error}`;
|
||||
logger.error("[MigrationService]", errorMsg);
|
||||
result.errors.push(errorMsg);
|
||||
result.success = false;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[MigrationService] Account migration completed", {
|
||||
accountsMigrated: result.accountsMigrated,
|
||||
errors: result.errors.length,
|
||||
warnings: result.warnings.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = `Account migration failed: ${error}`;
|
||||
logger.error("[MigrationService]", errorMsg);
|
||||
result.errors.push(errorMsg);
|
||||
result.success = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates SQL INSERT statement and parameters from a model object
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user