Add distributed settings and workspace import/export (M5)

This commit is contained in:
2026-06-07 15:51:00 +03:00
parent 80bedd2a8d
commit 548aa199d9
38 changed files with 3150 additions and 101 deletions
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The browser file adapter is mocked: export drives downloadJson, import reads
// via readTextFile. The service logic (merge, notify) is what we exercise.
vi.mock('../infrastructure/file-transfer', () => ({
downloadJson: vi.fn(),
readTextFile: vi.fn(),
}));
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { useDatasetStore } from '../stores/DatasetStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { exportWorkspace, importWorkspace } from './transfer';
const mockedDownload = vi.mocked(downloadJson);
const mockedRead = vi.mocked(readTextFile);
/** The most recent notification raised. */
function lastNote() {
const notes = useNotificationStore.getState().notifications;
return notes[notes.length - 1];
}
/** Drive an import from a JSON string (readTextFile is mocked to return it). */
async function importJson(text: string) {
mockedRead.mockResolvedValueOnce(text);
await importWorkspace({} as File);
}
beforeEach(() => {
vi.clearAllMocks();
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
useNotificationStore.getState().clear();
});
describe('exportWorkspace', () => {
it('reports and downloads nothing for an empty library', () => {
exportWorkspace(new Date('2026-06-07T00:00:00.000Z'));
expect(mockedDownload).not.toHaveBeenCalled();
expect(lastNote()).toMatchObject({ kind: 'info' });
expect(lastNote().message).toContain('No snippets to export');
});
it('downloads an envelope and reports counts', () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
useDatasetStore
.getState()
.addDatasets([
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
]);
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
expect(mockedDownload).toHaveBeenCalledTimes(1);
const [filename, json] = mockedDownload.mock.calls[0];
expect(filename).toBe('astrolabe-project-2026-06-07.json');
const env = JSON.parse(json) as { version: string; snippets: unknown[]; datasets: unknown[] };
expect(env.version).toBe('1.0');
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Exported 1 snippet and 1 dataset');
});
});
describe('importWorkspace', () => {
it('reports an invalid JSON file and leaves the workspace untouched', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
await importJson('{ not json');
expect(useSnippetStore.getState().snippets).toHaveLength(1);
expect(lastNote()).toMatchObject({ kind: 'error' });
expect(lastNote().message).toContain('valid JSON');
});
it('reports an empty file (no snippets found)', async () => {
await importJson(JSON.stringify({ version: '1.0', snippets: [] }));
expect(useSnippetStore.getState().snippets).toHaveLength(0);
expect(lastNote()).toMatchObject({ kind: 'info' });
expect(lastNote().message).toBe('No snippets found in file.');
});
it('merges an envelope: datasets and snippets are appended', async () => {
await importJson(
JSON.stringify({
version: '1.0',
snippets: [
{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{"mark":"bar"}' },
],
datasets: [{ id: 1, name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }],
}),
);
expect(useSnippetStore.getState().snippets).toHaveLength(1);
expect(useDatasetStore.getState().datasets).toHaveLength(1);
expect(useDatasetStore.getState().datasets[0].name).toBe('Sales');
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Imported 1 snippet and 1 dataset');
});
it('auto-suffixes a clashing dataset name and rewrites the importing snippet ref', async () => {
useDatasetStore
.getState()
.addDatasets([
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
]);
await importJson(
JSON.stringify({
version: '1.0',
snippets: [
{
id: 's9',
created: '2026-01-01T00:00:00.000Z',
name: 'Ref',
spec: '{"data":{"name":"Sales"},"mark":"bar"}',
},
],
datasets: [{ id: 1, name: 'Sales', data: [{ b: 2 }], format: 'json', source: 'inline' }],
}),
);
const names = useDatasetStore
.getState()
.datasets.map((d) => d.name)
.sort();
expect(names).toEqual(['Sales', 'Sales 2']);
const imported = useSnippetStore.getState().snippets.find((s) => s.name === 'Ref')!;
expect(imported.datasetRefs).toContain('Sales 2');
expect(imported.spec).toContain('Sales 2');
expect(lastNote()).toMatchObject({ kind: 'warning' });
expect(lastNote().message).toContain('Sales → Sales 2');
});
it('reassigns a colliding snippet id, keeping the existing one', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'Imported', spec: '{}' }],
}),
);
const snippets = useSnippetStore.getState().snippets;
expect(snippets).toHaveLength(2);
const imported = snippets.find((s) => s.name === 'Imported')!;
expect(imported.id).not.toBe('s1');
expect(snippets.find((s) => s.name === 'Existing')!.id).toBe('s1');
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Import / Export service (spec §08) — orchestration over the pure core helpers
* and the stores. The deterministic work (shape detection, normalization, name
* de-dupe, id reassignment, rename propagation, envelope/message building) lives
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
* stores, drives the browser file adapter, commits the merge, and reports.
*
* Order matters on import (spec §08 → Merge): datasets are committed **before**
* snippets so a snippet's by-name reference resolves against the just-added
* dataset (whose name may have been auto-suffixed to avoid a clash).
*/
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
import { snippetSizeBytes } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { notify } from '../stores/NotificationStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** Practical snippet-storage budget (spec §02 storage monitor / §08). */
const SNIPPET_BUDGET_BYTES = 5 * 1024 * 1024;
/** Round bytes to a short KB/MB string for the overage warning. */
function formatBytes(bytes: number): string {
const kb = bytes / 1024;
if (kb < 1024) return `${Math.max(1, Math.round(kb))} KB`;
return `${Math.round(kb / 1024)} MB`;
}
/**
* Export the whole workspace to a downloaded JSON file (spec §08 → Export).
* Flushes the live editor buffer first so in-progress draft edits are included.
* An empty library is reported and nothing is downloaded, even if datasets exist.
*/
export function exportWorkspace(now: Date = new Date()): void {
// Commit any valid, uncommitted buffer so the export reflects current work.
useSnippetStore.getState().commitDraft(now);
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
if (snippets.length === 0) {
notify({
kind: 'info',
title: 'Nothing to export',
message: 'No snippets to export. Create a snippet first, then export your workspace.',
});
return;
}
const envelope = buildExportEnvelope(snippets, datasets, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: exportSummaryMessage(snippets.length, datasets.length),
});
}
/**
* Import a picked JSON file: normalize, merge (never overwrite), and save
* (spec §08 → Import). The existing workspace is never lost — an unreadable or
* unparseable file leaves it untouched, and merges only ever append.
*/
export async function importWorkspace(file: File): Promise<void> {
let text: string;
try {
text = await readTextFile(file);
} catch (err) {
notify({
kind: 'error',
title: "Couldn't read the file",
message: 'The selected file could not be read. Choose the file again and retry.',
detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
});
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
notify({
kind: 'error',
title: 'Import failed',
message: 'Failed to import. Please check that the file is valid JSON.',
});
return;
}
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
if (normSnippets.length === 0) {
notify({
kind: 'info',
title: 'Nothing imported',
message: 'No snippets found in file.',
});
return;
}
// Datasets first: de-dupe their names against the library (and within the batch),
// then propagate any rename into the imported snippets so their references still
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §56).
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
existingDatasetNames,
normDatasets,
);
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
useDatasetStore.getState().addDatasets(dedupedDatasets);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
const existingBytes = useSnippetStore
.getState()
.snippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
const incomingBytes = finalSnippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
const overage = existingBytes + incomingBytes - SNIPPET_BUDGET_BYTES;
// TODO (spec §08 → Storage limit handling): a *hard* quota failure on save
// should commit no partial snippet import. The write-through subscriber
// currently surfaces such a failure as a per-record error toast, but the
// in-memory store keeps the records (they vanish on reload). True atomic
// rollback needs a write-before-store import path — a persistence-architecture
// change deferred until the M6 storage monitor (web.dev quota estimate) lands.
useSnippetStore.getState().addSnippets(finalSnippets);
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
// renamed or storage is over budget, otherwise a success.
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
const clauses: string[] = [];
if (renames.length > 0) {
clauses.push(
`Renamed to avoid clashes: ${renames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (overage > 0) {
clauses.push(
`This puts snippet storage about ${formatBytes(overage)} over the ~5 MB budget; ` +
`consider deleting some snippets.`,
);
}
if (clauses.length > 0) {
notify({
kind: 'warning',
title: 'Import complete',
message: `${summary}. ${clauses.join(' ')}`,
});
} else {
notify({ kind: 'success', title: 'Import complete', message: summary });
}
}