diff --git a/apps/desktop/src/main/vault-service.ts b/apps/desktop/src/main/vault-service.ts index bb50344..6c2d0b2 100644 --- a/apps/desktop/src/main/vault-service.ts +++ b/apps/desktop/src/main/vault-service.ts @@ -3,7 +3,7 @@ import { appstash, resolve } from 'appstash'; import { existsSync, promises as fs } from 'fs'; import * as path from 'path'; -import type { TotpEntry, VaultStatus } from '../shared/api'; +import type { RebuildReport, TotpEntry, VaultStatus } from '../shared/api'; const APP_NAME = 'dcrypt'; @@ -87,9 +87,9 @@ export class VaultService { * Re-runs the pgpm deploy into a fresh database and moves every row across, * so a vault created by an earlier module version picks up schema changes. */ - async rebuild(): Promise { + async rebuild(): Promise { await this.flush(); - await this.current().rebuild(vaultModulePath()); + return this.current().rebuild(vaultModulePath()); } /** diff --git a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx index c1cfea5..7cb465a 100644 --- a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx @@ -82,8 +82,12 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { const rebuild = async () => { setBusy(true); try { - await dcrypt.vault.rebuild(); - toast.success('Database rebuilt. Every item was carried over.'); + const report = await dcrypt.vault.rebuild(); + const rows = Object.values(report.copied).reduce((sum, n) => sum + n, 0); + toast.success( + `Database rebuilt: ${report.tables} tables deployed, ${rows} rows carried over ` + + `(${report.copied.items ?? 0} items).` + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } finally { diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 86b51ad..9ae1385 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -59,6 +59,12 @@ export type BrandIcon = | { kind: 'logo'; title: string; slug: string; light: string; dark: string } | { kind: 'glyph'; title: string; slug: string; path: string; hex: string }; +/** What a rebuild carried across, so the UI can show it was not a no-op. */ +export interface RebuildReport { + tables: number; + copied: Record; +} + /** * The complete surface the renderer can reach. Everything crosses the context * bridge as plain JSON; secrets flow through only as explicit call results, @@ -72,7 +78,7 @@ export interface DcryptApi { save(): Promise; changePassphrase(next: string): Promise; /** Re-deploys the pgpm module into a fresh database, keeping every item. */ - rebuild(): Promise; + rebuild(): Promise; /** Deletes the vault and every other file dcrypt keeps on this machine. */ eraseAll(): Promise; }; diff --git a/packages/vault/__tests__/vault.test.ts b/packages/vault/__tests__/vault.test.ts index b4c500e..80182cb 100644 --- a/packages/vault/__tests__/vault.test.ts +++ b/packages/vault/__tests__/vault.test.ts @@ -143,6 +143,8 @@ describe('Vault', () => { await vault.setField(code.id, 'seed', 'totp_seed', 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); const before = await vault.totpCode(code.id); + // the rebuild asserts the fresh database really carries the deployed schema, + // so this passing at all rules out a deploy that quietly did nothing await vault.rebuild(MODULE_PATH); // same ids, same ciphertext, same passphrase — nothing was re-keyed diff --git a/packages/vault/src/vault.ts b/packages/vault/src/vault.ts index dd84f21..a3b1080 100644 --- a/packages/vault/src/vault.ts +++ b/packages/vault/src/vault.ts @@ -33,6 +33,16 @@ import { const { pgcrypto } = require('@electric-sql/pglite/contrib/pgcrypto'); +const VAULT_SCHEMA = 'dcrypt_vault'; + +/** What a rebuild moved, so the UI can show that it was not a no-op. */ +export interface RebuildReport { + /** Tables in the freshly deployed schema that were copied through. */ + tables: number; + /** Rows carried across, per table. */ + copied: Record; +} + const DB_KEY_INFO = 'dcrypt/db-values'; const DB_KEY_SALT_META = 'db_key_salt'; @@ -145,23 +155,44 @@ export class Vault { // evict the cached pool so the next open never reaches this instance await teardownPgPools(); handle.unregister(); + await Vault.assertDeployed(handle.db as unknown as PGlite); // the adapter's PGlite type is resolved through the ESM declarations while // this CJS build resolves the CTS ones — identical runtime class return handle.db as unknown as PGlite; } + /** + * A deploy that decides it has nothing to do would leave an empty database + * that then silently swallows a rebuild, so prove the schema is really there. + */ + private static async assertDeployed(db: PGlite): Promise { + const wanted = COPY_ORDER.map((spec) => spec.table); + const found = await db.query<{ tablename: string }>( + 'SELECT tablename FROM pg_tables WHERE schemaname = $1', + [VAULT_SCHEMA] + ); + const present = new Set(found.rows.map((row) => row.tablename)); + const missing = wanted.filter((table) => !present.has(table)); + if (missing.length > 0) { + throw new Error( + `pgpm deployed nothing usable: ${VAULT_SCHEMA} is missing ${missing.join(', ')}` + ); + } + } + /** * Re-deploys the pgpm module into a fresh database and copies every row * across, so a vault created by an older module picks up schema changes. * Values move as ciphertext and the key salt is preserved, so no plaintext * is materialised and the master passphrase still opens the result. */ - async rebuild(modulePath: string): Promise { + async rebuild(modulePath: string): Promise { const old = this.database; const next = await Vault.deployFresh(modulePath); + const copied: Record = {}; try { for (const spec of COPY_ORDER) { - await copyTable(old, next, spec); + copied[spec.table] = await copyTable(old, next, spec); } // folders were inserted detached to satisfy their self-reference await reattachFolders(old, next); @@ -172,6 +203,7 @@ export class Vault { this.db = next; await old.close(); await this.save(); + return { tables: COPY_ORDER.length, copied }; } private static async readDbKeySalt(db: PGlite): Promise { @@ -568,7 +600,7 @@ const COPY_ORDER: CopySpec[] = [ }, ]; -const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise => { +const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise => { const binary = new Set(spec.binary ?? []); const detached = new Set(spec.detach ?? []); const selected = spec.columns @@ -581,7 +613,7 @@ const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise>( `SELECT ${selected} FROM dcrypt_vault.${spec.table}` ); - if (!rows.rows.length) return; + if (!rows.rows.length) return 0; const placeholders = spec.columns .map((column, index) => { @@ -599,6 +631,7 @@ const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise (detached.has(column) ? null : (row[column] ?? null))) ); } + return rows.rows.length; }; /** Second pass for folders, whose parent may be inserted after the child. */