Services: delete dead RelationshipService; rename persistence to snippet-persistence

This commit is contained in:
2026-06-12 19:46:14 +03:00
parent 538882b342
commit 9a8b44380d
12 changed files with 86 additions and 209 deletions
+1 -1
View File
@@ -398,7 +398,7 @@ every keystroke. A startup subscriber observes the draft and debounces the expen
work:
```ts
// src/app/orchestration/persistence.ts
// src/app/orchestration/snippet-persistence.ts
import { useSnippetStore } from '../stores/SnippetStore';
import { saveSnippet } from '../infrastructure/snippet-store'; // IndexedDB adapter
+1 -1
View File
@@ -451,7 +451,7 @@ export async function saveSnippet(s: Snippet): Promise<void> {
fire-and-forget `void saveSnippet(n)` re-buries the very error the adapter took care to
throw. Persistence write-backs are wired as store subscribers, so the surfacing path is:
`orchestration/persistence.ts` (write-through `.catch`) / `orchestration/startup.ts` (load
`orchestration/snippet-persistence.ts` (write-through `.catch`) / `orchestration/startup.ts` (load
`.catch`, then run in memory) → `services/storage-errors.ts` (pure error→message mapper) →
`notify()` (`stores/NotificationStore`) → `Toaster`.
@@ -260,22 +260,14 @@ export function datasetUsageCounts(
): Map<string, number>;
```
```ts
// src/app/services/RelationshipService.ts — snapshot wrapper for non-reactive callers
import { snippetsReferencingDataset } from '../../core/relationships';
import { useSnippetStore } from '../stores/SnippetStore';
export function findSnippetsReferencingDataset(name: string): Snippet[] {
return snippetsReferencingDataset(useSnippetStore.getState().snippets, name);
}
```
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's draft changes its refs (auto-save) or it is
published — no duplicated matching logic, no `getState()` snapshot that would
miss updates.
There is no app-layer wrapper module around the scan. The reactive UI — a
component subscribed to `snippets` — calls the core helper directly, so the
badge and Linked Snippets list update the moment any snippet's draft changes
its refs (auto-save) or it is published. A non-reactive caller binds the same
helper to a snapshot inline at its call site
(`snippetsReferencingDataset(useSnippetStore.getState().snippets, name)`) —
one matching implementation, no `getState()` wrapper that would go stale in
reactive code.
**Do**
@@ -283,7 +275,7 @@ miss updates.
reactive and snapshot callers share it. It is O(snippets) but the collections
are small (library budget ~5 MB); clarity beats an index.
- Call the core helper directly from a store-subscribed component for reactivity;
use the service wrapper only from non-reactive (snapshot) code.
bind it to a `getState()` snapshot inline for non-reactive code.
**Don't**
@@ -334,9 +326,10 @@ export function dedupeIncomingDatasetNames(
```
> 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.
> specs must be rewritten to the new name too. The import flow does this purely,
> before anything is committed: `applyDatasetRenamesToSnippets`
> (`core/import-normalize`) rewrites the incoming snippet set per applied rename
> using the same `renameDatasetInSpec` machinery as §6.
### 5.1 Where the import/export flow lives, and its rules
@@ -427,43 +420,22 @@ export function renameDatasetInSpec(spec: Json, oldName: string, newName: string
}
```
```ts
// src/app/services/RelationshipService.ts — thin store coordinator
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 number of snippets changed.
* Caller is responsible for collision policy on `newName` (reject vs suffix).
*/
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) useDatasetStore.getState().update(dataset.id, { name: newName }, now);
// 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) };
}
```
The one rename implementation in the live app is the store action
**`SnippetStore.renameDatasetRefs(oldName, newName, now)`** — the action that
owns the snippet collection. It matches by spec+draft _content_ (not
`datasetRefs`, which mirrors only the draft), rewrites `spec`/`draftSpec` via
`renameDatasetInSpec`, recomputes `datasetRefs` from the rewritten draft, and
returns the number of snippets changed. 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. `DatasetStore.update` calls it whenever a save
changes a dataset's name, so a rename propagates everywhere as part of the one
user action — there is no separate coordinator module to call.
> **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.
> import flow) instead resolve with `makeUniqueName` first. The propagation
> action itself does not invent a name — it assumes `newName` is the agreed
> target.
**Do**
@@ -493,17 +465,16 @@ export function renameDatasetEverywhere(
## 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 |
| `snippetsReferencingDataset`, `datasetUsageCounts` (reverse-lookup scan) | `src/core/relationships.ts` | yes | unit |
| `findSnippetsReferencingDataset`, usage count (snapshot wrappers) | `src/app/services/RelationshipService.ts` | no (reads store) | integration |
| `renameDatasetEverywhere``{ updated }` | `src/app/services/RelationshipService.ts` | no (mutates stores) | integration |
| `renameDatasetRefs` → updated count (rename propagation) | `src/app/stores/SnippetStore.ts` | no (mutates stores) | integration |
| `dedupeIncomingDatasetNames` | `src/core/import-normalize.ts` | yes | unit |
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 rule of thumb — _the **draft** spec is the source of
is a **store action or 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.
@@ -138,7 +138,7 @@ control and freedom," #5 "error prevention"; spec §10 Reliability).
- **No silent data loss.** Edits auto-save as a **draft**; a known-good **published**
version is always preserved separately (§03D). A failed persist **surfaces as a toast**,
never a swallowed promise (`orchestration/persistence.ts`).
never a swallowed promise (`orchestration/snippet-persistence.ts`).
- **A marked exit from every committed change.** Revert restores the published spec;
Escape closes modals; delete/revert/reset require confirmation first.
- **Resilient rendering recovers on its own.** An invalid spec shows a readable error and
@@ -19,7 +19,7 @@ vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
import { saveSnippet, deleteSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { AUTOSAVE_DEBOUNCE_MS, wirePersistence } from './persistence';
import { AUTOSAVE_DEBOUNCE_MS, wireSnippetPersistence } from './snippet-persistence';
const save = vi.mocked(saveSnippet);
const del = vi.mocked(deleteSnippet);
@@ -51,7 +51,7 @@ afterEach(() => {
describe('wireDraftAutoSave', () => {
it('persists a settled, valid edit after the debounce', async () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
teardown = wirePersistence();
teardown = wireSnippetPersistence();
save.mockClear();
useSnippetStore.getState().updateDraft('{"a":2}');
@@ -65,7 +65,7 @@ describe('wireDraftAutoSave', () => {
it('does not persist a half-typed, unparseable buffer', async () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
teardown = wirePersistence();
teardown = wireSnippetPersistence();
save.mockClear();
useSnippetStore.getState().updateDraft('{ "a":');
@@ -78,7 +78,7 @@ describe('wireDraftAutoSave', () => {
describe('wireWriteThrough', () => {
it('persists a newly created snippet', () => {
useSnippetStore.getState().hydrate([]);
teardown = wirePersistence();
teardown = wireSnippetPersistence();
save.mockClear();
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
@@ -90,7 +90,7 @@ describe('wireWriteThrough', () => {
it('deletes a removed snippet without re-saving the survivors', () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{}'), snippetWith('b', '{}')]);
teardown = wirePersistence();
teardown = wireSnippetPersistence();
save.mockClear();
useSnippetStore.getState().removeSnippet('a');
@@ -103,7 +103,7 @@ describe('wireWriteThrough', () => {
it('ignores state changes that do not touch the snippets array', () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{}')]);
teardown = wirePersistence();
teardown = wireSnippetPersistence();
save.mockClear();
useSnippetStore.getState().setEditorView('published');
@@ -114,7 +114,7 @@ describe('wireWriteThrough', () => {
it('surfaces a failed save as an error notification instead of losing it silently', async () => {
useSnippetStore.getState().hydrate([]);
teardown = wirePersistence();
teardown = wireSnippetPersistence();
save.mockClear();
save.mockRejectedValueOnce(new StorageQuotaError());
@@ -1,5 +1,5 @@
/**
* Persistence & auto-save wiring (docs/architecture/01 §5).
* Snippet persistence & auto-save wiring (docs/architecture/01 §5).
*
* Bridges the pure SnippetStore to the IndexedDB adapter via startup
* subscribers the store stays browser-free, and all reads/writes funnel
@@ -61,7 +61,7 @@ function wireWriteThrough(): Unsubscribe {
}
/** Wire all persistence subscribers. Returns a teardown that detaches them. */
export function wirePersistence(): Unsubscribe {
export function wireSnippetPersistence(): Unsubscribe {
const unsubs = [wireDraftAutoSave(), wireWriteThrough()];
return () => unsubs.forEach((u) => u());
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { wirePersistence } from './persistence';
import { wireSnippetPersistence } from './snippet-persistence';
import { wireDatasetPersistence } from './dataset-persistence';
import { wireThemePersistence } from './theme-persistence';
import { startRouting } from '../modals/UrlStateSync';
@@ -67,7 +67,7 @@ export async function initApp(): Promise<void> {
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set
// — otherwise it would redundantly re-save every record on each startup.
wirePersistence();
wireSnippetPersistence();
wireDatasetPersistence();
wireThemePersistence();
@@ -1,72 +0,0 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
import {
datasetUsageCount,
findSnippetsReferencingDataset,
renameDatasetEverywhere,
} from './RelationshipService';
const T = new Date('2026-06-01T00:00:00Z');
/** Seed a snippet whose published spec references `datasetName`. */
function seedSnippet(id: string, datasetName: string) {
return createSnippet({
id,
spec: JSON.stringify({ data: { name: datasetName }, mark: 'bar' }),
now: T,
});
}
beforeEach(() => {
useDatasetStore.getState().reset();
useSnippetStore.getState().reset();
});
describe('reverse lookup', () => {
test('finds referencing snippets case-insensitively and counts them', () => {
useSnippetStore.getState().hydrate([seedSnippet('a', 'Sales'), seedSnippet('b', 'SALES')], 'a');
// datasetRefs are recomputed on publish; publish each active snippet to seed.
useSnippetStore.getState().publish(T);
useSnippetStore.getState().selectSnippet('b');
useSnippetStore.getState().publish(T);
expect(
findSnippetsReferencingDataset('sales')
.map((s) => s.id)
.sort(),
).toEqual(['a', 'b']);
expect(datasetUsageCount('Sales')).toBe(2);
expect(datasetUsageCount('Other')).toBe(0);
});
});
describe('renameDatasetEverywhere', () => {
test('renames the dataset record and every referencing snippet', () => {
useDatasetStore.getState().add(
createDataset({
name: 'Sales',
data: [{ a: 1 }],
format: 'json',
source: 'inline',
now: T,
}),
);
useSnippetStore.getState().hydrate([seedSnippet('a', 'Sales')], 'a');
useSnippetStore.getState().publish(T);
const result = renameDatasetEverywhere('Sales', 'Revenue', new Date('2026-07-01T00:00:00Z'));
expect(result).toEqual({ updated: 1 });
expect(useDatasetStore.getState().datasets[0].name).toBe('Revenue');
const s = useSnippetStore.getState().snippets[0];
expect(s.datasetRefs).toEqual(['Revenue']);
expect(s.spec).toContain('"Revenue"');
});
test('is a no-op when old and new names are equal', () => {
expect(renameDatasetEverywhere('Sales', 'Sales')).toEqual({ updated: 0 });
});
});
-54
View File
@@ -1,54 +0,0 @@
/**
* 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 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.
*
* These functions read and mutate Zustand stores, so they live in the app layer
* (the pure rewrite/extraction helpers they build on live in `core/spec-refs`).
*/
import type { Snippet } from '@core/snippet';
import { snippetsReferencingDataset } from '@core/relationships';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
// The reverse-lookup scan itself is the pure `snippetsReferencingDataset`
// (`core/relationships`) — the *one* implementation arch/07 §6 calls for. These
// wrappers just bind it to a store snapshot for non-reactive callers (e.g. a
// programmatic rename); the reactive UI calls the core helper directly with its
// live `snippets` selection, so neither side duplicates the matching logic.
/** Snippets whose `datasetRefs` include `name` (case-insensitive), from the store. */
export function findSnippetsReferencingDataset(name: string): Snippet[] {
return snippetsReferencingDataset(useSnippetStore.getState().snippets, name);
}
/** Count for the dataset usage badge (spec §05 → List item). */
export function datasetUsageCount(name: string): number {
return findSnippetsReferencingDataset(name).length;
}
/**
* Rename a dataset and propagate everywhere (docs/architecture/07 §6): renames
* the dataset record, then rewrites every referencing snippet's spec, draftSpec,
* and datasetRefs (via `SnippetStore.renameDatasetRefs`, the single rename impl).
* The caller is responsible for collision policy on `newName` (the edit form
* rejects a taken name; programmatic paths pre-resolve a unique one). Returns the
* number of snippets updated, as `{ updated }` (arch/07 §6).
*/
export function renameDatasetEverywhere(
oldName: string,
newName: string,
now?: Date,
): { updated: number } {
if (oldName === newName) return { updated: 0 };
const dataset = useDatasetStore.getState().datasets.find((d) => d.name === oldName);
if (dataset) useDatasetStore.getState().update(dataset.id, { name: newName }, now);
return { updated: useSnippetStore.getState().renameDatasetRefs(oldName, newName, now) };
}
+32
View File
@@ -52,6 +52,38 @@ describe('save — create', () => {
});
});
describe('save — rename propagation (docs/architecture/07 §6)', () => {
test('renaming via the edit form rewrites every referencing snippet', () => {
store().startCreate();
store().updateForm({ name: 'Sales', input: '[{"a":1}]' });
expect(store().save(T)).toBe(true);
// A published snippet referencing the dataset by name.
useSnippetStore.getState().hydrate(
[
createSnippet({
id: 'a',
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }),
now: T,
}),
],
'a',
);
useSnippetStore.getState().publish(T);
expect(useSnippetStore.getState().snippets[0].datasetRefs).toEqual(['Sales']);
store().startEdit();
store().updateForm({ name: 'Revenue' });
expect(store().save(new Date('2026-07-01T00:00:00Z'))).toBe(true);
expect(store().datasets[0].name).toBe('Revenue');
const s = useSnippetStore.getState().snippets[0];
expect(s.datasetRefs).toEqual(['Revenue']);
expect(s.spec).toContain('"Revenue"');
expect(s.spec).not.toContain('"Sales"');
});
});
describe('commitUrlSnapshot — create from a fetched body', () => {
test('snapshots the body, infers format from content, and profiles it', () => {
store().startCreate();
+3 -3
View File
@@ -5,9 +5,9 @@
* active, and the live editor buffer (`draftText`). Actions are the single place
* snippet state mutates, so they are unit-testable without a DOM or IndexedDB.
*
* Persistence is NOT done here a startup subscriber (orchestration/persistence)
* observes this store and writes through to the IndexedDB adapter. That keeps the
* store pure and free of browser APIs.
* Persistence is NOT done here a startup subscriber
* (orchestration/snippet-persistence) observes this store and writes through to
* the IndexedDB adapter. That keeps the store pure and free of browser APIs.
*
* Draft/published model (spec §03D): every snippet carries a published `spec`
* and a working `draftSpec`. Ordinary editing keystrokes, auto-save touches
+2 -2
View File
@@ -6,8 +6,8 @@
* scan, never stored, so it can't drift. This module is that scan's single pure
* implementation: it takes plain snippet records and a name and returns plain
* data, so the same logic serves both the reactive UI (the Dataset Manager passes
* its live `snippets` selection) and the non-reactive `RelationshipService` (which
* passes a store snapshot). One definition, per arch/07 §6 ("'who references this'
* its live `snippets` selection) and any non-reactive caller (which passes a
* store snapshot inline). One definition, per arch/07 §6 ("'who references this'
* has exactly one implementation").
*
* Matching is **case-insensitive**, mirroring the naming policy (`core/naming`):