Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,20 @@ await importDB({
backupData: backup,
strategy: 'merge',
});

// Selective restore: only repopulate the listed stores
await importDB({
dbName: 'my-app-db',
backupData: backup,
strategy: 'overwrite',
storeNames: ['messages', 'contacts'],
});
```

> **Note:** `storeNames` filters records only. The schema for every store in the backup is still
> created, so a store left out of the list exists after the import — it is just not populated
> (empty under `overwrite`, unchanged under `merge`). Names not present in the backup are ignored.

### Download as JSON File

```typescript
Expand Down
2 changes: 1 addition & 1 deletion checklist-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"label": "Best Practices",
"message": "65%",
"schema": "aossie-best-practices-v1",
"updated": "2026-08-24",
"updated": "2026-08-25",
"met": 32,
"total": 49,
"percent": 65,
Expand Down
23 changes: 19 additions & 4 deletions src/core/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,9 @@ function insertRecords(
* @param options.dbName - The name of the target IndexedDB database.
* @param options.backupData - The parsed ExportFormat JSON to import.
* @param options.strategy - Either `"overwrite"` or `"merge"`.
* @param options.storeNames - Optional list of store names to restore records into. If omitted,
* records from every store in the backup are restored. Schema creation is unaffected, so stores
* left out of the list still exist after the import — they are simply not populated.
* @returns A promise that resolves when the import is complete.
*
* @example
Expand All @@ -293,20 +296,32 @@ function insertRecords(
* backupData: backup,
* strategy: 'merge',
* });
*
* // Selective restore: rebuild the full schema, but only repopulate user data
* // and leave derived cache stores empty so the app refetches them.
* await importDB({
* dbName: 'my-app-db',
* backupData: backup,
* strategy: 'overwrite',
* storeNames: ['portfolioPositions', 'portfolioTransactions'],
* });
* ```
*/
export async function importDB(options: ImportOptions): Promise<void> {
const { dbName, backupData, strategy } = options;
const { dbName, backupData, strategy, storeNames } = options;

const db = await openDatabaseForImport(dbName, backupData, strategy);

try {
// Determine which stores to populate from the backup
const backupStoreNames = Object.keys(backupData.stores);
const selected = storeNames ? new Set(storeNames) : null;
const dbStoreNames = Array.from(db.objectStoreNames);

// Only insert into stores that exist in both the backup and the database
const targetStores = backupStoreNames.filter((name) => dbStoreNames.includes(name));
// Only insert into stores that exist in both the backup and the database,
// narrowed to the caller's `storeNames` selection when one was provided.
const targetStores = Object.keys(backupData.stores).filter(
(name) => dbStoreNames.includes(name) && (selected === null || selected.has(name)),
);

if (targetStores.length === 0) {
return;
Expand Down
12 changes: 12 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,16 @@ export interface ImportOptions {
* - `"merge"` — Keep existing data and add/update records from the backup.
*/
strategy: 'overwrite' | 'merge';
/**
* Optional list of object store names to restore records into.
* If omitted, records from every store in the backup are restored.
*
* Store names present in `storeNames` but absent from the backup are ignored.
* Passing an empty array restores no records at all.
*
* This filters records only — the schema for every store in the backup is
* still created, so stores left out of `storeNames` exist but stay empty
* (under `"overwrite"`) or keep their current contents (under `"merge"`).
*/
storeNames?: string[];
}
159 changes: 159 additions & 0 deletions tests/importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,4 +521,163 @@ describe('importDB', () => {
const autoIncRecords = await readAllFromStore(dbName, 'autoInc');
expect(autoIncRecords).toHaveLength(1);
});

// ─── Selective restore via `storeNames` ────────────────────────────

/**
* Backup with one store of durable user data and two derived cache
* stores — the shape the `storeNames` option exists to serve.
*/
function buildSelectiveBackup(): ExportFormat {
return buildBackup({
databaseVersion: 1,
schema: {
users: { keyPath: 'id', autoIncrement: false, indexes: [] },
cache: { keyPath: 'id', autoIncrement: false, indexes: [] },
logs: { keyPath: 'id', autoIncrement: false, indexes: [] },
},
stores: {
users: [
{ key: 1, value: { id: 1, name: 'Alice' } },
{ key: 2, value: { id: 2, name: 'Bob' } },
],
cache: [{ key: 'c1', value: { id: 'c1', stale: true } }],
logs: [{ key: 'l1', value: { id: 'l1', line: 'boot' } }],
},
});
}

it('"overwrite" with storeNames restores only the listed stores', async () => {
const dbName = uniqueDBName('selective-overwrite');

await importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'overwrite',
storeNames: ['users'],
});

// Every store in the backup schema still exists — only the data is scoped.
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(dbName);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
expect(Array.from(db.objectStoreNames).sort()).toEqual(['cache', 'logs', 'users']);
db.close();

expect(await readAllFromStore(dbName, 'users')).toHaveLength(2);
expect(await readAllFromStore(dbName, 'cache')).toHaveLength(0);
expect(await readAllFromStore(dbName, 'logs')).toHaveLength(0);
});

it('"merge" with storeNames leaves unlisted stores untouched', async () => {
const dbName = uniqueDBName('selective-merge');

const db = await createTestDB(dbName, 1, [
{ name: 'users', keyPath: 'id', records: [{ value: { id: 1, name: 'Stale' } }] },
{ name: 'cache', keyPath: 'id', records: [{ value: { id: 'c1', stale: false } }] },
{ name: 'logs', keyPath: 'id', records: [] },
]);
db.close();

await importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'merge',
storeNames: ['users'],
});

// `users` is upserted from the backup...
const users = await readAllFromStore(dbName, 'users');
expect(users).toHaveLength(2);
expect(users.map((r) => r.value)).toContainEqual({ id: 1, name: 'Alice' });

// ...while the unlisted stores keep exactly what they already had.
const cache = await readAllFromStore(dbName, 'cache');
expect(cache).toHaveLength(1);
expect(cache[0]!.value).toEqual({ id: 'c1', stale: false });
expect(await readAllFromStore(dbName, 'logs')).toHaveLength(0);
});

it('omitting storeNames restores every store in the backup', async () => {
const dbName = uniqueDBName('selective-omitted');

await importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'overwrite',
});

expect(await readAllFromStore(dbName, 'users')).toHaveLength(2);
expect(await readAllFromStore(dbName, 'cache')).toHaveLength(1);
expect(await readAllFromStore(dbName, 'logs')).toHaveLength(1);
});

it('storeNames as an empty array restores no records but still creates the schema', async () => {
const dbName = uniqueDBName('selective-empty');

await importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'overwrite',
storeNames: [],
});

const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(dbName);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
expect(Array.from(db.objectStoreNames).sort()).toEqual(['cache', 'logs', 'users']);
db.close();

expect(await readAllFromStore(dbName, 'users')).toHaveLength(0);
expect(await readAllFromStore(dbName, 'cache')).toHaveLength(0);
expect(await readAllFromStore(dbName, 'logs')).toHaveLength(0);
});

it('storeNames entries missing from the backup are ignored', async () => {
const dbName = uniqueDBName('selective-unknown');

await importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'overwrite',
storeNames: ['users', 'not_in_backup'],
});

expect(await readAllFromStore(dbName, 'users')).toHaveLength(2);
expect(await readAllFromStore(dbName, 'cache')).toHaveLength(0);
});

it('storeNames listing only unknown stores restores nothing and does not throw', async () => {
const dbName = uniqueDBName('selective-all-unknown');

await expect(
importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'overwrite',
storeNames: ['not_in_backup'],
}),
).resolves.toBeUndefined();

expect(await readAllFromStore(dbName, 'users')).toHaveLength(0);
});

it('duplicate storeNames entries do not insert records twice', async () => {
const dbName = uniqueDBName('selective-duplicates');

// `overwrite` inserts with `add()`, so a double pass would fail with a
// ConstraintError rather than silently duplicating.
await importDB({
dbName,
backupData: buildSelectiveBackup(),
strategy: 'overwrite',
storeNames: ['users', 'users'],
});

expect(await readAllFromStore(dbName, 'users')).toHaveLength(2);
});
});
Loading