forked from jsnbuchanan/crowd-funder-for-time-pwa
- Restore runMigrations functionality for database schema migrations - Remove indexedDBMigrationService.ts (was for IndexedDB to SQLite migration) - Recreate migrationService.ts and db-sql/migration.ts for schema management - Add proper TypeScript error handling with type guards in AccountViewView - Fix CreateAndSubmitClaimResult property access in QuickActionBvcBeginView - Remove LeafletMouseEvent from Vue components array (it's a type, not component) - Add null check for UserNameDialog callback to prevent undefined assignment - Implement extractErrorMessage helper function for consistent error handling - Update router to remove database-migration route The migration system now properly handles database schema evolution across app versions, while the IndexedDB to SQLite migration service has been removed as it was specific to that one-time migration.
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
/**
|
|
* This is the original IndexedDB version of the database.
|
|
* It will eventually be replaced fully by the SQL version in databaseUtil.ts.
|
|
*
|
|
*/
|
|
|
|
import BaseDexie, { Table } from "dexie";
|
|
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
|
import * as R from "ramda";
|
|
|
|
import { Account, AccountsSchema } from "./tables/accounts";
|
|
import { Contact, ContactSchema } from "./tables/contacts";
|
|
import { Log, LogSchema } from "./tables/logs";
|
|
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret";
|
|
import {
|
|
MASTER_SETTINGS_KEY,
|
|
Settings,
|
|
SettingsSchema,
|
|
} from "./tables/settings";
|
|
import { Temp, TempSchema } from "./tables/temp";
|
|
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
|
|
import { logger } from "../utils/logger";
|
|
|
|
// Define types for tables that hold sensitive and non-sensitive data
|
|
type SecretTable = { secret: Table<Secret> };
|
|
type SensitiveTables = { accounts: Table<Account> };
|
|
type NonsensitiveTables = {
|
|
contacts: Table<Contact>;
|
|
logs: Table<Log>;
|
|
settings: Table<Settings>;
|
|
temp: Table<Temp>;
|
|
};
|
|
|
|
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
|
type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
|
|
type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
|
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
|
BaseDexie & T;
|
|
|
|
//// Initialize the DBs, starting with the sensitive ones.
|
|
|
|
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
|
|
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
|
|
secretDB.version(1).stores(SecretSchema);
|
|
|
|
// Initialize Dexie database for accounts
|
|
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
|
|
|
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
|
|
// so that it's clear whether the usage needs the private key inside.
|
|
//
|
|
// This is a promise because the decryption key comes from IndexedDB
|
|
// and someday it may come from a password or keystore or external wallet.
|
|
// It's important that usages take into account that there may be a delay due
|
|
// to a user action required to unlock the data.
|
|
export const accountsDBPromise = useSecretAndInitializeAccountsDB(
|
|
secretDB,
|
|
accountsDexie,
|
|
);
|
|
|
|
//// Now initialize the other DB.
|
|
|
|
// Initialize Dexie databases for non-sensitive data
|
|
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
|
|
|
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
|
|
|
|
// v1 also had contacts & settings
|
|
// v2 added Log
|
|
db.version(2).stores({
|
|
...ContactSchema,
|
|
...LogSchema,
|
|
...{ settings: "id" }, // old Settings schema
|
|
});
|
|
// v3 added Temp
|
|
db.version(3).stores(TempSchema);
|
|
db.version(4)
|
|
.stores(SettingsSchema)
|
|
.upgrade((tx) => {
|
|
return tx
|
|
.table("settings")
|
|
.toCollection()
|
|
.modify((settings) => {
|
|
settings.accountDid = ""; // make it non-null for the default master settings, but still indexable
|
|
});
|
|
});
|
|
|
|
const DEFAULT_SETTINGS: Settings = {
|
|
id: MASTER_SETTINGS_KEY,
|
|
activeDid: undefined,
|
|
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
|
};
|
|
|
|
// Event handler to initialize the non-sensitive database with default settings
|
|
db.on("populate", async () => {
|
|
try {
|
|
await db.settings.add(DEFAULT_SETTINGS);
|
|
} catch (error) {
|
|
logger.error("Error populating the database with default settings:", error);
|
|
}
|
|
});
|
|
|
|
// Helper function to safely open the database with retries
|
|
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
|
// logger.log("Starting safeOpenDatabase with retries:", retries);
|
|
for (let i = 0; i < retries; i++) {
|
|
try {
|
|
// logger.log(`Attempt ${i + 1}: Checking if database is open...`);
|
|
if (!db.isOpen()) {
|
|
// logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
|
|
|
|
// Create a promise that rejects after 5 seconds
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
setTimeout(() => reject(new Error("Database open timed out")), 500);
|
|
});
|
|
|
|
// Race between the open operation and the timeout
|
|
const openPromise = db.open();
|
|
// logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
|
|
await Promise.race([openPromise, timeoutPromise]);
|
|
|
|
// If we get here, the open succeeded
|
|
// logger.log(`Attempt ${i + 1}: Database opened successfully`);
|
|
return;
|
|
}
|
|
// logger.log(`Attempt ${i + 1}: Database was already open`);
|
|
return;
|
|
} catch (error) {
|
|
logger.error(`Attempt ${i + 1}: Database open failed:`, error);
|
|
if (i < retries - 1) {
|
|
logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function updateDefaultSettings(
|
|
settingsChanges: Settings,
|
|
): Promise<number> {
|
|
delete settingsChanges.accountDid; // just in case
|
|
// ensure there is no "id" that would override the key
|
|
delete settingsChanges.id;
|
|
try {
|
|
try {
|
|
// logger.log("Database state before open:", db.isOpen() ? "open" : "closed");
|
|
// logger.log("Database name:", db.name);
|
|
// logger.log("Database version:", db.verno);
|
|
await safeOpenDatabase();
|
|
} catch (openError: unknown) {
|
|
logger.error("Failed to open database:", openError, String(openError));
|
|
throw new Error(
|
|
`The database connection failed. We recommend you try again or restart the app.`,
|
|
);
|
|
}
|
|
const result = await db.settings.update(
|
|
MASTER_SETTINGS_KEY,
|
|
settingsChanges,
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error("Error updating default settings:", error);
|
|
if (error instanceof Error) {
|
|
throw error; // Re-throw if it's already an Error with a message
|
|
} else {
|
|
throw new Error(
|
|
`Failed to update settings. We recommend you try again or restart the app.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Manage the encryption key.
|
|
|
|
// It's not really secure to maintain the secret next to the user's data.
|
|
// However, until we have better hooks into a real wallet or reliable secure
|
|
// storage, we'll do this for user convenience. As they sign more records
|
|
// and integrate with more people, they'll value it more and want to be more
|
|
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
|
|
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
|
|
// PWA so it's not in a browser... and then we hope to be integrated with a
|
|
// real wallet or something else more secure.
|
|
|
|
// One might ask: why encrypt at all? We figure a basic encryption is better
|
|
// than none. Plus, we expect to support their own password or keystore or
|
|
// external wallet as better signing options in the future, so it's gonna be
|
|
// important to have the structure where each account access might require
|
|
// user action.
|
|
|
|
// (Once upon a time we stored the secret in localStorage, but it frequently
|
|
// got erased, even though the IndexedDB still had the identity data. This
|
|
// ended up throwing lots of errors to the user... and they'd end up in a state
|
|
// where they couldn't take action because they couldn't unlock that identity.)
|
|
|
|
async function useSecretAndInitializeAccountsDB(
|
|
secretDB: SecretDexie,
|
|
accountsDB: SensitiveDexie,
|
|
): Promise<SensitiveDexie> {
|
|
return secretDB
|
|
.open()
|
|
.then(() => {
|
|
return secretDB.secret.get(MASTER_SECRET_KEY);
|
|
})
|
|
.then((secretRow?: Secret) => {
|
|
let secret = secretRow?.secret;
|
|
if (secret != null) {
|
|
// they already have it in IndexedDB, so just pass it along
|
|
return secret;
|
|
} else {
|
|
// check localStorage (for users before v 0.3.37)
|
|
const localSecret = localStorage.getItem("secret");
|
|
if (localSecret != null) {
|
|
// they had one, so we want to move it to IndexedDB
|
|
secret = localSecret;
|
|
} else {
|
|
// they didn't have one, so let's generate one
|
|
secret = Encryption.createRandomEncryptionKey();
|
|
}
|
|
// it is not in IndexedDB, so add it now
|
|
return secretDB.secret
|
|
.add({ id: MASTER_SECRET_KEY, secret })
|
|
.then(() => {
|
|
return secret;
|
|
});
|
|
}
|
|
})
|
|
.then((secret?: string) => {
|
|
if (secret == null) {
|
|
throw new Error("No secret found or created.");
|
|
} else {
|
|
// apply encryption to the sensitive database using the secret key
|
|
encrypted(accountsDB, { secretKey: secret });
|
|
accountsDB.version(1).stores(AccountsSchema);
|
|
accountsDB.open();
|
|
return accountsDB;
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
logConsoleAndDb("Error processing secret & encrypted accountsDB.", error);
|
|
// alert("There was an error processing encrypted data. See the Help page.");
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
// retrieves default settings
|
|
// calls db.open()
|
|
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
|
|
await db.open();
|
|
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS;
|
|
}
|
|
|
|
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
|
|
const defaultSettings = await retrieveSettingsForDefaultAccount();
|
|
if (!defaultSettings.activeDid) {
|
|
return defaultSettings;
|
|
} else {
|
|
const overrideSettings =
|
|
(await db.settings
|
|
.where("accountDid")
|
|
.equals(defaultSettings.activeDid)
|
|
.first()) || {};
|
|
return R.mergeDeepRight(defaultSettings, overrideSettings);
|
|
}
|
|
}
|
|
|
|
export async function updateAccountSettings(
|
|
accountDid: string,
|
|
settingsChanges: Settings,
|
|
): Promise<void> {
|
|
settingsChanges.accountDid = accountDid;
|
|
delete settingsChanges.id; // key off account, not ID
|
|
const result = await db.settings
|
|
.where("accountDid")
|
|
.equals(settingsChanges.accountDid)
|
|
.modify(settingsChanges);
|
|
if (result === 0) {
|
|
if (!settingsChanges.id) {
|
|
// It is unfortunate that we have to set this explicitly.
|
|
// We didn't make id a "++id" at the beginning and Dexie won't let us change it,
|
|
// plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
|
|
settingsChanges.id = (await db.settings.count()) + 1;
|
|
}
|
|
await db.settings.add(settingsChanges);
|
|
}
|
|
}
|
|
|
|
export async function logToDb(message: string): Promise<void> {
|
|
await db.open();
|
|
const todayKey = new Date().toDateString();
|
|
// only keep one day's worth of logs
|
|
const previous = await db.logs.get(todayKey);
|
|
if (!previous) {
|
|
// when this is today's first log, clear out everything previous
|
|
// to avoid the log table getting too large
|
|
// (let's limit a different way someday)
|
|
await db.logs.clear();
|
|
}
|
|
const prevMessages = (previous && previous.message) || "";
|
|
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
|
|
await db.logs.update(todayKey, { message: fullMessage });
|
|
}
|
|
|
|
// similar method is in the sw_scripts/additional-scripts.js file
|
|
export async function logConsoleAndDb(
|
|
message: string,
|
|
isError = false,
|
|
): Promise<void> {
|
|
if (isError) {
|
|
logger.error(`${new Date().toISOString()} ${message}`);
|
|
} else {
|
|
logger.log(`${new Date().toISOString()} ${message}`);
|
|
}
|
|
await logToDb(message);
|
|
}
|