Track datasetRefs against the live draft so manual edits and extract link immediately

This commit is contained in:
2026-06-07 23:54:01 +03:00
parent cdd5cf149f
commit 7d247e8d2c
8 changed files with 183 additions and 57 deletions
+3 -2
View File
@@ -2,8 +2,9 @@
* Snippet ↔ dataset relationships (docs/architecture/07 §4 + §6).
*
* The bidirectional link is name-based and has a single source of truth: a
* snippet's `datasetRefs` (recomputed from its published spec). The reverse
* direction — "which snippets use this dataset" — is therefore DERIVED by a scan,
* snippet's `datasetRefs` (recomputed from its draft spec — the version being
* edited — on every draft change and on publish). The reverse direction —
* "which snippets use this dataset" — is therefore DERIVED by a scan,
* never stored, so it can't drift. Rename is the one graph operation: it renames
* the dataset record and propagates the new name into every referencing snippet's
* spec/draftSpec/refs.
+85
View File
@@ -254,6 +254,69 @@ describe('publish — datasetRefs recomputation', () => {
});
});
describe('datasetRefs track the live draft (spec §03F, arch 07 §3)', () => {
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' }, null, 2);
const inlineSpec = JSON.stringify({ data: { values: [{ a: 1 }] }, mark: 'bar' }, null, 2);
test('createSnippet links a named-data spec immediately (Chart Builder path)', () => {
store().createSnippet({
id: 'a',
spec: refSpec('Sales'),
now: new Date('2026-01-01T00:00:00Z'),
});
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
});
test('auto-save links a hand-typed reference without publishing', () => {
const a = createSnippet({ id: 'a', spec: '{}', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().updateDraft(refSpec('Sales'));
store().commitDraft(new Date('2026-02-01T00:00:00Z')); // the debounced auto-save
const s = selectActiveSnippet(store())!;
expect(s.datasetRefs).toEqual(['Sales']); // linked before any publish
expect(s.spec).toBe('{}'); // published spec untouched — refs mirror the draft
});
test('replaceActiveDraft (Extract to Dataset) links the dataset immediately', () => {
const a = createSnippet({ id: 'a', spec: inlineSpec, now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
expect(selectActiveSnippet(store())?.datasetRefs).toEqual([]); // inline data → no refs
store().replaceActiveDraft(refSpec('Orders'), new Date('2026-02-01T00:00:00Z'));
const s = selectActiveSnippet(store())!;
expect(s.datasetRefs).toEqual(['Orders']); // linked the moment the draft is rewritten
expect(s.spec).toBe(inlineSpec); // still a draft edit — published unchanged until publish
});
test('revert drops a link that existed only in the discarded draft', () => {
const a = createSnippet({ id: 'a', spec: inlineSpec, now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().replaceActiveDraft(refSpec('Orders'), new Date('2026-02-01T00:00:00Z'));
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Orders']);
store().revert(new Date('2026-03-01T00:00:00Z'));
const s = selectActiveSnippet(store())!;
expect(s.datasetRefs).toEqual([]); // draft restored to the inline-data published spec
expect(s.draftSpec).toBe(inlineSpec);
});
test('an unparseable draft is skipped — refs keep their last valid value (no flicker)', () => {
const a = createSnippet({ id: 'a', spec: '{}', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().updateDraft(refSpec('Sales'));
store().commitDraft(new Date('2026-01-02T00:00:00Z'));
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']);
store().updateDraft('{ "data": { "name": "Sa'); // mid-keystroke, invalid JSON
expect(store().commitDraft(new Date('2026-01-03T00:00:00Z'))).toBe(false);
expect(selectActiveSnippet(store())?.datasetRefs).toEqual(['Sales']); // unchanged
});
});
describe('renameSnippet (spec §02 metadata panel)', () => {
test('renames and advances modified, without touching the editor buffer', () => {
const a = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
@@ -409,6 +472,28 @@ describe('renameDatasetRefs', () => {
expect(store().renameDatasetRefs('Sales', 'Sales')).toBe(0);
expect(store().renameDatasetRefs('Unknown', 'Other')).toBe(0);
});
test('rewrites a name referenced only by the published spec (draft removed it)', () => {
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')); // spec + draft reference Sales
// Remove the reference from the DRAFT only — do not publish. datasetRefs now
// mirrors the (empty) draft, but the published spec still references Sales.
store().updateDraft('{"mark":"bar"}');
store().commitDraft(new Date('2026-01-03T00:00:00Z'));
expect(selectActiveSnippet(store())?.datasetRefs).toEqual([]);
const updated = store().renameDatasetRefs('Sales', 'Revenue', new Date('2026-02-01T00:00:00Z'));
expect(updated).toBe(1); // matched via the published spec, not datasetRefs
const renamed = store().snippets.find((s) => s.id === 'a')!;
expect(renamed.spec).toContain('"Revenue"'); // published spec rewritten — no rot
expect(renamed.spec).not.toContain('"Sales"');
});
});
describe('library view state (spec §02 → Search / Sort)', () => {
+37 -10
View File
@@ -24,7 +24,7 @@ import {
type CreateSnippetOptions,
type Snippet,
} from '@core/snippet';
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
import {
DEFAULT_SORT_BY,
DEFAULT_SORT_ORDER,
@@ -204,10 +204,13 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
createSnippet: (options) => {
get().commitDraft(); // flush the outgoing snippet's valid edits before switching away
const created = createSnippet(options);
// Mirror datasetRefs from the spec at creation, like publish does, so a snippet
// built with a named-data reference (Chart Builder, §06) is linked to its dataset
// immediately. Inline-data specs (the sample template) resolve to no refs.
const snippet = { ...created, datasetRefs: recomputeDatasetRefs(created.spec) };
// Mirror datasetRefs from the draft at creation, so a snippet built with a
// named-data reference (Chart Builder, §06) is linked to its dataset immediately.
// datasetRefs tracks the draft — the version being edited — at every mutation
// (create/auto-save/extract/revert/publish), so the link reflects what the user
// sees, not only the last publish (docs/architecture/07 §3). Inline-data specs
// (the sample template) resolve to no refs. spec === draftSpec at creation.
const snippet = { ...created, datasetRefs: recomputeDatasetRefs(created.draftSpec) };
set((s) => ({
snippets: [snippet, ...s.snippets],
activeSnippetId: snippet.id,
@@ -314,9 +317,13 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
const { activeSnippetId } = get();
if (!activeSnippetId) return;
const modified = (now ?? new Date()).toISOString();
const datasetRefs = recomputeDatasetRefs(text);
set((s) => ({
snippets: s.snippets.map((x) =>
x.id === activeSnippetId ? { ...x, draftSpec: text, modified } : x,
// Recompute datasetRefs from the new draft so a programmatic rewrite that
// introduces a by-name reference — Extract-to-Dataset (spec §03F) — links
// the dataset to the snippet immediately, not only on the next publish.
x.id === activeSnippetId ? { ...x, draftSpec: text, datasetRefs, modified } : x,
),
draftText: text,
editorView: 'draft',
@@ -341,9 +348,15 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
}
const modified = (now ?? new Date()).toISOString();
// Recompute datasetRefs from the just-committed draft so a hand-typed by-name
// reference links to its dataset as soon as auto-save fires. This runs on the
// debounced auto-save and only on valid JSON (the parse guard above), so it is
// not "every keystroke" and never sees a transiently-invalid draft — it keeps
// datasetRefs mirroring the draft the user is editing (docs/architecture/07 §3).
const datasetRefs = recomputeDatasetRefs(draftText);
set({
snippets: snippets.map((s) =>
s.id === activeSnippetId ? { ...s, draftSpec: draftText, modified } : s,
s.id === activeSnippetId ? { ...s, draftSpec: draftText, datasetRefs, modified } : s,
),
});
return true;
@@ -358,8 +371,18 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
let updated = 0;
let activeDraftAfter: string | null = null;
// Whether a spec text references the old name (case-insensitive). Reads the
// content rather than reserializing, so a non-referencing snippet's text is
// left byte-for-byte intact (no spurious reformat/modified bump).
const referencesOld = (text: string): boolean =>
extractDatasetRefs(text).some((r) => r.toLowerCase() === lower);
const next = snippets.map((s) => {
if (!s.datasetRefs.some((r) => r.toLowerCase() === lower)) return s;
// Consult both spec and draft, not only datasetRefs: datasetRefs now mirrors
// the DRAFT, so a name still referenced only by the published spec (the user
// removed it from the draft but hasn't published) would otherwise be missed
// and the published spec would rot to a now-renamed dataset (arch 07 §6).
if (!referencesOld(s.spec) && !referencesOld(s.draftSpec)) return s;
updated++;
const spec = renameDatasetInSpec(s.spec, oldName, newName);
const draftSpec = renameDatasetInSpec(s.draftSpec, oldName, newName);
@@ -368,7 +391,7 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
...s,
spec,
draftSpec,
datasetRefs: recomputeDatasetRefs(spec),
datasetRefs: recomputeDatasetRefs(draftSpec),
modified: (now ?? new Date()).toISOString(),
};
});
@@ -422,9 +445,13 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
if (!active) return false;
const modified = (now ?? new Date()).toISOString();
// The draft is restored to the published spec, so datasetRefs must mirror it
// again — drop any link that existed only in the discarded draft (e.g. an
// Extract-to-Dataset rewrite the user reverted before publishing).
const datasetRefs = recomputeDatasetRefs(active.spec);
set((s) => ({
snippets: s.snippets.map((x) =>
x.id === activeSnippetId ? { ...x, draftSpec: x.spec, modified } : x,
x.id === activeSnippetId ? { ...x, draftSpec: x.spec, datasetRefs, modified } : x,
),
// Reload the editor with the restored draft, on the editable view (spec §03D).
draftText: active.spec,