forked from trent_larson/crowd-funder-for-time-pwa
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:
48
src/config/featureFlags.ts
Normal file
48
src/config/featureFlags.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
64
src/db/tables/activeIdentity.ts
Normal file
64
src/db/tables/activeIdentity.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
// =================================================
|
||||
|
||||
Reference in New Issue
Block a user