fix problem with hidden contacts due to bad iViewContent values, and rename to hideTheirContent

This commit is contained in:
2026-02-15 19:29:23 -07:00
parent bb9b0d3c2f
commit 7838eea30f
7 changed files with 93 additions and 471 deletions

View File

@@ -175,6 +175,7 @@ const MIGRATIONS = [
},
{
name: "002_add_iViewContent_to_contacts",
// Note that many times iViewContent was set to null despite the DEFAULT setting.
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
@@ -213,6 +214,60 @@ const MIGRATIONS = [
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
`,
},
{
name: "007_add_hideTheirContent_to_contacts",
// Since we have problems where iViewContent is not set, let's default to show content.
// Add hideTheirContent: null/absent/false = show content (safe default)
sql: `
ALTER TABLE contacts ADD COLUMN hideTheirContent BOOLEAN DEFAULT 0;
UPDATE contacts SET hideTheirContent = CASE WHEN iViewContent = 0 THEN 1 ELSE 0 END WHERE iViewContent IS NOT NULL;
`,
},
{
name: "008_remove_iViewContent_from_contacts",
// Recreate contacts without iViewContent: backup, drop, recreate, restore
sql: `
PRAGMA foreign_keys = OFF;
CREATE TABLE _contact_labels_backup_008 AS SELECT * FROM contact_labels;
DROP TABLE IF EXISTS contact_labels;
CREATE TABLE _contacts_backup_008 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT,
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN,
hideTheirContent BOOLEAN DEFAULT 0
);
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts;
DROP TABLE contacts;
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE contact_labels (
did TEXT NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY (did, label),
FOREIGN KEY (did) REFERENCES contacts(did) ON DELETE CASCADE
);
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008;
DROP TABLE _contact_labels_backup_008;
PRAGMA foreign_keys = ON;
`,
},
];
/**

View File

@@ -16,7 +16,8 @@ export type Contact = {
did: string;
contactMethods?: Array<ContactMethod>;
iViewContent?: boolean;
/** When true, hide this contact's activity from the feed. Default (null/undefined) = show. */
hideTheirContent?: boolean;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;

View File

@@ -12,9 +12,7 @@
*
* 1. **Single Application**: Each migration runs exactly once per database
* 2. **Tracked Execution**: All applied migrations are recorded in a migrations table
* 3. **Schema Validation**: Actual database schema is validated before and after migrations
* 4. **Graceful Recovery**: Handles cases where schema exists but tracking is missing
* 5. **Comprehensive Logging**: Detailed logging for debugging and monitoring
* 3. **Comprehensive Logging**: Detailed logging for debugging and monitoring
*
* ## Migration Flow
*
@@ -26,9 +24,7 @@
* b. Check if schema already exists
* c. Skip if already applied
* d. Apply migration SQL
* e. Validate schema was created
* f. Record migration as applied
* 4. Final validation of all migrations
* e. Record migration as applied
* ```
*
* ## Usage Example
@@ -77,25 +73,6 @@ interface Migration {
statements?: string[];
}
/**
* Migration validation result
*
* Contains the results of validating that a migration was successfully
* applied by checking the actual database schema.
*
* @interface MigrationValidation
*/
interface MigrationValidation {
/** Whether the migration validation passed overall */
isValid: boolean;
/** Whether expected tables exist */
tableExists: boolean;
/** Whether expected columns exist */
hasExpectedColumns: boolean;
/** List of validation errors encountered */
errors: string[];
}
/**
* Migration registry to store and manage database migrations
*
@@ -207,354 +184,6 @@ export function registerMigration(migration: Migration): void {
migrationRegistry.registerMigration(migration);
}
/**
* Validate that a migration was successfully applied by checking schema
*
* This function performs post-migration validation to ensure that the
* expected database schema changes were actually applied. It checks for
* the existence of tables, columns, and other schema elements that should
* have been created by the migration.
*
* @param migration - The migration to validate
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to validation results
*
* @example
* ```typescript
* const validation = await validateMigrationApplication(migration, sqlQuery);
* if (!validation.isValid) {
* console.error('Migration validation failed:', validation.errors);
* }
* ```
*/
/**
* Helper function to check if a SQLite result indicates a table exists
* @param result - The result from a sqlite_master query
* @returns true if the table exists
*/
function checkSqliteTableResult(result: unknown): boolean {
return (
(result as unknown as { values: unknown[][] })?.values?.length > 0 ||
(Array.isArray(result) && result.length > 0)
);
}
/**
* Helper function to validate that a table exists in the database
* @param tableName - Name of the table to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if table exists
*/
async function validateTableExists<T>(
tableName: string,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
const result = await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
);
return checkSqliteTableResult(result);
} catch (error) {
logger.error(`❌ [Validation] Error checking table ${tableName}:`, error);
return false;
}
}
/**
* Helper function to validate that a column exists in a table
* @param tableName - Name of the table
* @param columnName - Name of the column to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if column exists
*/
async function validateColumnExists<T>(
tableName: string,
columnName: string,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
await sqlQuery(`SELECT ${columnName} FROM ${tableName} LIMIT 1`);
return true;
} catch (error) {
logger.error(
`❌ [Validation] Error checking column ${columnName} in ${tableName}:`,
error,
);
return false;
}
}
/**
* Helper function to validate multiple tables exist
* @param tableNames - Array of table names to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to array of validation results
*/
async function validateMultipleTables<T>(
tableNames: string[],
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<{ exists: boolean; missing: string[] }> {
const missing: string[] = [];
for (const tableName of tableNames) {
const exists = await validateTableExists(tableName, sqlQuery);
if (!exists) {
missing.push(tableName);
}
}
return {
exists: missing.length === 0,
missing,
};
}
/**
* Helper function to add validation error with consistent logging
* @param validation - The validation object to update
* @param message - Error message to add
* @param error - The error object for logging
*/
function addValidationError(
validation: MigrationValidation,
message: string,
error: unknown,
): void {
validation.isValid = false;
validation.errors.push(message);
logger.error(`❌ [Migration-Validation] ${message}:`, error);
}
async function validateMigrationApplication<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<MigrationValidation> {
const validation: MigrationValidation = {
isValid: true,
tableExists: false,
hasExpectedColumns: false,
errors: [],
};
try {
if (migration.name === "001_initial") {
// Validate core tables exist for initial migration
const tables = [
"accounts",
"secret",
"settings",
"contacts",
"logs",
"temp",
];
const tableValidation = await validateMultipleTables(tables, sqlQuery);
if (!tableValidation.exists) {
validation.isValid = false;
validation.errors.push(
`Missing tables: ${tableValidation.missing.join(", ")}`,
);
logger.error(
`❌ [Migration-Validation] Missing tables:`,
tableValidation.missing,
);
}
validation.tableExists = tableValidation.exists;
} else if (migration.name === "002_add_iViewContent_to_contacts") {
// Validate iViewContent column exists in contacts table
const columnExists = await validateColumnExists(
"contacts",
"iViewContent",
sqlQuery,
);
if (!columnExists) {
addValidationError(
validation,
"Column iViewContent missing from contacts table",
new Error("Column not found"),
);
} else {
validation.hasExpectedColumns = true;
}
} else if (migration.name === "004_active_identity_management") {
// Validate active_identity table exists and has correct structure
const activeIdentityExists = await validateTableExists(
"active_identity",
sqlQuery,
);
if (!activeIdentityExists) {
addValidationError(
validation,
"Table active_identity missing",
new Error("Table not found"),
);
} else {
validation.tableExists = true;
// Check that active_identity has the expected structure
const hasExpectedColumns = await validateColumnExists(
"active_identity",
"id, activeDid, lastUpdated",
sqlQuery,
);
if (!hasExpectedColumns) {
addValidationError(
validation,
"active_identity table missing expected columns",
new Error("Columns not found"),
);
} else {
validation.hasExpectedColumns = true;
}
}
// Check that hasBackedUpSeed column exists in settings table
// Note: This validation is included here because migration 004 is consolidated
// and includes the functionality from the original migration 003
const hasBackedUpSeedExists = await validateColumnExists(
"settings",
"hasBackedUpSeed",
sqlQuery,
);
if (!hasBackedUpSeedExists) {
addValidationError(
validation,
"Column hasBackedUpSeed missing from settings table",
new Error("Column not found"),
);
}
}
// Add validation for future migrations here
// } else if (migration.name === "003_future_migration") {
// // Validate future migration schema changes
// }
} catch (error) {
validation.isValid = false;
validation.errors.push(`Validation error: ${error}`);
logger.error(
`❌ [Migration-Validation] Validation failed for ${migration.name}:`,
error,
);
}
return validation;
}
/**
* Check if migration is already applied by examining actual schema
*
* This function performs schema introspection to determine if a migration
* has already been applied, even if it's not recorded in the migrations
* table. This is useful for handling cases where the database schema exists
* but the migration tracking got out of sync.
*
* @param migration - The migration to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if schema already exists
*
* @example
* ```typescript
* const schemaExists = await isSchemaAlreadyPresent(migration, sqlQuery);
* if (schemaExists) {
* console.log('Schema already exists, skipping migration');
* }
* ```
*/
async function isSchemaAlreadyPresent<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
if (migration.name === "001_initial") {
// Check if accounts table exists (primary indicator of initial migration)
const result = (await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'`,
)) as unknown as { values: unknown[][] };
const hasTable =
result?.values?.length > 0 ||
(Array.isArray(result) && result.length > 0);
// Reduced logging - only log on error
return hasTable;
} else if (migration.name === "002_add_iViewContent_to_contacts") {
// Check if iViewContent column exists in contacts table
try {
await sqlQuery(`SELECT iViewContent FROM contacts LIMIT 1`);
// Reduced logging - only log on error
return true;
} catch (error) {
// Reduced logging - only log on error
return false;
}
} else if (migration.name === "003_add_hasBackedUpSeed_to_settings") {
// Check if hasBackedUpSeed column exists in settings table
try {
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
return true;
} catch (error) {
return false;
}
} else if (migration.name === "004_active_identity_management") {
// Check if active_identity table exists and has correct structure
try {
// Check that active_identity table exists
const activeIdentityResult = await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='active_identity'`,
);
const hasActiveIdentityTable =
(activeIdentityResult as unknown as { values: unknown[][] })?.values
?.length > 0 ||
(Array.isArray(activeIdentityResult) &&
activeIdentityResult.length > 0);
if (!hasActiveIdentityTable) {
return false;
}
// Check that active_identity has the expected structure
try {
await sqlQuery(
`SELECT id, activeDid, lastUpdated FROM active_identity LIMIT 1`,
);
// Also check that hasBackedUpSeed column exists in settings
// This is included because migration 004 is consolidated
try {
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
return true;
} catch (error) {
return false;
}
} catch (error) {
return false;
}
} catch (error) {
logger.error(
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
error,
);
return false;
}
}
// Add schema checks for future migrations here
// } else if (migration.name === "003_future_migration") {
// // Check if future migration schema already exists
// }
} catch (error) {
logger.error(
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
error,
);
return false;
}
return false;
}
/**
* Run all registered migrations against the database
*
@@ -600,7 +229,7 @@ export async function runMigrations<T>(
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
try {
logger.debug("📋 [Migration] Starting migration process...");
logger.debug("[Migration] Starting migration process...");
// Create migrations table if it doesn't exist
// Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration
@@ -628,7 +257,7 @@ export async function runMigrations<T>(
// Only log migration counts in development
logger.debug(
`📊 [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
`[Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
);
let appliedCount = 0;
@@ -645,70 +274,35 @@ export async function runMigrations<T>(
continue;
}
// Check 2: Does the schema already exist in the database?
const isSchemaPresent = await isSchemaAlreadyPresent(migration, sqlQuery);
// Handle case where schema exists but isn't recorded
if (isSchemaPresent) {
try {
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(
`✅ [Migration] Marked existing schema as applied: ${migration.name}`,
);
skippedCount++;
continue;
} catch (insertError) {
logger.warn(
`⚠️ [Migration] Could not record existing schema ${migration.name}:`,
insertError,
);
// Continue with normal migration process as fallback
}
}
// Apply the migration
logger.debug(`🔄 [Migration] Applying migration: ${migration.name}`);
logger.debug(`[Migration] Applying migration: ${migration.name}`);
try {
// Execute the migration SQL as single atomic operation
logger.debug(`🔧 [Migration] Executing SQL for: ${migration.name}`);
logger.debug(`🔧 [Migration] SQL content: ${migration.sql}`);
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
logger.debug(`[Migration] SQL content: ${migration.sql}`);
// Execute the migration SQL directly - it should be atomic
// The SQL itself should handle any necessary transactions
const execResult = await sqlExec(migration.sql);
logger.debug(
`🔧 [Migration] SQL execution result: ${JSON.stringify(execResult)}`,
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
);
// Validate the migration was applied correctly
const validation = await validateMigrationApplication(
migration,
sqlQuery,
);
if (!validation.isValid) {
logger.warn(
`⚠️ [Migration] Validation failed for ${migration.name}:`,
validation.errors,
);
}
// Record that the migration was applied
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(`🎉 [Migration] Successfully applied: ${migration.name}`);
logger.debug(` [Migration] Successfully applied: ${migration.name}`);
appliedCount++;
} catch (error) {
logger.error(`❌ [Migration] Error applying ${migration.name}:`, error);
// Provide explicit rollback instructions for migration failures
logger.error(
`🔄 [Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
`[Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
);
logger.error(` 1. Stop the application immediately`);
logger.error(
@@ -740,41 +334,12 @@ export async function runMigrations<T>(
errorMessage.includes("already exists"))
) {
logger.debug(
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Validating and marking as complete.`,
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}).`,
);
// Validate the existing schema
const validation = await validateMigrationApplication(
migration,
sqlQuery,
);
if (!validation.isValid) {
logger.warn(
`⚠️ [Migration] Schema validation failed for ${migration.name}:`,
validation.errors,
);
// Don't mark as applied if validation fails
continue;
}
// Mark the migration as applied since the schema change already exists
try {
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(`✅ [Migration] Marked as applied: ${migration.name}`);
appliedCount++;
} catch (insertError) {
// If we can't insert the migration record, log it but don't fail
logger.warn(
`⚠️ [Migration] Could not record ${migration.name} as applied:`,
insertError,
);
}
} else {
// For other types of errors, still fail the migration
logger.error(
`❌ [Migration] Failed to apply ${migration.name}:`,
` [Migration] Failed to apply ${migration.name}:`,
error,
);
throw new Error(`Migration ${migration.name} failed: ${error}`);
@@ -800,11 +365,10 @@ export async function runMigrations<T>(
// Only show completion message in development
logger.log(
`🎉 [Migration] Migration process complete! Summary: ${appliedCount} applied, ${skippedCount} skipped`,
`[Migration] Migration process complete. Summary: ${appliedCount} applied, ${skippedCount} skipped`,
);
} catch (error) {
logger.error("\n💥 [Migration] Migration process failed:", error);
logger.error("[MigrationService] Migration process failed:", error);
logger.error(" [Migration] Migration process failed:", error);
throw error;
}
}

View File

@@ -714,7 +714,7 @@ export class CapacitorPlatformService
*
* For critical tables like `contacts`, the method validates:
* - Table structure using `PRAGMA table_info`
* - Presence of important columns (e.g., `iViewContent`)
* - Presence of important columns (e.g., `hideTheirContent`)
* - Column data types and constraints
*
* ## Error Handling:
@@ -784,7 +784,7 @@ export class CapacitorPlatformService
}
}
// Step 3: Check contacts table schema (including iViewContent column)
// Step 3: Check contacts table schema (including hideTheirContent column)
if (existingTables.includes("contacts")) {
try {
const contactsSchema = await this.db.query(
@@ -795,23 +795,23 @@ export class CapacitorPlatformService
contactsSchema,
);
// Check for iViewContent column specifically
const hasIViewContent = contactsSchema.values?.some(
// Check for hideTheirContent column specifically
const hasIHideContent = contactsSchema.values?.some(
(col: unknown) =>
(typeof col === "object" &&
col !== null &&
"name" in col &&
(col as { name: string }).name === "iViewContent") ||
(Array.isArray(col) && col[1] === "iViewContent"),
(col as { name: string }).name === "hideTheirContent") ||
(Array.isArray(col) && col[1] === "hideTheirContent"),
);
if (hasIViewContent) {
if (hasIHideContent) {
logger.debug(
`✅ [DB-Integrity] iViewContent column exists in contacts table`,
`✅ [DB-Integrity] hideTheirContent column exists in contacts table`,
);
} else {
logger.error(
`❌ [DB-Integrity] iViewContent column missing from contacts table`,
`❌ [DB-Integrity] hideTheirContent column missing from contacts table`,
);
}
} catch (error) {

View File

@@ -296,7 +296,7 @@ export const PlatformServiceMixin = {
column === "warnIfProdServer" ||
column === "warnIfTestServer" ||
// contacts
column === "iViewContent" ||
column === "hideTheirContent" ||
column === "registered" ||
column === "seesMe"
) {
@@ -911,7 +911,7 @@ export const PlatformServiceMixin = {
// Create a new contact object with proper typing
const normalizedContact: Contact = {
did: contact.did,
iViewContent: contact.iViewContent,
hideTheirContent: contact.hideTheirContent,
name: contact.name,
nextPubKeyHashB64: contact.nextPubKeyHashB64,
notes: contact.notes,
@@ -1380,8 +1380,10 @@ export const PlatformServiceMixin = {
? contact.profileImageUrl
: null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
hideTheirContent:
contact.hideTheirContent !== undefined
? contact.hideTheirContent
: null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
@@ -1392,7 +1394,7 @@ export const PlatformServiceMixin = {
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, hideTheirContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
@@ -1403,7 +1405,7 @@ export const PlatformServiceMixin = {
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.hideTheirContent,
safeContact.contactMethods,
],
);

View File

@@ -204,7 +204,7 @@
class="flex flex-col items-center"
>
<button
v-if="contactFromDid?.iViewContent"
v-if="!contactFromDid?.hideTheirContent"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You watch their activity"
@click="confirmViewContent(contactFromDid, false)"
@@ -213,7 +213,7 @@
<font-awesome icon="eye" class="fa-fw" />
</button>
<button
v-else-if="!contactFromDid?.iViewContent"
v-else-if="contactFromDid?.hideTheirContent"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You do not watch their activity"
@click="confirmViewContent(contactFromDid, true)"
@@ -1141,7 +1141,7 @@ export default class DIDView extends Vue {
this.notify.confirm(contentVisibilityPrompt, async () => {
const success = await this.setViewContent(contact, view);
if (success) {
contact.iViewContent = view; // see visibility note about not working inside setVisibility
contact.hideTheirContent = !view; // inverted: view=true -> hideTheirContent=false
}
});
}
@@ -1154,7 +1154,7 @@ export default class DIDView extends Vue {
* @returns Boolean indicating success
*/
async setViewContent(contact: Contact, visibility: boolean) {
await this.$updateContact(contact.did, { iViewContent: visibility });
await this.$updateContact(contact.did, { hideTheirContent: !visibility });
const message =
"You will" +
(visibility ? "" : " not") +

View File

@@ -773,7 +773,7 @@ export default class HomeView extends Vue {
private async loadContacts() {
this.allContacts = await this.$contacts();
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.filter((c) => c.hideTheirContent)
.map((c) => c.did);
}