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.