diff --git a/apps/jetstream/src/app/components/settings/Settings.tsx b/apps/jetstream/src/app/components/settings/Settings.tsx index 5d1bdc8aa..9e32e7458 100644 --- a/apps/jetstream/src/app/components/settings/Settings.tsx +++ b/apps/jetstream/src/app/components/settings/Settings.tsx @@ -24,6 +24,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AnalyticsTrackingSetting } from './AnalyticsTrackingSetting'; import LoggerConfig from './LoggerConfig'; import { SettingsDeleteAccount } from './SettingsDeleteAccount'; +import { SettingsExportImportHistory } from './SettingsExportImportHistory'; const HEIGHT_BUFFER = 170; export const Settings = () => { @@ -265,6 +266,8 @@ export const Settings = () => {

+ +

Logging

diff --git a/apps/jetstream/src/app/components/settings/SettingsExportImportHistory.tsx b/apps/jetstream/src/app/components/settings/SettingsExportImportHistory.tsx new file mode 100644 index 000000000..30d3071d6 --- /dev/null +++ b/apps/jetstream/src/app/components/settings/SettingsExportImportHistory.tsx @@ -0,0 +1,91 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { ANALYTICS_KEYS, INPUT_ACCEPT_FILETYPES, MIME_TYPES } from '@jetstream/shared/constants'; +import { saveFile } from '@jetstream/shared/ui-utils'; +import { InputReadFileContent } from '@jetstream/types'; +import { FileSelector, fireToast, Spinner } from '@jetstream/ui'; +import { useAmplitude } from '@jetstream/ui-core'; +import { ClientDataImportError, exportClientHistoryData, importClientHistoryData, ImportResultSummary } from '@jetstream/ui/db'; +import { useState } from 'react'; + +function getImportedTotal(summary: ImportResultSummary): number { + return Object.values(summary).reduce((total, count) => total + count, 0); +} + +export const SettingsExportImportHistory = () => { + const { trackEvent } = useAmplitude(); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + + async function handleExport() { + try { + setExporting(true); + const envelope = await exportClientHistoryData(); + saveFile(JSON.stringify(envelope, null, 2), `jetstream-history-${Date.now()}.json`, MIME_TYPES.JSON); + trackEvent(ANALYTICS_KEYS.settings_export_history); + fireToast({ message: 'Your history was exported successfully.', type: 'success' }); + } catch (ex) { + logger.error('[SETTINGS] Error exporting history', ex); + fireToast({ + message: 'There was a problem exporting your history. Try again or file a support ticket for assistance.', + type: 'error', + }); + } finally { + setExporting(false); + } + } + + async function handleImport({ content }: InputReadFileContent) { + try { + setImporting(true); + // FileSelector reads non-text extensions as an ArrayBuffer, so normalize to a string before parsing. + const text = typeof content === 'string' ? content : new TextDecoder().decode(content); + const summary = await importClientHistoryData(JSON.parse(text)); + trackEvent(ANALYTICS_KEYS.settings_import_history, summary); + fireToast({ + message: `Imported ${getImportedTotal(summary)} history record(s). Refresh the page to see imported Apex, API, and deployment history.`, + type: 'success', + }); + } catch (ex) { + logger.error('[SETTINGS] Error importing history', ex); + // ClientDataImportError carries a user-facing explanation (e.g. "update Jetstream"); anything else + // is a parse/validation failure with a message that would not mean anything to the user. + fireToast({ + message: + ex instanceof ClientDataImportError + ? ex.message + : 'We could not import that file. Make sure it is an unmodified Jetstream history export and try again.', + type: 'error', + }); + } finally { + setImporting(false); + } + } + + return ( +
+

Export / Import History

+

+ Export your query, load mapping, API, Apex, deployment, and recent record history to a file to back it up or move it to another + browser. Importing merges the file into your existing history without creating duplicates. +

+ +
+ + {importing && } +
+
+ ); +}; + +export default SettingsExportImportHistory; diff --git a/libs/shared/constants/src/lib/shared-constants.ts b/libs/shared/constants/src/lib/shared-constants.ts index 33ffe7662..42f5b92ed 100644 --- a/libs/shared/constants/src/lib/shared-constants.ts +++ b/libs/shared/constants/src/lib/shared-constants.ts @@ -1,6 +1,7 @@ import type { InputAcceptTypeCsv, InputAcceptTypeExcel, + InputAcceptTypeJson, InputAcceptTypeTsv, InputAcceptTypeXml, InputAcceptTypeZip, @@ -27,12 +28,14 @@ export const INPUT_ACCEPT_FILETYPES: { TSV: InputAcceptTypeTsv; EXCEL: InputAcceptTypeExcel; XML: InputAcceptTypeXml; + JSON: InputAcceptTypeJson; } = { ZIP: '.zip', CSV: '.csv', TSV: '.tsv', EXCEL: '.xlsx', XML: '.xml', + JSON: '.json', }; export const HTTP = { @@ -229,6 +232,7 @@ export const ANALYTICS_KEYS = { query_HistoryTypeChanged: 'query_HistoryTypeChanged', query_HistoryEditQueryOpened: 'query_HistoryEditQueryOpened', query_HistoryExport: 'query_HistoryExport', + query_HistoryBackupNudgeClick: 'query_HistoryBackupNudgeClick', query_LoadMore: 'query_LoadMore', query_ManualQueryOpened: 'query_ManualQueryOpened', query_ManualSoqlOpened: 'query_ManualSoqlOpened', @@ -332,6 +336,8 @@ export const ANALYTICS_KEYS = { settings_password_action: 'settings_password_action', settings_revoke_session: 'settings_revoke_session', settings_color_scheme_changed: 'settings_color_scheme_changed', + settings_export_history: 'settings_export_history', + settings_import_history: 'settings_import_history', /** ORGANIZATIONS */ organizations_create_modal_open: 'organizations_create_modal_open', diff --git a/libs/shared/ui-core/src/query/QueryHistory/QueryHistoryModal.tsx b/libs/shared/ui-core/src/query/QueryHistory/QueryHistoryModal.tsx index 32d79538d..b803ff0e5 100644 --- a/libs/shared/ui-core/src/query/QueryHistory/QueryHistoryModal.tsx +++ b/libs/shared/ui-core/src/query/QueryHistory/QueryHistoryModal.tsx @@ -1,14 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; +import { APP_ROUTES } from '@jetstream/shared/ui-router'; import { formatNumber, useNonInitialEffect } from '@jetstream/shared/ui-utils'; import { multiWordObjectFilter } from '@jetstream/shared/utils'; import { QueryHistoryItem, QueryHistorySelection, SalesforceOrgUi, UpDown } from '@jetstream/types'; import { EmptyState, Grid, GridCol, Icon, List, Modal, SearchInput, Spinner } from '@jetstream/ui'; +import { fromAppState } from '@jetstream/ui/app-state'; import { getDexieDb, queryHistoryDb } from '@jetstream/ui/db'; import { useLiveQuery } from 'dexie-react-hooks'; +import { useAtomValue } from 'jotai'; import uniqBy from 'lodash/uniqBy'; import { createRef, forwardRef, useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router'; import { fromQueryHistoryState } from '../..'; import { useAmplitude } from '../../analytics'; import { QueryRestoreErrors } from '../RestoreQuery/query-restore-utils'; @@ -45,6 +49,9 @@ export interface QueryHistoryProps { export const QueryHistoryModal = forwardRef(({ className, selectedOrg, initialType, onRestore, onclose }, ref) => { const { trackEvent } = useAmplitude(); + const ability = useAtomValue(fromAppState.abilityState); + // RecordSync (history backup/sync) is a paid feature; free users keep history only in this browser. + const canSyncHistory = ability.can('access', 'RecordSync'); const [whichType, setWhichType] = useState(() => initialType || 'HISTORY'); const [whichOrg, setWhichOrg] = useState('SELECTED'); const ulRef = createRef(); @@ -190,6 +197,10 @@ export const QueryHistoryModal = forwardRef(({ className setWhichOrg('ALL'); } + function handleBackupNudgeClick() { + trackEvent(ANALYTICS_KEYS.query_HistoryBackupNudgeClick); + } + return ( (({ className onClose={() => onclose()} > {isRestoring && } + {!canSyncHistory && ( +
+ + Your query history is saved only in this browser.{' '} + + Upgrade to back it up & sync across devices + +
+ )} {selectObjectsList.length <= 1 && } {selectObjectsList.length > 1 && ( diff --git a/libs/shared/ui-db/src/index.ts b/libs/shared/ui-db/src/index.ts index 26fc521c7..16d557cd0 100644 --- a/libs/shared/ui-db/src/index.ts +++ b/libs/shared/ui-db/src/index.ts @@ -1,5 +1,7 @@ export * from './lib/analysis-job-history-retention'; export * from './lib/api-request-history.db'; +export * from './lib/client-data-export.db'; +export * from './lib/client-data-import.db'; export * from './lib/client-data.db'; export * from './lib/query-history-object.db'; export * from './lib/query-history.db'; diff --git a/libs/shared/ui-db/src/lib/__tests__/client-data-merge.utils.spec.ts b/libs/shared/ui-db/src/lib/__tests__/client-data-merge.utils.spec.ts new file mode 100644 index 000000000..d2578c44b --- /dev/null +++ b/libs/shared/ui-db/src/lib/__tests__/client-data-merge.utils.spec.ts @@ -0,0 +1,188 @@ +import { ApiHistoryItem, LoadSavedMappingItem, QueryHistoryItem, RecentHistoryItem } from '@jetstream/types'; +import { describe, expect, it } from 'vitest'; +import { + mergeApiRequestHistory, + mergeLoadSavedMapping, + mergeQueryHistory, + mergeRecentHistoryItem, + toQueryHistoryObject, +} from '../client-data-merge.utils'; + +function queryHistory(overrides: Partial = {}): QueryHistoryItem { + return { + key: 'qh_o:accountselectid', + hashedKey: 'h', + org: 'o', + sObject: 'Account', + label: 'Account', + soql: 'SELECT Id FROM Account', + runCount: 1, + isTooling: false, + isFavorite: false, + lastRun: new Date('2026-01-01T00:00:00Z'), + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; +} + +describe('mergeQueryHistory', () => { + it('returns a clone of the imported record when nothing exists', () => { + const imported = queryHistory(); + const merged = mergeQueryHistory(undefined, imported); + expect(merged).toEqual(imported); + expect(merged).not.toBe(imported); + }); + + it('is idempotent — re-importing an identical record does not inflate run count or flip favorite', () => { + const existing = queryHistory({ runCount: 5, isFavorite: true }); + const merged = mergeQueryHistory(existing, { ...existing }); + expect(merged.runCount).toBe(5); + expect(merged.isFavorite).toBe(true); + expect(merged.lastRun).toEqual(existing.lastRun); + }); + + it('keeps the max run count', () => { + expect(mergeQueryHistory(queryHistory({ runCount: 5 }), queryHistory({ runCount: 3 })).runCount).toBe(5); + expect(mergeQueryHistory(queryHistory({ runCount: 2 }), queryHistory({ runCount: 7 })).runCount).toBe(7); + }); + + it('unions the favorite flag (favorite if either is)', () => { + expect(mergeQueryHistory(queryHistory({ isFavorite: false }), queryHistory({ isFavorite: true })).isFavorite).toBe(true); + expect(mergeQueryHistory(queryHistory({ isFavorite: true }), queryHistory({ isFavorite: false })).isFavorite).toBe(true); + expect(mergeQueryHistory(queryHistory({ isFavorite: false }), queryHistory({ isFavorite: false })).isFavorite).toBe(false); + }); + + it('takes content fields from the most-recently-run record and keeps the latest lastRun', () => { + const existing = queryHistory({ soql: 'OLD', lastRun: new Date('2026-01-01T00:00:00Z') }); + const imported = queryHistory({ soql: 'NEW', lastRun: new Date('2026-02-01T00:00:00Z') }); + + const importedWins = mergeQueryHistory(existing, imported); + expect(importedWins.soql).toBe('NEW'); + expect(importedWins.lastRun).toEqual(new Date('2026-02-01T00:00:00Z')); + + const existingWins = mergeQueryHistory(imported, existing); + expect(existingWins.soql).toBe('NEW'); + expect(existingWins.lastRun).toEqual(new Date('2026-02-01T00:00:00Z')); + }); + + it('keeps the earliest createdAt', () => { + const merged = mergeQueryHistory( + queryHistory({ createdAt: new Date('2026-03-01T00:00:00Z') }), + queryHistory({ createdAt: new Date('2026-01-01T00:00:00Z') }), + ); + expect(merged.createdAt).toEqual(new Date('2026-01-01T00:00:00Z')); + }); +}); + +describe('mergeApiRequestHistory', () => { + function apiHistory(overrides: Partial = {}): ApiHistoryItem { + return { + key: 'api_o:get:/services/data', + hashedKey: 'h', + org: 'o', + label: '/services/data', + lastRun: new Date('2026-01-01T00:00:00Z'), + isFavorite: 'false', + request: { method: 'GET', url: '/services/data', headers: {}, body: '', bodyType: 'JSON' }, + response: { status: 200, statusText: 'OK' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; + } + + it('unions the string favorite flag', () => { + expect(mergeApiRequestHistory(apiHistory({ isFavorite: 'true' }), apiHistory({ isFavorite: 'false' })).isFavorite).toBe('true'); + expect(mergeApiRequestHistory(apiHistory({ isFavorite: 'false' }), apiHistory({ isFavorite: 'false' })).isFavorite).toBe('false'); + }); + + it('keeps the latest lastRun and earliest createdAt', () => { + const merged = mergeApiRequestHistory( + apiHistory({ lastRun: new Date('2026-01-01T00:00:00Z'), createdAt: new Date('2026-02-01T00:00:00Z') }), + apiHistory({ lastRun: new Date('2026-03-01T00:00:00Z'), createdAt: new Date('2026-01-01T00:00:00Z') }), + ); + expect(merged.lastRun).toEqual(new Date('2026-03-01T00:00:00Z')); + expect(merged.createdAt).toEqual(new Date('2026-01-01T00:00:00Z')); + }); +}); + +describe('mergeLoadSavedMapping', () => { + function mapping(overrides: Partial = {}): LoadSavedMappingItem { + return { + key: 'lsm_account:3:123', + hashedKey: 'h', + name: 'My Mapping', + sobject: 'Account', + csvFields: ['a'], + sobjectFields: ['Name'], + mapping: {}, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; + } + + it('keeps the record with the later updatedAt and the earliest createdAt', () => { + const merged = mergeLoadSavedMapping( + mapping({ name: 'OLD', updatedAt: new Date('2026-01-01T00:00:00Z'), createdAt: new Date('2026-02-01T00:00:00Z') }), + mapping({ name: 'NEW', updatedAt: new Date('2026-03-01T00:00:00Z'), createdAt: new Date('2026-01-01T00:00:00Z') }), + ); + expect(merged.name).toBe('NEW'); + expect(merged.updatedAt).toEqual(new Date('2026-03-01T00:00:00Z')); + expect(merged.createdAt).toEqual(new Date('2026-01-01T00:00:00Z')); + }); +}); + +describe('mergeRecentHistoryItem', () => { + function recent(overrides: Partial = {}): RecentHistoryItem { + return { + key: 'ri_o:sobject', + hashedKey: 'h', + org: 'o', + items: [], + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; + } + + it('unions items by name, keeping the most-recently-used entry', () => { + const existing = recent({ + items: [ + { name: 'Account', lastUsed: new Date('2026-01-01T00:00:00Z') }, + { name: 'Contact', lastUsed: new Date('2026-01-02T00:00:00Z') }, + ], + }); + const imported = recent({ + items: [ + { name: 'Account', lastUsed: new Date('2026-03-01T00:00:00Z') }, + { name: 'Lead', lastUsed: new Date('2026-02-01T00:00:00Z') }, + ], + }); + + const merged = mergeRecentHistoryItem(existing, imported); + + expect(merged.items.map(({ name }) => name).sort()).toEqual(['Account', 'Contact', 'Lead']); + expect(merged.items.find(({ name }) => name === 'Account')?.lastUsed).toEqual(new Date('2026-03-01T00:00:00Z')); + }); + + it('caps the merged list at 75 items', () => { + const makeItems = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => ({ name: `${prefix}-${index}`, lastUsed: new Date('2026-01-01T00:00:00Z') })); + const merged = mergeRecentHistoryItem(recent({ items: makeItems('a', 50) }), recent({ items: makeItems('b', 50) })); + expect(merged.items).toHaveLength(75); + }); +}); + +describe('toQueryHistoryObject', () => { + it('derives a lowercased key and string isTooling flag', () => { + expect(toQueryHistoryObject(queryHistory({ org: 'O', sObject: 'Account', isTooling: true, label: 'Account Label' }))).toEqual({ + key: 'qho_o:account:true', + org: 'O', + sObject: 'Account', + sObjectLabel: 'Account Label', + isTooling: 'true', + }); + }); +}); diff --git a/libs/shared/ui-db/src/lib/client-data-export.db.ts b/libs/shared/ui-db/src/lib/client-data-export.db.ts new file mode 100644 index 000000000..1add91af5 --- /dev/null +++ b/libs/shared/ui-db/src/lib/client-data-export.db.ts @@ -0,0 +1,88 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { INDEXED_DB } from '@jetstream/shared/constants'; +import { getLocalStore } from '@jetstream/shared/data'; +import { + ApexHistoryItem, + ApiHistoryItem, + CLIENT_DATA_EXPORT_APP, + CLIENT_DATA_EXPORT_VERSION, + ClientDataExportEnvelope, + ClientDataExportRecentRecord, + LoadSavedMappingItem, + QueryHistoryItem, + RecentHistoryItem, + SalesforceApiHistoryItem, + SalesforceDeployHistoryItem, +} from '@jetstream/types'; +import { getDexieDb } from './ui-db'; + +/** + * Builds a single portable envelope of the user's browser-stored history so it can be saved to a file + * and re-imported later (or on another device). Reads the Dexie synced tables and the localforage + * local-only datasets. Deploy history is included as metadata only — the large binary package files + * (stored under `HISTORY:DEPLOY:FILE:*`) are intentionally excluded to keep the export small. + * + * Date fields are left as `Date` objects; `JSON.stringify` serializes them to ISO strings when the + * caller writes the file, and they are revived on import. + */ +export async function exportClientHistoryData(): Promise { + const dexieDb = getDexieDb(); + const [ + query_history, + load_saved_mapping, + recent_history_item, + api_request_history, + apex_history, + salesforce_api_history, + deploy_history, + recent_records, + ] = await Promise.all([ + dexieDb.query_history.toArray(), + dexieDb.load_saved_mapping.toArray(), + dexieDb.recent_history_item.toArray(), + dexieDb.api_request_history.toArray(), + getLocalforageMap(INDEXED_DB.KEYS.apexHistory), + getLocalforageMap(INDEXED_DB.KEYS.salesforceApiHistory), + getDeployHistoryMetadata(), + getLocalforageMap(INDEXED_DB.KEYS.recentRecords), + ]); + + return { + version: CLIENT_DATA_EXPORT_VERSION, + app: CLIENT_DATA_EXPORT_APP, + exportedAt: new Date().toISOString(), + data: { + query_history: query_history as QueryHistoryItem[], + load_saved_mapping: load_saved_mapping as LoadSavedMappingItem[], + recent_history_item: recent_history_item as RecentHistoryItem[], + api_request_history: api_request_history as ApiHistoryItem[], + apex_history, + salesforce_api_history, + deploy_history, + recent_records, + }, + }; +} + +async function getLocalforageMap(key: string): Promise> { + try { + return (await getLocalStore().getItem>(key)) || {}; + } catch (ex) { + logger.warn('[DB][EXPORT] Error reading localforage key', key, ex); + return {}; + } +} + +/** + * Deploy history is stored as an array. We strip `fileKey` so an imported item never dangle-references + * a missing package blob (the blobs themselves are not exported). + */ +async function getDeployHistoryMetadata(): Promise { + try { + const items = (await getLocalStore().getItem(INDEXED_DB.KEYS.deployHistory)) || []; + return items.map(({ fileKey, ...item }) => item); + } catch (ex) { + logger.warn('[DB][EXPORT] Error reading deploy history', ex); + return []; + } +} diff --git a/libs/shared/ui-db/src/lib/client-data-import.db.ts b/libs/shared/ui-db/src/lib/client-data-import.db.ts new file mode 100644 index 000000000..891a367bd --- /dev/null +++ b/libs/shared/ui-db/src/lib/client-data-import.db.ts @@ -0,0 +1,252 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { INDEXED_DB } from '@jetstream/shared/constants'; +import { getLocalStore } from '@jetstream/shared/data'; +import { + ApexHistoryItem, + ApiHistoryItem, + CLIENT_DATA_EXPORT_VERSION, + ClientDataExportEnvelope, + ClientDataExportEnvelopeSchema, + ClientDataExportRecentRecord, + LoadSavedMappingItem, + QueryHistoryItem, + RecentHistoryItem, + SalesforceApiHistoryItem, + SalesforceDeployHistoryItem, +} from '@jetstream/types'; +import uniqBy from 'lodash/uniqBy'; +import { + mergeApiRequestHistory, + mergeLoadSavedMapping, + mergeQueryHistory, + mergeRecentHistoryItem, + toQueryHistoryObject, +} from './client-data-merge.utils'; +import { getDexieDb, getHashedRecordKey } from './ui-db'; + +const RECENT_RECORDS_MAX_ITEMS = 25; // mirrors record-utils.ts +const DEPLOY_HISTORY_MAX_ITEMS = 500; // mirrors deploy-metadata.utils.tsx + +export interface ImportResultSummary { + query_history: number; + load_saved_mapping: number; + recent_history_item: number; + api_request_history: number; + apex_history: number; + salesforce_api_history: number; + deploy_history: number; + recent_records: number; +} + +/** + * Import failure whose message is written for the user and is safe to surface directly in the UI, + * unlike schema validation errors which are only meaningful to a developer. + */ +export class ClientDataImportError extends Error {} + +/** + * Imports a previously exported history envelope into the browser, upserting by each record's + * deterministic content-based key so re-importing the same file never creates duplicates. + * + * Validation (shape, app marker, version, date revival, reserved-key stripping) happens up front via + * the Zod schema — no data is written until the envelope is known-good. Conflicts are resolved with a + * smart merge per dataset (most-recently-run record wins, favorites are unioned, run counts take the + * max) rather than a blind overwrite, which keeps re-imports idempotent. + * + * @throws ZodError when the file is not a valid Jetstream history export. + * @throws ClientDataImportError when the file was produced by a newer, unsupported export version. + */ +export async function importClientHistoryData(raw: unknown): Promise { + const envelope = ClientDataExportEnvelopeSchema.parse(raw) as unknown as ClientDataExportEnvelope; + + if (envelope.version > CLIENT_DATA_EXPORT_VERSION) { + throw new ClientDataImportError( + 'This backup was created by a newer version of Jetstream and cannot be imported. Please update Jetstream and try again.', + ); + } + + const { data } = envelope; + + return { + query_history: await upsertQueryHistory(data.query_history), + load_saved_mapping: await upsertLoadSavedMapping(data.load_saved_mapping), + recent_history_item: await upsertRecentHistoryItems(data.recent_history_item), + api_request_history: await upsertApiRequestHistory(data.api_request_history), + apex_history: await mergeLocalforageHistoryMap(INDEXED_DB.KEYS.apexHistory, data.apex_history), + salesforce_api_history: await mergeLocalforageHistoryMap( + INDEXED_DB.KEYS.salesforceApiHistory, + data.salesforce_api_history, + ), + deploy_history: await mergeDeployHistory(data.deploy_history), + recent_records: await mergeRecentRecords(data.recent_records), + }; +} + +/** + * Dexie tables — `hashedKey` (and `isFavoriteIdx` for query history) are always recomputed rather than + * trusted from the file, mirroring the localforage→dexie migration. Hashing is async (`crypto.subtle`), + * so each record is merged and hashed concurrently instead of one-at-a-time. `bulkPut` of the merged set + * produces a dexie-observable change per record, so normal sync pushes imported records to the server + * for paid users with no extra handling. + */ + +async function upsertQueryHistory(imported: QueryHistoryItem[]): Promise { + if (!imported.length) { + return 0; + } + const dexieDb = getDexieDb(); + const existingList = await dexieDb.query_history.bulkGet(imported.map((item) => item.key)); + const merged = await Promise.all( + imported.map(async (importedItem, i) => { + const item = mergeQueryHistory(existingList[i] ?? undefined, importedItem); + item.hashedKey = await getHashedRecordKey(item.key); + item.isFavoriteIdx = item.isFavorite ? 'true' : 'false'; + return item; + }), + ); + await dexieDb.query_history.bulkPut(merged); + await deriveQueryHistoryObjects(merged); + return merged.length; +} + +async function upsertApiRequestHistory(imported: ApiHistoryItem[]): Promise { + if (!imported.length) { + return 0; + } + const dexieDb = getDexieDb(); + const existingList = await dexieDb.api_request_history.bulkGet(imported.map((item) => item.key)); + const merged = await Promise.all( + imported.map(async (importedItem, i) => { + const item = mergeApiRequestHistory(existingList[i] ?? undefined, importedItem); + item.hashedKey = await getHashedRecordKey(item.key); + return item; + }), + ); + await dexieDb.api_request_history.bulkPut(merged); + return merged.length; +} + +async function upsertLoadSavedMapping(imported: LoadSavedMappingItem[]): Promise { + if (!imported.length) { + return 0; + } + const dexieDb = getDexieDb(); + const existingList = await dexieDb.load_saved_mapping.bulkGet(imported.map((item) => item.key)); + const merged = await Promise.all( + imported.map(async (importedItem, i) => { + const item = mergeLoadSavedMapping(existingList[i] ?? undefined, importedItem); + item.hashedKey = await getHashedRecordKey(item.key); + return item; + }), + ); + await dexieDb.load_saved_mapping.bulkPut(merged); + return merged.length; +} + +async function upsertRecentHistoryItems(imported: RecentHistoryItem[]): Promise { + if (!imported.length) { + return 0; + } + const dexieDb = getDexieDb(); + const existingList = await dexieDb.recent_history_item.bulkGet(imported.map((item) => item.key)); + const merged = await Promise.all( + imported.map(async (importedItem, i) => { + const item = mergeRecentHistoryItem(existingList[i] ?? undefined, importedItem); + item.hashedKey = await getHashedRecordKey(item.key); + return item; + }), + ); + await dexieDb.recent_history_item.bulkPut(merged); + return merged.length; +} + +/** + * Re-derive the (non-synced) `_query_history_object` lookup rows that power the object filter list in + * the query history modal, rather than exporting/importing them directly. + */ +async function deriveQueryHistoryObjects(items: QueryHistoryItem[]): Promise { + try { + const objects = uniqBy(items.map(toQueryHistoryObject), 'key'); + await getDexieDb()._query_history_object.bulkPut(objects); + } catch (ex) { + logger.warn('[DB][IMPORT] Error deriving query history objects', ex); + } +} + +/** + * localforage datasets (local-only, never synced). + */ + +/** Shared merge for the keyed history maps (apex, salesforce api) — later `lastRun` wins per key. */ +async function mergeLocalforageHistoryMap(storageKey: string, imported: Record): Promise { + const importedKeys = Object.keys(imported); + if (!importedKeys.length) { + return 0; + } + try { + const existing = (await getLocalStore().getItem>(storageKey)) || {}; + const merged: Record = { ...existing }; + for (const [key, item] of Object.entries(imported)) { + const current = merged[key]; + merged[key] = !current || new Date(item.lastRun).getTime() >= new Date(current.lastRun).getTime() ? item : current; + } + await getLocalStore().setItem(storageKey, merged); + } catch (ex) { + logger.warn('[DB][IMPORT] Error merging localforage history map', storageKey, ex); + throw ex; + } + return importedKeys.length; +} + +async function mergeRecentRecords(imported: Record): Promise { + const orgIds = Object.keys(imported); + if (!orgIds.length) { + return 0; + } + let importedCount = 0; + try { + const existing = (await getLocalStore().getItem>(INDEXED_DB.KEYS.recentRecords)) || {}; + const merged: Record = { ...existing }; + for (const [orgId, importedItems] of Object.entries(imported)) { + importedCount += importedItems.length; + const combined = [...(merged[orgId] || []), ...importedItems]; + merged[orgId] = uniqBy(combined, 'recordId').slice(0, RECENT_RECORDS_MAX_ITEMS); + } + await getLocalStore().setItem(INDEXED_DB.KEYS.recentRecords, merged); + } catch (ex) { + logger.warn('[DB][IMPORT] Error merging recent records', ex); + throw ex; + } + return importedCount; +} + +/** + * Deploy history dedups by `key`; existing items are kept on collision because they may reference a + * local package blob that the export intentionally omitted. Imported items are guaranteed to have no + * `fileKey` so they can never dangle-reference a missing blob. + */ +async function mergeDeployHistory(imported: SalesforceDeployHistoryItem[]): Promise { + if (!imported.length) { + return 0; + } + try { + const existing = (await getLocalStore().getItem(INDEXED_DB.KEYS.deployHistory)) || []; + const byKey = new Map(); + for (const item of existing) { + byKey.set(item.key, item); + } + for (const { fileKey, ...item } of imported) { + if (!byKey.has(item.key)) { + byKey.set(item.key, item); + } + } + const merged = Array.from(byKey.values()) + .sort((a, b) => new Date(b.finish).getTime() - new Date(a.finish).getTime()) + .slice(0, DEPLOY_HISTORY_MAX_ITEMS); + await getLocalStore().setItem(INDEXED_DB.KEYS.deployHistory, merged); + } catch (ex) { + logger.warn('[DB][IMPORT] Error merging deploy history', ex); + throw ex; + } + return imported.length; +} diff --git a/libs/shared/ui-db/src/lib/client-data-merge.utils.ts b/libs/shared/ui-db/src/lib/client-data-merge.utils.ts new file mode 100644 index 000000000..f70afa135 --- /dev/null +++ b/libs/shared/ui-db/src/lib/client-data-merge.utils.ts @@ -0,0 +1,85 @@ +import { ApiHistoryItem, LoadSavedMappingItem, QueryHistoryItem, QueryHistoryObject, RecentHistoryItem } from '@jetstream/types'; +import { max as maxDate } from 'date-fns/max'; +import { min as minDate } from 'date-fns/min'; +import uniqBy from 'lodash/uniqBy'; + +const RECENT_HISTORY_MAX_ITEMS = 75; // mirrors recent-history-items.db.ts + +/** + * Pure smart-merge helpers used when importing history. Kept free of Dexie/localforage so they can be + * unit tested in isolation. The merges are designed to be idempotent: re-importing the same file must + * not inflate counts or flip favorites back. + */ + +export function mergeQueryHistory(existing: QueryHistoryItem | undefined, imported: QueryHistoryItem): QueryHistoryItem { + if (!existing) { + return { ...imported }; + } + // Content fields (soql/label/sObject/...) come from whichever record ran most recently. + const base = imported.lastRun.getTime() >= existing.lastRun.getTime() ? imported : existing; + return { + ...base, + isFavorite: existing.isFavorite || imported.isFavorite, + runCount: Math.max(existing.runCount ?? 0, imported.runCount ?? 0), + lastRun: maxDate([existing.lastRun, imported.lastRun]), + createdAt: minDate([existing.createdAt, imported.createdAt]), + updatedAt: new Date(), + customLabel: existing.customLabel ?? imported.customLabel ?? null, + }; +} + +export function mergeApiRequestHistory(existing: ApiHistoryItem | undefined, imported: ApiHistoryItem): ApiHistoryItem { + if (!existing) { + return { ...imported }; + } + const base = imported.lastRun.getTime() >= existing.lastRun.getTime() ? imported : existing; + return { + ...base, + isFavorite: existing.isFavorite === 'true' || imported.isFavorite === 'true' ? 'true' : 'false', + lastRun: maxDate([existing.lastRun, imported.lastRun]), + createdAt: minDate([existing.createdAt, imported.createdAt]), + updatedAt: new Date(), + }; +} + +export function mergeLoadSavedMapping(existing: LoadSavedMappingItem | undefined, imported: LoadSavedMappingItem): LoadSavedMappingItem { + if (!existing) { + return { ...imported }; + } + const base = imported.updatedAt.getTime() >= existing.updatedAt.getTime() ? imported : existing; + return { + ...base, + createdAt: minDate([existing.createdAt, imported.createdAt]), + updatedAt: maxDate([existing.updatedAt, imported.updatedAt]), + }; +} + +export function mergeRecentHistoryItem(existing: RecentHistoryItem | undefined, imported: RecentHistoryItem): RecentHistoryItem { + const combinedItems = [...(existing?.items ?? []), ...imported.items]; + // Sort newest-first so uniqBy keeps the most-recently-used entry for each name. + const items = uniqBy( + combinedItems.sort((a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()), + 'name', + ).slice(0, RECENT_HISTORY_MAX_ITEMS); + return { + ...(existing ?? imported), + items, + createdAt: existing ? minDate([existing.createdAt, imported.createdAt]) : imported.createdAt, + updatedAt: new Date(), + }; +} + +/** + * Derives the (non-synced) `_query_history_object` lookup row for a query history record. Mirrors + * `getQueryHistoryObject` in query-history-object.db.ts so the object filter list in the query history + * modal stays populated after an import. + */ +export function toQueryHistoryObject(item: QueryHistoryItem): QueryHistoryObject { + return { + key: `qho_${item.org}:${item.sObject}:${item.isTooling}`.toLowerCase(), + org: item.org, + sObject: item.sObject, + sObjectLabel: item.label, + isTooling: item.isTooling ? 'true' : 'false', + }; +} diff --git a/libs/shared/ui-db/src/lib/client-data.db.ts b/libs/shared/ui-db/src/lib/client-data.db.ts index 1f8dfafda..4c53fd70b 100644 --- a/libs/shared/ui-db/src/lib/client-data.db.ts +++ b/libs/shared/ui-db/src/lib/client-data.db.ts @@ -176,16 +176,18 @@ class DexieInitializer { } else if (!this.scopedUserId) { return; } - // Sync is disabled — disconnect if we have an active connection, otherwise no-op - if (!enable) { - if (this.hasConnectedSync) { - await dexieDataSync.disconnect(); - this.hasConnectedSync = false; - } + // The scope failed to build (stores unbound) — there is no database to sync (or disconnect) against + if (!hasDexieDb()) { return; } - // The scope failed to build (stores unbound) — there is no database to sync against - if (!hasDexieDb()) { + // Sync is disabled - ensure any persisted sync node is marked OFFLINE so dexie-syncable does not try to + // auto-reconnect on db open with a protocol that was never registered this session. + // disconnect() only flips the persisted node's status and does not require the protocol to be registered, + // and is safe to call when no sync node exists. hasConnectedSync is a per-session flag, so it cannot be + // relied on here: the persisted node survives across page loads while the flag resets to false. + if (!enable) { + await dexieDataSync.disconnect(); + this.hasConnectedSync = false; return; } // Register the sync protocol once per process (the protocol name is instance-independent) diff --git a/libs/types/src/lib/sync/__tests__/sync.types.spec.ts b/libs/types/src/lib/sync/__tests__/sync.types.spec.ts index ab2c2bbb2..9d35cfc3e 100644 --- a/libs/types/src/lib/sync/__tests__/sync.types.spec.ts +++ b/libs/types/src/lib/sync/__tests__/sync.types.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { SyncRecordOperationSchema } from '../sync.types'; +import { ClientDataExportEnvelopeSchema, SyncRecordOperationSchema } from '../sync.types'; /** * Security coverage for the sync record schema (prototype pollution): @@ -64,3 +64,95 @@ describe('SyncRecordOperationSchema — data sanitization', () => { } }); }); + +describe('ClientDataExportEnvelopeSchema', () => { + const emptyData = { + query_history: [], + load_saved_mapping: [], + recent_history_item: [], + api_request_history: [], + apex_history: {}, + salesforce_api_history: {}, + deploy_history: [], + recent_records: {}, + }; + + const baseEnvelope = (overrides: Record = {}) => ({ + version: 1, + app: 'jetstream', + exportedAt: '2026-06-30T00:00:00Z', + data: emptyData, + ...overrides, + }); + + const queryHistoryRecord = (overrides: Record = {}) => ({ + key: 'qh_o:accountselectid', + hashedKey: 'h', + org: 'o', + sObject: 'Account', + label: 'Account', + soql: 'SELECT Id FROM Account', + runCount: 2, + isTooling: false, + isFavorite: true, + lastRun: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + ...overrides, + }); + + it('revives ISO date strings to Date objects', () => { + const result = ClientDataExportEnvelopeSchema.parse(baseEnvelope({ data: { ...emptyData, query_history: [queryHistoryRecord()] } })); + const [record] = result.data.query_history; + expect(record.lastRun).toBeInstanceOf(Date); + expect(record.createdAt).toBeInstanceOf(Date); + expect(record.updatedAt).toBeInstanceOf(Date); + // Passthrough fields survive validation + expect((record as any).soql).toBe('SELECT Id FROM Account'); + }); + + it('defaults missing data sections to empty collections', () => { + const result = ClientDataExportEnvelopeSchema.parse({ version: 1, app: 'jetstream', exportedAt: '2026-06-30T00:00:00Z', data: {} }); + expect(result.data.query_history).toEqual([]); + expect(result.data.apex_history).toEqual({}); + expect(result.data.recent_records).toEqual({}); + }); + + it.each(['org', 'sObject', 'soql', 'label'])('rejects a query history record missing the required "%s" field', (field) => { + const record: Record = queryHistoryRecord(); + delete record[field]; + const result = ClientDataExportEnvelopeSchema.safeParse(baseEnvelope({ data: { ...emptyData, query_history: [record] } })); + expect(result.success).toBe(false); + }); + + it('defaults derivable query history fields (runCount/isTooling/isFavorite) instead of rejecting', () => { + const { runCount: _runCount, isTooling: _isTooling, isFavorite: _isFavorite, ...record } = queryHistoryRecord(); + const result = ClientDataExportEnvelopeSchema.parse(baseEnvelope({ data: { ...emptyData, query_history: [record] } })); + expect(result.data.query_history[0]).toMatchObject({ runCount: 0, isTooling: false, isFavorite: false }); + }); + + it('rejects a file that is not a Jetstream export', () => { + expect(ClientDataExportEnvelopeSchema.safeParse(baseEnvelope({ app: 'something-else' })).success).toBe(false); + }); + + it('rejects an envelope with a non-numeric version', () => { + expect(ClientDataExportEnvelopeSchema.safeParse(baseEnvelope({ version: 'one' })).success).toBe(false); + }); + + it('strips reserved keys nested inside passthrough fields', () => { + // Build via JSON.parse so `__proto__` lands as an own key, matching how a file body is materialized. + const record = JSON.parse( + '{"key":"qh_o:x","org":"o","sObject":"Account","label":"Account","soql":"SELECT Id","runCount":1,"isTooling":false,"isFavorite":false,"hashedKey":"h","lastRun":"2026-01-01T00:00:00Z","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","extra":{"__proto__":{"polluted":true},"keep":1}}', + ); + const result = ClientDataExportEnvelopeSchema.safeParse(baseEnvelope({ data: { ...emptyData, query_history: [record] } })); + + expect(result.success).toBe(true); + if (result.success) { + // safeParse returns the whole envelope, so the records live under result.data.data.* + const parsedExtra = (result.data.data.query_history[0] as any).extra; + expect(Object.prototype.hasOwnProperty.call(parsedExtra, '__proto__')).toBe(false); + expect(parsedExtra.keep).toBe(1); + } + expect(({} as any).polluted).toBeUndefined(); + }); +}); diff --git a/libs/types/src/lib/sync/sync.types.ts b/libs/types/src/lib/sync/sync.types.ts index b5dfca9b2..5f8e2717b 100644 --- a/libs/types/src/lib/sync/sync.types.ts +++ b/libs/types/src/lib/sync/sync.types.ts @@ -2,7 +2,13 @@ import { parseISO } from 'date-fns/parseISO'; import { z } from 'zod'; import { Maybe } from '../types'; import { SavedFieldMapping } from '../ui/load-records-types'; -import { SalesforceApiHistoryRequest, SalesforceApiHistoryResponse } from '../ui/types'; +import { + ApexHistoryItem, + SalesforceApiHistoryItem, + SalesforceApiHistoryRequest, + SalesforceApiHistoryResponse, + SalesforceDeployHistoryItem, +} from '../ui/types'; const DateTimeSchema = z.union([z.string(), z.date()]).transform((val) => (val instanceof Date ? val : parseISO(val))); @@ -202,3 +208,157 @@ export interface ApiHistoryItem { updatedAt: Date; } export type ApiHistoryBodyType = 'JSON' | 'TEXT'; + +/** + * CLIENT DATA EXPORT / IMPORT + * + * Envelope used by the Settings "Export / Import History" feature so users can back up and restore + * their browser-stored history. Covers both the Dexie synced tables and the localforage local-only + * datasets. Deploy history is included as metadata only (the binary package files are intentionally + * excluded to keep the file small). + */ +export const CLIENT_DATA_EXPORT_VERSION = 1; +export const CLIENT_DATA_EXPORT_APP = 'jetstream'; + +/** + * Minimal recent-record shape. The source type lives in ui-core; it is redeclared here to keep the + * types lib (and ui-db) decoupled from feature libraries. + */ +export interface ClientDataExportRecentRecord { + recordId: string; + sobject: string; + name?: Maybe; +} + +export interface ClientDataExportData { + query_history: QueryHistoryItem[]; + load_saved_mapping: LoadSavedMappingItem[]; + recent_history_item: RecentHistoryItem[]; + api_request_history: ApiHistoryItem[]; + apex_history: Record; + salesforce_api_history: Record; + deploy_history: SalesforceDeployHistoryItem[]; + recent_records: Record; +} + +export interface ClientDataExportEnvelope { + version: number; + app: typeof CLIENT_DATA_EXPORT_APP; + exportedAt: string; + data: ClientDataExportData; +} + +/** + * Item schemas: revive known `Date` fields, require the identity/content fields the import merge and + * the history UIs actually read, and pass everything else through (`catchall`) so adding fields later + * does not break importing older/newer files. Fields the app can derive (run counts, favorite/tooling + * flags) are defaulted rather than required so a slightly thin legacy record still imports, but a file + * missing the content fields is rejected outright — importing it would write unusable rows into the + * local DB and, for sync users, push them to the server. Indexed/derived fields (`hashedKey`, + * `isFavoriteIdx`) are recomputed on import and are intentionally NOT trusted from the file. + */ +const QueryHistoryItemImportSchema = z + .object({ + key: z.string(), + org: z.string(), + sObject: z.string(), + label: z.string(), + customLabel: z.string().nullish(), + soql: z.string(), + runCount: z.number().default(0), + isTooling: z.boolean().default(false), + isFavorite: z.boolean().default(false), + lastRun: DateTimeSchema, + createdAt: DateTimeSchema, + updatedAt: DateTimeSchema, + }) + .catchall(z.unknown()); + +const LoadSavedMappingItemImportSchema = z + .object({ + key: z.string(), + name: z.string(), + sobject: z.string(), + csvFields: z.array(z.string()).default([]), + sobjectFields: z.array(z.string()).default([]), + mapping: z.record(z.string(), z.unknown()), + createdAt: DateTimeSchema, + updatedAt: DateTimeSchema, + }) + .catchall(z.unknown()); + +const RecentHistoryItemImportSchema = z + .object({ + key: z.string(), + org: z.string(), + items: z.array(z.object({ name: z.string(), lastUsed: DateTimeSchema }).catchall(z.unknown())).default([]), + createdAt: DateTimeSchema, + updatedAt: DateTimeSchema, + }) + .catchall(z.unknown()); + +const ApiHistoryItemImportSchema = z + .object({ + key: z.string(), + org: z.string(), + label: z.string(), + request: z.record(z.string(), z.unknown()), + response: z.record(z.string(), z.unknown()).nullish(), + isFavorite: z.enum(['true', 'false']).default('false'), + lastRun: DateTimeSchema, + createdAt: DateTimeSchema, + updatedAt: DateTimeSchema, + }) + .catchall(z.unknown()); + +const ApexHistoryItemImportSchema = z + .object({ key: z.string(), org: z.string(), label: z.string(), apex: z.string(), lastRun: DateTimeSchema }) + .catchall(z.unknown()); + +const SalesforceApiHistoryItemImportSchema = z + .object({ + key: z.string(), + org: z.string(), + label: z.string(), + request: z.record(z.string(), z.unknown()), + response: z.record(z.string(), z.unknown()).nullish(), + lastRun: DateTimeSchema, + }) + .catchall(z.unknown()); + +const DeployHistoryItemImportSchema = z + .object({ + key: z.string(), + destinationOrg: z.record(z.string(), z.unknown()), + status: z.string(), + type: z.string(), + start: DateTimeSchema, + finish: DateTimeSchema, + }) + .catchall(z.unknown()); + +const RecentRecordImportSchema = z.object({ recordId: z.string(), sobject: z.string(), name: z.string().nullish() }).catchall(z.unknown()); + +export const ClientDataExportEnvelopeSchema = z + .object({ + version: z.number(), + app: z.literal(CLIENT_DATA_EXPORT_APP), + exportedAt: z.string(), + // Each section defaults to empty, so a file missing a section still imports; `data` itself is required. + data: z.object({ + query_history: z.array(QueryHistoryItemImportSchema).default([]), + load_saved_mapping: z.array(LoadSavedMappingItemImportSchema).default([]), + recent_history_item: z.array(RecentHistoryItemImportSchema).default([]), + api_request_history: z.array(ApiHistoryItemImportSchema).default([]), + apex_history: z.record(z.string(), ApexHistoryItemImportSchema).default({}), + salesforce_api_history: z.record(z.string(), SalesforceApiHistoryItemImportSchema).default({}), + deploy_history: z.array(DeployHistoryItemImportSchema).default([]), + recent_records: z.record(z.string(), z.array(RecentRecordImportSchema)).default({}), + }), + }) + .transform((envelope) => { + // Defense-in-depth: drop dangerous own keys (__proto__/constructor/prototype) anywhere in the + // imported payload before it is merged into the prototype-based localforage lookup maps. + stripReservedKeys(envelope.data); + return envelope; + }); diff --git a/libs/types/src/lib/ui/types.ts b/libs/types/src/lib/ui/types.ts index 323f30243..f98ab07f3 100644 --- a/libs/types/src/lib/ui/types.ts +++ b/libs/types/src/lib/ui/types.ts @@ -210,12 +210,14 @@ export type InputAcceptType = | InputAcceptTypeTsv | InputAcceptTypeExcel | InputAcceptTypeXml + | InputAcceptTypeJson | InputAcceptTypeImage; export type InputAcceptTypeZip = '.zip'; export type InputAcceptTypeCsv = '.csv'; export type InputAcceptTypeTsv = '.tsv'; export type InputAcceptTypeExcel = '.xlsx'; export type InputAcceptTypeXml = '.xml'; +export type InputAcceptTypeJson = '.json'; export type InputAcceptTypeImage = '.png' | '.jpg' | '.jpeg' | '.gif' | '.webp' | '.svg'; // Generic status types