mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement M1 authoring loop: library, editor, live preview, persistence
M1 MVP from docs/IMPLEMENTATION-PLAN.md, with the /alignment pass applied. - core: Snippet model + factory; prepareSpecForRender (copy-not-mutate); per-theme Vega chart config - state/orchestration: SnippetStore with debounced auto-save; IndexedDB adapter + read-time migration; startup hydration + write-through persistence - ui: SnippetLibrary, SpecEditor (Monaco edcore.main — full editor features, JSON-only languages), LivePreview - build: Monaco/Vega manual chunks; raised PWA precache ceiling - alignment: flush a valid draft on snippet switch (+regression tests); TODO breadcrumbs for the preview render race and the window.confirm delete - housekeeping: gitignore .claude/projects/
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* IndexedDB wrapper (docs/architecture/02 §2).
|
||||
*
|
||||
* The ONLY low-level IndexedDB access in the app. Wraps the event-based native
|
||||
* API into promises and exposes a tiny CRUD surface per object store. Typed
|
||||
* stores (snippet-store, dataset-store) build on top of this; nothing outside
|
||||
* `src/app/infrastructure/` imports `indexedDB` directly.
|
||||
*/
|
||||
|
||||
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).
|
||||
*/
|
||||
const DB_VERSION = 1;
|
||||
|
||||
export const SNIPPETS_STORE = 'snippets';
|
||||
export const DATASETS_STORE = 'datasets';
|
||||
|
||||
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);
|
||||
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/** Run a transaction and resolve on commit (durability), not on request success. */
|
||||
function tx<T>(
|
||||
store: string,
|
||||
mode: IDBTransactionMode,
|
||||
run: (s: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return openDB().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(store, mode);
|
||||
const request = run(transaction.objectStore(store));
|
||||
transaction.oncomplete = () => resolve(request.result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const get = <T>(store: string, key: IDBValidKey): Promise<T | undefined> =>
|
||||
tx<T | undefined>(store, 'readonly', (s) => s.get(key) as IDBRequest<T | undefined>);
|
||||
|
||||
export const getAll = <T>(store: string): Promise<T[]> =>
|
||||
tx<T[]>(store, 'readonly', (s) => s.getAll() as IDBRequest<T[]>);
|
||||
|
||||
export const put = <T>(store: string, value: T): Promise<IDBValidKey> =>
|
||||
tx<IDBValidKey>(store, 'readwrite', (s) => s.put(value as unknown as Record<string, unknown>));
|
||||
|
||||
export const del = (store: string, key: IDBValidKey): Promise<undefined> =>
|
||||
tx<undefined>(store, 'readwrite', (s) => s.delete(key) as IDBRequest<undefined>);
|
||||
|
||||
/** Test-only: forget the memoized connection so a fresh `openDB` reopens. */
|
||||
export function _resetDbForTests(): void {
|
||||
dbPromise = null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Monaco worker wiring (docs/architecture/08 §1).
|
||||
*
|
||||
* Self-hosting raw `monaco-editor` means we must point `MonacoEnvironment` at the
|
||||
* web workers ourselves — the CDN loader is deliberately not used (offline,
|
||||
* privacy, determinism). Vite's `?worker` imports become hashed bundles that the
|
||||
* PWA precaches. The `json` worker is what powers JSON validation + completion;
|
||||
* everything else uses the base editor worker.
|
||||
*
|
||||
* Side-effect module: import it once, before creating any editor.
|
||||
*/
|
||||
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: (_workerId, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()),
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Vega-Lite schema service for Monaco (docs/architecture/08 §1).
|
||||
*
|
||||
* Registers the **bundled** Vega-Lite JSON schema with Monaco's JSON language
|
||||
* service. This is what makes `$schema` resolve locally (no network fetch, no
|
||||
* "unable to load schema" warning) and powers schema-aware validation,
|
||||
* autocomplete, and hover docs.
|
||||
*
|
||||
* Two deliberate departures from vega/editor's setup:
|
||||
* - `enableSchemaRequest: false` — offline-first; the schema is bundled, never
|
||||
* fetched (vega/editor sets it true because it is an online tool).
|
||||
* - `fileMatch: ['*']` — bind by model, not only by the doc's `$schema` value,
|
||||
* so validation/autocomplete work even if the user removes the `$schema`
|
||||
* line. (Every JSON model in this app is a Vega-Lite spec.)
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
|
||||
|
||||
/** The canonical URI specs reference via `$schema`; we register the schema here. */
|
||||
const VEGA_LITE_SCHEMA_URI = 'https://vega.github.io/schema/vega-lite/v6.json';
|
||||
|
||||
/**
|
||||
* Recursively copy each `description` to `markdownDescription`. Monaco renders
|
||||
* rich (markdown) hover docs only from `markdownDescription`; without this, the
|
||||
* schema's docs would hover as plain text. Done once, in place, at setup.
|
||||
*/
|
||||
function addMarkdownDescriptions(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(addMarkdownDescriptions);
|
||||
return;
|
||||
}
|
||||
if (node !== null && typeof node === 'object') {
|
||||
const obj = node as Record<string, unknown>;
|
||||
if (typeof obj.description === 'string' && obj.markdownDescription === undefined) {
|
||||
obj.markdownDescription = obj.description;
|
||||
}
|
||||
for (const key of Object.keys(obj)) addMarkdownDescriptions(obj[key]);
|
||||
}
|
||||
}
|
||||
|
||||
let configured = false;
|
||||
|
||||
/** Register the Vega-Lite schema with Monaco's JSON service. Idempotent. */
|
||||
export function configureVegaLiteJson(): void {
|
||||
if (configured) return;
|
||||
configured = true;
|
||||
|
||||
addMarkdownDescriptions(vegaLiteSchema);
|
||||
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
enableSchemaRequest: false,
|
||||
schemas: [
|
||||
{
|
||||
uri: VEGA_LITE_SCHEMA_URI,
|
||||
fileMatch: ['*'],
|
||||
schema: vegaLiteSchema,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { CURRENT_SNIPPET_VERSION } from '@core/snippet';
|
||||
import { migrateSnippet } from './snippet-migrations';
|
||||
|
||||
describe('migrateSnippet', () => {
|
||||
test('stamps the current version and fills missing fields with defaults', () => {
|
||||
const s = migrateSnippet({ id: 7, spec: '{"mark":"bar"}' });
|
||||
|
||||
expect(s.id).toBe('7');
|
||||
expect(s.version).toBe(CURRENT_SNIPPET_VERSION);
|
||||
expect(s.name).toBe('Untitled');
|
||||
expect(s.draftSpec).toBe('{"mark":"bar"}'); // defaults to published spec
|
||||
expect(s.comment).toBe('');
|
||||
expect(s.tags).toEqual([]);
|
||||
expect(s.datasetRefs).toEqual([]);
|
||||
expect(s.meta).toEqual({});
|
||||
});
|
||||
|
||||
test('coerces an object-form spec into canonical JSON text', () => {
|
||||
const s = migrateSnippet({ id: 'a', spec: { mark: 'point' } });
|
||||
expect(s.spec).toBe(JSON.stringify({ mark: 'point' }, null, 2));
|
||||
});
|
||||
|
||||
test('preserves unknown fields so a newer build round-trips without loss', () => {
|
||||
const s = migrateSnippet({ id: 'a', spec: '{}', futureField: 42 });
|
||||
expect((s as unknown as Record<string, unknown>).futureField).toBe(42);
|
||||
});
|
||||
|
||||
test('keeps a distinct draftSpec when present', () => {
|
||||
const s = migrateSnippet({ id: 'a', spec: '{"a":1}', draftSpec: '{"a":2}' });
|
||||
expect(s.spec).toBe('{"a":1}');
|
||||
expect(s.draftSpec).toBe('{"a":2}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Read-time migration for Snippet records (docs/architecture/02 §4).
|
||||
*
|
||||
* The IndexedDB database version governs store *layout*; this governs the shape
|
||||
* of an individual *record*. Every snippet read from storage passes through
|
||||
* `migrateSnippet`, which fills in missing/old fields and stamps the current
|
||||
* version. It must tolerate unknown fields (spread the original, only fill gaps)
|
||||
* so a record written by a newer build round-trips without data loss.
|
||||
*/
|
||||
|
||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from '@core/snippet';
|
||||
|
||||
/** Coerce a stored spec field (object or string) into the canonical string form. */
|
||||
function asSpecText(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return fallback;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upgrade a raw stored record to the current Snippet shape. */
|
||||
export function migrateSnippet(raw: unknown): Snippet {
|
||||
const r = { ...(raw as Record<string, unknown>) };
|
||||
// Records written before versioning existed are treated as v1.
|
||||
// (No structural migrations yet; this is the hook for future versions.)
|
||||
|
||||
const spec = asSpecText(r.spec, '{}');
|
||||
const draftSpec = asSpecText(r.draftSpec, spec);
|
||||
|
||||
return {
|
||||
...r,
|
||||
id: String(r.id),
|
||||
version: CURRENT_SNIPPET_VERSION,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
|
||||
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
|
||||
spec,
|
||||
draftSpec,
|
||||
comment: typeof r.comment === 'string' ? r.comment : '',
|
||||
tags: Array.isArray(r.tags) ? (r.tags as string[]) : [],
|
||||
datasetRefs: Array.isArray(r.datasetRefs) ? (r.datasetRefs as string[]) : [],
|
||||
meta: typeof r.meta === 'object' && r.meta !== null ? (r.meta as Record<string, unknown>) : {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Snippet persistence adapter (docs/architecture/02).
|
||||
*
|
||||
* The typed seam between the snippet store and IndexedDB. Exposes plain async
|
||||
* functions returning domain `Snippet` objects; migrates every record on read;
|
||||
* fails loudly on quota so the UI can warn rather than silently lose work.
|
||||
*/
|
||||
|
||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from '@core/snippet';
|
||||
import { del, getAll, put, SNIPPETS_STORE } from './db';
|
||||
import { migrateSnippet } from './snippet-migrations';
|
||||
|
||||
/** Raised when a write fails because the snippet storage budget is exhausted. */
|
||||
export class StorageQuotaError extends Error {
|
||||
constructor(message = 'Snippet storage is full. Export and remove snippets to free space.') {
|
||||
super(message);
|
||||
this.name = 'StorageQuotaError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Load every snippet, upgrading each record to the current shape. */
|
||||
export async function loadSnippets(): Promise<Snippet[]> {
|
||||
const records = await getAll<unknown>(SNIPPETS_STORE);
|
||||
return records.map(migrateSnippet);
|
||||
}
|
||||
|
||||
/** Persist a snippet at the current schema version. Propagates quota failures. */
|
||||
export async function saveSnippet(snippet: Snippet): Promise<void> {
|
||||
try {
|
||||
await put(SNIPPETS_STORE, { ...snippet, version: CURRENT_SNIPPET_VERSION });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
|
||||
throw new StorageQuotaError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Permanently remove a snippet by id. */
|
||||
export async function deleteSnippet(id: string): Promise<void> {
|
||||
await del(SNIPPETS_STORE, id);
|
||||
}
|
||||
Reference in New Issue
Block a user