mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
433 lines
16 KiB
Markdown
433 lines
16 KiB
Markdown
# Naming & Relationships
|
|
|
|
How Astrolabe keeps entity **names unique** within a collection, and how it
|
|
tracks the **bidirectional links** between snippets and datasets so they stay
|
|
consistent as entities are created, imported, and renamed.
|
|
|
|
Two concerns live here, and they reinforce each other:
|
|
|
|
1. **Name uniqueness** — every dataset has a unique name. Names are the primary
|
|
key users see and the key snippets reference, so duplicates would be
|
|
ambiguous. We reject duplicate names on create/rename, and auto-suffix
|
|
collisions during bulk import.
|
|
2. **Relationship tracking** — a snippet references datasets *by name* through
|
|
its `datasetRefs: string[]` field. This is a bidirectional, name-based link:
|
|
from a snippet you read its refs; from a dataset you scan snippets to find
|
|
who uses it. Renaming a dataset must propagate to every snippet that points
|
|
at it, in both the spec and the `datasetRefs` array, or the links rot.
|
|
|
|
The hard, testable logic is **pure** and lives in `src/core/`. The parts that
|
|
read and mutate stores live in `src/app/services/`.
|
|
|
|
---
|
|
|
|
## 1. Why names, not IDs, are the link
|
|
|
|
Datasets carry a numeric `id`, but snippets reference them **by name** because
|
|
that is what Vega-Lite uses: a spec resolves data through a named-data
|
|
reference, `{ "data": { "name": "MyDataset" } }`. The name *is* the contract
|
|
between a spec and the dataset library. Storing a numeric id in the spec would
|
|
mean the spec is no longer a standalone, paste-anywhere Vega-Lite document.
|
|
|
|
The consequence: names must be unique (two datasets named `Sales` would make
|
|
`{ "data": { "name": "Sales" } }` ambiguous), and renaming a dataset is a
|
|
**graph operation**, not a single field write — every reference to the old name
|
|
must move with it.
|
|
|
|
---
|
|
|
|
## 2. Name uniqueness (pure — `src/core/naming.ts`)
|
|
|
|
### 2.1 Uniqueness check
|
|
|
|
Comparisons are **case-insensitive** (`Sales` and `sales` collide), so a single
|
|
display name maps to a single dataset regardless of how a user types a
|
|
reference. The check takes an optional `excludeId` so a rename can ignore the
|
|
record being renamed (renaming `Sales` to `Sales` is not a collision with
|
|
itself).
|
|
|
|
```ts
|
|
// src/core/naming.ts
|
|
|
|
/** Case-insensitive set of names already in use, minus an optional excluded id. */
|
|
export function isNameTaken(
|
|
desired: string,
|
|
datasets: ReadonlyArray<{ id: number; name: string }>,
|
|
excludeId?: number,
|
|
): boolean {
|
|
const lower = desired.trim().toLowerCase();
|
|
return datasets.some((d) => d.id !== excludeId && d.name.toLowerCase() === lower);
|
|
}
|
|
```
|
|
|
|
### 2.2 Making a unique name
|
|
|
|
When a desired name is taken — during import, "extract inline data", or
|
|
"build chart" — we do **not** overwrite the existing dataset. We derive the
|
|
next free name by appending a numeric suffix: `Name` → `Name 2` → `Name 3`.
|
|
The function takes the set of existing names so it has no store dependency and
|
|
is trivially unit-testable.
|
|
|
|
```ts
|
|
// src/core/naming.ts
|
|
|
|
/**
|
|
* Returns `desired` if free, else the first available `${desired} ${n}` (n >= 2).
|
|
* `existingNames` is the set of names already in the collection.
|
|
* Comparison is case-insensitive; the returned name preserves `desired`'s casing.
|
|
*/
|
|
export function makeUniqueName(desired: string, existingNames: Iterable<string>): string {
|
|
const taken = new Set<string>();
|
|
for (const n of existingNames) taken.add(n.toLowerCase());
|
|
|
|
const base = desired.trim();
|
|
if (!taken.has(base.toLowerCase())) return base;
|
|
|
|
let n = 2;
|
|
while (taken.has(`${base} ${n}`.toLowerCase())) n++;
|
|
return `${base} ${n}`;
|
|
}
|
|
```
|
|
|
|
> If a base name already ends in a number (`Q1 2024`), the suffix still appends
|
|
> (`Q1 2024 2`). That is intentional: we never parse meaning out of the name,
|
|
> we only guarantee a free slot. Keep this dumb and predictable.
|
|
|
|
**Do**
|
|
|
|
- Use `isNameTaken` to reject duplicate create/rename in the UI before saving,
|
|
and surface an error toast.
|
|
- Use `makeUniqueName` for every non-interactive path (import, extract, build)
|
|
where blocking the user would be worse than a silent, reported rename.
|
|
- Pass `excludeId` on rename so an unchanged or case-only edit is allowed.
|
|
|
|
**Don't**
|
|
|
|
- Don't compare names case-sensitively anywhere — pick `toLowerCase()` once and
|
|
use it consistently.
|
|
- Don't let `makeUniqueName` mutate a store or read store state; it takes plain
|
|
data and returns a string.
|
|
|
|
---
|
|
|
|
## 3. The bidirectional snippet ↔ dataset link
|
|
|
|
```
|
|
datasetRefs: ["Sales", "Regions"] (forward, on the snippet)
|
|
Snippet ───────────────────────────────────────────────────► Dataset "Sales"
|
|
▲ │
|
|
└──────────── scan all snippets for "Sales" in datasetRefs ◄──────┘
|
|
(reverse, derived)
|
|
```
|
|
|
|
- **Forward** (snippet → datasets): read `snippet.datasetRefs`. Cheap, stored.
|
|
- **Reverse** (dataset → snippets): there is no stored back-pointer. We compute
|
|
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.
|
|
|
|
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
|
|
|
|
A Vega-Lite spec can reference named data in several places: the top-level
|
|
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and
|
|
named entries in top-level `datasets`. Rather than enumerate Vega-Lite's grammar,
|
|
we walk the spec recursively and collect every `{ data: { name } }` we find.
|
|
This is pure, deterministic, and the most heavily unit-tested function here.
|
|
|
|
```ts
|
|
// src/core/spec-refs.ts
|
|
|
|
type Json = unknown;
|
|
|
|
/** Collects every dataset name referenced by `{ data: { name } }` anywhere in the spec. */
|
|
export function extractDatasetRefs(spec: Json): string[] {
|
|
const names = new Set<string>();
|
|
|
|
const walk = (node: Json): void => {
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) walk(item);
|
|
return;
|
|
}
|
|
if (node && typeof node === 'object') {
|
|
const obj = node as Record<string, Json>;
|
|
const data = obj.data as Record<string, Json> | undefined;
|
|
if (data && typeof data === 'object' && typeof data.name === 'string') {
|
|
names.add(data.name);
|
|
}
|
|
for (const key of Object.keys(obj)) walk(obj[key]);
|
|
}
|
|
};
|
|
|
|
walk(typeof spec === 'string' ? safeParse(spec) : spec);
|
|
return [...names];
|
|
}
|
|
|
|
function safeParse(s: string): Json {
|
|
try {
|
|
return JSON.parse(s);
|
|
} catch {
|
|
return null; // an unparseable draft simply has no resolvable refs
|
|
}
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// src/core/spec-refs.ts — thin wrapper used at publish time
|
|
|
|
/** The list stored on snippet.datasetRefs. Sorted + de-duped for stable diffs. */
|
|
export function recomputeDatasetRefs(spec: Json): string[] {
|
|
return extractDatasetRefs(spec).sort();
|
|
}
|
|
```
|
|
|
|
> A `spec` may be an object or a string (see the Data Model). Normalize once,
|
|
> at the boundary, so the recursive walk never has to care.
|
|
|
|
**Do**
|
|
|
|
- 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.
|
|
|
|
**Don't**
|
|
|
|
- Don't let two code paths each have their own idea of "referenced names".
|
|
Renamer and ref-recomputer must use the same extractor.
|
|
|
|
---
|
|
|
|
## 4. Reverse lookup: who uses this dataset?
|
|
|
|
The Dataset Manager shows a **usage badge** and a **Linked Snippets** list; the
|
|
Snippet Library shows a snippet's linked datasets. Both come from one selector
|
|
scan — no stored back-pointer to drift.
|
|
|
|
```ts
|
|
// src/app/services/RelationshipService.ts
|
|
|
|
import { useSnippetStore } from '../stores/SnippetStore';
|
|
import type { Snippet } from '../../core/types';
|
|
|
|
/** Snippets whose datasetRefs include `name` (case-insensitive). */
|
|
export function findSnippetsReferencingDataset(name: string): Snippet[] {
|
|
const lower = name.toLowerCase();
|
|
return useSnippetStore.getState().snippets.filter((s) =>
|
|
s.datasetRefs.some((ref) => ref.toLowerCase() === lower),
|
|
);
|
|
}
|
|
|
|
/** Count for the usage badge. */
|
|
export function datasetUsageCount(name: string): number {
|
|
return findSnippetsReferencingDataset(name).length;
|
|
}
|
|
```
|
|
|
|
Because this reads `useSnippetStore.getState().snippets`, exposing it as a
|
|
selector for the UI makes the badge and Linked Snippets list reactive for free —
|
|
they update the moment any snippet is published with changed refs.
|
|
|
|
**Do**
|
|
|
|
- Keep reverse lookup a pure scan over the store. It is O(snippets) but the
|
|
collections are small (library budget ~5 MB); clarity beats an index.
|
|
- Expose it as a selector where the UI needs reactivity.
|
|
|
|
**Don't**
|
|
|
|
- Don't add a `referencedBy` array to datasets. A stored reverse pointer is a
|
|
second source of truth that *will* fall out of sync with `datasetRefs`.
|
|
|
|
---
|
|
|
|
## 5. Import: auto-suffix collisions, then report
|
|
|
|
On import we never overwrite an existing dataset. A dataset whose name collides
|
|
is renamed to a unique name via `makeUniqueName`, and **every rename is
|
|
collected and reported to the user** (toast / summary) so the change is never
|
|
silent. Crucially, names are reserved *as we go* — within a single import, two
|
|
incoming `Sales` datasets become `Sales 2` and `Sales 3`, not two `Sales 2`.
|
|
|
|
```ts
|
|
// src/app/services/ImportService.ts
|
|
|
|
import { makeUniqueName } from '../../core/naming';
|
|
import type { Dataset } from '../../core/types';
|
|
|
|
export interface DatasetRename {
|
|
from: string;
|
|
to: string;
|
|
}
|
|
|
|
/**
|
|
* Returns incoming datasets with collision-free names, plus the renames applied.
|
|
* `existing` are names already in the library; `incoming` are datasets to add.
|
|
*/
|
|
export function dedupeIncomingDatasetNames(
|
|
existing: ReadonlyArray<string>,
|
|
incoming: ReadonlyArray<Dataset>,
|
|
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
|
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
|
const renames: DatasetRename[] = [];
|
|
|
|
const datasets = incoming.map((d) => {
|
|
const unique = makeUniqueName(d.name, reserved);
|
|
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
|
if (unique !== d.name) renames.push({ from: d.name, to: unique });
|
|
return unique === d.name ? d : { ...d, name: unique };
|
|
});
|
|
|
|
return { datasets, renames };
|
|
}
|
|
```
|
|
|
|
> If imported snippets reference the renamed dataset, their `datasetRefs` and
|
|
> specs must be rewritten to the new name too — reuse the rename machinery in
|
|
> §6 over the imported snippet set, or run `renameDatasetEverywhere` per applied
|
|
> rename after the import is committed.
|
|
|
|
**Do**
|
|
|
|
- Reserve each chosen name immediately so collisions *within* one import are
|
|
also resolved.
|
|
- Return the rename list and show it; a silent rename looks like data loss.
|
|
|
|
**Don't**
|
|
|
|
- Don't overwrite or merge a same-named existing dataset on import. Suffix and
|
|
keep both — the user decides what to delete.
|
|
|
|
---
|
|
|
|
## 6. Rename propagation: keep the link consistent
|
|
|
|
Renaming a dataset is the operation that ties §2–§5 together. A rename must, in
|
|
one atomic step:
|
|
|
|
1. Update the dataset's own `name`.
|
|
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.
|
|
|
|
The spec rewrite is pure; the orchestration reads and writes stores.
|
|
|
|
```ts
|
|
// src/core/spec-refs.ts — pure rewrite
|
|
|
|
/** Returns a copy of `spec` with every data.name === oldName replaced by newName. */
|
|
export function renameDatasetInSpec(spec: Json, oldName: string, newName: string): Json {
|
|
const obj = typeof spec === 'string' ? safeParse(spec) : spec;
|
|
|
|
const rewrite = (node: Json): Json => {
|
|
if (Array.isArray(node)) return node.map(rewrite);
|
|
if (node && typeof node === 'object') {
|
|
const out: Record<string, Json> = {};
|
|
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
|
|
if (
|
|
k === 'data' &&
|
|
v && typeof v === 'object' &&
|
|
(v as Record<string, Json>).name === oldName
|
|
) {
|
|
out[k] = { ...(v as object), name: newName };
|
|
} else {
|
|
out[k] = rewrite(v);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
return node;
|
|
};
|
|
|
|
const rewritten = rewrite(obj);
|
|
// Preserve the original spec's stored shape (string vs object).
|
|
return typeof spec === 'string' ? JSON.stringify(rewritten, null, 2) : rewritten;
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// src/app/services/RelationshipService.ts — store coordination
|
|
|
|
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.
|
|
* Caller is responsible for collision policy on `newName` (reject vs suffix).
|
|
*/
|
|
export function renameDatasetEverywhere(oldName: string, newName: string): { 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 });
|
|
|
|
// 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 };
|
|
}
|
|
```
|
|
|
|
> **Collision on rename.** The UI rename form rejects a name already in use via
|
|
> `isNameTaken(newName, datasets, dataset.id)`. Programmatic renames (e.g. an
|
|
> import flow) instead resolve with `makeUniqueName` before calling
|
|
> `renameDatasetEverywhere`. The propagation function itself does not invent a
|
|
> name — it assumes `newName` is the agreed target.
|
|
|
|
**Do**
|
|
|
|
- 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.
|
|
|
|
**Don't**
|
|
|
|
- Don't update `datasetRefs` without also rewriting the spec — the rendered
|
|
named-data reference would still point at the old, now-missing name.
|
|
- Don't rename the dataset and skip propagation "for now". A half-applied rename
|
|
is the exact inconsistency this whole document exists to prevent.
|
|
|
|
---
|
|
|
|
## 7. Where things live
|
|
|
|
| Concern | Location | Pure? | Tested |
|
|
|---|---|---|---|
|
|
| `makeUniqueName`, `isNameTaken` | `src/core/naming.ts` | yes | unit |
|
|
| `extractDatasetRefs`, `recomputeDatasetRefs` | `src/core/spec-refs.ts` | yes | unit |
|
|
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
|
|
| `findSnippetsReferencingDataset`, usage count | `src/app/services/RelationshipService.ts` | no (reads store) | integration |
|
|
| `renameDatasetEverywhere` | `src/app/services/RelationshipService.ts` | no (mutates stores) | integration |
|
|
| `dedupeIncomingDatasetNames` | `src/app/services/ImportService.ts` | nearly (uses `makeUniqueName`) | unit/integration |
|
|
|
|
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
|
|
truth, `datasetRefs` mirrors it, the reverse lookup is derived* — is what keeps
|
|
the bidirectional link from ever needing manual repair.
|