Browse Source

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
activedid_migration
Matthew Raymer 13 hours ago
parent
commit
b2e678dc2f
  1. 298
      doc/activeDid-table-separation-progress.md
  2. 48
      src/config/featureFlags.ts
  3. 130
      src/db-sql/migration.ts
  4. 64
      src/db/tables/activeIdentity.ts
  5. 144
      src/utils/PlatformServiceMixin.ts

298
doc/activeDid-table-separation-progress.md

@ -0,0 +1,298 @@
# ActiveDid Table Separation Progress Report
**Author**: Matthew Raymer
**Date**: 2025-08-21T12:32Z
**Status**: 🔍 **INVESTIGATION COMPLETE** - Ready for implementation planning
## Executive Summary
This document tracks the investigation and progress of separating the `activeDid` field
from the `settings` table into a dedicated `active_identity` table. The project aims
to improve data integrity, reduce cache drift, and simplify transaction logic for
identity management in TimeSafari.
## Investigation Results
### Reference Audit Findings
**Total ActiveDid References**: 505 across the codebase
- **Write Operations**: 100 (20%)
- **Read Operations**: 260 (51%)
- **Other References**: 145 (29%) - includes type definitions, comments, etc.
**Component Impact**: 15+ Vue components directly access `settings.activeDid`
### Current Database Schema
The `settings` table currently contains **30 fields** mixing identity state with user
preferences:
```sql
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT, -- Links to identity (null = master)
activeDid TEXT, -- Current active identity (master only)
apiServer TEXT, -- API endpoint
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT, -- User's name
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT, -- Deprecated
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT, -- JSON string
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
```
### Component State Management
#### PlatformServiceMixin Cache System
- **`_currentActiveDid`**: Component-level cache for activeDid
- **`$updateActiveDid()`**: Method to sync cache with database
- **Change Detection**: Watcher triggers component updates on activeDid changes
- **State Synchronization**: Cache updates when `$saveSettings()` changes activeDid
#### Common Usage Patterns
```typescript
// Standard pattern across 15+ components
this.activeDid = settings.activeDid || "";
// API header generation
const headers = await getHeaders(this.activeDid);
// Identity validation
if (claim.issuer === this.activeDid) { ... }
```
### Migration Infrastructure Status
#### Existing Capabilities
- **`migrateSettings()`**: Fully implemented and functional
- **Settings Migration**: Handles 30 fields with proper type conversion
- **Data Integrity**: Includes validation and error handling
- **Rollback Capability**: Migration service has rollback infrastructure
#### Migration Order
1. **Accounts** (foundational - contains DIDs)
2. **Settings** (references accountDid, activeDid)
3. **ActiveDid** (depends on accounts and settings)
4. **Contacts** (independent, but migrated after accounts)
### Testing Infrastructure
#### Current Coverage
- **Playwright Tests**: `npm run test:web` and `npm run test:mobile`
- **No Unit Tests**: Found for migration or settings management
- **Integration Tests**: Available through Playwright test suite
- **Platform Coverage**: Web, Mobile (Android/iOS), Desktop (Electron)
## Risk Assessment
### High Risk Areas
1. **Component State Synchronization**: 505 references across codebase
2. **Cache Drift**: `_currentActiveDid` vs database `activeDid`
3. **Cross-Platform Consistency**: Web + Mobile + Desktop
### Medium Risk Areas
1. **Foreign Key Constraints**: activeDid → accounts.did relationship
2. **Migration Rollback**: Complex 30-field settings table
3. **API Surface Changes**: Components expect `settings.activeDid`
### Low Risk Areas
1. **Migration Infrastructure**: Already exists and functional
2. **Data Integrity**: Current migration handles complex scenarios
3. **Testing Framework**: Playwright tests available for validation
## Implementation Phases
### Phase 1: Foundation Analysis ✅ **COMPLETE**
- [x] **ActiveDid Reference Audit**: 505 references identified and categorized
- [x] **Database Schema Analysis**: 30-field settings table documented
- [x] **Component Usage Mapping**: 15+ components usage patterns documented
- [x] **Migration Infrastructure Assessment**: Existing service validated
### Phase 2: Design & Implementation (Medium Complexity)
- [ ] **New Table Schema Design**
- Define `active_identity` table structure
- Plan foreign key relationships to `accounts.did`
- Design migration SQL statements
- Validate against existing data patterns
- [ ] **Component Update Strategy**
- Map all 505 references for update strategy
- Plan computed property changes
- Design state synchronization approach
- Preserve existing API surface
- [ ] **Testing Infrastructure Planning**
- Unit tests for new table operations
- Integration tests for identity switching
- Migration rollback validation
- Cross-platform testing strategy
### Phase 3: Migration & Validation (Complex Complexity)
- [ ] **Migration Execution Testing**
- Test on development database
- Validate data integrity post-migration
- Measure performance impact
- Test rollback scenarios
- [ ] **Cross-Platform Validation**
- Web platform functionality
- Mobile platform functionality
- Desktop platform functionality
- Cross-platform consistency
- [ ] **User Acceptance Testing**
- Identity switching workflows
- Settings persistence
- Error handling scenarios
- Edge case validation
## Technical Requirements
### New Table Schema
```sql
-- Proposed active_identity table
CREATE TABLE IF NOT EXISTS active_identity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activeDid TEXT NOT NULL,
lastUpdated TEXT NOT NULL,
FOREIGN KEY (activeDid) REFERENCES accounts(did)
);
-- Index for performance
CREATE INDEX IF NOT EXISTS idx_active_identity_activeDid ON active_identity(activeDid);
```
### Migration Strategy
1. **Extract activeDid**: Copy from settings table to new table
2. **Update References**: Modify components to use new table
3. **Remove Field**: Drop activeDid from settings table
4. **Validate**: Ensure data integrity and functionality
### Component Updates Required
- **PlatformServiceMixin**: Update activeDid management
- **15+ Vue Components**: Modify activeDid access patterns
- **Migration Service**: Add activeDid table migration
- **Database Utilities**: Update settings operations
## Success Criteria
### Phase 1 ✅ **ACHIEVED**
- Complete activeDid usage audit with counts
- Database schema validation with data integrity check
- Migration service health assessment
- Clear dependency map for component updates
### Phase 2
- New table schema designed and validated
- Component update strategy documented
- Testing infrastructure planned
- Migration scripts developed
### Phase 3
- Migration successfully executed
- All platforms functional
- Performance maintained or improved
- Zero data loss
## Dependencies
### Technical Dependencies
- **Existing Migration Infrastructure**: Settings migration service
- **Database Access Patterns**: PlatformServiceMixin methods
- **Component Architecture**: Vue component patterns
### Platform Dependencies
- **Cross-Platform Consistency**: Web + Mobile + Desktop
- **Testing Framework**: Playwright test suite
- **Build System**: Vite configuration for all platforms
### Testing Dependencies
- **Migration Validation**: Rollback testing
- **Integration Testing**: Cross-platform functionality
- **User Acceptance**: Identity switching workflows
## Next Steps
### Immediate Actions (Next Session)
1. **Create New Table Schema**: Design `active_identity` table structure
2. **Component Update Planning**: Map all 505 references for update strategy
3. **Migration Script Development**: Create activeDid extraction migration
### Success Metrics
- **Data Integrity**: 100% activeDid data preserved
- **Performance**: No degradation in identity switching
- **Platform Coverage**: All platforms functional
- **Testing Coverage**: Comprehensive migration validation
## References
- **Codebase Analysis**: `src/views/*.vue`, `src/utils/PlatformServiceMixin.ts`
- **Database Schema**: `src/db-sql/migration.ts`
- **Migration Service**: `src/services/indexedDBMigrationService.ts`
- **Settings Types**: `src/db/tables/settings.ts`
## Competence Hooks
- **Why this works**: Separation of concerns improves data integrity, reduces
cache drift, simplifies transaction logic
- **Common pitfalls**: Missing component updates, foreign key constraint
violations, migration rollback failures
- **Next skill**: Database schema normalization and migration planning
- **Teach-back**: "How would you ensure zero downtime during the activeDid
table migration?"
## Collaboration Hooks
- **Reviewers**: Database team for schema design, Frontend team for component
updates, QA team for testing strategy
- **Sign-off checklist**: Migration tested, rollback verified, performance
validated, component state consistent
---
**Status**: Investigation complete, ready for implementation planning
**Next Review**: 2025-08-28
**Estimated Complexity**: High (cross-platform refactoring with 505 references)

48
src/config/featureFlags.ts

@ -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]);
}

130
src/db-sql/migration.ts

@ -124,6 +124,136 @@ const MIGRATIONS = [
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE; 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

@ -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(),
};
}

144
src/utils/PlatformServiceMixin.ts

@ -49,6 +49,11 @@ import {
type Settings, type Settings,
type SettingsWithJsonStrings, type SettingsWithJsonStrings,
} from "@/db/tables/settings"; } from "@/db/tables/settings";
import {
DEFAULT_SCOPE,
type ActiveIdentity
} from "@/db/tables/activeIdentity";
import { FLAGS } from "@/config/featureFlags";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts"; import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
@ -964,6 +969,145 @@ export const PlatformServiceMixin = {
return await this.$saveUserSettings(currentDid, changes); 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 // CACHE MANAGEMENT METHODS
// ================================================= // =================================================

Loading…
Cancel
Save