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
@@ -125,9 +125,15 @@ export function makeUniqueName(desired: string, existingNames: Iterable<string>)
it by scanning snippets. Keeping it _derived_ means it can never disagree with
the forward links — there is one source of truth.
`datasetRefs` is **derived from the spec**, not hand-maintained. It is
recomputed whenever a snippet is published (its draft spec is promoted), so it
always mirrors the dataset names actually referenced in the published spec.
`datasetRefs` is **derived from the spec**, not hand-maintained. It mirrors the
dataset names referenced by the **draft** spec — the version being edited — and
is recomputed on every change to the draft (auto-save, the Extract-to-Dataset
rewrite, revert) and on publish. Tracking the draft (not only the last publish)
keeps a snippet's linked-datasets display and the reverse lookup in step with
what the editor shows — so a hand-typed reference or an Extract links its dataset
without waiting for a publish. Recomputation runs only on a _valid_ spec —
auto-save is debounced and parse-gated (spec §03B) — so a transiently-invalid
draft never disturbs the links.
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
@@ -186,7 +192,7 @@ function safeParse(s: string): Json {
```
```ts
// src/core/spec-refs.ts — thin wrapper used at publish time
// src/core/spec-refs.ts — thin wrapper, run on every draft change and on publish
/** The list stored on snippet.datasetRefs. Sorted + de-duped for stable diffs. */
export function recomputeDatasetRefs(spec: Json): string[] {
@@ -202,8 +208,11 @@ export function recomputeDatasetRefs(spec: Json): string[] {
- Treat `extractDatasetRefs` as the single source of truth for "what does this
spec reference". The reverse-lookup and rename paths both depend on it
agreeing with what the renderer actually resolves.
- Recompute and store `datasetRefs` on **publish**, not on every keystroke
the draft can be transiently invalid, and only the published spec is shared.
- Recompute and store `datasetRefs` on **every draft change and on publish**
but only through the parse-gated, debounced auto-save (`commitDraft`) and the
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
links in step with the edited draft while never recomputing from a
transiently-invalid spec.
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
@@ -264,8 +273,9 @@ export function findSnippetsReferencingDataset(name: string): Snippet[] {
The reactive UI does **not** go through the service: a component subscribed to
`snippets` calls the core helper directly, so the badge and Linked Snippets list
update the moment any snippet is published with changed refs — no duplicated
matching logic, no `getState()` snapshot that would miss updates.
update the moment any snippet's draft changes its refs (auto-save) or it is
published — no duplicated matching logic, no `getState()` snapshot that would
miss updates.
**Do**
@@ -375,8 +385,9 @@ one atomic step:
2. For **every snippet referencing the old name**: rewrite the named-data
references inside its spec (`{ "data": { "name": "old" } }`
`{ "data": { "name": "new" } }`) — in **both** `spec` and `draftSpec`.
3. Recompute that snippet's `datasetRefs` from the rewritten spec, so the
forward link mirrors reality and the reverse scan stays correct.
3. Recompute that snippet's `datasetRefs` from the rewritten **draft** spec (the
tracked surface), so the forward link mirrors reality and the reverse scan
stays correct.
The spec rewrite is pure; the orchestration reads and writes stores.
@@ -417,42 +428,34 @@ export function renameDatasetInSpec(spec: Json, oldName: string, newName: string
```
```ts
// src/app/services/RelationshipService.ts — store coordination
// src/app/services/RelationshipService.ts — thin store coordinator
import { isNameTaken, makeUniqueName } from '../../core/naming';
import { renameDatasetInSpec, recomputeDatasetRefs } from '../../core/spec-refs';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
/**
* Renames a dataset and propagates the rename to every referencing snippet
* (spec, draftSpec, and datasetRefs). Returns the snippets that changed.
* (spec, draftSpec, and datasetRefs). Returns the number of snippets changed.
* Caller is responsible for collision policy on `newName` (reject vs suffix).
*/
export function renameDatasetEverywhere(oldName: string, newName: string): { updated: number } {
export function renameDatasetEverywhere(
oldName: string,
newName: string,
now?: Date,
): { updated: number } {
if (oldName === newName) return { updated: 0 };
// 1. Rename the dataset record itself.
const dataset = useDatasetStore.getState().datasets.find((d) => d.name === oldName);
if (!dataset) return { updated: 0 };
useDatasetStore.getState().update(dataset.id, { name: newName });
if (dataset) useDatasetStore.getState().update(dataset.id, { name: newName }, now);
// 2 + 3. Rewrite every referencing snippet's specs and refs.
let updated = 0;
for (const snippet of useSnippetStore.getState().snippets) {
if (!snippet.datasetRefs.some((r) => r.toLowerCase() === oldName.toLowerCase())) continue;
const spec = renameDatasetInSpec(snippet.spec, oldName, newName);
const draftSpec = renameDatasetInSpec(snippet.draftSpec, oldName, newName);
useSnippetStore.getState().update(snippet.id, {
spec,
draftSpec,
datasetRefs: recomputeDatasetRefs(spec),
});
updated++;
}
return { updated };
// 2 + 3. Delegate the spec/draftSpec/refs rewrite to the ONE rename impl — the
// store action that owns the snippet collection. It matches by spec+draft
// *content* (not `datasetRefs`, which mirrors only the draft) and recomputes
// `datasetRefs` from the rewritten draft. Keeping the loop in the store means
// the reactive editor buffer is refreshed in the same atomic update when the
// active snippet's draft is rewritten.
return { updated: useSnippetStore.getState().renameDatasetRefs(oldName, newName, now) };
}
```
@@ -466,10 +469,17 @@ export function renameDatasetEverywhere(oldName: string, newName: string): { upd
- Rewrite `spec` **and** `draftSpec`. A user mid-edit must not see their draft
silently break because the dataset was renamed underneath them.
- Recompute `datasetRefs` from the rewritten spec rather than string-replacing
the array — the spec is the source of truth, the array is its mirror.
- Use the §4 reverse lookup to find affected snippets, so "who references this"
has exactly one implementation.
- Recompute `datasetRefs` from the rewritten **draft** spec rather than
string-replacing the array — the draft is the source of truth, the array is
its mirror.
- Find affected snippets by scanning each one's **spec and draft content** for
the old name (`extractDatasetRefs`), not by its `datasetRefs` array. Because
`datasetRefs` mirrors the draft, a name referenced only by the still-published
spec (the user removed it from the draft but hasn't published) is absent from
the array; matching on content rewrites it anyway, so the published spec can't
rot to a renamed-away dataset. Scan the content (don't reserialize) so a
non-referencing snippet's text stays byte-for-byte intact. The import-side
`applyDatasetRenamesToSnippets` already follows this spec-content rule (§5.1).
**Don't**
@@ -494,6 +504,6 @@ export function renameDatasetEverywhere(oldName: string, newName: string): { upd
The dividing line: anything that takes plain data and returns plain data is
**core** and unit-tested in isolation; anything that reaches into a Zustand store
is an **app service**. The rename rule of thumb — _the spec is the source of
is an **app service**. The rule of thumb — _the **draft** spec is the source of
truth, `datasetRefs` mirrors it, the reverse lookup is derived_ — is what keeps
the bidirectional link from ever needing manual repair.
+2 -2
View File
@@ -45,7 +45,7 @@ Every snippet carries two versions of its spec: a **published** (stable) version
- A **Publish** action promotes the current draft to become the published version (the two are made identical).
- Publish is also triggered by the keyboard shortcut Cmd/Ctrl+S.
- On publish, the snippet's dataset references are recomputed from the now-published spec (see _Datasets_ for reference linking).
- The snippet's dataset references track the draft continuously (recomputed on auto-save, extract, and revert), so publish needs no special reference handling — promoting the draft simply carries the already-current references onto the published version (see _Datasets_ for reference linking).
- A success toast confirms the snippet was published.
- Publish is unavailable when no snippet is active.
@@ -72,7 +72,7 @@ When a snippet's spec embeds its data inline, the user can lift that data out in
- When the active snippet's draft spec contains inline data, an **Extract to Dataset** action is available in the pane header; it is hidden when the spec has no inline data.
- Choosing it opens a modal that shows a read-only preview of the inline data and asks the user for a dataset name (required).
- The user enters a name and confirms creation. Names must be non-empty and unique; if the name is blank or already in use, the modal shows an inline error and the action does not proceed.
- On success, the system: saves the inline data as a new dataset (preserving its detected format), rewrites the snippet's draft spec so the inline data is replaced by a reference to the dataset by name, links the dataset to the snippet, and reloads the editor to show the rewritten spec.
- On success, the system: saves the inline data as a new dataset (preserving its detected format), rewrites the snippet's draft spec so the inline data is replaced by a reference to the dataset by name, links the dataset to the snippet **immediately** (the link appears without waiting for a publish, since references track the draft), and reloads the editor to show the rewritten spec. Reverting the draft before publishing removes the link again.
- A toast confirms the dataset was created, and the modal closes.
- The user can cancel the modal at any time, leaving the spec unchanged.
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
+1 -1
View File
@@ -28,7 +28,7 @@ A snippet carries two specs at once. `draftSpec` is the editable working copy; `
### datasetRefs
`datasetRefs` records the **names** of datasets the spec depends on. It is the link used to display a snippet's linked datasets and, conversely, to find which snippets use a given dataset (see _Cross-entity relationships_). It is maintained to mirror the dataset names actually referenced in the spec.
`datasetRefs` records the **names** of datasets the spec depends on. It is the link used to display a snippet's linked datasets and, conversely, to find which snippets use a given dataset (see _Cross-entity relationships_). It mirrors the dataset names referenced by the **draft** spec — the version the user is editing — and is recomputed on every change to the draft (auto-save, the Extract-to-Dataset rewrite, revert) and on publish. Tracking the draft means a snippet's linked datasets reflect what the editor currently shows, not only the last published version; recomputation only ever runs on a valid (parseable) spec, so a half-typed draft never disturbs the links.
## B. Dataset
+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,
+4 -3
View File
@@ -283,8 +283,8 @@ export function reassignCollidingSnippetIds(
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) into the imported
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
* from the rewritten spec — the spec is the source of truth, `datasetRefs` mirrors
* it. Matching is case-insensitive (consistent with the naming helpers). Only
* from the rewritten draft — the draft is the source of truth, `datasetRefs`
* mirrors it. Matching is case-insensitive (consistent with the naming helpers). Only
* snippets that actually change are cloned.
*
* Applied to the imported set ONLY — existing snippets keep their references,
@@ -323,7 +323,8 @@ export function applyDatasetRenamesToSnippets(
draftSpec = renameDatasetInSpec(draftSpec, oldName, to);
}
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(spec) };
// datasetRefs mirrors the draft — the version the editor shows (arch 07 §3).
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec) };
});
}
+3 -1
View File
@@ -40,7 +40,9 @@ export interface Snippet {
comment: string;
/** User-assigned labels. */
tags: string[];
/** Names of datasets referenced by this spec (maintained on publish). */
/** Names of datasets referenced by the draft spec; mirrors the version being
* edited, recomputed on every draft change (create/auto-save/extract/revert)
* and on publish (docs/architecture/07 §3). */
datasetRefs: string[];
/** Free-form, extensible metadata bag. */
meta: Record<string, unknown>;