mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Services: delete dead RelationshipService; rename persistence to snippet-persistence
This commit is contained in:
+7
-7
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`):
|
||||
|
||||
Reference in New Issue
Block a user