forked from trent_larson/crowd-funder-for-time-pwa
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// =================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user