mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Track datasetRefs against the live draft so manual edits and extract link immediately
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user