Files
astrolabe/src/app/stores/DatasetStore.ts
T

456 lines
17 KiB
TypeScript

/**
* Dataset library state (spec §05, docs/architecture/01 + 07).
*
* Holds the durable dataset collection plus the Datasets-manager view state: which
* dataset is selected, which pane is showing (list browse, detail, the create
* form, or the edit form), and the in-progress create/edit form with its inline
* validation error.
*
* Two layers of action live here:
* - Low-level mutators (`add`/`update`/`remove`) are the single place the
* `datasets` array changes; the persistence subscriber writes them through to
* IndexedDB, and services (e.g. the import flow in services/transfer) reuse them.
* - Form orchestration (`save`) validates the form (name required + unique,
* data detectable) and turns it into an `add`/`update`, keeping the modal
* component thin.
*
* Persistence is NOT done here — a startup subscriber observes this store and
* writes through to the IndexedDB adapter, so the store stays browser-free.
*/
import { create } from 'zustand';
import { detectFormat, type DataFormat } from '@core/format-detection';
import {
createDataset,
computeDatasetProfile,
snapshotFromText,
type DataSource,
type Dataset,
} from '@core/dataset';
import { isNameTaken } from '@core/naming';
import { useSnippetStore } from './SnippetStore';
/** Which pane the Datasets manager is showing (spec §05 → Layout). */
type DatasetView = 'list' | 'detail' | 'new' | 'edit';
/** The in-progress create/edit form. `input` is the paste area (inline) or URL field. */
export interface DatasetForm {
name: string;
source: DataSource;
input: string;
comment: string;
}
const EMPTY_FORM: DatasetForm = { name: '', source: 'inline', input: '', comment: '' };
export interface DatasetState {
datasets: Dataset[];
selectedId: number | null;
view: DatasetView;
form: DatasetForm;
/** Inline validation message for the create/edit form, or null when valid. */
formError: string | null;
/** Replace the library from storage. */
hydrate: (datasets: Dataset[]) => void;
/** Select a dataset (→ detail), or clear the selection (→ list). */
select: (id: number | null) => void;
/** Set the manager view directly (used by the modal's own navigation). */
setView: (view: DatasetView) => void;
/** Open the create form with an empty draft. */
startCreate: () => void;
/** Open the edit form pre-filled from the selected dataset. */
startEdit: () => void;
/** Patch the in-progress form (clears any stale error). */
updateForm: (patch: Partial<DatasetForm>) => void;
/** Leave the form, returning to the selected detail or the list. */
cancelForm: () => void;
/**
* Validate and commit the current form: creates a new dataset (view `new`) or
* updates the selected one (view `edit`), re-profiling its data and renaming
* referencing snippets when an edit changes the name. Returns whether it
* committed; on failure `formError` is set. `now` is injectable for tests.
*/
save: (now?: Date) => boolean;
/**
* Commit a URL dataset from an already-fetched body. The component performs the
* network fetch via the remote-data adapter and passes the result here, keeping
* this store browser-free. Validates the form, snapshots + profiles the body, and
* creates (view `new`) or updates (view `edit`) the dataset — including renaming
* referencing snippets on an edit. Returns whether it committed.
*/
commitUrlSnapshot: (fetched: { text: string }, now?: Date) => boolean;
/**
* Re-snapshot an existing URL dataset from a freshly-fetched body ("Refresh"):
* re-detect the format, re-profile, and advance `fetchedAt`/`modified`. Name,
* comment, and URL are preserved. Returns false for a non-URL dataset.
*/
refreshDataset: (id: number, fetched: { text: string }, now?: Date) => boolean;
/**
* Low-level: add a fully-formed dataset and select it. The id is (re)assigned
* via `nextDatasetId`, so a `createDataset` default id (`Date.now()`) can never
* reach storage — see `nextDatasetId` for why that matters.
*/
add: (dataset: Dataset) => void;
/**
* Append imported datasets (spec §08 → datasets imported before snippets). Each
* is given a fresh monotonic numeric id (`nextDatasetId`, the same authority as
* `add`) so a batch never collides on the `Date.now()` default nor with existing
* ids — safe because datasets are referenced by **name**, not id
* (docs/architecture/07 §1). Names are assumed already de-duped by the import
* service. Selection is unchanged.
*/
addDatasets: (incoming: Dataset[]) => void;
/** Low-level: merge a patch into a dataset, advancing `modified`. */
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
/** Low-level: remove a dataset; clears the selection if it was selected. */
remove: (id: number) => void;
/** Reset to initial state (tests, future "new workspace"). */
reset: () => void;
}
/** Newest-modified first — the manager's default ordering (spec §05 → Layout). */
export function byModifiedDesc(a: Dataset, b: Dataset): number {
return b.modified.localeCompare(a.modified);
}
/**
* Next free numeric id for a dataset: one past the current maximum. This store is
* the **single id authority** — both single (`add`) and batch (`addDatasets`)
* insertion route through it — so a `createDataset` default id (`Date.now()`)
* never reaches IndexedDB, where a tight creation loop (the documented import path)
* could repeat the same millisecond and collide on the numeric key. Dataset ids
* are an internal IndexedDB key only — snippets reference datasets by **name**
* (docs/architecture/07 §1) — so reusing an id freed by a deletion is harmless,
* which is why one-past-the-max suffices without a persistent counter.
*/
function nextDatasetId(datasets: ReadonlyArray<Dataset>): number {
return datasets.reduce((max, d) => Math.max(max, d.id), 0) + 1;
}
/**
* Cheap form validation: returns an error message, or `null` when the form is
* saveable. Deliberately does **not** `JSON.parse` the input — it only checks
* name/input presence, name uniqueness, and that a format is detectable — so it
* is safe to run on every render to drive the Save button's disabled state
* (spec §05). Error copy follows the council resolution (docs/architecture/10
* §error copy → GOV.UK error-message): action-oriented, specific, says how to fix.
*/
function validateForm(
form: DatasetForm,
datasets: Dataset[],
excludeId: number | undefined,
): string | null {
const name = form.name.trim();
if (name === '') return 'Enter a dataset name.';
if (isNameTaken(name, datasets, excludeId)) {
return `A dataset named "${name}" already exists. Choose a different name.`;
}
const input = form.input.trim();
if (input === '') {
return form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.';
}
if (form.source === 'url') {
if (!/^https?:\/\//i.test(input)) return 'Enter a URL starting with http:// or https://.';
return null;
}
// Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
if (!detectFormat(form.input).format) return 'Enter valid JSON, CSV, or TSV data.';
return null;
}
/**
* Validate an **inline** form into a saveable shape, or return an error message.
* URL datasets never reach here — they are fetched first and committed through
* `commitUrlSnapshot` (which snapshots the fetched body), so this only shapes the
* pasted-inline payload.
*/
function resolveForm(
form: DatasetForm,
datasets: Dataset[],
excludeId: number | undefined,
): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } {
const error = validateForm(form, datasets, excludeId);
if (error) return { error };
// validateForm guaranteed a detectable format above.
const format = detectFormat(form.input).format as DataFormat;
// JSON/TopoJSON are stored parsed; CSV/TSV keep their raw text (data model §09B).
const data =
format === 'json' || format === 'topojson' ? (JSON.parse(form.input) as unknown) : form.input;
return { name: form.name.trim(), data, format, source: 'inline' };
}
export const useDatasetStore = create<DatasetState>((set, get) => ({
datasets: [],
selectedId: null,
view: 'list',
form: EMPTY_FORM,
formError: null,
hydrate: (datasets) => set({ datasets }),
select: (id) =>
set({
selectedId: id,
view: id === null ? 'list' : 'detail',
form: EMPTY_FORM,
formError: null,
}),
setView: (view) => set({ view }),
startCreate: () => set({ view: 'new', form: EMPTY_FORM, formError: null }),
startEdit: () => {
const { datasets, selectedId } = get();
const ds = datasets.find((d) => d.id === selectedId);
if (!ds) return;
set({
view: 'edit',
formError: null,
form: {
name: ds.name,
source: ds.source,
comment: ds.comment,
// Re-render the editable text: the URL for url datasets, raw text for
// csv/tsv, pretty-printed JSON for json/topojson.
input:
ds.source === 'url'
? (ds.url ?? '')
: ds.format === 'csv' || ds.format === 'tsv'
? String(ds.data)
: JSON.stringify(ds.data, null, 2),
},
});
},
updateForm: (patch) => set((s) => ({ form: { ...s.form, ...patch }, formError: null })),
cancelForm: () =>
set((s) => ({
view: s.selectedId === null ? 'list' : 'detail',
form: EMPTY_FORM,
formError: null,
})),
save: (now) => {
const { view, form, datasets, selectedId } = get();
const editing = view === 'edit';
const excludeId = editing ? (selectedId ?? undefined) : undefined;
// URL datasets that need the network — a create, a URL change, or an inline→URL
// conversion — are routed through `commitUrlSnapshot` after the component
// fetches; save() never fetches. The one URL case it commits is a metadata-only
// edit of an existing snapshot (same URL): just update name/comment, no re-fetch.
if (form.source === 'url') {
const error = validateForm(form, datasets, excludeId);
if (error) {
set({ formError: error });
return false;
}
if (!editing || selectedId === null) return false;
const existing = datasets.find((d) => d.id === selectedId);
if (!existing || existing.source !== 'url' || form.input.trim() !== (existing.url ?? '')) {
return false;
}
const name = form.name.trim();
get().update(selectedId, { name, comment: form.comment }, now);
if (name !== existing.name) {
useSnippetStore.getState().renameDatasetRefs(existing.name, name, now);
}
set({ view: 'detail', form: EMPTY_FORM, formError: null });
return true;
}
const resolved = resolveForm(form, datasets, excludeId);
if ('error' in resolved) {
set({ formError: resolved.error });
return false;
}
if (editing && selectedId !== null) {
const existing = datasets.find((d) => d.id === selectedId);
if (!existing) return false;
const profile = computeDatasetProfile(resolved.data, resolved.format);
// Re-profile and update the record (including any new name), then propagate
// the rename across referencing snippets so each spec and its datasetRefs
// stay consistent (docs/architecture/07 §6). SnippetStore never imports this
// store, so the direct call is cycle-free. Clear any url/fetchedAt left over
// from a URL→inline conversion so the record carries no stale remote origin.
get().update(
selectedId,
{
name: resolved.name,
data: resolved.data,
format: resolved.format,
source: resolved.source,
// A URL→inline conversion must shed the remote origin; `update` deletes
// keys set to `undefined`, so these are removed rather than left
// present-but-undefined.
url: undefined,
fetchedAt: undefined,
comment: form.comment,
...profile,
},
now,
);
if (resolved.name !== existing.name) {
useSnippetStore.getState().renameDatasetRefs(existing.name, resolved.name, now);
}
set({ view: 'detail', form: EMPTY_FORM, formError: null });
return true;
}
const dataset = createDataset({
name: resolved.name,
data: resolved.data,
format: resolved.format,
source: resolved.source,
comment: form.comment,
now,
});
get().add(dataset);
// Switching to the detail view with the new dataset selected IS the success
// confirmation, so no toast is raised — the result is on-screen (spec §05;
// docs/architecture/10 → Toast copy). Extract-to-dataset, which creates a
// dataset off-screen, does toast (see ExtractStore).
set({ view: 'detail', form: EMPTY_FORM, formError: null });
return true;
},
commitUrlSnapshot: (fetched, now) => {
const { view, form, datasets, selectedId } = get();
const editing = view === 'edit';
const excludeId = editing ? (selectedId ?? undefined) : undefined;
const error = validateForm(form, datasets, excludeId);
if (error) {
set({ formError: error });
return false;
}
const name = form.name.trim();
const url = form.input.trim();
const iso = (now ?? new Date()).toISOString();
// The fetched body snapshots + profiles exactly like inline data (snapshot
// model): detect format from content, shape, and profile through core.
const { data, format } = snapshotFromText(fetched.text, url);
if (editing && selectedId !== null) {
const existing = datasets.find((d) => d.id === selectedId);
if (!existing) return false;
const profile = computeDatasetProfile(data, format);
get().update(
selectedId,
{
name,
data,
format,
source: 'url',
url,
fetchedAt: iso,
comment: form.comment,
...profile,
},
now,
);
if (name !== existing.name) {
useSnippetStore.getState().renameDatasetRefs(existing.name, name, now);
}
set({ view: 'detail', form: EMPTY_FORM, formError: null });
return true;
}
const dataset = createDataset({
name,
data,
format,
source: 'url',
url,
fetchedAt: iso,
comment: form.comment,
now,
});
get().add(dataset);
set({ view: 'detail', form: EMPTY_FORM, formError: null });
return true;
},
refreshDataset: (id, fetched, now) => {
const dataset = get().datasets.find((d) => d.id === id);
if (!dataset || dataset.source !== 'url' || !dataset.url) return false;
const iso = (now ?? new Date()).toISOString();
const { data, format } = snapshotFromText(fetched.text, dataset.url);
const profile = computeDatasetProfile(data, format);
// Name, comment, and url are preserved; only the snapshot + its profile and the
// fetch time change. `update` advances `modified`.
get().update(id, { data, format, fetchedAt: iso, ...profile }, now);
return true;
},
add: (dataset) =>
set((s) => {
const withId = { ...dataset, id: nextDatasetId(s.datasets) };
return { datasets: [withId, ...s.datasets], selectedId: withId.id };
}),
addDatasets: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
let nextId = nextDatasetId(s.datasets);
const withIds = incoming.map((d) => ({ ...d, id: nextId++ }));
return { datasets: [...withIds, ...s.datasets] };
});
},
update: (id, patch, now) => {
const modified = patch.modified ?? (now ?? new Date()).toISOString();
set((s) => ({
datasets: s.datasets.map((d) => {
if (d.id !== id) return d;
const merged = { ...d, ...patch, modified };
// A patch key set to `undefined` removes it, rather than leaving a
// present-but-undefined key — so a URL→inline conversion drops `url`/
// `fetchedAt` cleanly, keeping the "inline records carry no remote-origin
// keys" invariant (docs/architecture/07 §6).
for (const key of Object.keys(patch) as (keyof Dataset)[]) {
if (patch[key] === undefined) delete merged[key];
}
return merged;
}),
}));
},
remove: (id) =>
set((s) => ({
datasets: s.datasets.filter((d) => d.id !== id),
selectedId: s.selectedId === id ? null : s.selectedId,
view: s.selectedId === id ? 'list' : s.view,
form: s.selectedId === id ? EMPTY_FORM : s.form,
})),
reset: () =>
set({ datasets: [], selectedId: null, view: 'list', form: EMPTY_FORM, formError: null }),
}));
/** Selector: the selected dataset record, or null. Derive — never store. */
export const selectSelectedDataset = (s: DatasetState): Dataset | null =>
s.datasets.find((d) => d.id === s.selectedId) ?? null;
/**
* Selector: whether the current form is saveable, powering the Save button's
* disabled state (spec §05 → Save is disabled until a name and valid data/URL
* are present). Mirrors the `save()` action's `excludeId` derivation so an edit
* can keep its own name.
*/
export const selectCanSave = (s: DatasetState): boolean =>
validateForm(s.form, s.datasets, s.view === 'edit' ? (s.selectedId ?? undefined) : undefined) ===
null;