feat: implement Active Pointer + Smart Deletion Pattern for accounts

- Consolidate migrations: merge 002/003 into 001_initial with UNIQUE did constraint
- Add foreign key: active_identity.activeDid REFERENCES accounts.did ON DELETE RESTRICT
- Replace empty string defaults with NULL for proper empty state handling
- Implement atomic smart deletion with auto-switch logic in IdentitySwitcherView
- Add DAL methods: $getAllAccountDids, $getActiveDid, $setActiveDid, $pickNextAccountDid
- Add migration bootstrapping to auto-select first account if none selected
- Block deletion of last remaining account with user notification

Refs: doc/active-pointer-smart-deletion-pattern.md
This commit is contained in:
Matthew Raymer
2025-09-07 10:30:48 +00:00
parent c9cfeafd50
commit a20c321a16
4 changed files with 540 additions and 38 deletions

View File

@@ -36,11 +36,15 @@ const MIGRATIONS = [
{
name: "001_initial",
sql: `
-- Enable foreign key constraints for data integrity
PRAGMA foreign_keys = ON;
-- Create accounts table with UNIQUE constraint on did
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
did TEXT NOT NULL UNIQUE, -- UNIQUE constraint for foreign key support
identityEncrBase64 TEXT, -- encrypted & base64-encoded
mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded
passkeyCredIdHex TEXT,
@@ -102,7 +106,8 @@ const MIGRATIONS = [
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
registered BOOLEAN,
iViewContent BOOLEAN DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
@@ -122,46 +127,22 @@ const MIGRATIONS = [
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`,
},
{
name: "002_add_iViewContent_to_contacts",
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
{
name: "003_active_did_separation",
sql: `
-- SIMPLIFIED MIGRATION: Create active_identity table and migrate data
-- This migration handles the separation of activeDid from settings
-- using a simpler, more reliable approach that works on all SQLite versions
-- Create new active_identity table with proper constraints
-- Create active_identity table with foreign key constraint
CREATE TABLE IF NOT EXISTS active_identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
activeDid TEXT NOT NULL DEFAULT '',
lastUpdated TEXT NOT NULL DEFAULT (datetime('now'))
activeDid TEXT DEFAULT NULL, -- NULL instead of empty string
lastUpdated TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (activeDid) REFERENCES accounts(did) ON DELETE RESTRICT
);
-- Add performance indexes
CREATE INDEX IF NOT EXISTS idx_active_identity_activeDid ON active_identity(activeDid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id);
-- Insert default record (will be updated during migration)
INSERT OR IGNORE INTO active_identity (id, activeDid, lastUpdated) VALUES (1, '', datetime('now'));
-- MIGRATE EXISTING DATA: Copy activeDid from settings to active_identity
-- This prevents data loss when migration runs on existing databases
-- Use a more robust approach that handles missing columns gracefully
UPDATE active_identity
SET activeDid = COALESCE(
(SELECT activeDid FROM settings WHERE id = 1 AND activeDid IS NOT NULL AND activeDid != ''),
''
),
lastUpdated = datetime('now')
WHERE id = 1;
`,
-- Seed singleton row
INSERT INTO active_identity (id, activeDid, lastUpdated) VALUES (1, NULL, datetime('now'));
`,
},
];
@@ -184,4 +165,41 @@ export async function runMigrations<T>(
logger.info("[Migration] Running migration service");
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
logger.info("[Migration] Database migrations completed");
// Bootstrapping: Ensure active account is selected after migrations
logger.info("[Migration] Running bootstrapping hooks");
try {
// Check if we have accounts but no active selection
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
const accountsCount = accountsResult
? (accountsResult.values?.[0]?.[0] as number)
: 0;
const activeResult = await sqlQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
);
const activeDid = activeResult
? (activeResult.values?.[0]?.[0] as string)
: null;
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
logger.info("[Migration] Auto-selecting first account as active");
const firstAccountResult = await sqlQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
);
const firstAccountDid = firstAccountResult
? (firstAccountResult.values?.[0]?.[0] as string)
: null;
if (firstAccountDid) {
await sqlExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[firstAccountDid],
);
logger.info(`[Migration] Set active account to: ${firstAccountDid}`);
}
}
} catch (error) {
logger.warn("[Migration] Bootstrapping hook failed (non-critical):", error);
}
}

View File

@@ -737,6 +737,87 @@ export const PlatformServiceMixin = {
}
},
// =================================================
// SMART DELETION PATTERN DAL METHODS
// =================================================
/**
* Get all account DIDs ordered by creation date
* Required for smart deletion pattern
*/
async $getAllAccountDids(): Promise<string[]> {
const result = await this.$dbQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did",
);
return result?.values?.map((row) => row[0] as string) || [];
},
/**
* Get account DID by ID
* Required for smart deletion pattern
*/
async $getAccountDidById(id: number): Promise<string> {
const result = await this.$dbQuery(
"SELECT did FROM accounts WHERE id = ?",
[id],
);
return result?.values?.[0]?.[0] as string;
},
/**
* Get active DID (returns null if none selected)
* Required for smart deletion pattern
*/
async $getActiveDid(): Promise<string | null> {
const result = await this.$dbQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
);
return (result?.values?.[0]?.[0] as string) || null;
},
/**
* Set active DID (can be null for no selection)
* Required for smart deletion pattern
*/
async $setActiveDid(did: string | null): Promise<void> {
await this.$dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],
);
},
/**
* Count total accounts
* Required for smart deletion pattern
*/
async $countAccounts(): Promise<number> {
const result = await this.$dbQuery("SELECT COUNT(*) FROM accounts");
return (result?.values?.[0]?.[0] as number) || 0;
},
/**
* Deterministic "next" picker for account selection
* Required for smart deletion pattern
*/
$pickNextAccountDid(all: string[], current?: string): string {
const sorted = [...all].sort();
if (!current) return sorted[0];
const i = sorted.indexOf(current);
return sorted[(i + 1) % sorted.length];
},
/**
* Ensure an active account is selected (repair hook)
* Required for smart deletion pattern bootstrapping
*/
async $ensureActiveSelected(): Promise<void> {
const active = await this.$getActiveDid();
const all = await this.$getAllAccountDids();
if (active === null && all.length > 0) {
await this.$setActiveDid(this.$pickNextAccountDid(all));
}
},
// =================================================
// ULTRA-CONCISE DATABASE METHODS (shortest names)
// =================================================

View File

@@ -272,15 +272,48 @@ export default class IdentitySwitcherView extends Vue {
this.notify.confirm(
NOTIFY_DELETE_IDENTITY_CONFIRM.text,
async () => {
await this.$exec(`DELETE FROM accounts WHERE id = ?`, [id]);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
await this.smartDeleteAccount(id);
},
-1,
);
}
/**
* Smart deletion with atomic transaction and last account protection
* Follows the Active Pointer + Smart Deletion Pattern
*/
async smartDeleteAccount(id: string) {
await this.$withTransaction(async () => {
const total = await this.$countAccounts();
if (total <= 1) {
this.notify.warning(
"Cannot delete the last account. Keep at least one.",
);
throw new Error("blocked:last-item");
}
const accountDid = await this.$getAccountDidById(parseInt(id));
const activeDid = await this.$getActiveDid();
if (activeDid === accountDid) {
const allDids = await this.$getAllAccountDids();
const nextDid = this.$pickNextAccountDid(
allDids.filter((d) => d !== accountDid),
accountDid,
);
await this.$setActiveDid(nextDid);
this.notify.success(`Switched active to ${nextDid} before deletion.`);
}
await this.$exec("DELETE FROM accounts WHERE id = ?", [id]);
});
// Update UI
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
}
notifyCannotDelete() {
this.notify.warning(
NOTIFY_CANNOT_DELETE_ACTIVE_IDENTITY.message,