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