mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: custom named themes + Theme Builder
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* IndexedDB wrapper — store-layout verification and self-healing (the
|
||||
* interrupted-upgrade recovery documented on `openDB`). Runs against
|
||||
* fake-indexeddb; each test gets a pristine database.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import {
|
||||
DATASETS_STORE,
|
||||
SNIPPETS_STORE,
|
||||
THEMES_STORE,
|
||||
_resetDbForTests,
|
||||
getAll,
|
||||
openDB,
|
||||
put,
|
||||
} from './db';
|
||||
|
||||
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE];
|
||||
|
||||
/** Open the raw database at `version` with a custom (or absent) upgrade body. */
|
||||
function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('astrolabe', version);
|
||||
req.onupgradeneeded = () => upgrade?.(req.result);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('open failed'));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// A fresh factory per test — no databases survive between tests.
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
_resetDbForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetDbForTests();
|
||||
});
|
||||
|
||||
describe('openDB', () => {
|
||||
it('creates every expected store on first run', async () => {
|
||||
const db = await openDB();
|
||||
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
|
||||
});
|
||||
|
||||
it('upgrades a v1 database (snippets + datasets) to include the themes store', async () => {
|
||||
const v1 = await rawOpen(1, (db) => {
|
||||
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
|
||||
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
|
||||
});
|
||||
v1.close();
|
||||
|
||||
const db = await openDB();
|
||||
expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true);
|
||||
});
|
||||
|
||||
it('self-heals a database stamped at the current version with stores missing', async () => {
|
||||
// The interrupted-upgrade state: version already 2, but the themes store
|
||||
// was never created (e.g. a hot reload opened v2 before the create-store
|
||||
// code existed). onupgradeneeded will never fire again for v2.
|
||||
const broken = await rawOpen(2, (db) => {
|
||||
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
|
||||
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
|
||||
});
|
||||
broken.close();
|
||||
|
||||
const db = await openDB();
|
||||
expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true);
|
||||
expect(db.version).toBe(3); // healed by a forced extra upgrade pass
|
||||
// Transactions on every store now work.
|
||||
await put(THEMES_STORE, { id: 1, name: 'ok' });
|
||||
await expect(getAll(THEMES_STORE)).resolves.toEqual([{ id: 1, name: 'ok' }]);
|
||||
});
|
||||
|
||||
it('opens a database whose version is already past DB_VERSION', async () => {
|
||||
// A prior self-heal bump leaves the version above the code's constant; a
|
||||
// versioned open would throw VersionError. All stores already exist here.
|
||||
const ahead = await rawOpen(7, (db) => {
|
||||
for (const store of ALL_STORES) db.createObjectStore(store, { keyPath: 'id' });
|
||||
});
|
||||
ahead.close();
|
||||
|
||||
const db = await openDB();
|
||||
expect(db.version).toBe(7);
|
||||
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
|
||||
});
|
||||
|
||||
it('self-heals an ahead-of-code database with stores missing', async () => {
|
||||
const ahead = await rawOpen(5, (db) => {
|
||||
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
|
||||
});
|
||||
ahead.close();
|
||||
|
||||
const db = await openDB();
|
||||
expect(db.version).toBe(6);
|
||||
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves existing records across the self-heal upgrade', async () => {
|
||||
const broken = await rawOpen(2, (db) => {
|
||||
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
|
||||
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = broken.transaction(SNIPPETS_STORE, 'readwrite');
|
||||
t.objectStore(SNIPPETS_STORE).put({ id: 'a', name: 'kept' });
|
||||
t.oncomplete = () => resolve();
|
||||
t.onerror = () => reject(t.error ?? new Error('tx failed'));
|
||||
});
|
||||
broken.close();
|
||||
|
||||
await openDB();
|
||||
await expect(getAll(SNIPPETS_STORE)).resolves.toEqual([{ id: 'a', name: 'kept' }]);
|
||||
});
|
||||
});
|
||||
@@ -11,35 +11,71 @@ const DB_NAME = 'astrolabe';
|
||||
/**
|
||||
* Store-layout version. Bump only when the set of object stores / indexes
|
||||
* changes — independent of per-record schema versions (see snippet-migrations).
|
||||
* v2 added the `themes` store (custom chart themes).
|
||||
*/
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export const SNIPPETS_STORE = 'snippets';
|
||||
export const DATASETS_STORE = 'datasets';
|
||||
export const THEMES_STORE = 'themes';
|
||||
|
||||
/** Every object store the app expects — the open-time verification checklist. */
|
||||
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE] as const;
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
/** Open (and memoize) the database, creating object stores on first run. */
|
||||
export function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
/**
|
||||
* One `indexedDB.open` as a promise. Omitting `version` opens at whatever
|
||||
* version the database already has (never an upgrade). The upgrade handler
|
||||
* creates every missing store — guarded per store, so it is idempotent across
|
||||
* any old→new version jump.
|
||||
*/
|
||||
function openAt(version?: number): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = version === undefined ? indexedDB.open(DB_NAME) : indexedDB.open(DB_NAME, version);
|
||||
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
// Guard every create so upgrades stay idempotent.
|
||||
if (!db.objectStoreNames.contains(SNIPPETS_STORE)) {
|
||||
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(DATASETS_STORE)) {
|
||||
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
|
||||
for (const store of EXPECTED_STORES) {
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
db.createObjectStore(store, { keyPath: 'id' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (and memoize) the database, creating object stores on first run.
|
||||
*
|
||||
* The open **verifies** the store layout instead of trusting the version
|
||||
* number: an interrupted upgrade can stamp the new version without creating
|
||||
* the new stores (observed in dev — a hot reload opened the bumped version
|
||||
* before the store-creation code existed), after which `onupgradeneeded`
|
||||
* never fires again and every transaction on the missing store throws
|
||||
* NotFoundError. If any expected store is missing after a successful open,
|
||||
* reopen at `version + 1` to force another (idempotent) upgrade pass — the
|
||||
* database self-heals rather than being stuck until manually deleted.
|
||||
*/
|
||||
export function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = openAt(DB_VERSION)
|
||||
.catch((err: unknown) => {
|
||||
// A database already past DB_VERSION (a prior self-heal bump) makes a
|
||||
// versioned open throw VersionError; open at its current version instead.
|
||||
if (err instanceof DOMException && err.name === 'VersionError') return openAt();
|
||||
throw err;
|
||||
})
|
||||
.then((db) => {
|
||||
if (EXPECTED_STORES.every((store) => db.objectStoreNames.contains(store))) return db;
|
||||
const next = db.version + 1;
|
||||
db.close();
|
||||
return openAt(next);
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { loadSettings, type UserSettings } from '@core/settings';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
import { isChartThemeId, type ChartThemeId } from '@core/vega-themes';
|
||||
import { isChartThemeSelection, type ChartThemeSelection } from '@core/vega-themes';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
|
||||
@@ -30,7 +30,7 @@ const DEFAULT_THEME: UiTheme = 'light';
|
||||
const DEFAULT_FIT_MODE: FitMode = 'default';
|
||||
|
||||
/** Spec §04 — the Chart theme picker defaults to the house style. */
|
||||
const DEFAULT_CHART_THEME: ChartThemeId = 'astrolabe';
|
||||
const DEFAULT_CHART_THEME: ChartThemeSelection = 'astrolabe';
|
||||
|
||||
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
|
||||
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
||||
@@ -122,14 +122,18 @@ export function loadPreviewFitMode(): FitMode {
|
||||
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||
}
|
||||
|
||||
/** The persisted chart theme, or the default — unknown ids fall back. */
|
||||
export function loadChartTheme(): ChartThemeId {
|
||||
/**
|
||||
* The persisted chart theme, or the default — unknown ids fall back. A
|
||||
* `custom:<id>` passes on shape; whether the record still exists is resolved at
|
||||
* render time (missing custom themes render as the house style).
|
||||
*/
|
||||
export function loadChartTheme(): ChartThemeSelection {
|
||||
const stored = readRaw().ui?.chartTheme;
|
||||
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME;
|
||||
return isChartThemeSelection(stored) ? stored : DEFAULT_CHART_THEME;
|
||||
}
|
||||
|
||||
/** Persist the chart theme, preserving every other key already in the record. */
|
||||
export function saveChartTheme(chartTheme: ChartThemeId): void {
|
||||
export function saveChartTheme(chartTheme: ChartThemeSelection): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CURRENT_THEME_VERSION } from '@core/custom-theme';
|
||||
import { migrateCustomTheme } from './theme-migrations';
|
||||
|
||||
describe('migrateCustomTheme', () => {
|
||||
it('passes a current record through unchanged (plus version stamp)', () => {
|
||||
const record = {
|
||||
id: 3,
|
||||
version: CURRENT_THEME_VERSION,
|
||||
name: 'Brand',
|
||||
config: { font: 'Georgia' },
|
||||
created: '2026-06-12T10:00:00.000Z',
|
||||
modified: '2026-06-12T11:00:00.000Z',
|
||||
};
|
||||
expect(migrateCustomTheme(record)).toEqual(record);
|
||||
});
|
||||
|
||||
it('fills missing or invalid fields with safe defaults', () => {
|
||||
const migrated = migrateCustomTheme({ id: '7', config: ['not', 'an', 'object'] });
|
||||
expect(migrated.id).toBe(7);
|
||||
expect(migrated.version).toBe(CURRENT_THEME_VERSION);
|
||||
expect(migrated.name).toBe('Untitled theme');
|
||||
expect(migrated.config).toEqual({});
|
||||
expect(typeof migrated.created).toBe('string');
|
||||
expect(typeof migrated.modified).toBe('string');
|
||||
});
|
||||
|
||||
it('keeps unknown fields written by a newer build', () => {
|
||||
const migrated = migrateCustomTheme({
|
||||
id: 1,
|
||||
name: 'Next',
|
||||
config: {},
|
||||
futureField: 'kept',
|
||||
});
|
||||
expect((migrated as unknown as Record<string, unknown>).futureField).toBe('kept');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Read-time migration for CustomTheme records (docs/architecture/02 §4).
|
||||
*
|
||||
* Mirrors snippet/dataset migrations: every theme read from storage passes
|
||||
* through `migrateCustomTheme`, which fills missing/invalid fields and stamps
|
||||
* the current version. Unknown fields are tolerated (spread the original, only
|
||||
* fill gaps) so a record written by a newer build round-trips without loss.
|
||||
*/
|
||||
|
||||
import { CURRENT_THEME_VERSION, type CustomTheme } from '@core/custom-theme';
|
||||
import { isJsonObject } from '@core/spec-config';
|
||||
|
||||
/** Upgrade a raw stored record to the current CustomTheme shape. */
|
||||
export function migrateCustomTheme(raw: unknown): CustomTheme {
|
||||
const r = { ...(raw as Record<string, unknown>) };
|
||||
return {
|
||||
...r,
|
||||
id: typeof r.id === 'number' ? r.id : Number(r.id),
|
||||
version: CURRENT_THEME_VERSION,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled theme',
|
||||
config: isJsonObject(r.config) ? r.config : {},
|
||||
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
|
||||
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Custom theme persistence adapter (docs/architecture/02; scope doc §4.4).
|
||||
*
|
||||
* The typed seam between the CustomThemeStore and IndexedDB's `themes` object
|
||||
* store. Exposes plain async functions returning domain `CustomTheme` objects
|
||||
* and migrates every record on read — same contract as dataset-store.
|
||||
*/
|
||||
|
||||
import { CURRENT_THEME_VERSION, type CustomTheme } from '@core/custom-theme';
|
||||
import { THEMES_STORE, del, getAll, put } from './db';
|
||||
import { migrateCustomTheme } from './theme-migrations';
|
||||
|
||||
/** Load every custom theme, upgrading each record to the current shape. */
|
||||
export async function loadCustomThemes(): Promise<CustomTheme[]> {
|
||||
const records = await getAll<unknown>(THEMES_STORE);
|
||||
return records.map(migrateCustomTheme);
|
||||
}
|
||||
|
||||
/** Persist a custom theme at the current schema version. Propagates failures. */
|
||||
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
|
||||
await put(THEMES_STORE, { ...theme, version: CURRENT_THEME_VERSION });
|
||||
}
|
||||
|
||||
/** Permanently remove a custom theme by id. */
|
||||
export async function deleteCustomTheme(id: number): Promise<void> {
|
||||
await del(THEMES_STORE, id);
|
||||
}
|
||||
Reference in New Issue
Block a user