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:
2026-06-05 00:16:24 +03:00
parent 056644450c
commit ca54bb66b1
34 changed files with 1557 additions and 74 deletions
+80
View File
@@ -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;
}
+18
View File
@@ -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()),
};
+63
View File
@@ -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>) : {},
};
}
+42
View File
@@ -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);
}