Add dataset library, extract-to-dataset, and render-time reference resolution

This commit is contained in:
2026-06-05 15:49:40 +03:00
parent 25849461e0
commit a4e4d96d3b
41 changed files with 3909 additions and 19 deletions
+4 -3
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import type { FitMode } from '@core/rendering';
import type { UiTheme } from '@core/theme';
import type { ModalName } from '../modals/types';
/**
* Centralized cross-cutting application state, as a Zustand store. Keep this
@@ -13,9 +14,9 @@ import type { UiTheme } from '@core/theme';
*/
export type { UiTheme };
/** Which modal, if any, is currently open. At most one at a time (spec §01C). */
export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBuilder' | 'extract';
// The modal union is defined once in the modal system (docs/architecture/03) and
// re-exported here for the many callers that reach it through the app store.
export type { ModalName };
export interface AppState {
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { selectSelectedDataset, useDatasetStore, type DatasetForm } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
const store = () => useDatasetStore.getState();
const T = new Date('2026-06-01T00:00:00Z');
beforeEach(() => {
store().reset();
useSnippetStore.getState().reset();
});
describe('save — create', () => {
test('valid inline JSON is added, profiled, and selected', () => {
store().startCreate();
store().updateForm({ name: 'Sales', input: '[{"a":1,"b":2},{"a":3,"b":4}]' });
expect(store().save(T)).toBe(true);
const ds = selectSelectedDataset(store());
expect(ds?.name).toBe('Sales');
expect(ds?.format).toBe('json');
expect(ds?.rowCount).toBe(2);
expect(ds?.columns).toEqual(['a', 'b']);
expect(store().view).toBe('detail');
});
test('CSV input is stored as raw text and profiled', () => {
store().startCreate();
store().updateForm({ name: 'Regions', input: 'city,pop\nA,10\nB,20' });
expect(store().save(T)).toBe(true);
const ds = selectSelectedDataset(store());
expect(ds?.format).toBe('csv');
expect(ds?.data).toBe('city,pop\nA,10\nB,20');
expect(ds?.rowCount).toBe(2);
});
test('a valid URL infers format from the extension', () => {
store().startCreate();
store().updateForm({ name: 'Remote', source: 'url', input: 'https://example.com/data.csv' });
expect(store().save(T)).toBe(true);
const ds = selectSelectedDataset(store());
expect(ds?.source).toBe('url');
expect(ds?.format).toBe('csv');
expect(ds?.rowCount).toBeNull(); // URL datasets aren't profiled
});
});
describe('save — validation', () => {
const submit = (patch: Partial<DatasetForm>) => {
store().startCreate();
store().updateForm(patch);
return store().save(T);
};
test('blank name is rejected', () => {
expect(submit({ name: ' ', input: '[{"a":1}]' })).toBe(false);
expect(store().formError).toMatch(/name/i);
expect(store().datasets).toHaveLength(0);
});
test('duplicate name (case-insensitive) is rejected', () => {
store().add(
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
);
expect(submit({ name: 'sales', input: '[{"a":1}]' })).toBe(false);
expect(store().formError).toMatch(/already exists/i);
});
test('empty and unrecognized inline data are rejected', () => {
expect(submit({ name: 'Empty', input: ' ' })).toBe(false);
expect(submit({ name: 'Junk', input: 'this is not data' })).toBe(false);
expect(store().formError).toMatch(/valid JSON/i);
});
test('a non-http URL is rejected', () => {
expect(submit({ name: 'Bad', source: 'url', input: 'ftp://x/y.csv' })).toBe(false);
expect(store().formError).toMatch(/url/i);
});
});
describe('save — edit', () => {
test('updating inline data re-profiles and advances modified', () => {
store().add(
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
);
const id = store().selectedId!;
store().startEdit();
store().updateForm({ input: '[{"a":1,"b":2},{"a":3,"b":4}]' });
const later = new Date('2026-07-01T00:00:00Z');
expect(store().save(later)).toBe(true);
const ds = store().datasets.find((d) => d.id === id)!;
expect(ds.columnCount).toBe(2);
expect(ds.modified).toBe(later.toISOString());
});
test('renaming a referenced dataset propagates into referencing snippets', () => {
store().add(
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
);
const snippet = createSnippet({
id: 's1',
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }),
now: T,
});
useSnippetStore.getState().hydrate([snippet], 's1');
useSnippetStore.getState().publish(T); // seed datasetRefs = ['Sales']
store().startEdit();
store().updateForm({ name: 'Revenue' });
expect(store().save(new Date('2026-08-01T00:00:00Z'))).toBe(true);
expect(store().datasets[0].name).toBe('Revenue');
const s = useSnippetStore.getState().snippets.find((x) => x.id === 's1')!;
expect(s.datasetRefs).toEqual(['Revenue']);
expect(s.spec).toContain('"Revenue"');
});
});
describe('remove & view transitions', () => {
test('removing the selected dataset clears the selection and returns to the list', () => {
store().add(
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
);
const id = store().selectedId!;
store().remove(id);
expect(store().selectedId).toBeNull();
expect(store().view).toBe('list');
});
test('cancelForm returns to detail when a dataset is selected', () => {
store().add(
createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }),
);
store().startEdit();
expect(store().view).toBe('edit');
store().cancelForm();
expect(store().view).toBe('detail');
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* 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 (RelationshipService, future import) 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, detectFormatFromUrl, type DataFormat } from '@core/format-detection';
import { createDataset, computeDatasetProfile, 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). */
export 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;
/** Low-level: add a fully-formed dataset and select it. */
add: (dataset: 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);
}
/** Validate a form into a saveable shape, or return an error message. */
function resolveForm(
form: DatasetForm,
datasets: Dataset[],
excludeId: number | undefined,
): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } {
// Error copy follows the council resolution (docs/architecture/10 §error copy →
// GOV.UK error-message): action-oriented, specific, and says how to fix it.
const name = form.name.trim();
if (name === '') return { error: 'Enter a dataset name.' };
if (isNameTaken(name, datasets, excludeId)) {
return { error: `A dataset named "${name}" already exists. Choose a different name.` };
}
const input = form.input.trim();
if (input === '') {
return {
error: form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.',
};
}
if (form.source === 'url') {
if (!/^https?:\/\//i.test(input)) {
return { error: 'Enter a URL starting with http:// or https://.' };
}
// Format is inferred from the extension; default to JSON when unknown (§05).
return { name, data: input, format: detectFormatFromUrl(input) ?? 'json', source: 'url' };
}
// Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
const { format } = detectFormat(form.input);
if (!format) {
return { error: 'Enter valid JSON, CSV, or TSV data.' };
}
// 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, 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 stored payload as editable text: raw for csv/tsv/url,
// pretty-printed JSON for json/topojson.
input:
ds.source === '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;
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, resolved.source);
// 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.
get().update(
selectedId,
{
name: resolved.name,
data: resolved.data,
format: resolved.format,
source: resolved.source,
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);
set({ view: 'detail', form: EMPTY_FORM, formError: null });
return true;
},
// TODO: createDataset ids default to `Date.now()` and `add` does not reassign
// on collision (despite that file's comment). Interactive create is safe, but
// the documented non-interactive import path (naming.ts) creates many datasets
// in a tight loop where `Date.now()` repeats — duplicate numeric keys would
// collide in IndexedDB. Give the store a monotonic id source (or reassign here)
// before wiring import.
add: (dataset) => set((s) => ({ datasets: [dataset, ...s.datasets], selectedId: dataset.id })),
update: (id, patch, now) => {
const modified = patch.modified ?? (now ?? new Date()).toISOString();
set((s) => ({
datasets: s.datasets.map((d) => (d.id === id ? { ...d, ...patch, modified } : d)),
}));
},
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;
+133
View File
@@ -0,0 +1,133 @@
/**
* Extract-inline-data → Dataset state (spec §03F).
*
* Backs the Extract modal: the reverse of a named reference. It captures the
* active snippet draft's inline data, takes a dataset name, and on confirm saves
* the data as a new dataset and rewrites the draft so the inline data is replaced
* by a by-name reference (`{ "data": { "name": … } }`).
*
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
* for a single-view chart. Inline data nested inside layers/concats is left for a
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
* editor only offers the action when this store can act on it.
*/
import { create } from 'zustand';
import type { DataFormat } from '@core/format-detection';
import { createDataset } from '@core/dataset';
import { isNameTaken } from '@core/naming';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
/** The inline `data` block of a parsed spec, if it carries `values`. */
interface InlineData {
values: unknown;
format: DataFormat;
}
/**
* Read the top-level inline data from a draft spec's text, or null when there is
* none (no snippet, unparseable, or no `data.values`). The format comes from an
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
*/
export function readInlineData(draftText: string): InlineData | null {
let parsed: unknown;
try {
parsed = JSON.parse(draftText);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const data = (parsed as Record<string, unknown>).data;
if (!data || typeof data !== 'object') return null;
const values = (data as Record<string, unknown>).values;
if (values === undefined) return null;
const declared = (data as Record<string, unknown>).format;
const type =
declared && typeof declared === 'object'
? (declared as Record<string, unknown>).type
: undefined;
const format: DataFormat =
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
return { values, format };
}
/** True when the active snippet's draft has top-level inline data to extract. */
export function hasInlineData(draftText: string): boolean {
return readInlineData(draftText) !== null;
}
export interface ExtractState {
/** Proposed dataset name (required, unique). */
name: string;
/** The inline data captured at open, for the read-only preview. */
source: InlineData | null;
/** Inline validation message, or null. */
error: string | null;
/** Capture the active snippet's inline data and reset the form. */
init: () => void;
setName: (name: string) => void;
/**
* Validate, create the dataset, and rewrite the active draft to reference it by
* name. Returns whether it committed; on failure `error` is set. `now`
* injectable for tests.
*/
confirm: (now?: Date) => boolean;
reset: () => void;
}
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null };
export const useExtractStore = create<ExtractState>((set, get) => ({
...INITIAL,
init: () => {
const draft = useSnippetStore.getState().draftText;
set({ name: '', source: readInlineData(draft), error: null });
},
setName: (name) => set({ name, error: null }),
confirm: (now) => {
const name = get().name.trim();
if (name === '') {
set({ error: 'Enter a dataset name.' });
return false;
}
if (isNameTaken(name, useDatasetStore.getState().datasets)) {
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
return false;
}
const source = get().source;
if (!source) {
set({ error: 'No inline data to extract.' });
return false;
}
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
// `values` is already the right runtime shape for each, so store it directly.
const dataset = createDataset({
name,
data: source.values,
format: source.format,
source: 'inline',
now,
});
useDatasetStore.getState().add(dataset);
// Rewrite the top-level data block to a by-name reference, preserving the rest
// of the spec and its pretty-printed text shape.
const draftText = useSnippetStore.getState().draftText;
const spec = JSON.parse(draftText) as Record<string, unknown>;
spec.data = { name };
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
// TODO (M6, spec §03F): success toast "Dataset created" — deferred with the
// other success toasts (see SnippetStore publish/revert breadcrumbs).
set(INITIAL);
return true;
},
reset: () => set(INITIAL),
}));
+89
View File
@@ -224,3 +224,92 @@ describe('editorView + selectShownText (spec §03D)', () => {
expect(store().editorView).toBe('draft');
});
});
describe('publish — datasetRefs recomputation', () => {
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' });
test('publishing recomputes datasetRefs from the now-published spec', () => {
const a = createSnippet({ id: 'a', spec: '{}', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().updateDraft(refSpec('Sales'));
store().publish(new Date('2026-02-01T00:00:00Z'));
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
});
test('refs drop when a published spec no longer references a dataset', () => {
const a = createSnippet({
id: 'a',
spec: refSpec('Sales'),
now: new Date('2026-01-01T00:00:00Z'),
});
store().hydrate([a], 'a');
// The factory leaves datasetRefs empty until a publish runs; publish to seed it.
store().publish(new Date('2026-01-02T00:00:00Z'));
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
store().updateDraft('{"mark":"bar"}');
store().publish(new Date('2026-02-01T00:00:00Z'));
expect(selectActiveSnippet(store())?.datasetRefs).toEqual([]);
});
});
describe('renameDatasetRefs', () => {
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' }, null, 2);
test('rewrites spec, draftSpec, and refs of referencing snippets only', () => {
const a = createSnippet({
id: 'a',
spec: refSpec('Sales'),
now: new Date('2026-01-01T00:00:00Z'),
});
const b = createSnippet({
id: 'b',
spec: '{"mark":"line"}',
now: new Date('2026-01-02T00:00:00Z'),
});
store().hydrate([a, b], 'b');
store().publish(new Date('2026-01-03T00:00:00Z')); // seed a's refs would need a active; seed via select
store().selectSnippet('a');
store().publish(new Date('2026-01-04T00:00:00Z'));
const updated = store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
expect(updated).toBe(1);
const renamed = store().snippets.find((s) => s.id === 'a')!;
expect(renamed.datasetRefs).toEqual(['Revenue']);
expect(renamed.spec).toContain('"Revenue"');
expect(renamed.draftSpec).toContain('"Revenue"');
// The non-referencing snippet is untouched.
expect(store().snippets.find((s) => s.id === 'b')!.spec).toBe('{"mark":"line"}');
});
test('refreshes the active draft buffer when the active snippet is rewritten', () => {
const a = createSnippet({
id: 'a',
spec: refSpec('Sales'),
now: new Date('2026-01-01T00:00:00Z'),
});
store().hydrate([a], 'a');
store().publish(new Date('2026-01-02T00:00:00Z'));
const epochBefore = store().bufferEpoch;
store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
expect(store().draftText).toContain('"Revenue"');
expect(store().bufferEpoch).toBe(epochBefore + 1);
});
test('no-ops when the names are equal or nothing references the old name', () => {
const a = createSnippet({
id: 'a',
spec: refSpec('Sales'),
now: new Date('2026-01-01T00:00:00Z'),
});
store().hydrate([a], 'a');
store().publish(new Date('2026-01-02T00:00:00Z'));
expect(store().renameDatasetRefs('Sales', 'Sales')).toBe(0);
expect(store().renameDatasetRefs('Unknown', 'Other')).toBe(0);
});
});
+76 -2
View File
@@ -19,6 +19,7 @@
import { create } from 'zustand';
import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet';
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
/** Which version of the active snippet the editor is showing (spec §03D). */
export type EditorView = 'draft' | 'published';
@@ -48,6 +49,13 @@ export interface SnippetState {
removeSnippet: (id: string) => void;
/** Update the draft buffer only (no persistence; debounced commit follows). */
updateDraft: (text: string) => void;
/**
* Replace the active snippet's draft spec with new text and reload the editor
* on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
* as Extract-to-Dataset (spec §03F), which substitutes inline data for a
* by-name reference. No-op when no snippet is active. `now` injectable.
*/
replaceActiveDraft: (text: string, now?: Date) => void;
/**
* Persist the editor buffer into the active snippet's **draft**, if it parses
* as JSON. Returns whether it committed (a half-typed, unparseable buffer is
@@ -57,6 +65,14 @@ export interface SnippetState {
commitDraft: (now?: Date) => boolean;
/** Switch the editor between the draft and published views (spec §03D). */
setEditorView: (view: EditorView) => void;
/**
* Propagate a dataset rename across every referencing snippet: rewrite the
* named-data references in both `spec` and `draftSpec`, recompute `datasetRefs`,
* and refresh the live editor buffer if the active snippet was rewritten — so a
* user mid-edit doesn't see their draft silently break (docs/architecture/07
* §6). Returns the number of snippets changed. `now` injectable.
*/
renameDatasetRefs: (oldName: string, newName: string, now?: Date) => number;
/**
* Promote the active snippet's current draft to its published version (spec
* §03D → Publish). Flushes the live buffer first, then makes `spec` identical
@@ -151,6 +167,20 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
updateDraft: (draftText) => set({ draftText }),
replaceActiveDraft: (text, now) => {
const { activeSnippetId } = get();
if (!activeSnippetId) return;
const modified = (now ?? new Date()).toISOString();
set((s) => ({
snippets: s.snippets.map((x) =>
x.id === activeSnippetId ? { ...x, draftSpec: text, modified } : x,
),
draftText: text,
editorView: 'draft',
bufferEpoch: s.bufferEpoch + 1,
}));
},
commitDraft: (now) => {
const { activeSnippetId, draftText, snippets } = get();
if (!activeSnippetId) return false;
@@ -178,6 +208,43 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
setEditorView: (editorView) => set({ editorView }),
renameDatasetRefs: (oldName, newName, now) => {
if (oldName === newName) return 0;
const lower = oldName.toLowerCase();
const { snippets, activeSnippetId, editorView } = get();
let updated = 0;
let activeDraftAfter: string | null = null;
const next = snippets.map((s) => {
if (!s.datasetRefs.some((r) => r.toLowerCase() === lower)) return s;
updated++;
const spec = renameDatasetInSpec(s.spec, oldName, newName);
const draftSpec = renameDatasetInSpec(s.draftSpec, oldName, newName);
if (s.id === activeSnippetId) activeDraftAfter = draftSpec;
return {
...s,
spec,
draftSpec,
datasetRefs: recomputeDatasetRefs(spec),
modified: (now ?? new Date()).toISOString(),
};
});
if (updated === 0) return 0;
set((st) => ({
snippets: next,
// If the active snippet's draft was rewritten, reload the editor buffer so
// the live (draft-view) buffer reflects the new name and the next auto-save
// doesn't clobber the rewrite with the stale old name.
...(activeDraftAfter !== null && editorView === 'draft'
? { draftText: activeDraftAfter, bufferEpoch: st.bufferEpoch + 1 }
: activeDraftAfter !== null
? { bufferEpoch: st.bufferEpoch + 1 }
: {}),
}));
return updated;
},
publish: (now) => {
// Flush the live buffer into the draft first, so Publish promotes exactly
// what the user sees (an invalid buffer leaves the last valid draft in place).
@@ -189,8 +256,15 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
set({
snippets: snippets.map((s) =>
s.id === activeSnippetId
? // M3: recompute datasetRefs from the now-published spec (spec §03D).
{ ...s, spec: s.draftSpec, modified }
? // Promote the draft and recompute datasetRefs from the now-published
// spec, so the bidirectional snippet↔dataset link mirrors reality
// (spec §03D, docs/architecture/07 §3).
{
...s,
spec: s.draftSpec,
datasetRefs: recomputeDatasetRefs(s.draftSpec),
modified,
}
: s,
),
});