Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
192 changes: 192 additions & 0 deletions client/packages/cli/__tests__/backupDownload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { test, expect, describe, beforeAll, afterAll, afterEach } from 'vitest';
import { createServer, type Server } from 'node:http';
import { existsSync } from 'node:fs';
import { readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import zlib from 'node:zlib';
import { downloadBackupToFile } from '../src/lib/backupDownload.ts';

// Exercises the real pipeline end-to-end against a local HTTP server: zstd
// decompression of entity shards, canonical entry order (config.json, then
// entities/*.jsonl, then files/<locationId>), and the partial-file rename.

// Not yet in this @types/node version, same as createZstdDecompress in the
// pipeline itself.
const zstd = (s: string): Buffer =>
(zlib as any).zstdCompressSync(Buffer.from(s));

const bodies: Record<string, { body: Buffer; encoding?: string }> = {
'/config.json': { body: zstd('{"schema":{}}'), encoding: 'zstd' },
'/entities/todos.jsonl': {
body: zstd('{"entity":{"id":"1"}}\n'),
encoding: 'zstd',
},
'/entities/$files.jsonl': {
body: zstd('{"entity":{"location-id":"loc-1"}}\n'),
encoding: 'zstd',
},
'/blobs/loc-1': { body: Buffer.from('blob-one') },
'/blobs/loc-2': { body: Buffer.from('blob-two') },
};

let server: Server;
let baseUrl: string;

beforeAll(async () => {
server = createServer((req, res) => {
const found = bodies[req.url ?? ''];
if (!found) {
res.writeHead(404).end();
return;
}
const headers: Record<string, string> = {};
if (found.encoding) headers['content-encoding'] = found.encoding;
res.writeHead(200, headers).end(found.body);
});
server.listen(0);
await once(server, 'listening');
const address = server.address();
if (typeof address === 'string' || address === null) {
throw new Error('Expected a TCP address');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});

afterAll(() => {
server.close();
});

const backup = {
id: 'backup-1',
isn: '1',
backupAt: new Date('2026-08-01T00:00:00Z'),
filesSize: 16,
dbSize: 100,
uncompressedSize: 40,
description: null,
expiresAt: new Date('2026-08-08T00:00:00Z'),
};

const storageFiles = [
{ locationId: 'loc-1', path: 'a.png', url: () => `${baseUrl}/blobs/loc-1` },
{ locationId: 'loc-2', path: 'b.png', url: () => `${baseUrl}/blobs/loc-2` },
];

const manager = {
listFiles: async (_backupId: string) => [
{ name: 'config.json', size: 10 },
{ name: 'entities/todos.jsonl', size: 10 },
{ name: 'entities/$files.jsonl', size: 10 },
],
getFileUrl: async (_backupId: string, name: string) => `${baseUrl}/${name}`,
streamStorageFiles: async function* (
_backupId: string,
_opts?: { signal?: AbortSignal },
) {
for (const f of storageFiles) {
yield { locationId: f.locationId, path: f.path, url: f.url() };
}
},
} as any;

const outPath = join(tmpdir(), `backup-download-test-${process.pid}.zip`);

afterEach(async () => {
await rm(outPath, { force: true });
await rm(`${outPath}.partial`, { force: true });
});

describe('downloadBackupToFile', () => {
test('writes a zip with the canonical entry order', async () => {
const result = await downloadBackupToFile({
manager,
backup,
outPath,
signal: new AbortController().signal,
onProgress: () => {},
});

expect(result.entities).toBe(2);
expect(result.files).toBe(2);
expect(existsSync(`${outPath}.partial`)).toBe(false);

const { ZipReader, Uint8ArrayReader, TextWriter } = await import(
'@zip.js/zip.js'
);
const reader = new ZipReader(
new Uint8ArrayReader(new Uint8Array(await readFile(outPath))),
);
const entries = await reader.getEntries();

// Entry order is the restore contract: config first, all entity shards
// before any storage blob.
expect(entries.map((e) => e.filename)).toEqual([
'config.json',
'entities/todos.jsonl',
'entities/$files.jsonl',
'files/loc-1',
'files/loc-2',
]);

// Entity shards land decompressed; blobs land verbatim.
const readText = (entry: any) => entry.getData(new TextWriter());
expect(await readText(entries[0])).toBe('{"schema":{}}');
expect(await readText(entries[1])).toBe('{"entity":{"id":"1"}}\n');
expect(await readText(entries[3])).toBe('blob-one');
expect(await readText(entries[4])).toBe('blob-two');
await reader.close();
});

test('removes the partial file when a fetch fails', async () => {
const failingManager = {
...manager,
getFileUrl: async (_backupId: string, name: string) =>
`${baseUrl}/missing-${name}`,
};

await expect(
downloadBackupToFile({
manager: failingManager,
backup,
outPath,
signal: new AbortController().signal,
onProgress: () => {},
}),
).rejects.toThrow(/Failed to fetch config.json/);

expect(existsSync(outPath)).toBe(false);
expect(existsSync(`${outPath}.partial`)).toBe(false);
});

test('aborting removes the partial file', async () => {
const controller = new AbortController();
const slowManager = {
...manager,
streamStorageFiles: async function* () {
yield {
locationId: 'loc-1',
path: 'a.png',
url: `${baseUrl}/blobs/loc-1`,
};
controller.abort();
// Give the pipeline a moment to observe the abort mid-drain.
await new Promise((resolve) => setTimeout(resolve, 20));
},
};

await expect(
downloadBackupToFile({
manager: slowManager,
backup,
outPath,
signal: controller.signal,
onProgress: () => {},
}),
).rejects.toThrow();

expect(existsSync(outPath)).toBe(false);
expect(existsSync(`${outPath}.partial`)).toBe(false);
});
});
202 changes: 202 additions & 0 deletions client/packages/cli/__tests__/backups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { test, expect, describe, vi, beforeEach } from 'vitest';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Effect, Layer, Logger } from 'effect';
import { GlobalOpts } from '../src/context/globalOpts.ts';
import { AuthToken } from '../src/context/authToken.ts';
import { CurrentApp } from '../src/context/currentApp.ts';

vi.mock('../src/index.ts', () => ({}));

const state = vi.hoisted(() => ({
manager: undefined as any,
downloadResult: undefined as any,
downloadError: undefined as Error | undefined,
downloadCalls: [] as any[],
promptResponses: [] as unknown[],
}));

// Mock at the SDK boundary: any `new InstantPlatformApi(...)` returns a stub
// whose `.backups(appId)` is the per-test fake manager.
vi.mock('@instantdb/platform', async (importOriginal) => {
const orig: any = await importOriginal();
return {
...orig,
PlatformApi: class {
backups(_appId: string) {
return state.manager;
}
},
};
});

// The download pipeline is network- and disk-heavy; the command tests only
// care that it's invoked with the right backup and destination.
vi.mock('../src/lib/backupDownload.ts', () => ({
downloadBackupToFile: vi.fn(async (opts: any) => {
state.downloadCalls.push(opts);
if (state.downloadError) throw state.downloadError;
return state.downloadResult;
}),
}));

vi.mock('../src/ui/lib.ts', async (importOriginal) => {
const orig: any = await importOriginal();
return {
...orig,
renderUnwrap: () => {
if (state.promptResponses.length === 0) {
return Promise.reject(new Error('No prompt response queued'));
}
return Promise.resolve(state.promptResponses.shift());
},
};
});

const { backupListCmd } = await import('../src/commands/backup/list.ts');
const { backupDownloadCmd } = await import(
'../src/commands/backup/download.ts'
);

let logs: string[] = [];

const makeBackup = (overrides: any = {}) => ({
id: 'backup-1',
isn: '1',
backupAt: new Date('2026-08-01T00:00:00Z'),
filesSize: 1000,
dbSize: 2000,
uncompressedSize: 3000,
description: 'Automated Daily Snapshot',
expiresAt: new Date('2026-08-08T00:00:00Z'),
...overrides,
});

const buildManager = (backups: any[]) => ({
list: vi.fn(async () => backups),
});

const outPath = () => join(tmpdir(), `backup-test-${Date.now()}.zip`);

const run = (effect: any, opts: { yes: boolean }) =>
Effect.runPromise(
effect.pipe(
Effect.provide(
Layer.mergeAll(
Layer.succeed(GlobalOpts, { yes: opts.yes }),
Layer.succeed(AuthToken, {
getAuthToken: Effect.succeed('test-token'),
getSource: Effect.succeed('env' as const),
setAuthToken: () => Effect.succeed(undefined),
}),
Layer.succeed(CurrentApp, {
appId: 'test-app',
source: 'env' as const,
}),
Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => {
logs.push(String(message));
}),
),
),
),
),
);

beforeEach(() => {
logs = [];
state.manager = buildManager([]);
state.downloadResult = { entities: 3, files: 2, zipBytes: 1234 };
state.downloadError = undefined;
state.downloadCalls = [];
state.promptResponses = [];
});

describe('backup list', () => {
test('renders backups', async () => {
state.manager = buildManager([makeBackup()]);
await run(backupListCmd({}), { yes: true });
const output = logs.join('\n');
expect(output).toContain('2026-08-01 00:00 UTC');
expect(output).toContain('ID: backup-1');
expect(output).toContain('Description: Automated Daily Snapshot');
expect(output).toContain('Expires: 2026-08-08 00:00 UTC');
});

test('outputs JSON with --json', async () => {
state.manager = buildManager([makeBackup()]);
await run(backupListCmd({ json: true }), { yes: true });
const parsed = JSON.parse(logs.join('\n'));
expect(parsed).toHaveLength(1);
expect(parsed[0].id).toBe('backup-1');
});

test('handles no backups', async () => {
await run(backupListCmd({}), { yes: true });
expect(logs.join('\n')).toContain('No backups yet.');
});
});

describe('backup download', () => {
test('downloads by id', async () => {
const backup = makeBackup();
state.manager = buildManager([backup]);
const out = outPath();
await run(backupDownloadCmd('backup-1', { out }), { yes: true });
expect(state.downloadCalls).toHaveLength(1);
expect(state.downloadCalls[0].backup).toEqual(backup);
expect(state.downloadCalls[0].outPath).toBe(out);
expect(logs.join('\n')).toContain('Saved 3 namespaces and 2 storage files');
});

test('errors on an unknown id', async () => {
state.manager = buildManager([makeBackup()]);
await expect(
run(backupDownloadCmd('nope', { out: outPath() }), { yes: true }),
).rejects.toThrow(/No backup found with id nope/);
});

test('--latest picks the newest backup', async () => {
const older = makeBackup();
const newer = makeBackup({
id: 'backup-2',
backupAt: new Date('2026-08-02T00:00:00Z'),
});
state.manager = buildManager([older, newer]);
await run(backupDownloadCmd(undefined, { latest: true, out: outPath() }), {
yes: true,
});
expect(state.downloadCalls[0].backup.id).toBe('backup-2');
});

test('requires an id or --latest when prompts are skipped', async () => {
state.manager = buildManager([makeBackup()]);
await expect(
run(backupDownloadCmd(undefined, {}), { yes: true }),
).rejects.toThrow(/Must specify a backup id or --latest/);
});

test('prompts for a backup and confirmation interactively', async () => {
const backup = makeBackup();
state.manager = buildManager([backup]);
// First the backup picker, then the download confirmation.
state.promptResponses = [backup, true];
await run(backupDownloadCmd(undefined, { out: outPath() }), {
yes: false,
});
expect(state.downloadCalls).toHaveLength(1);
expect(state.downloadCalls[0].backup).toEqual(backup);
});

test('reports a cancelled download', async () => {
state.manager = buildManager([makeBackup()]);
state.downloadError = Object.assign(new Error('aborted'), {
name: 'AbortError',
});
await run(backupDownloadCmd('backup-1', { out: outPath() }), {
yes: true,
});
expect(logs.join('\n')).toContain('Download cancelled.');
});
});
Loading
Loading