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.
+
+
+ {exporting && }
+ Export History
+
+
+
+ {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