feat(db): implement active identity table separation

Separate activeDid from monolithic settings table into dedicated
active_identity table to improve data normalization and reduce cache
drift. Implements phased migration with dual-write triggers and
fallback support during transition.

- Add migrations 003 (create table) and 004 (drop legacy column)
- Extend PlatformServiceMixin with new façade methods
- Add feature flags for controlled rollout
- Include comprehensive validation and error handling
- Maintain backward compatibility during transition phase

BREAKING CHANGE: Components should use $getActiveDid()/$setActiveDid()
instead of direct settings.activeDid access
This commit is contained in:
Matthew Raymer
2025-08-21 13:26:13 +00:00
parent cd327b0b91
commit b2e678dc2f
5 changed files with 684 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
/**
* Feature Flags Configuration
*
* Controls the rollout of new features and migrations
*
* @author Matthew Raymer
* @date 2025-08-21
*/
export const FLAGS = {
/**
* When true, disallow legacy fallback reads from settings.activeDid
* Set to true after all components are migrated to the new façade
*/
USE_ACTIVE_IDENTITY_ONLY: false,
/**
* Controls Phase C column removal from settings table
* Set to true when ready to drop the legacy activeDid column
*/
DROP_SETTINGS_ACTIVEDID: false,
/**
* Log warnings when dual-read falls back to legacy settings.activeDid
* Useful for monitoring migration progress
*/
LOG_ACTIVE_ID_FALLBACK: process.env.NODE_ENV === 'development',
/**
* Enable the new active_identity table and migration
* Set to true to start the migration process
*/
ENABLE_ACTIVE_IDENTITY_MIGRATION: true,
};
/**
* Get feature flag value with type safety
*/
export function getFlag<K extends keyof typeof FLAGS>(key: K): typeof FLAGS[K] {
return FLAGS[key];
}
/**
* Check if a feature flag is enabled
*/
export function isFlagEnabled<K extends keyof typeof FLAGS>(key: K): boolean {
return Boolean(FLAGS[key]);
}

View File

@@ -124,6 +124,136 @@ const MIGRATIONS = [
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
{
name: "003_active_identity_table_separation",
sql: `
-- Create active_identity table with proper constraints
CREATE TABLE IF NOT EXISTS active_identity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL DEFAULT 'default',
active_did TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CONSTRAINT uq_active_identity_scope UNIQUE (scope),
CONSTRAINT fk_active_identity_account FOREIGN KEY (active_did)
REFERENCES accounts(did) ON UPDATE CASCADE ON DELETE RESTRICT
);
-- Create index for performance
CREATE INDEX IF NOT EXISTS idx_active_identity_scope ON active_identity(scope);
CREATE INDEX IF NOT EXISTS idx_active_identity_active_did ON active_identity(active_did);
-- Seed from existing settings.activeDid if valid
INSERT INTO active_identity (scope, active_did)
SELECT 'default', s.activeDid
FROM settings s
WHERE s.activeDid IS NOT NULL
AND EXISTS (SELECT 1 FROM accounts a WHERE a.did = s.activeDid)
AND s.id = 1
ON CONFLICT(scope) DO UPDATE SET
active_did=excluded.active_did,
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');
-- Fallback: choose first known account if still empty
INSERT INTO active_identity (scope, active_did)
SELECT 'default', a.did
FROM accounts a
WHERE NOT EXISTS (SELECT 1 FROM active_identity ai WHERE ai.scope='default')
LIMIT 1;
-- Create one-way mirroring trigger (settings.activeDid → active_identity.active_did)
DROP TRIGGER IF EXISTS trg_settings_activeDid_to_active_identity;
CREATE TRIGGER trg_settings_activeDid_to_active_identity
AFTER UPDATE OF activeDid ON settings
FOR EACH ROW
WHEN NEW.activeDid IS NOT OLD.activeDid AND NEW.activeDid IS NOT NULL
BEGIN
UPDATE active_identity
SET active_did = NEW.activeDid,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE scope = 'default';
INSERT INTO active_identity (scope, active_did, updated_at)
SELECT 'default', NEW.activeDid, strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE NOT EXISTS (
SELECT 1 FROM active_identity ai WHERE ai.scope = 'default'
);
END;
`,
},
{
name: "004_drop_settings_activeDid_column",
sql: `
-- Phase C: Remove activeDid column from settings table
-- Note: SQLite requires table rebuild for column removal
-- Create new settings table without activeDid column
CREATE TABLE settings_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
-- activeDid intentionally omitted
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT,
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
-- Copy data from old table (excluding activeDid)
INSERT INTO settings_new (
id, accountDid, apiServer, filterFeedByNearby, filterFeedByVisible,
finishedOnboarding, firstName, hideRegisterPromptOnNewContact,
isRegistered, lastName, lastAckedOfferToUserJwtId,
lastAckedOfferToUserProjectsJwtId, lastNotifiedClaimId,
lastViewedClaimId, notifyingNewActivityTime, notifyingReminderMessage,
notifyingReminderTime, partnerApiServer, passkeyExpirationMinutes,
profileImageUrl, searchBoxes, showContactGivesInline,
showGeneralAdvanced, showShortcutBvc, vapid, warnIfProdServer,
warnIfTestServer, webPushServer
)
SELECT
id, accountDid, apiServer, filterFeedByNearby, filterFeedByVisible,
finishedOnboarding, firstName, hideRegisterPromptOnNewContact,
isRegistered, lastName, lastAckedOfferToUserJwtId,
lastAckedOfferToUserProjectsJwtId, lastNotifiedClaimId,
lastViewedClaimId, notifyingNewActivityTime, notifyingReminderMessage,
notifyingReminderTime, partnerApiServer, passkeyExpirationMinutes,
profileImageUrl, searchBoxes, showContactGivesInline,
showGeneralAdvanced, showShortcutBvc, vapid, warnIfProdServer,
warnIfTestServer, webPushServer
FROM settings;
-- Drop old table and rename new one
DROP TABLE settings;
ALTER TABLE settings_new RENAME TO settings;
-- Recreate indexes
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
-- Drop the mirroring trigger (no longer needed)
DROP TRIGGER IF EXISTS trg_settings_activeDid_to_active_identity;
`,
},
];
/**

View File

@@ -0,0 +1,64 @@
/**
* Active Identity Table Definition
*
* Manages the currently active identity/DID for the application.
* Replaces the activeDid field from the settings table to improve
* data normalization and reduce cache drift.
*
* @author Matthew Raymer
* @date 2025-08-21
*/
/**
* Active Identity record structure
*/
export interface ActiveIdentity {
/** Primary key */
id?: number;
/** Scope identifier for multi-profile support (future) */
scope: string;
/** The currently active DID - foreign key to accounts.did */
active_did: string;
/** Last update timestamp in ISO format */
updated_at?: string;
}
/**
* Database schema for the active_identity table
*/
export const ActiveIdentitySchema = {
active_identity: "++id, &scope, active_did, updated_at",
};
/**
* Default scope for single-user mode
*/
export const DEFAULT_SCOPE = "default";
/**
* Validation helper to ensure valid DID format
*/
export function isValidDid(did: string): boolean {
return typeof did === 'string' && did.length > 0 && did.startsWith('did:');
}
/**
* Create a new ActiveIdentity record
*/
export function createActiveIdentity(
activeDid: string,
scope: string = DEFAULT_SCOPE
): ActiveIdentity {
if (!isValidDid(activeDid)) {
throw new Error(`Invalid DID format: ${activeDid}`);
}
return {
scope,
active_did: activeDid,
updated_at: new Date().toISOString(),
};
}

View File

@@ -49,6 +49,11 @@ import {
type Settings,
type SettingsWithJsonStrings,
} from "@/db/tables/settings";
import {
DEFAULT_SCOPE,
type ActiveIdentity
} from "@/db/tables/activeIdentity";
import { FLAGS } from "@/config/featureFlags";
import { logger } from "@/utils/logger";
import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts";
import { Account } from "@/db/tables/accounts";
@@ -964,6 +969,145 @@ export const PlatformServiceMixin = {
return await this.$saveUserSettings(currentDid, changes);
},
// =================================================
// ACTIVE IDENTITY METHODS (New table separation)
// =================================================
/**
* Get the current active DID from the active_identity table
* Falls back to legacy settings.activeDid during Phase A transition
*
* @param scope Scope identifier (default: 'default')
* @returns Promise<string | null> The active DID or null if not found
*/
async $getActiveDid(scope: string = DEFAULT_SCOPE): Promise<string | null> {
try {
// Try new active_identity table first
const row = await this.$first<ActiveIdentity>(
'SELECT active_did FROM active_identity WHERE scope = ? LIMIT 1',
[scope]
);
if (row?.active_did) {
return row.active_did;
}
// Fallback to legacy settings.activeDid during Phase A (unless flag prevents it)
if (!FLAGS.USE_ACTIVE_IDENTITY_ONLY) {
if (FLAGS.LOG_ACTIVE_ID_FALLBACK) {
logger.warn('[ActiveDid] Fallback to legacy settings.activeDid');
}
const legacy = await this.$first<Settings>(
'SELECT activeDid FROM settings WHERE id = ? LIMIT 1',
[MASTER_SETTINGS_KEY]
);
return legacy?.activeDid || null;
}
return null;
} catch (error) {
logger.error('[PlatformServiceMixin] Error getting activeDid:', error);
return null;
}
},
/**
* Update the active DID in the active_identity table
* Also maintains legacy settings.activeDid during Phase A transition
*
* @param did The DID to set as active (or null to clear)
* @param scope Scope identifier (default: 'default')
* @returns Promise<void>
*/
async $setActiveDid(did: string | null, scope: string = DEFAULT_SCOPE): Promise<void> {
try {
if (!did) {
throw new Error('Cannot set null DID as active');
}
// Validate that the DID exists in accounts table
const accountExists = await this.$first<Account>(
'SELECT did FROM accounts WHERE did = ? LIMIT 1',
[did]
);
if (!accountExists) {
throw new Error(`Cannot set activeDid to non-existent account: ${did}`);
}
await this.$withTransaction(async () => {
// Update/insert into active_identity table
const existingRecord = await this.$first<ActiveIdentity>(
'SELECT id FROM active_identity WHERE scope = ? LIMIT 1',
[scope]
);
if (existingRecord) {
// Update existing record
await this.$dbExec(
`UPDATE active_identity
SET active_did = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE scope = ?`,
[did, scope]
);
} else {
// Insert new record
await this.$dbExec(
`INSERT INTO active_identity (scope, active_did, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[scope, did]
);
}
// Maintain legacy settings.activeDid during Phase A (unless Phase C is complete)
if (!FLAGS.DROP_SETTINGS_ACTIVEDID) {
await this.$dbExec(
'UPDATE settings SET activeDid = ? WHERE id = ?',
[did, MASTER_SETTINGS_KEY]
);
}
});
// Update component cache for change detection
await this.$updateActiveDid(did);
logger.info(`[PlatformServiceMixin] Active DID updated to: ${did}`);
} catch (error) {
logger.error('[PlatformServiceMixin] Error setting activeDid:', error);
throw error;
}
},
/**
* Switch to a different active identity
* Convenience method that validates and sets the new active DID
*
* @param did The DID to switch to
* @returns Promise<void>
*/
async $switchActiveIdentity(did: string): Promise<void> {
await this.$setActiveDid(did);
},
/**
* Get all available active identity scopes
* Useful for multi-profile support in the future
*
* @returns Promise<string[]> Array of scope identifiers
*/
async $getActiveIdentityScopes(): Promise<string[]> {
try {
const scopes = await this.$query<{ scope: string }>(
'SELECT DISTINCT scope FROM active_identity ORDER BY scope'
);
return scopes.map(row => row.scope);
} catch (error) {
logger.error('[PlatformServiceMixin] Error getting active identity scopes:', error);
return [];
}
},
// =================================================
// CACHE MANAGEMENT METHODS
// =================================================