diff --git a/CHANGELOG.md b/CHANGELOG.md index d18a538..be7aac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Changed +- **Phase 4 of the app.ts → services refactor complete** (#276): five more + focused modules under `src/application/`, all constructible without + `App`/`AppState`/DOM, behavior byte-identical. `WorkbenchParameterSession` + (`app.params`) owns the `{name:Type}` prepare/gate/hardening/recents + policy (the var strip stays a DOM view calling it); `ExportService` + (`app.exports`) owns direct + script export behind an injectable + `ExportSink`, consolidating the last duplicated cancellation-state copies + (its 40 test scenarios moved to an in-memory-sink spec); + `QueryDocumentSession` (`app.queryDoc`) owns the Spec evaluation/document + lifecycle and `SavedQueryService` (`app.saved`) the create/commit/history/ + share persistence (typed results; the shell keeps the exact messages and + post-commit repaint cascade); `SchemaGraphSession` (`app.graph`) owns the + lineage operation lifecycle with its stale-request guards, the abort state + now session-private; `AppPreferences` (`app.prefs`) is the typed + preference-persistence surface (`save` + `toggleTheme` — deliberately no + speculative per-key setters). `app.ts` is down to 1,761 lines of shell + wiring from the original 2,964. - **Server metadata and reference lifecycle extracted into `SchemaCatalogService`** (#276 Phase 4A). Version probe, schema tree loading, lazy column loading, SQL reference/completions assembly, and the diff --git a/src/application/app-preferences.ts b/src/application/app-preferences.ts new file mode 100644 index 0000000..708e5a0 --- /dev/null +++ b/src/application/app-preferences.ts @@ -0,0 +1,77 @@ +// #276 Phase 4D's AppPreferences — the true browser-preference keys (as +// opposed to the domain records — saved queries, history, query variables — +// which keep their own dedicated `save*` methods on `App`, untouched here), +// extracted from app.ts's `savePref`/`toggleTheme` (issue #276 §10). +// Constructible without App/AppState/DOM. No imports from `src/ui/**` or +// `src/editor/**` (a pretest check enforces this). +// +// Narrow scope (plan review): this service owns ONLY the persist half of +// each preference. Every write site except `toggleTheme` already mutates its +// own state field itself (splitters.ts sets `ctx.state.sidebarPx` before +// calling `ctx.save(...)`; dashboard.ts sets `state.dashLayout`/`dashCols` +// before `app.savePref(...)`; app.ts's `setResultRowLimit` sets +// `state.resultRowLimit` first) — so `save(name, value)` is a pure typed +// persist call, no state slice needed. `toggleTheme` is the one exception +// (issue ruling): the state flip AND the persist happen together here: the +// DOM half (the `data-theme` attribute + header icon swap) stays in app.ts's +// own `toggleTheme`, which composes this service's `toggleTheme()` with that +// DOM update. `createState` (state.ts) still owns every key's READ/seed at +// startup — this service is write-only, never called during bootstrap. + +import type { SaveStr } from '../state.js'; +import { KEYS } from '../state.js'; + +/** The true-preference subset of state.ts's own `KEYS` map — every OTHER key + * there (saved/history/libraryName/varValues/filterActive/filterCurated/ + * varRecent/varRecentDisabled) is a domain record with its own dedicated + * `save*` method on `App` (`saveJSON`/`saveVarValues`/`saveFilterActive`/…), + * untouched by this service. */ +export type PreferenceKey = + | 'theme' | 'sidebarPx' | 'editorPct' | 'sideSplitPct' | 'cellDrawerPx' + | 'sidePanel' | 'resultRowLimit' | 'dashLayout' | 'dashCols'; + +/** The one state field this service reads/writes (`toggleTheme` only) — a + * plain settable property, not a signal (matches `AppState.theme`). */ +export interface AppPreferencesStateSlice { + theme: string; +} + +export interface AppPreferencesDeps { + saveStr: SaveStr; + state: AppPreferencesStateSlice; +} + +export interface AppPreferences { + /** Generic persist-only setter — the exact `(name, value)` shape app.ts's + * public `savePref` has always exposed (dashboard.ts/saved-history.ts/ + * splitters.ts call it through that unchanged delegate). This IS the + * service's write API: per-key typed setters were considered and dropped + * (review) — every real call site already holds a validated + * `{name, value}` pair, so a per-key surface would ship with zero + * callers (CLAUDE.md rule 5: no speculative primitives). */ + save(name: PreferenceKey, value: unknown): void; + /** Flips `state.theme` light↔dark AND persists it in one call (issue + * ruling — the one preference whose state mutation moves here, not just + * its persist half); returns the new value so the DOM-half caller + * (app.ts's own `toggleTheme`) doesn't need to re-read `state.theme`. */ + toggleTheme(): string; +} + +/** Build an `AppPreferences` bound to `deps`. Trivial constructor — no + * validation, no defaulting; the caller supplies every field of `deps` + * exactly as it wants it used. */ +export function createAppPreferences(deps: AppPreferencesDeps): AppPreferences { + const { state } = deps; + + function save(name: PreferenceKey, value: unknown): void { + deps.saveStr(KEYS[name], String(value)); + } + + function toggleTheme(): string { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + save('theme', state.theme); + return state.theme; + } + + return { save, toggleTheme }; +} diff --git a/src/application/export-service.ts b/src/application/export-service.ts new file mode 100644 index 0000000..12acc22 --- /dev/null +++ b/src/application/export-service.ts @@ -0,0 +1,564 @@ +// #276 Phase 4B2's ExportService — the streaming single-file export (issue +// #87) and multi-statement script export (issue #99) POLICY extracted from +// app.ts (issue #276 §5), a PURE MOVE of the export bodies (already +// re-pointed onto `WorkbenchParameterSession`/`app.params` by Phase 4B1) +// wholesale: `exportEntry`'s statement-count dispatch, `exportDirect`'s +// picker-first/stream/hold-back-buffer path, `exportScriptEntry`/ +// `exportScript`'s directory-picker/per-statement transport loop, and both +// cancel paths — byte-identical to app.ts's own prior history for these +// functions. Constructible without App/AppState/DOM, like +// `workbench-parameter-session.ts` before it; no imports from `src/ui/**` or +// `src/editor/**` (check:arch enforces this). +// +// Deliberately NOT included (mirrors the issue's own carve-outs, same +// reasoning as workbench-parameter-session.ts's header comment): +// `canExport`/`canExportScript` (env capability checks — app.ts-owned, read +// here only through the injected `deps.canExport`/`deps.canExportScript` +// booleans), `showExportProgress` (the DOM progress banner — app.ts-owned, +// called through `deps.hooks.showExportProgress`, matching its existing call +// shape `(onCancel) => { update(bytes), remove() }`), and `downloadFile` (a +// generic Blob+anchor file-save helper with no export-specific policy — it +// builds a Blob, which is DOM/browser material, not export policy; stays in +// app.ts, used by the File menu, untouched by this move). +// +// `state.exporting` stays an `AppState` signal (mirrors `workbench- +// session.ts`'s own `state.running` precedent) — this service is its SOLE +// production writer; `deps.state` is the SAME live slice `app.state` backs +// (a structural `Pick`, not a snapshot), so `app.state.exporting.value` +// reads/the `setExportBtn` effect observe this service's writes directly, no +// re-mirroring needed. +// +// `WritableFileStreamLike`/`FileHandleLike`/`DirectoryHandleLike` (formerly +// app.ts-local seam types over the File System Access API, which this +// project's lib.dom build doesn't carry at all) move here, exported, since +// they're this service's own transport shapes now; `ExportSink` wraps +// `showSaveFilePicker`/`showDirectoryPicker` behind an injected seam (mirrors +// `app.Chart`/`app.Dagre`/the editor ports) so this module never references +// `window`/`env` directly. +// +// `ScriptExportEntry`/`ScriptExportResult` below mirror (never import) +// `src/ui/results.ts`'s identically-named, identically-shaped types: `tab.result` +// is deliberately opaque (`Record | null`, state.ts) at this +// boundary — ui/results.ts is the one place that owns the real shape a run +// (or a script export) actually produces, exactly the same "produced here, +// read back there through the opaque field" relationship `core/script- +// result.ts`'s own `ScriptEntry` has with the transport service (Phase 1) +// vs. results.ts's re-export of it. `src/application/**` may never import +// `src/ui/**` (check:arch), so a structural mirror — not an import — is the +// only option; the two are kept in sync by hand (small, stable shapes). + +import type { Signal } from '@preact/signals-core'; +import { splitStatements, isRowReturning } from '../core/sql-split.js'; +import { mergedSourceArgs } from '../core/param-pipeline.js'; +import type { PreparedSource } from '../core/param-pipeline.js'; +import { prepareExportSql, isSchemaMutatingSql } from '../core/format.js'; +import { formatFileMeta, exportFilename, scriptExportName } from '../core/export.js'; +import { findExceptionFrame } from '../core/stream.js'; +import type { QueryTab } from '../state.js'; +import type { ResultSort } from '../core/sort.js'; +import type { ChCtx, exportQuery, runQuery, killQuery } from '../net/ch-client.js'; +import type { WorkbenchParameterSession } from './workbench-parameter-session.js'; + +// ── File System Access seam (moved from app.ts) ───────────────────────────── + +/** A `FileSystemWritableFileStream`-shaped handle — narrower than the DOM + * lib's own (this project's lib.dom build doesn't carry the File System + * Access API at all, hence the sink below being the injected seam). */ +export interface WritableFileStreamLike { + write(chunk: Uint8Array): Promise; + close(): Promise; + abort(): Promise; +} +/** A `FileSystemFileHandle`-shaped handle — `ExportSink.pickFile`'s resolved + * value, and `DirectoryHandleLike.getFileHandle`'s. `move` (Chrome 110+) is + * optional — feature-detected at the one call site that uses it. */ +export interface FileHandleLike { + name: string; + createWritable(): Promise; + move?(name: string): Promise; +} +/** A `FileSystemDirectoryHandle`-shaped handle — `ExportSink.pickDirectory`'s + * resolved value. */ +export interface DirectoryHandleLike { + getFileHandle(name: string, opts?: { create?: boolean }): Promise; +} + +/** The single-file/directory picker seam — wraps `showSaveFilePicker`/ + * `showDirectoryPicker` (env-injected, feature-detected — see app.ts's own + * `canExport`/`canExportScript`) so this module never touches `window` + * directly. Production wires this to the two real pickers; tests inject an + * in-memory sink. Rejecting with an `Error` named `'AbortError'` (matching + * the real pickers' own dismissal signal) is how a caller reports the user + * closed the dialog — both export entry points treat that name specially. */ +export interface ExportSink { + pickFile(input: { + suggestedName: string; + types: { description: string; accept: Record }[]; + }): Promise; + pickDirectory(input: { mode: 'readwrite' }): Promise; +} + +// ── tab.result shapes this service produces (mirrors ui/results.ts — see the +// module doc above for why this is a structural mirror, not an import) ────── + +/** One statement's export outcome in a script-export run (#99) — metadata + * only, never the exported rows. Mirrors `ui/results.ts`'s identically-named + * `ScriptExportEntry`. */ +export interface ScriptExportEntry { + i: number; + sql?: string; + type: string; + status: string; + file: string | null; + bytes: number; + startedAt: number | null; + ms: number | null; + error: string | null; +} + +/** A script-export run's `tab.result` shape (#99). Mirrors `ui/results.ts`'s + * identically-named `ScriptExportResult`. */ +export interface ScriptExportResult { + scriptExport: ScriptExportEntry[]; + startedAt: number; + elapsedMs?: number; + colWidths?: Record; +} + +// ── Injected dependency seam ───────────────────────────────────────────────── + +/** The `state.exporting`/`state.resultSort` slice this service reads/writes — + * a structural `Pick`, not a snapshot: production passes `app.state` itself + * (satisfies this directly), so a write here is observed immediately by + * every consumer of the live signal/property (mirrors `workbench- + * session.ts`'s own `WorkbenchStateSlice` convention). This service is the + * SOLE production writer of `exporting` (mirrors `running`'s own + * precedent). */ +export interface ExportStateSlice { + exporting: Signal; + /** Reassigned wholesale (`state.resultSort = {...}`) at the start of an + * export-script wave — a plain settable property, not a signal (matches + * `WorkbenchStateSlice.resultSort`). */ + resultSort: ResultSort; +} + +/** DOM/render hooks this service calls into — the shell's job, injected + * (mirrors `workbench-session.ts`'s own `WorkbenchHooks` convention). */ +export interface ExportHooks { + /** Per-statement (script export) results-pane repaint. */ + renderResults(): void; + /** The inline "Exporting… · s" banner (app.ts-owned DOM + * builder) — called once per single-file export with the export's own + * Cancel callback; returns the same `{update(bytes), remove()}` handle the + * pre-extraction code built inline. */ + showExportProgress(onCancel: () => void): { update(bytes: number): void; remove(): void }; + /** A user-facing toast (app.ts wires this to `flashToast(message, { + * document: doc })`); this service never imports `ui/toast.js`. */ + toast(message: string): void; + /** Fire-and-forget schema reload after a schema-mutating script statement + * actually ran (mirrors `WorkbenchHooks.loadSchema`). */ + loadSchema(): void; +} + +/** Every side effect this service needs, injected as a narrow bag — mirrors + * `query-execution-service.ts`'s own `QueryExecutionDeps`/`workbench- + * session.ts`'s own `WorkbenchSessionDeps` conventions. Transport deps carry + * the exact `ch-client.js` functions this service's export paths use + * (`exportQuery` for both the single-file and per-statement-rows paths, + * `runQuery` for a script's non-row effect statements, `killQuery` for both + * cancel paths) plus a live `ctx` PROVIDER (not a snapshot — the caller may + * rebuild it after a token refresh, same as `QueryExecutionDeps.ctx`). Kept + * as the raw ch-client functions + `ctx()` rather than routed through + * `app.exec` — `app.exec`'s `executeRead`/`executeScript` return already- + * parsed results, but the export paths need the raw streaming `Response` + * itself (for `streamToFile`'s hold-back-buffer inspection), which + * `app.exec`'s surface doesn't expose. */ +export interface ExportServiceDeps { + exportQuery: typeof exportQuery; + runQuery: typeof runQuery; + killQuery: typeof killQuery; + ctx(): ChCtx; + ensureConfig(): Promise; + /** Resolves the live bearer/basic credential, or null when signed out / + * unrefreshable — both export entry points call `ctx().onSignedOut()` and + * return in that case (byte-equivalent to the ported app.ts's + * `chCtx.onSignedOut(); return;`). Unlike `workbench-session.ts`'s own + * `onAuthFailed` hook, this service already depends on `ctx()` directly + * (see above), so there's no separate hook to keep it ignorant of chCtx. */ + getToken(): Promise; + /** SQL-string-quoting function `killQuery` needs (matches `core/format.js`'s + * `sqlString`). */ + sqlString: (s: unknown) => string; + /** Perf clock — export/script-row elapsed ms, matches app.ts's `now`. */ + now(): number; + /** The #173 wave wall clock (epoch ms) — matches app.ts's `wallNow`; + * `exportEntry` resolves one snapshot per export wave (gate + args), same + * F6 invariant as run()/runScript(). */ + wallNow(): number; + uid(prefix: string): string; + /** Env capability checks (`showSaveFilePicker`/`showDirectoryPicker` + + * secure-context feature detection) — the boolean LOGIC stays app.ts-owned + * (`app.canExport`/`app.canExportScript`); this service only reads the + * result, mirroring the defensive re-check the pre-extraction code already + * performed inside `exportDirect`/`exportScriptEntry` themselves (in + * addition to the Export button's own aria-disabled gating). */ + canExport(): boolean; + canExportScript(): boolean; + sink: ExportSink; + state: ExportStateSlice; + activeTab(): QueryTab; + /** The `{name:Type}` query-variable POLICY (#276 Phase 4B1) — narrowed to + * exactly the three methods the export paths call, matching `workbench- + * session.ts`'s own narrow `Pick` convention + * for `exec`. */ + params: Pick; + /** `tab.chSession`/transport material — stays app.ts-local (Phase 4C's + * concern, not this service's), injected as a plain function dep. */ + sessionParamsFor(tab: QueryTab, sqls: string[]): Record; + hooks: ExportHooks; +} + +// ── The service ────────────────────────────────────────────────────────────── + +export interface ExportService { + exportEntry(): Promise | undefined; + exportDirect(sqlInput: string, waveMs: number): Promise; + cancelExport(): void; + cancelExportScript(): void; +} + +// A latin1 decode (1 char per byte) for byte-accurate exception-frame slicing +// — pure, no injected deps. +const latin1 = (bytes: Uint8Array): string => { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return s; +}; + +/** Build an `ExportService` bound to `deps`. Trivial constructor — no + * validation, no defaulting; the caller supplies every field exactly as it + * wants it used. */ +export function createExportService(deps: ExportServiceDeps): ExportService { + // Full, uncapped export of a query — never the loaded grid — streamed + // straight to a user-chosen file. Its own query_id + abort, kept separate + // from the workbench session's own private run bookkeeping so an export and + // a grid run never clobber each other's cancel state. + let exportAbort: AbortController | null = null; + let exportQueryId: string | null = null; + // Script-export state (issue #99) — its own abort/query-id, reassigned each + // iteration so Cancel reaches the in-flight statement, and kept distinct + // from both the workbench session's own run bookkeeping and the single- + // export state above. + let exportScriptAbort: AbortController | null = null; + let exportScriptQueryId: string | null = null; + let exportScriptCancelled = false; + let exportScriptTick: ReturnType | null = null; + + // The Export button dispatches by statement count: one statement keeps the + // rich single-file flow below; more than one opens the script-export flow + // (its own directory + per-statement log, since one file per script makes + // no sense). Mirrors runEntry's split/branch. + function exportEntry(): Promise | undefined { + if (deps.activeTab().editorMode !== 'sql') return undefined; + if (deps.state.exporting.value) return undefined; + const waveMs = deps.wallNow(); // one wall clock for this export wave (gate + args) + if (deps.params.varGateBlocked(waveMs)) return undefined; // don't export with unfilled variables (#134) + const input = deps.activeTab().sqlDraft; + const statements = splitStatements(input); + if (!statements.length) { deps.hooks.toast('Nothing to export'); return undefined; } + if (statements.length === 1) return exportDirect(statements[0], waveMs); + return exportScriptEntry(statements, input, waveMs); + } + + async function exportDirect(sqlInput: string, waveMs: number): Promise { + if (deps.activeTab().editorMode !== 'sql') return; + if (deps.state.exporting.value) return; + if (!deps.canExport()) return; // aria-disabled button; defensive guard + const tab = deps.activeTab(); + // Export streams the execution view (#165) — identical bytes without blocks. + const { sql, format } = prepareExportSql(deps.params.execStatementSql(sqlInput)); + if (!sql) { deps.hooks.toast('Nothing to export'); return; } + const { ext, mime } = formatFileMeta(format); + // Prepared args captured NOW — synchronously with exportEntry's gate + // check, BEFORE the picker/auth awaits below (review F6 invariant, shared + // with run/runScript/exportScript): gate and args see the same varValues + // snapshot; edits during those awaits apply to the next export. (Session + // params stay live below — they don't read varValues.) + const paramArgs = mergedSourceArgs(deps.params.prepareTabSource(sql, waveMs)); + + // Flip the flag before the picker (not after, like the file handle) so a + // second click while the native dialog is still open is blocked by the + // guard above — the button's own disabled state (setExportBtn) also + // reflects this via an effect, but the guard is the authority. + deps.state.exporting.value = true; + try { + // Picker FIRST, before any await: showSaveFilePicker requires the click's + // transient activation, which a prior await (e.g. a token refresh in + // ensureConfig/getToken can be a network round trip) would forfeit. + let handle: FileHandleLike; + try { + handle = await deps.sink.pickFile({ + suggestedName: exportFilename(tab.name, Date.now(), ext), + types: [{ description: format + ' data', accept: { [mime]: ['.' + ext] } }], + }); + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') return; // user dismissed the picker + deps.hooks.toast('Save dialog failed: ' + String((e instanceof Error && e.message) || e)); + return; + } + + // Now the awaits are safe — we already hold the file handle. + await deps.ensureConfig(); + if (!(await deps.getToken())) { deps.ctx().onSignedOut(); return; } + + exportQueryId = 'export-' + deps.uid(''); + exportAbort = new AbortController(); + const progress = deps.hooks.showExportProgress(cancelExport); + try { + const resp = await deps.exportQuery(deps.ctx(), sql, { + queryId: exportQueryId, signal: exportAbort.signal, format, + // Native query-parameter substitution (#134/#173), same as run() — + // paramArgs is the wave-start snapshot captured above (review F6). + params: { ...deps.sessionParamsFor(tab, [sql]), ...paramArgs }, + }); + const tag = resp.headers.get('X-ClickHouse-Exception-Tag'); // null on servers < 24.11 + const err = await streamToFile(resp, handle, { + signal: exportAbort.signal, tag, onProgress: (bytes) => progress.update(bytes), + }); + if (err) deps.hooks.toast('Export incomplete — server error mid-stream: ' + err); + else deps.hooks.toast('Export complete'); + } catch (e) { + // AbortError (cancelled) and 'signed out' (chCtx.onSignedOut already + // rendered the login screen) both already have their own signal — an + // extra toast on top would just be a confusing second message. + const msg = String((e instanceof Error && e.message) || e); + if (!(e instanceof Error && e.name === 'AbortError') && msg !== 'signed out') { + deps.hooks.toast('Export failed: ' + msg); + } + } finally { + progress.remove(); + exportAbort = null; + exportQueryId = null; + } + } finally { + deps.state.exporting.value = false; + } + } + + // Stream `resp.body` to `handle` with a hold-back buffer: ClickHouse's + // mid-stream exception frame (findExceptionFrame) is at most 16 KiB and + // always trailing, so bytes are only committed to disk once they've aged + // out of a 32 KiB window — at EOF the retained tail is inspected and only + // the clean prefix is written, so a mid-stream exception is never written + // into the file. Memory stays flat (one HOLDBACK-sized buffer) regardless of + // result size. Reads the stream directly (not via a TransformStream) because + // the write is conditional (withhold, inspect, commit) — a passthrough + // transform can't un-write. Returns the CH error message, or null when clean. + async function streamToFile( + resp: Response, handle: FileHandleLike, + { signal, tag, onProgress }: { signal: AbortSignal; tag: string | null; onProgress: (bytes: number) => void }, + ): Promise { + const writable = await handle.createWritable(); + const HOLDBACK = 32 * 1024; // >= ClickHouse's MAX_EXCEPTION_SIZE (16 KiB) + margin + const reader = resp.body!.getReader(); + let held = new Uint8Array(0); + let written = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (signal.aborted) throw new DOMException('aborted', 'AbortError'); + const merged = new Uint8Array(held.length + value.length); + merged.set(held); + merged.set(value, held.length); + const commit = Math.max(0, merged.length - HOLDBACK); + if (commit > 0) { + await writable.write(merged.subarray(0, commit)); + written += commit; + onProgress(written); + } + held = merged.subarray(commit); + } + // EOF: inspect the retained tail (latin1: 1 char per byte, for byte-accurate slicing). + const frame = findExceptionFrame(latin1(held), tag); + const clean = frame ? held.subarray(0, frame.cleanBytes) : held; + if (clean.length) { + await writable.write(clean); + written += clean.length; + onProgress(written); + } + await writable.close(); + return frame ? frame.message : null; + } catch (e) { + // writable.abort() would discard everything already committed: on + // Chrome/File System Access API it leaves a hidden, 0-byte + // `.crswap` swap file behind and never materializes the visible + // target at all — so a cancelled/failed export recovers nothing. + // close() instead finalizes the bytes already written under the + // target handle, then move() (Chrome 110+) renames it in place with + // a `.partial` suffix so it reads as an inspectable, clearly-labeled + // partial artifact rather than a clean export. Best-effort: on + // browsers without move() (or if it throws), the file is still + // recoverable under its original name, just without the suffix. + await writable.close().catch(() => {}); + if (typeof handle.move === 'function') await handle.move(handle.name + '.partial').catch(() => {}); + throw e; + } finally { + reader.releaseLock(); + } + } + + // Mirrors cancel() (the grid run) but on the export's own id/abort. + function cancelExport(): void { + if (exportAbort) exportAbort.abort(); + deps.killQuery(deps.ctx(), exportQueryId, deps.sqlString); + } + + // Directory picker first (transient-activation rule, same as exportDirect's + // save-file picker), and skip the prompt entirely when there's nothing to + // export — no point asking for a folder a script will never write into. + async function exportScriptEntry(statements: string[], originalInput: string, waveMs: number): Promise { + if (!deps.canExportScript()) { + deps.hooks.toast('Script export requires Chrome/Edge directory access over HTTPS'); + return; + } + if (!statements.some(isRowReturning)) { + deps.hooks.toast('Nothing to export — script has no result-producing statements.'); + return; + } + // One prepared batch for the whole export wave (#173), captured NOW — + // synchronously with exportEntry's gate check, BEFORE the directory-picker + // and auth awaits below (review F6 invariant, shared with run/runScript/ + // exportDirect): gate and args see the same varValues snapshot; edits + // during those awaits apply to the next export. `statements` came from + // splitStatements(originalInput), so the batch aligns by index. + const paramSrc = deps.params.prepareTabSource(originalInput, waveMs); + // Flip the flag before the picker (mirrors exportDirect) so a second click + // while the directory dialog / auth is still in flight is blocked by + // exportEntry's guard — exportScript itself doesn't set this until after + // those awaits, which would otherwise leave a re-entrancy window open. + deps.state.exporting.value = true; + try { + let dir: DirectoryHandleLike; + try { + dir = await deps.sink.pickDirectory({ mode: 'readwrite' }); + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') return; // dismissed → silent no-op + deps.hooks.toast('Folder dialog failed: ' + String((e instanceof Error && e.message) || e)); + return; + } + await deps.ensureConfig(); + if (!(await deps.getToken())) { deps.ctx().onSignedOut(); return; } + await exportScript(statements, dir, paramSrc); + } finally { + // No-op if exportScript already reset it — covers every early-return + // path above that never reaches exportScript's own finally. + deps.state.exporting.value = false; + } + } + + // Run a script's statements sequentially into `dir`, one file per + // row-returning statement, for effect otherwise. A single shared session + // carries SET/TEMPORARY state across statements (sessionParamsFor). The log + // lives in tab.result.scriptExport — per-statement metadata only (status, + // file, bytes, time); the exported rows themselves are never held in + // memory/state, so a multi-million-row script export stays flat. Stop on + // first failure, mirroring runScript's script grid — but with no retry + // (statements run one-at-a-time in a single session, so SESSION_IS_LOCKED + // can't self-collide, and a partially-written file shouldn't be silently + // re-attempted). + async function exportScript(statements: string[], dir: DirectoryHandleLike, paramSrc: PreparedSource): Promise { + const tab = deps.activeTab(); + const t0 = deps.now(); + const sp = deps.sessionParamsFor(tab, statements); + // `paramSrc` is the wave's prepared batch (#173), captured by + // exportScriptEntry at wave start, before its awaits (review F6). + const entries: ScriptExportEntry[] = statements.map((sql, i) => ({ + i, sql, type: isRowReturning(sql) ? 'rows' : 'effect', + status: 'pending', file: null, bytes: 0, startedAt: null, ms: 0, error: null, + })); + const scriptExportResult: ScriptExportResult = { scriptExport: entries, startedAt: t0 }; + Object.assign(tab, { result: scriptExportResult }); + deps.state.resultSort = { col: null, dir: 'asc' }; + exportScriptCancelled = false; + deps.state.exporting.value = true; + const taken = new Set(); + try { + // Live elapsed for the running row (bytes tick via onProgress; this ticks + // time). Started inside the try so a throw here still clears it below — + // an interval set before the try would otherwise leak forever. + exportScriptTick = setInterval(() => deps.hooks.renderResults(), 200); + deps.hooks.renderResults(); + for (const e of entries) { + if (exportScriptCancelled) { e.status = 'skipped'; continue; } + // Wire text = the pipeline's per-statement execution view (#165); + // verbatim for effect/DDL statements and for block-free SQL. + const execStmt = paramSrc.statements[e.i].sql; + const { sql, format } = prepareExportSql(execStmt); + // Per-statement prepared args (#134/#173): the pipeline binds only + // row-returning statements, so an effect/DDL statement (incl. CREATE + // VIEW) is sent with its {name:Type} placeholders intact. + const params = { ...sp, ...paramSrc.statements[e.i].args }; + exportScriptQueryId = 'export-' + deps.uid(''); + exportScriptAbort = new AbortController(); + const signal = exportScriptAbort.signal; + e.startedAt = deps.now(); + e.status = e.type === 'rows' ? 'exporting' : 'running'; + deps.hooks.renderResults(); + try { + if (e.type !== 'rows') { + const out = await deps.runQuery(deps.ctx(), execStmt, + { format: 'TSV', signal, queryId: exportScriptQueryId, params }); + if (out.error != null) throw new Error(out.error); + e.status = 'ok'; + } else { + const { ext } = formatFileMeta(format); + const name = scriptExportName(e.i, e.sql || '', ext, taken); + taken.add(name); + e.file = name; + const fileHandle = await dir.getFileHandle(name, { create: true }); + const resp = await deps.exportQuery(deps.ctx(), sql, + { queryId: exportScriptQueryId, signal, format, params }); + const tag = resp.headers.get('X-ClickHouse-Exception-Tag'); + const midErr = await streamToFile(resp, fileHandle, + { signal, tag, onProgress: (b) => { e.bytes = b; } }); + if (midErr) { + e.status = 'failed'; + e.error = 'File may be incomplete; server failed after streaming started. ' + midErr; + e.ms = deps.now() - e.startedAt!; + break; // stop-on-first-failure + } + e.status = 'ok'; + } + e.ms = deps.now() - e.startedAt!; + deps.hooks.renderResults(); + } catch (ex) { + e.ms = deps.now() - e.startedAt!; + if (ex instanceof Error && ex.name === 'AbortError') { e.status = 'cancelled'; exportScriptCancelled = true; } + else { e.status = 'failed'; e.error = String((ex instanceof Error && ex.message) || ex); } + break; // stop-on-first-failure + } + } + for (const e of entries) if (e.status === 'pending') e.status = 'skipped'; + } finally { + clearInterval(exportScriptTick as ReturnType); exportScriptTick = null; + exportScriptAbort = null; + exportScriptQueryId = null; + deps.state.exporting.value = false; + scriptExportResult.elapsedMs = deps.now() - t0; + // A schema-mutating effect statement that actually ran refreshes the tree + // (mirrors runScript) even though this export ran outside runScript. + if (entries.some((e) => e.status === 'ok' && isSchemaMutatingSql(e.sql))) deps.hooks.loadSchema(); + deps.hooks.renderResults(); + } + } + + // Mirrors cancelExport but on the script's own active id/abort. + function cancelExportScript(): void { + exportScriptCancelled = true; // stops the loop from starting the next statement + if (exportScriptAbort) exportScriptAbort.abort(); + deps.killQuery(deps.ctx(), exportScriptQueryId, deps.sqlString); + } + + return { exportEntry, exportDirect, cancelExport, cancelExportScript }; +} diff --git a/src/application/query-document-session.ts b/src/application/query-document-session.ts new file mode 100644 index 0000000..3e267d1 --- /dev/null +++ b/src/application/query-document-session.ts @@ -0,0 +1,207 @@ +// #276 Phase 4C's QueryDocumentSession — the Spec-evaluation/document +// lifecycle extracted from app.ts (issue #276 §8, plan-review rulings): +// applySpecEvaluation/evaluateSpecDraft/revalidateSpecDrafts/ +// revealFirstSpecError/registerSpecValidator, plus the editor-mode POLICY +// half of `setEditorMode` (`resolveEditorMode` below — whether a mode switch +// is allowed, and why not; the DOM/focus toggles that use the answer stay in +// app.ts's own `setEditorMode`). Constructible without App/AppState/DOM, like +// `workbench-parameter-session.ts`/`schema-catalog-service.ts` before it. +// +// Deliberately NOT included (plan-review ruling): `sessionParams`/ +// `needsSession`/`sessionParamsFor` stay app.ts-local (Phase 5 decides their +// home) — they're `tab.chSession`/transport material, unrelated to Spec +// evaluation. `activateInvalidSpecDraft` (>80% shell: toast/batch/focus) +// stays app.ts too. +// +// Diagnostics are typed as core/spec-draft.js's own `SpecValidationDiagnostic` +// — NOT `src/editor/spec-editor.types.js`'s `SpecDiagnostic` — so this module +// never imports `src/editor/**` (check:arch enforces `src/application/**` +// never importing `src/ui/**`/`src/editor/**`). `SpecValidationDiagnostic` is +// a strict superset of every field `SpecDiagnostic` optionally declares (see +// its own doc comment in core/spec-draft.ts), so it is directly assignable +// wherever a real `SpecDiagnostic` is expected — app.ts's own +// `hooks.setDiagnostics`/`hooks.revealDiagnostic` wiring passes these +// straight to `app.specEditor.setDiagnostics`/`.revealDiagnostic` without a +// cast. +// +// Every DOM/editor touch this session used to perform directly (repainting +// the Spec editor's diagnostics gutter, revealing a diagnostic, repainting +// tabs, the Save button, the editor-mode chrome) is now a `hooks` callback — +// app.ts supplies real closures over `app.specEditor`/`app.actions`/ +// `app.updateSaveBtn`/`app.updateEditorModeUi` (some of those app.ts members +// are only assigned later in `createApp`, so app.ts's own hook closures keep +// the exact same `if (app.actions) …`-style guards the pre-extraction inline +// code used — this session calls every hook unconditionally, unaware of that +// construction-order concern). + +import { evaluateSpecText } from '../core/spec-draft.js'; +import type { SpecValidationDiagnostic, SpecValidatorFn, QuerySpecValidationService } from '../core/spec-draft.js'; +import { savedForTab } from '../state.js'; +import type { QueryTab, AppState } from '../state.js'; + +// ── Construction deps ──────────────────────────────────────────────────────── + +export interface QueryDocumentSessionHooks { + /** Repaint the Spec editor's diagnostics gutter for the tab this call cares + * about — `evaluateSpecDraft`/`revalidateSpecDrafts` only ever call this + * for the CURRENTLY active tab (mirrors the pre-extraction inline code: + * `applySpecEvaluation` alone never repaints anything). */ + setDiagnostics(diagnostics: SpecValidationDiagnostic[]): void; + /** Jump the Spec editor's caret/scroll to diagnostic `index` (of the active + * tab's current `specDiagnostics`). */ + revealDiagnostic(index: number): void; + rerenderTabs(): void; + updateSaveBtn(): void; + updateEditorModeUi(): void; +} + +/** Live accessors — read fresh on every call, mirroring every other #276 + * session's own `*SessionDeps` convention (never a snapshot). `state` is + * narrowed to exactly the fields this session reads: `tabs` (iterating every + * open document in `revalidateSpecDrafts`) and `savedQueries` + * (`resolveEditorMode`'s `savedForTab` check) — production passes `app.state` + * itself, which satisfies this directly. */ +export interface QueryDocumentSessionDeps { + state: Pick; + activeTab(): QueryTab; + /** The app-owned Spec validation service (core/spec-draft.js) — canonical + * schema validation plus registered feature rules, with `.register` for + * `registerSpecValidator`. Production passes `app.specValidators`. */ + specValidators: QuerySpecValidationService; + hooks: QueryDocumentSessionHooks; +} + +/** One Spec-evaluation outcome: the parsed draft (`null` when the textual + * Spec isn't valid JSON — see `QueryTab.specParsed`'s own invariant) plus + * every diagnostic the canonical schema + registered validators produced. */ +export interface SpecEvaluationResult { + parsed: unknown; + diagnostics: SpecValidationDiagnostic[]; +} + +/** `resolveEditorMode`'s verdict — `message` is present only when there's a + * user-facing reason to explain the block (the "spec mode needs a saved + * query" case); the defensive not-sql-not-spec case carries none, matching + * the pre-extraction inline code's own silent early return there. */ +export interface EditorModeGate { + ok: boolean; + message?: string; +} + +// ── The session ────────────────────────────────────────────────────────────── + +export interface QueryDocumentSession { + /** Parse + validate `text` against `specValidators` and write the result + * onto `tab` (`specText`/`specParsed`/`specDiagnostics`/`dirtySpec`) — no + * DOM/hooks side effect. The low-level op every other method builds on; + * app.ts's own `sqlEditor.onDocChange` filter-role gate calls this + * directly (not `evaluateSpecDraft`) so it can pair it with its own, + * different, tail of UI updates. */ + applySpecEvaluation(tab: QueryTab, text: string, opts?: { dirty?: boolean }): SpecEvaluationResult; + /** `applySpecEvaluation` plus the full UI-refresh tail: repaints the Spec + * editor's diagnostics ONLY when `tab` is the currently active one, then + * unconditionally repaints tabs / the Save button / the editor-mode + * chrome. */ + evaluateSpecDraft(tab: QueryTab, text: string, opts?: { dirty?: boolean }): SpecEvaluationResult; + /** Re-run `applySpecEvaluation` for every open tab (e.g. after a validator + * registers/unregisters, or a Save that could change what "blocking" + * means elsewhere). `refreshUi: false` skips the repaint tail entirely + * (still re-evaluates every tab's diagnostics). */ + revalidateSpecDrafts(opts?: { refreshUi?: boolean }): void; + /** Jump to the first error-severity diagnostic on `tab` (defaults to the + * active tab) via `hooks.revealDiagnostic`; a no-op if there is none. */ + revealFirstSpecError(tab?: QueryTab): void; + /** Register a validator at `path`; every open Spec draft is revalidated + * both on register and on the returned unregister. */ + registerSpecValidator(path: (string | number)[], validate: SpecValidatorFn): () => void; + /** The editor-mode POLICY half of app.ts's `setEditorMode`: whether + * switching `tab` into `mode` is allowed right now. Entering 'spec' mode + * requires `tab` to already be linked to a saved query (an unsaved tab has + * no persisted Spec to edit as JSON) — the `message` explains that case to + * the user. app.ts's own `setEditorMode` performs the DOM/focus side of a + * successful switch (assigning `tab.editorMode`, repainting the editor-mode + * chrome, focusing the target editor). */ + resolveEditorMode(tab: QueryTab, mode: 'sql' | 'spec'): EditorModeGate; +} + +/** Build a `QueryDocumentSession` bound to `deps`. Trivial constructor — no + * validation, no defaulting; the caller supplies every field exactly as it + * wants it used. */ +export function createQueryDocumentSession(deps: QueryDocumentSessionDeps): QueryDocumentSession { + function applySpecEvaluation( + tab: QueryTab, text: string, { dirty = true }: { dirty?: boolean } = {}, + ): SpecEvaluationResult { + const evaluated = evaluateSpecText(text, deps.specValidators, { sql: tab.sqlDraft, tab }); + tab.specText = text; + tab.specParsed = evaluated.parsed as QueryTab['specParsed']; + tab.specDiagnostics = evaluated.diagnostics; + tab.dirtySpec = dirty; + return evaluated; + } + + function evaluateSpecDraft( + tab: QueryTab, text: string, { dirty = true }: { dirty?: boolean } = {}, + ): SpecEvaluationResult { + const evaluated = applySpecEvaluation(tab, text, { dirty }); + // `evaluated.diagnostics` (not `tab.specDiagnostics`, though + // `applySpecEvaluation` just assigned the very same array onto it) — the + // tab's own field is declared `SpecDiagnostic[]` (state.ts, the editor's + // looser app-wide contract), which a real `SpecValidationDiagnostic[]` + // assigns INTO fine but doesn't read back OUT of without narrowing; using + // the freshly-returned value sidesteps that one-directional relationship + // entirely instead of asserting it back down. + if (tab === deps.activeTab()) deps.hooks.setDiagnostics(evaluated.diagnostics); + deps.hooks.rerenderTabs(); + deps.hooks.updateSaveBtn(); + deps.hooks.updateEditorModeUi(); + return evaluated; + } + + function revalidateSpecDrafts({ refreshUi = true }: { refreshUi?: boolean } = {}): void { + for (const tab of deps.state.tabs.value) { + applySpecEvaluation(tab, tab.specText, { dirty: tab.dirtySpec }); + } + if (!refreshUi) return; + const tab = deps.activeTab(); + // `tab.specDiagnostics` is declared `SpecDiagnostic[]` (state.ts, the + // editor's looser app-wide contract) but was just written, in the loop + // above, by `applySpecEvaluation` — this module's only writer of that + // field — as a real `SpecValidationDiagnostic[]`. The one-directional + // relationship (see this module's header comment) makes this a safe, + // narrowing-only assertion, not a new runtime assumption; unconditional, + // matching the pre-extraction inline code exactly (it never guarded on + // whether the active tab happened to be freshly re-evaluated either). + deps.hooks.setDiagnostics(tab.specDiagnostics as SpecValidationDiagnostic[]); + deps.hooks.rerenderTabs(); + deps.hooks.updateSaveBtn(); + deps.hooks.updateEditorModeUi(); + } + + function revealFirstSpecError(tab: QueryTab = deps.activeTab()): void { + const index = tab.specDiagnostics?.findIndex((diagnostic) => diagnostic.severity === 'error') ?? -1; + if (index >= 0) deps.hooks.revealDiagnostic(index); + } + + function registerSpecValidator(path: (string | number)[], validate: SpecValidatorFn): () => void { + const unregister = deps.specValidators.register(path, validate); + revalidateSpecDrafts(); + return () => { unregister(); revalidateSpecDrafts(); }; + } + + function resolveEditorMode(tab: QueryTab, mode: 'sql' | 'spec'): EditorModeGate { + if (mode === 'spec' && !savedForTab(deps.state, tab)) { + return { ok: false, message: 'Save this query to create an editable Spec.' }; + } + if (mode !== 'sql' && mode !== 'spec') return { ok: false }; + return { ok: true }; + } + + return { + applySpecEvaluation, + evaluateSpecDraft, + revalidateSpecDrafts, + revealFirstSpecError, + registerSpecValidator, + resolveEditorMode, + }; +} diff --git a/src/application/saved-query-service.ts b/src/application/saved-query-service.ts new file mode 100644 index 0000000..35559da --- /dev/null +++ b/src/application/saved-query-service.ts @@ -0,0 +1,185 @@ +// #276 Phase 4C's SavedQueryService — the saved-query create/commit policy, +// history recording, and share-URL building extracted from app.ts (issue +// #276 §8, plan-review rulings): wraps state.ts's own `createSavedQuery`/ +// `commitSavedQuery`/`recordHistory` (already narrowed to exactly the +// `AppState` fields each reads — see state.ts) plus `core/share.js`'s +// `encodeShare`. Constructible without App/AppState/DOM, like +// `query-document-session.ts` alongside it. +// +// Deliberately NOT included (plan-review rulings): +// - This service never calls `QueryDocumentSession` — `commit`/ +// `buildShareUrl` both take an ALREADY-EVALUATED Spec (`{parsed, +// diagnostics}`, the exact shape `evaluateSpecText`/ +// `QueryDocumentSession.evaluateSpecDraft` produce) as an input +// parameter. The SHELL (app.ts) sequences evaluate → commit/share itself; +// no service-to-service dependency exists in either direction. +// - The post-commit cascade (`revalidateSpecDrafts` → +// `specEditor.syncFromState` → `updateSaveBtn` → `rerenderTabs` → +// `renderSavedHistory` → [`renderResults`] → `updateEditorModeUi`) stays a +// SHELL convention — app.ts's `commitLinkedQuery`/`openSavePopover`'s +// commit closure keep their exact call sequences, driven off this +// service's typed result. This service never imports `ui/results.js`, +// `ui/saved-history.js`, or any editor port. +// - Clipboard/location writes stay shell — `buildShareUrl` only builds the +// URL string; app.ts's own `share()` performs `history.replaceState` and +// the clipboard write off the returned value. +// - `savedForTab`/`tabPanel`-based gates that don't touch persistence +// (`saveActiveQuery`'s linked-tab check, `openSavePopover`'s own +// "Nothing to save" pre-check before a popover even opens) stay app.ts — +// they never call into this service. + +import { + createSavedQuery, commitSavedQuery, recordHistory as stateRecordHistory, +} from '../state.js'; +import type { + QueryTab, AppState, SaveJSON, SpecValidationService, HistoryResultSnapshot, QuerySpecDraft, +} from '../state.js'; +import { hasBlockingSpecErrors } from '../core/spec-draft.js'; +import type { SpecValidationDiagnostic } from '../core/spec-draft.js'; +import { queryPanel, withQuerySpec } from '../core/saved-query.js'; +import { isQuerylessPanel } from '../core/panel-cfg.js'; +import { encodeShare } from '../core/share.js'; +import type { SavedQueryV2 } from '../generated/json-schema.types.js'; + +// ── Construction deps ──────────────────────────────────────────────────────── + +/** Live accessors onto `app.state` — a structural `Pick`, not a snapshot: + * production passes `app.state` itself (satisfies this directly), mirroring + * every other #276 session's own deps convention. Exactly the fields + * `createSavedQuery`/`commitSavedQuery`/`recordHistory` (state.ts, all three + * narrowed to their own exact reads) need between them. */ +export interface SavedQueryServiceDeps { + state: Pick; + saveJSON: SaveJSON; + /** `createSavedQuery`'s minting timestamp — a genuine wall-clock read + * (production wires this to `() => Date.now()`, called at the exact + * moment a query is created), NOT app.ts's own `now`/`wallNow` seams + * (unrelated clocks — see app.ts's own construction comments): this keeps + * `createSavedQuery`'s minted ids byte-identical to the pre-extraction + * inline `Date.now()` call while still giving tests an injectable seam. */ + now(): number; + /** The app-owned Spec validation service (core/spec-draft.js's `.validate` + * surface — state.ts's own narrower `SpecValidationService`, not the + * fuller `QuerySpecValidationService` `QueryDocumentSession` needs; + * `createSavedQuery`/`commitSavedQuery` only ever call `.validate`). + * Production passes `app.specValidators` (structurally assignable). */ + specValidators: SpecValidationService; +} + +// ── Result types ───────────────────────────────────────────────────────────── + +export type CreateSavedResult = + | { ok: true; entry: SavedQueryV2 } + /** `createSavedQuery` itself returned null (already-linked tab, blank SQL + * on a non-text panel, blank name, or a blocking validation diagnostic) — + * the pre-extraction inline code never distinguished a reason here either + * (silent no-op), so neither does this result. */ + | { ok: false }; + +export type CommitLinkedResult = + | { ok: true; entry: SavedQueryV2 } + | { + ok: false; + reason: + /** The evaluated Spec has no parsed draft, or a blocking diagnostic. */ + | 'invalid-spec' + /** Blank SQL on a panel type that isn't SQL-optional (#166's text panel). */ + | 'empty' + /** `commitSavedQuery` itself returned null (tab no longer linked, or its + * own re-validation against the normalized Spec rejected it) — a + * defensive case the pre-extraction inline code never toasted either. */ + | 'rejected'; + }; + +export type ShareResult = + | { ok: true; url: string } + | { ok: false; reason: 'invalid-spec' | 'empty' }; + +/** `buildShareUrl`'s input: the exact fields it reads off the tab, the + * already-evaluated Spec (see this module's header comment on why this + * service never evaluates one itself), and the three `location` parts the + * encoded fragment is appended to (app.ts supplies `loc.origin`/ + * `loc.pathname`/`loc.search` — this service never touches `location` + * itself, matching the "clipboard/location writes stay shell" ruling). */ +export interface ShareUrlInput { + tab: Pick; + evaluated: { parsed: unknown; diagnostics: SpecValidationDiagnostic[] }; + origin: string; + pathname: string; + search: string; +} + +// ── The service ────────────────────────────────────────────────────────────── + +export interface SavedQueryService { + /** Creation-only path (app.ts's Save-popover commit): mint a brand-new + * saved query from an unsaved tab's current `sqlDraft`/`specParsed` plus + * `name`/`description`. Rejects (silently — see `CreateSavedResult`) an + * already-linked tab, per `createSavedQuery`'s own guard. */ + create(tab: QueryTab, name: unknown, description: unknown): CreateSavedResult; + /** Update-in-place path (app.ts's `commitLinkedQuery`, the "Save" button on + * an already-linked tab): persist `evaluated` as the linked saved query's + * new Spec. Takes the Spec evaluation as an input (see this module's + * header comment) rather than evaluating it itself. */ + commit(tab: QueryTab, evaluated: { parsed: unknown; diagnostics: SpecValidationDiagnostic[] }): CommitLinkedResult; + /** Record a successful run in history (state.ts's own `recordHistory`) — + * never touches rendering; app.ts's own `app.recordHistory` delegate + * conditionally repaints the History side panel itself after calling + * this. */ + recordHistory(tab: QueryTab, sqlText?: string): void; + /** Build the shareable URL for an already-evaluated Spec, or a typed + * rejection reason — never writes `location`/clipboard itself. */ + buildShareUrl(input: ShareUrlInput): ShareResult; +} + +/** Build a `SavedQueryService` bound to `deps`. Trivial constructor — no + * validation, no defaulting; the caller supplies every field exactly as it + * wants it used. */ +export function createSavedQueryService(deps: SavedQueryServiceDeps): SavedQueryService { + function create(tab: QueryTab, name: unknown, description: unknown): CreateSavedResult { + const entry = createSavedQuery(deps.state, tab, name, description, deps.saveJSON, deps.now(), deps.specValidators); + return entry ? { ok: true, entry } : { ok: false }; + } + + function commit( + tab: QueryTab, evaluated: { parsed: unknown; diagnostics: SpecValidationDiagnostic[] }, + ): CommitLinkedResult { + if (!evaluated.parsed || hasBlockingSpecErrors(evaluated.diagnostics)) { + return { ok: false, reason: 'invalid-spec' }; + } + const panel = queryPanel({ spec: evaluated.parsed }); + if (!String(tab.sqlDraft || '').trim() && !isQuerylessPanel(panel)) { + return { ok: false, reason: 'empty' }; + } + const entry = commitSavedQuery(deps.state, tab, evaluated.parsed as QuerySpecDraft | null, deps.saveJSON, deps.specValidators); + return entry ? { ok: true, entry } : { ok: false, reason: 'rejected' }; + } + + function recordHistoryFn(tab: QueryTab, sqlText?: string): void { + // `tab.result` is state.ts's deliberately opaque `Record | + // null` — by the time this is ever called (only after a successful run), + // it already holds a real `QueryResult`-shaped value (rawText/rows/ + // progress.elapsed_ns), the exact fields `HistoryResultSnapshot` pins. + const result = (tab.result as HistoryResultSnapshot | null)!; + // `now` deliberately omitted (`undefined`) — matches the pre-extraction + // inline call exactly: `recordHistory`'s own default (`Date.now()`) mints + // the entry's timestamp, independent of this service's own injected + // `deps.now` (which only feeds `createSavedQuery`'s id). + stateRecordHistory(deps.state, { sqlDraft: tab.sqlDraft, result }, deps.saveJSON, undefined, sqlText); + } + + function buildShareUrl(input: ShareUrlInput): ShareResult { + const { tab, evaluated } = input; + if (!evaluated.parsed || hasBlockingSpecErrors(evaluated.diagnostics)) { + return { ok: false, reason: 'invalid-spec' }; + } + const sql = String(tab.sqlDraft || ''); + const panel = queryPanel({ spec: evaluated.parsed }); + if (!sql.trim() && !isQuerylessPanel(panel)) return { ok: false, reason: 'empty' }; + const query = withQuerySpec({ id: tab.savedId, sql }, evaluated.parsed); + const url = input.origin + input.pathname + input.search + '#' + encodeShare(query); + return { ok: true, url }; + } + + return { create, commit, recordHistory: recordHistoryFn, buildShareUrl }; +} diff --git a/src/application/schema-graph-session.ts b/src/application/schema-graph-session.ts new file mode 100644 index 0000000..23efec1 --- /dev/null +++ b/src/application/schema-graph-session.ts @@ -0,0 +1,312 @@ +// #276 Phase 4D's SchemaGraphSession — the inline schema-lineage drawer +// (issue #124's two-phase progressive draw) and the rich fullscreen +// expand/detail flow, extracted from app.ts (see that file's history around +// `cancelSchemaGraph`/`showSchemaGraph`/`expandSchemaGraph`/`openNodeDetail`, +// formerly inline in `createApp`) so it's constructible without App/AppState/ +// DOM (issue #276 §9). Byte-for-byte port of the async control flow: the +// tab.result / tab.result.schemaGraph in-place mutation, the reference- +// identity stale-write guard (mirrors #97's shape, same as WorkbenchSession's +// own run() guard — deliberately NOT a generation counter), and the +// last-clicked-wins node-detail WeakMap all move verbatim. No imports from +// `src/ui/**` or `src/editor/**` (a pretest check enforces this) — DOM stays +// in app.ts: `openSchemaView`'s view object is opened synchronously by the +// shell BEFORE the awaited `expand()` call (so it survives the click gesture) +// and the shell alone calls `view.render`/`view.fail` — this session never +// sees the view object. Likewise `openDetailPane` (the loading-placeholder + +// final mount) stays shell-side; this session only resolves the detail data +// (or `null` when a later click superseded this one). +// +// Deviation (recorded for review): `expand()`'s auth-failure path is +// distinguished from a generic fetch failure via a thrown +// `SchemaGraphAuthRequiredError` (carrying the exact user-facing message) — +// the session has no `view` to call `.fail(...)` on directly, so the shell +// catches this specific error type to choose between "Sign in to view the +// schema graph." and the generic "Could not load the schema graph.". + +import type { ChCtx } from '../net/ch-client.js'; +import type { + loadSchemaLineage, loadLineageTransitive, loadSchemaCards, loadTableDetail, CardColumnRow, TableDetail, +} from '../net/ch-client.js'; +import { buildSchemaGraph, expandLineage } from '../core/schema-graph.js'; +import type { SchemaGraphNode, SchemaGraphEdge } from '../core/schema-graph.js'; +import { buildCardGraph } from '../core/schema-cards.js'; +import type { CardGraphNode, CardGraphEdge, CardModel, SchemaCardColumnRow } from '../core/schema-cards.js'; +import type { PositionMap } from '../core/graph-layout.js'; +import { newResult } from '../core/stream.js'; +import type { StreamResult } from '../core/stream.js'; + +// ── Shapes redeclared locally rather than imported across the +// application→ui boundary (rule 1) ────────────────────────────────────────── + +/** A schema entity reference — structurally the same shape as app.types.ts's + * own `SchemaFocus` (three real runtime shapes share it there: the drag/click + * FOCUS payload, and a resolved lineage-graph NODE for `loadNodeDetail`) — + * redeclared here rather than imported, the same convention app.types.ts's + * own `ChCtx` doc comment documents for the opposite direction ("src/ui/** + * may depend on src/application/**, never the reverse"). */ +export interface SchemaGraphFocus { + db?: string; + name?: string; + table?: string; + kind?: string; + id?: string; +} + +/** The progressive-load schema-lineage data this session writes into + * `tab.result.schemaGraph` — mirrors ui/results.ts's `ResultSchemaGraph` + * (itself extending explain-graph.ts's `SchemaLineageGraph`) field-for-field + * without importing either (rule 1): this session and those ui readers + * independently describe the same runtime shape. `savedPositions` is never + * written by this session's own `show()`/`cancel()` — only `expand()` + * populates it, in place, on whichever schemaGraph object is live when + * Expand is clicked. */ +export interface SchemaGraphInlineData { + focus?: SchemaGraphFocus; + nodes: SchemaGraphNode[]; + edges: SchemaGraphEdge[]; + tableCount?: number; + loading?: boolean; + progress?: { done: number; total: number }; + partial?: boolean; + savedPositions?: PositionMap; +} + +/** `tab.result` widened just enough to read/write the inline schema-graph + * slice above — state.ts's own `QueryTab.result` is deliberately opaque + * (`Record | null`); this session (like app.ts before it) + * reads/writes through its OWN cast of that same opaque field, never a + * shared declared type. */ +type SchemaGraphResult = StreamResult & { schemaGraph?: SchemaGraphInlineData }; + +/** The tab slice this session reads/writes — Pick-shaped, structurally + * satisfied by the real `QueryTab` (state.ts), same convention as + * `workbench-session.ts`'s own state-slice interfaces. */ +export interface SchemaGraphTab { + result: Record | null; +} + +/** `expand()`'s resolved data — the rich (card) fullscreen dataset. The shell + * reasserts `id`/`label` non-optional (explain-graph.ts's `SchemaLineageNode` + * requires them, same as the pre-extraction code did) and calls + * `view.render(...)`; this session never constructs or sees that ui shape. */ +export interface SchemaGraphExpandData { + nodes: (CardGraphNode & { card: CardModel })[]; + edges: CardGraphEdge[]; + focus: SchemaGraphFocus; + truncated: boolean; + savedPositions: PositionMap; +} + +/** Thrown by `expand()` when the caller is signed out / unrefreshable — the + * session has no `view` to fail directly (rule 4), so it carries the exact + * user-facing message the shell should show via `view.fail(err.message)`, + * distinct from the generic catch-all for any other failure. */ +export class SchemaGraphAuthRequiredError extends Error {} + +// ── Injected DOM/render hooks ──────────────────────────────────────────────── + +export interface SchemaGraphHooks { + /** Per-phase (show) results-pane repaint. */ + renderResults(): void; + /** Fired when `deps.getToken()` resolves null (signed out / unrefreshable) + * in `show()` — restores the ported code's original + * `chCtx.onSignedOut(); return;` behavior without this session knowing + * about `chCtx`/`ConnectionSession` (matches `WorkbenchHooks.onAuthFailed`'s + * own doc comment). `expand()` fires this too, alongside its own thrown + * `SchemaGraphAuthRequiredError` (the shell needs both: the hook to sign + * out, the error to pick `view.fail`'s message). */ + onAuthFailed(): void; +} + +// ── Construction deps ──────────────────────────────────────────────────────── + +export interface SchemaGraphDeps { + ensureConfig(): Promise; + getToken(): Promise; + /** The live ClickHouse auth context — a *provider*, not a value (matches + * `schema-catalog-service.ts`'s own `ctx` seam). */ + ctx(): ChCtx; + loadSchemaLineage: typeof loadSchemaLineage; + loadLineageTransitive: typeof loadLineageTransitive; + loadSchemaCards: typeof loadSchemaCards; + loadTableDetail: typeof loadTableDetail; + activeTab(): SchemaGraphTab; + hooks: SchemaGraphHooks; +} + +// ── The service ────────────────────────────────────────────────────────────── + +export interface SchemaGraphSession { + /** Render the ClickHouse object-lineage graph for a dropped/clicked + * database/table into the data pane (issue #124's two-phase draw). */ + show(focus: SchemaGraphFocus): Promise; + /** Abort any in-flight inline-pane fetch. `clearResult: true` (a manual + * Cancel) additionally settles the visible result: keeps a Phase-A graph + * (marked `partial`) if one had already drawn, else drops back to the + * empty placeholder. */ + cancel(opts?: { clearResult?: boolean }): void; + /** Load the enriched rich-card dataset for the fullscreen expand view. + * Throws `SchemaGraphAuthRequiredError` when signed out/unrefreshable; + * any other failure (a lineage/cards fetch, a graph-build throw) just + * propagates — the shell's catch-all maps both to `view.fail(...)`. */ + expand(focus: SchemaGraphFocus): Promise; + /** Resolve one clicked node's detail data, or `null` when a later click on + * `token` superseded this one before the fetch resolved (last-clicked + * wins, not last-resolved — #97). `token` keys the last-clicked-wins + * bookkeeping per overlay surface (the shell passes its detached-view + * `Document`) — an opaque object identity, not documented as `Document` + * here since this session has no other reason to know about DOM. */ + loadNodeDetail(node: SchemaGraphFocus, token: object): Promise; +} + +/** Build a `SchemaGraphSession` bound to `deps`. Trivial constructor — no + * validation, no defaulting; the caller supplies every field of `deps` + * exactly as it wants it used. */ +export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSession { + // The inline pane's in-flight fetch controller — PRIVATE session state + // (formerly `app.state.schemaGraphAbortController`), same "cancellation + // state lives in its owner" shape as WorkbenchSession's own + // `abortController`. + let abortController: AbortController | null = null; + + // Last-clicked-wins bookkeeping for the node-detail pane (#97) — keyed by + // the shell's opaque per-overlay token (a `Document` in production), so a + // slow fetch for an earlier click can't clobber a newer pane once it + // resolves. + const latestDetailRequest = new WeakMap(); + + function cancel({ clearResult = false }: { clearResult?: boolean } = {}): void { + if (abortController) abortController.abort(); + abortController = null; + if (!clearResult) return; + const tab = deps.activeTab(); + const result = tab.result as SchemaGraphResult | null; + const sg = result?.schemaGraph; + if (!sg || !sg.loading) return; + if (sg.nodes && sg.nodes.length) { + sg.loading = false; + sg.partial = true; + } else { + tab.result = null; + } + deps.hooks.renderResults(); + } + + async function show(focus: SchemaGraphFocus): Promise { + if (!focus || !focus.db) return; + await deps.ensureConfig(); + if (!(await deps.getToken())) { deps.hooks.onAuthFailed(); return; } + cancel(); // a new click/drag replaces whatever graph was in flight + const tab = deps.activeTab(); + // Show a loading placeholder first — even Phase A (system.tables + + // system.dictionaries) is a network round trip. + const result: SchemaGraphResult = newResult('Table'); + result.schemaGraph = { focus, loading: true, nodes: [], edges: [] }; + Object.assign(tab, { result }); + // `result` is the stale-write guard (mirrors #97's identity-guard shape, + // and WorkbenchSession's own run() guard): captured once, checked before + // every later write, so a second graph request (or the shell's own + // Run/Explain replacing tab.result) that replaces tab.result mid-fetch + // can never have this call's (Phase A or Phase B) result land on the new + // tab.result. `tab.result`'s declared type (state.ts's opaque + // `Record | null`) has no overlap with our own + // `SchemaGraphResult` for a direct `!==` — widen `result` to `unknown` + // (not a further cast to another concrete type) for the comparison only; + // the identity check itself is unaffected. + const superseded = (): boolean => tab.result !== (result as unknown); + deps.hooks.renderResults(); + const controller = new AbortController(); + abortController = controller; + try { + const lineage = await deps.loadSchemaLineage(deps.ctx(), focus, { + signal: controller.signal, + onBase: (base) => { + if (superseded()) return; // superseded before Phase A even landed + const g = buildSchemaGraph(base, focus); + result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges, tableCount: (base.tables || []).length, loading: true }; + deps.hooks.renderResults(); + }, + onProgress: (done, total) => { + if (superseded() || !result.schemaGraph || !result.schemaGraph.loading) return; + result.schemaGraph.progress = { done, total }; + deps.hooks.renderResults(); + }, + }); + if (superseded()) return; // superseded while Phase B was resolving + const g = buildSchemaGraph(lineage, focus); + // tableCount lets the renderer explain an empty result ("N tables, none linked"). + result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges, tableCount: (lineage.tables || []).length }; + } catch (e) { + // AbortError means cancel() already left the pane in a clean state + // (partial graph or the empty placeholder) — nothing more to do. + if (e instanceof Error && e.name === 'AbortError') return; + if (superseded()) return; + const errorResult: SchemaGraphResult = newResult('Table'); + errorResult.error = String((e instanceof Error && e.message) || e); + Object.assign(tab, { result: errorResult }); + } finally { + if (abortController === controller) abortController = null; + } + deps.hooks.renderResults(); + } + + // `ch.CardColumnRow` (the real loader shape) has no index signature; + // `core/schema-cards.ts`'s `SchemaCardColumnRow` (the shape `buildCardGraph` + // needs) does — reconstructing each row as a fresh object literal satisfies + // it directly (every field the card model reads is already there; nothing + // here changes what's read or its values). + const toCardColumns = (byKey: Record): Record => { + const out: Record = {}; + for (const [key, rows] of Object.entries(byKey)) out[key] = rows.map((row) => ({ ...row })); + return out; + }; + + async function expand(focus: SchemaGraphFocus): Promise { + // Pin the result whose Expand was clicked NOW, before any await: a tab + // switch during the fetch must not redirect the saved-positions map to a + // different tab's result (mirrors show()'s own captured-before-any-await + // tab reference). + const clickedTab = deps.activeTab(); + const clickedResult = clickedTab.result as SchemaGraphResult | null; + const sg = clickedResult?.schemaGraph || null; + await deps.ensureConfig(); + if (!(await deps.getToken())) { + deps.hooks.onAuthFailed(); + throw new SchemaGraphAuthRequiredError('Sign in to view the schema graph.'); + } + // Walk lineage transitively across DB boundaries (soft-capped) — pulls in + // objects an other database references, instead of dead-ending at the edge. + const lineage = await deps.loadLineageTransitive(deps.ctx(), focus); + const g = buildSchemaGraph(lineage.rows, focus); + // Fresh node/edge literals (`{...n}`): `SchemaGraphNode` (buildSchemaGraph's + // fixed-field output) has no index signature; `ExpandLineageNode` (what + // expandLineage's graph needs) does — every field it reads is already there. + const ex = expandLineage({ nodes: g.nodes.map((n) => ({ ...n })), edges: g.edges }, focus.db || ''); // closure around focus.db, tags external nodes + // Card metadata for every database the expansion reached (external nodes too). + const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))]; + const cards = await deps.loadSchemaCards(deps.ctx(), dbs); + const cardGraph = buildCardGraph({ nodes: ex.nodes, edges: ex.edges }, + { tables: lineage.rows.tables, columnsByKey: toCardColumns(cards.columnsByKey) }); + // Persist manually-moved node positions per result: the map hangs off the + // live schemaGraph result (captured above) so re-opening keeps the layout. + const positions: PositionMap = (sg && sg.savedPositions) || {}; + if (sg) sg.savedPositions = positions; + return { + nodes: cardGraph.nodes, + edges: cardGraph.edges, + focus, + truncated: lineage.truncated || ex.truncated, + savedPositions: positions, + }; + } + + async function loadNodeDetail(node: SchemaGraphFocus, token: object): Promise { + if (!node || !node.db || !node.name) return null; + latestDetailRequest.set(token, node); + const detail = await deps.loadTableDetail(deps.ctx(), node.db, node.name); + if (latestDetailRequest.get(token) !== node) return null; // superseded by a later click + return detail; + } + + return { show, cancel, expand, loadNodeDetail }; +} diff --git a/src/application/workbench-parameter-session.ts b/src/application/workbench-parameter-session.ts new file mode 100644 index 0000000..cf5220d --- /dev/null +++ b/src/application/workbench-parameter-session.ts @@ -0,0 +1,326 @@ +// #276 Phase 4B1's WorkbenchParameterSession — the `{name:Type}` query- +// variable POLICY extracted from app.ts (issue #276 §7, THINNER CUT per plan +// review): analyze/prepare/gate/execution-view, the #170 hardening bookkeeping, +// the #172 v2 schema-cache enum-suggestion inference, and the #171 recent- +// value + persistence policy over `varValues`/`filterActive`/`varRecent`/ +// `varRecentDisabled`. Constructible without App/AppState/DOM, like +// `workbench-session.ts`/`schema-catalog-service.ts` before it. +// +// Deliberately NOT included (plan-review rulings): `renderVarStrip` (the DOM +// view) stays in app.ts wholesale, calling this session's methods directly — +// the full `analyze() -> ParameterViewModel[]` view-model API the issue +// sketches is deferred, not built here. `setRunBtn` (DOM) also stays in +// app.ts. `sessionParams`/`needsSession`/`sessionParamsFor` stay app.ts-local +// (they're `tab.chSession`/transport material — Phase 4C's concern, not this +// session's). +// +// Every state field this session reads/writes (`varValues`/`filterActive`/ +// `varRecent`/`varRecentDisabled`) stays a LIVE `AppState` field, never a +// snapshot — the session receives accessor closures (mirrors +// `dashboard-session.ts`'s own `DashboardSessionDeps` convention), and +// `filter-bar.ts`/`dashboard.ts`/`results.ts` keep mutating `app.state.*` +// directly (through the SAME live objects these accessors read), so they +// keep observing this session's own reads without any consumer edit. +// +// `hardenedVars` (#170 review: names whose value hardened to invalid) is +// owned here as a private `Set`, exposed as a plain (never +// reassigned, only mutated in place) property — `app.hardenedVars` in app.ts +// is assigned this SAME instance (not a copy), so `app.hardenedVars.has(...)` +// (app.test.ts) keeps working unchanged. +// +// `saveVarRecent`'s two faces: `saveVarRecent()` (this session's own, real, +// `saveJSON`-calling implementation — what `app.saveVarRecent`'s one-line +// delegate calls) vs. `deps.hooks.saveVarRecent()` (used ONLY internally, by +// `recordBoundParams`/`clearVarRecent`/`clearAllVarRecent`, to trigger the +// persist). The two are kept distinct on purpose: `deps.hooks.saveVarRecent` +// is wired by app.ts to `() => app.saveVarRecent()` — a property read on the +// mutable `app` object, evaluated fresh on every call — so a caller that +// swaps `app.saveVarRecent` (app.test.ts's `app.saveVarRecent = +// vi.fn(app.saveVarRecent)` mock-substitution case) still observes every +// automatic persist this policy performs, byte-identical to the +// pre-extraction code's own `app.saveVarRecent()` property-style call inside +// `clearVarRecent`/`clearAllVarRecent`/`recordBoundParams`. Routing the real +// implementation through that same hook would recurse (`app.saveVarRecent` +// -> hook -> `app.saveVarRecent` -> ...), so the hook and the real +// implementation are deliberately two different entry points to the same +// effect. + +import { + analyzeParameterizedSources, prepareParameterizedBatch, executionView, +} from '../core/param-pipeline.js'; +import type { + ParameterAnalysis, PreparedSource, PreparedBatch, PreparedFieldState, ValidationMode, FieldControl, +} from '../core/param-pipeline.js'; +import { isRowReturning } from '../core/sql-split.js'; +import { effectiveFilterActive } from '../state.js'; +import type { QueryTab, SaveJSON } from '../state.js'; +import { KEYS } from '../state.js'; +import type { RecentMap } from '../core/recent-values.js'; +import { recordRecent, clearRecent, clearAllRecent } from '../core/recent-values.js'; +import { enumValues, parseParamType } from '../core/param-type.js'; +import type { ParamComparisonEntry } from '../core/param-comparison.js'; +import { resolveComparisonColumnType } from '../core/from-scope.js'; +import type { SchemaDb } from '../core/from-scope.js'; + +// ── Construction deps ──────────────────────────────────────────────────────── + +export interface WorkbenchParameterSessionHooks { + /** Fired when `varGateBlocked` finds a blocking condition — the shell's own + * toast (app.ts wires this to `flashToast(message, { document: doc })`); + * this session never imports `ui/toast.js`. */ + onGateBlocked(message: string): void; + /** See this module's header comment: routed back through the mutable, + * test-visible `app.saveVarRecent` property (app.ts wires this to + * `() => app.saveVarRecent()`) rather than calling this session's own + * `saveVarRecent()` directly, so every automatic persist + * `recordBoundParams`/`clearVarRecent`/`clearAllVarRecent` performs stays + * observable through that exact seam. */ + saveVarRecent(): void; +} + +/** Live accessors onto `app.state` — read fresh on every call (never a + * snapshot), mirroring `dashboard-session.ts`'s own `DashboardSessionDeps` + * convention. `varValues`/`filterActive` are mutated IN PLACE by their + * consumers (renderVarStrip's `onValueInput`, filter-bar.ts, dashboard.ts) — + * no setter needed. `varRecent` is reassigned wholesale (a new `RecentMap` + * replaces the old one), hence `setVarRecent`. */ +export interface WorkbenchParameterSessionDeps { + varValues(): Record; + filterActive(): Record; + varRecent(): RecentMap; + setVarRecent(map: RecentMap): void; + varRecentDisabled(): boolean; + /** The schema-cache accessor for `inferredEnumOptions`'s #172 v2 + * suggestion tier — `state.schema.value` cast the same way the + * pre-extraction code did (only ever read against the workbench's own + * already-loaded schema cache, never a new query). */ + schema(): SchemaDb[] | null; + activeTab(): QueryTab; + /** The #173 wave wall clock (epoch ms) — matches app.ts's own `wallNow`. */ + wallNow(): number; + /** `core/storage.js`'s `saveJSON` seam (state.ts's own `SaveJSON` shape) — + * this session's own `saveVarValues`/`saveFilterActive`/`saveVarRecent`/ + * `saveVarRecentDisabled` call it directly against the KEYS below. */ + saveJSON: SaveJSON; + hooks: WorkbenchParameterSessionHooks; +} + +// ── The session ────────────────────────────────────────────────────────────── + +export interface WorkbenchParameterSession { + /** Names of `{name:Type}` variables whose value has hardened to invalid + * (#170 review) — a single `Set` instance, mutated in place (never + * reassigned), so app.ts's `app.hardenedVars = session.hardenedVars` + * aliases the SAME object other modules/tests read/mutate. */ + readonly hardenedVars: Set; + tabAnalysis(sql: string): ParameterAnalysis; + prepareAnalyzedBatch(analysis: ParameterAnalysis, wallNowMs: number, validationMode?: ValidationMode): PreparedBatch; + prepareTabBatch(sql: string, wallNowMs: number, validationMode?: ValidationMode): PreparedBatch; + prepareTabSource(sql: string, wallNowMs: number, validationMode?: ValidationMode): PreparedSource; + execStatementSql(stmt: string): string; + varGateBlocked(wallNowMs?: number): boolean; + hardenVar(name: string, field?: PreparedFieldState): void; + inputGate(analysis: ParameterAnalysis): { missing: string[]; invalid: string[]; errors: string[] }; + inferredEnumOptions( + v: FieldControl, sql: string, comparisonColumns: Record, + ): string[] | null; + recordBoundParams(boundParams: Array<{ name: string; rawValue: unknown }>): void; + clearVarRecent(name: string): void; + clearAllVarRecent(): void; + saveVarValues(): void; + saveFilterActive(): void; + saveVarRecent(): void; + saveVarRecentDisabled(): void; +} + +/** Build a `WorkbenchParameterSession` bound to `deps`. Trivial constructor — + * no validation, no defaulting; the caller supplies every field exactly as + * it wants it used. */ +export function createWorkbenchParameterSession(deps: WorkbenchParameterSessionDeps): WorkbenchParameterSession { + // #170 review: names of `{name:Type}` variables whose value has hardened to + // invalid (blur/Enter/execute committed a strict verdict of invalid). + // setRunBtn's gate-less fallback (called from unrelated re-renders — + // renderVarStrip's tail call on every SQL-editor keystroke, and the + // hasSelection effect on every cursor/selection move) recomputes in + // lenient 'input' mode, which reads a still-incomplete prefix (e.g. a + // lone '-') as merely incomplete, not invalid — without this bookkeeping + // that recompute would silently re-enable Run while the field itself + // still paints red. Editing the field's value again (its own `oninput`) + // clears the name here, returning it to normal lenient behavior. + const hardenedVars = new Set(); + + // The optional-block activation map (#165): explicit filterActive entries + // win; params without one derive activation from their stored value. + const activeMap = (): Record => effectiveFilterActive(deps.varValues(), deps.filterActive()); + + // The workbench SQL as the pipeline's single parameterized source (#173). + function tabAnalysis(sql: string): ParameterAnalysis { + return analyzeParameterizedSources([ + { id: 'tab', label: 'editor tab', kind: 'tab', sql, bindPolicy: 'row-returning' }, + ]); + } + + // Analyze + prepare `sql` as the workbench's single parameterized source + // (#173): one call per SQL string per wave, drawing values from the shared + // varValues (+ the #165 activation map). Returns the full prepared batch + // ({fields, sources, diagnostics}) — `fields` is per-`{name:Type}` (#170's + // validated state, for the var-strip's inline affordance); `sources[0]` is + // this single source's `{statements, missing, invalid, errors, runnable}`. + // Args for a request come from a source's statements (or mergedSourceArgs + // when the SQL ships as one request); each statement's `sql` is its + // execution view (#165) — byte-identical for SQL without optional blocks. + function prepareAnalyzedBatch(analysis: ParameterAnalysis, wallNowMs: number, validationMode: ValidationMode = 'execute'): PreparedBatch { + return prepareParameterizedBatch(analysis, { + values: deps.varValues(), active: activeMap(), wallNowMs, validationMode, + }); + } + function prepareTabBatch(sql: string, wallNowMs: number, validationMode: ValidationMode = 'execute'): PreparedBatch { + return prepareAnalyzedBatch(tabAnalysis(sql), wallNowMs, validationMode); + } + function prepareTabSource(sql: string, wallNowMs: number, validationMode: ValidationMode = 'execute'): PreparedSource { + return prepareTabBatch(sql, wallNowMs, validationMode).sources[0]; + } + + // The execution text of one statement (#165): only active optional blocks + // retained, markers stripped — byte-identical for SQL without blocks. Follows + // the #134 bind gate: a non-row-returning statement passes through verbatim. + function execStatementSql(stmt: string): string { + return isRowReturning(stmt) ? executionView(stmt, activeMap()) : stmt; + } + + // Block execution while any {name:Type} variable in the active tab is unfilled + // or invalid, or while its value can't serialize (e.g. an array value against + // a scalar declaration) — toasting why (#134/#173). Gating on the whole + // tab.sqlDraft — the exact set the variable strip shows — keeps every execution + // path consistent: the Run button (setRunBtn), the Run/⌘↵ path, Explain, and + // Export all agree. `wallNowMs` is the caller's wave clock. + function varGateBlocked(wallNowMs: number = deps.wallNow()): boolean { + const tab = deps.activeTab(); + const src = tab ? prepareTabSource(tab.sqlDraft, wallNowMs) : null; + if (!src) return false; + const blockers = src.missing.concat(src.invalid); + if (blockers.length) { + deps.hooks.onGateBlocked('Enter a value for: ' + blockers.join(', ')); + return true; + } + if (src.errors.length) { + deps.hooks.onGateBlocked(src.errors[0]); + return true; + } + return false; + } + + // Keep `hardenedVars` (#170 review) in sync with a field's just-computed + // 'execute'-mode verdict: added when it's invalid, cleared otherwise — so a + // corrected-then-reharded value, or a variable that simply stopped being + // invalid, doesn't linger in the set. Shared by every place that commits a + // strict verdict for a field (blur, Enter, and the strip's initial/rebuild + // paint, which is itself an 'execute'-mode read of the persisted value). + function hardenVar(name: string, field?: PreparedFieldState): void { + if (field && field.state === 'invalid') hardenedVars.add(name); + else hardenedVars.delete(name); + } + + // The Run button's lenient ('input'-mode) gate for an already-computed + // analysis. #170 review: a field that hardened to invalid (blur/Enter/ + // execute committed a strict invalid verdict) must keep blocking Run even + // though this recompute is lenient — 'input' mode reads a still-incomplete + // prefix like '-' as merely incomplete, not invalid — so `hardenedVars` + // is folded in. Only names the batch actually declares are considered, so a + // hardened flag for a variable that dropped out of the tab's SQL (or + // belongs to a different tab) doesn't block Run forever; the + // `!src.invalid.includes` filter just avoids listing a name twice. Shared + // by setRunBtn's own fallback and renderVarStrip's tail (review F9: one + // analysis per strip repaint feeds both consumers). + function inputGate(analysis: ParameterAnalysis): { missing: string[]; invalid: string[]; errors: string[] } { + const gateBatch = prepareAnalyzedBatch(analysis, deps.wallNow(), 'input'); + const src = gateBatch.sources[0]; + const hardened = [...hardenedVars].filter((name) => name in gateBatch.fields && !src.invalid.includes(name)); + return { missing: src.missing, invalid: src.invalid.concat(hardened), errors: src.errors }; + } + + // #172 v2 (schema-cache inference — the SUGGESTION tier): the enum member + // list a plain `{name:String}` param's compared column implies, or null. + // The declared type's own Enum members (v1, authoritative and blocking — + // #170 validates those as a real Enum) are fieldControlKind's business; + // this helper only ever resolves the workbench's own SQL against the + // already-loaded schema cache (`paramComparisonColumns` + + // `resolveComparisonColumnType`), never a new query, and the declared type + // stays String, so #170 never blocks on a non-member. + function inferredEnumOptions( + v: FieldControl, sql: string, comparisonColumns: Record, + ): string[] | null { + // `.base` is value-transparent (#238): a `LowCardinality(String)` param + // reaches this suggestion tier too, same as plain `String` — intentional, + // since LowCardinality is just a storage encoding of String values. + if (parseParamType(v.type).base !== 'String') return null; + const cmp = comparisonColumns[v.name]; + if (!cmp) return null; + const colType = resolveComparisonColumnType(sql, cmp.pos, cmp, deps.schema()); + return colType ? enumValues(colType) : null; + } + + // --- #171 recent values + persistence ------------------------------------- + + function saveVarValues(): void { deps.saveJSON(KEYS.varValues, deps.varValues()); } + function saveFilterActive(): void { deps.saveJSON(KEYS.filterActive, deps.filterActive()); } + function saveVarRecent(): void { deps.saveJSON(KEYS.varRecent, deps.varRecent()); } + function saveVarRecentDisabled(): void { deps.saveJSON(KEYS.varRecentDisabled, deps.varRecentDisabled()); } + + // Record every successful statement's `boundParams` (#173's immutable + // per-statement snapshots) into the recent-value history — the single hook + // point every success path (run/runScript's single-statement + per-script- + // statement paths, and the dashboard's per-tile completion) calls. A no-op + // while the disable-history preference is on (existing history is left + // alone, only new recording stops) or when nothing was actually bound. + // Array-valued `rawValue` (an `Array(...)`-typed param) is skipped — v1 + // recents are a text-value affordance, like #172's (not yet built) enum + // controls; #160's curated-`filter:`-param opt-out hook has nothing to + // check yet (no curated param exists before #160 lands). + function recordBoundParams(boundParams: Array<{ name: string; rawValue: unknown }>): void { + if (deps.varRecentDisabled() || !boundParams || !boundParams.length) return; + let map = deps.varRecent(); + for (const p of boundParams) { + if (typeof p.rawValue !== 'string') continue; + map = recordRecent(map, p.name, p.rawValue); + } + if (map !== deps.varRecent()) { + deps.setVarRecent(map); + deps.hooks.saveVarRecent(); + } + } + // Per-field "Clear recent" (the dropdown footer) / "Clear all recent + // values" (the File menu) — both no-op (no re-persist) when there was + // nothing to clear, mirroring recordRecent's own same-reference no-op. + function clearVarRecent(name: string): void { + const next = clearRecent(deps.varRecent(), name); + if (next !== deps.varRecent()) { + deps.setVarRecent(next); + deps.hooks.saveVarRecent(); + } + } + function clearAllVarRecent(): void { + deps.setVarRecent(clearAllRecent()); + deps.hooks.saveVarRecent(); + } + + return { + hardenedVars, + tabAnalysis, + prepareAnalyzedBatch, + prepareTabBatch, + prepareTabSource, + execStatementSql, + varGateBlocked, + hardenVar, + inputGate, + inferredEnumOptions, + recordBoundParams, + clearVarRecent, + clearAllVarRecent, + saveVarValues, + saveFilterActive, + saveVarRecent, + saveVarRecentDisabled, + }; +} diff --git a/src/state.ts b/src/state.ts index af09ddb..438508a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -210,7 +210,6 @@ export interface AppState { bannerDismissedFor: Signal; serverVersion: string | null; running: Signal; - schemaGraphAbortController: AbortController | null; resultView: Signal<'table' | 'json' | 'panel' | 'filter'>; exporting: Signal; detachedView: Signal; @@ -393,11 +392,6 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // Run state (signals): `running` flips the Run button + results pane via // effects; `resultView` is the active Table/JSON/Chart tab. Via `.value`. running: signal(false), - // In-flight schema-lineage fetch (issue #124's inline drawer graph) — its own - // AbortController, separate from the workbench session's own run/script - // controller and the export controllers, since a graph fetch isn't gated by - // `running` and a second click/drag must be able to supersede an in-flight one. - schemaGraphAbortController: null, resultView: signal<'table' | 'json' | 'panel' | 'filter'>('table'), // True while a streaming Export (issue #87) is in flight — separate from // `running` (the grid run) so an export and a grid run never clobber each @@ -526,11 +520,17 @@ export function allocTabId(state: AppState): string { const rnd = () => Math.random().toString(36).slice(2, 6); const makeId = (prefix: string, now: number) => prefix + now + rnd(); -export const tabsForSaved = (state: AppState, id: string): QueryTab[] => +// Narrowed to `Pick` (#276 Phase 4C) — the only field read +// — so `patchSavedSpec`'s own narrowed `state` param (below) can pass it +// through unchanged; every real caller already passes a full `AppState`, +// which satisfies this directly. +export const tabsForSaved = (state: Pick, id: string): QueryTab[] => state.tabs.value.filter((t) => t.savedId === id); -/** First linked tab whose textual Spec is not currently parseable JSON. */ -export const invalidSpecTabForSaved = (state: AppState, id: string): QueryTab | null => +/** First linked tab whose textual Spec is not currently parseable JSON. + * Narrowed to `Pick` for the same reason as + * `tabsForSaved` above (which this delegates to). */ +export const invalidSpecTabForSaved = (state: Pick, id: string): QueryTab | null => tabsForSaved(state, id).find((tab) => tab.specDiagnostics?.some((diagnostic) => diagnostic.code === 'invalid-json')) || null; @@ -575,9 +575,15 @@ export function savedForTab( /** * Create a saved query from an unsaved tab. Linked tabs use commitSavedQuery() * instead, so popover metadata can never compete with the textual Spec draft. + * Narrowed to `Pick` + * (#276 Phase 4C — the exact fields read/written) instead of full `AppState`, + * same convention as `savedForTab`/`recordScriptHistory` — every real caller + * (app.ts's own `SavedQueryService`) already has a full `AppState` to pass, + * which satisfies this directly. */ export function createSavedQuery( - state: AppState, tab: QueryTab | null | undefined, name: unknown, description?: unknown, + state: Pick, + tab: QueryTab | null | undefined, name: unknown, description?: unknown, save: SaveJSON = saveJSON, now: number = Date.now(), validationService: SpecValidationService = defaultSpecValidationService, ): SavedQueryV2 | null { @@ -617,9 +623,11 @@ export function createSavedQuery( return entry; } -/** Atomically persist both documents of a linked tab in one Library write. */ +/** Atomically persist both documents of a linked tab in one Library write. + * Narrowed to `Pick` (#276 Phase + * 4C), same convention as `createSavedQuery` above. */ export function commitSavedQuery( - state: AppState, tab: QueryTab, spec: QuerySpecDraft | null | undefined, + state: Pick, tab: QueryTab, spec: QuerySpecDraft | null | undefined, save: SaveJSON = saveJSON, validationService: SpecValidationService = defaultSpecValidationService, ): SavedQueryV2 | null { @@ -647,9 +655,14 @@ export function commitSavedQuery( * Generic committed-Spec writer for pencil/star/future controls. The patch is * applied independently to the persisted entry and every linked valid draft, * preserving unrelated unsaved fields. Invalid JSON blocks the whole write. + * Narrowed to `Pick` + * (#276 Phase 4C — `tabs` via `invalidSpecTabForSaved`/`tabsForSaved`, both + * narrowed the same way above), same convention as `createSavedQuery`/ + * `commitSavedQuery`. `renameSaved`/`toggleFavorite` below keep passing a + * full `AppState` through unchanged (it satisfies this directly). */ export function patchSavedSpec( - state: AppState, id: string, patch: SpecPatch, + state: Pick, id: string, patch: SpecPatch, save: SaveJSON = saveJSON, validationService: SpecValidationService = defaultSpecValidationService, ): PatchSavedResult { @@ -877,9 +890,14 @@ export interface HistoryResultSnapshot { /** * Record a successful run in history. `sqlText` overrides the recorded SQL (used * when a selection — not the whole tab — was run); it defaults to `tab.sqlDraft`. + * Narrowed to `Pick` (#276 Phase 4C) — the only field + * read/written (via `pushHistory`, already narrowed this way) — same + * convention as `recordScriptHistory`; app.ts's own `SavedQueryService` + * passes a `Pick` that also carries `savedQueries`/`resultView`/ + * `libraryDirty` for its other methods, which satisfies this directly. */ export function recordHistory( - state: AppState, + state: Pick, tab: { sqlDraft: string | null; result: HistoryResultSnapshot }, save: SaveJSON = saveJSON, now: number = Date.now(), sqlText?: string | null, ): void { diff --git a/src/ui/app.ts b/src/ui/app.ts index 0e5027f..5827069 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -7,33 +7,22 @@ import { h, fixedAnchor } from './dom.js'; import { Icon } from './icons.js'; import { - createState, activeTab, KEYS, recordHistory, recordScriptHistory, - createSavedQuery, commitSavedQuery, savedForTab, tabPanel, - normalizeRowLimit, MOBILE_BREAKPOINT_PX, effectiveFilterActive, + createState, activeTab, + savedForTab, tabPanel, + normalizeRowLimit, MOBILE_BREAKPOINT_PX, } from '../state.js'; -import type { QueryTab, AppState, SpecValidationService, HistoryResultSnapshot, QuerySpecDraft } from '../state.js'; +import type { QueryTab, AppState, SpecValidationService } from '../state.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; -import { splitStatements, isRowReturning, leadingKeyword } from '../core/sql-split.js'; -import { - analyzeParameterizedSources, prepareParameterizedBatch, mergedSourceArgs, mergedSourceSql, executionView, analysisView, - fieldControls, fieldControlKind, -} from '../core/param-pipeline.js'; -import type { - ParameterAnalysis, PreparedSource, PreparedBatch, PreparedFieldState, ValidationMode, FieldControl, -} from '../core/param-pipeline.js'; +import { splitStatements, leadingKeyword } from '../core/sql-split.js'; +import { analysisView, fieldControls, fieldControlKind } from '../core/param-pipeline.js'; import { hasOptionalBlocks } from '../core/optional-blocks.js'; import { saveJSON, saveStr } from '../core/storage.js'; -import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, isSchemaMutatingSql, prepareExportSql, formatBytes, formatRows } from '../core/format.js'; -import { buildSchemaGraph, expandLineage } from '../core/schema-graph.js'; -import { buildCardGraph } from '../core/schema-cards.js'; -import type { SchemaCardColumnRow } from '../core/schema-cards.js'; -import { toTSV, formatFileMeta, exportFilename, scriptExportName } from '../core/export.js'; -import { newResult, parseErrorPos, findExceptionFrame } from '../core/stream.js'; -import { encodeShare } from '../core/share.js'; -import { queryName, queryPanel, withQuerySpec } from '../core/saved-query.js'; +import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, formatBytes, formatRows } from '../core/format.js'; +import { toTSV } from '../core/export.js'; +import { newResult, parseErrorPos } from '../core/stream.js'; import { effectiveDashboardRole } from '../core/result-choice.js'; import { - CORE_SPEC_VALIDATORS, createSpecValidatorRegistry, evaluateSpecText, formatSpecText, + CORE_SPEC_VALIDATORS, createSpecValidatorRegistry, formatSpecText, hasBlockingSpecErrors, } from '../core/spec-draft.js'; import type { SpecValidatorEntry, QuerySpecValidationService } from '../core/spec-draft.js'; @@ -50,10 +39,10 @@ import type { QueryOrName } from './tabs.js'; import { effect, batch } from '@preact/signals-core'; import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; -import type { Result, QueryResult, ScriptResult, ScriptEntry, ScriptExportEntry, ScriptExportResult, ResultSchemaGraph } from './results.js'; +import type { Result, QueryResult, ScriptResult, ScriptEntry } from './results.js'; import { renderDashboard } from './dashboard.js'; import { openSchemaView } from './explain-graph.js'; -import type { SchemaLineageGraph, SchemaLineageNode, DetachedGraphApp } from './explain-graph.js'; +import type { SchemaLineageNode, DetachedGraphApp } from './explain-graph.js'; import { openDetailPane } from './schema-detail.js'; import type { NodeDetail, DetailNode } from './schema-detail.js'; import { renderSavedHistory } from './saved-history.js'; @@ -66,11 +55,8 @@ import { buildEnumField } from './enum-field.js'; import type { EnumField } from './enum-field.js'; import { wireComboInput } from './combobox.js'; import type { ComboField } from './combobox.js'; -import { recordRecent, clearRecent, clearAllRecent, recentOptions } from '../core/recent-values.js'; -import { enumValues, parseParamType } from '../core/param-type.js'; +import { recentOptions } from '../core/recent-values.js'; import { paramComparisonColumns } from '../core/param-comparison.js'; -import type { ParamComparisonEntry } from '../core/param-comparison.js'; -import { resolveComparisonColumnType } from '../core/from-scope.js'; import type { SchemaDb } from '../core/from-scope.js'; import type { AssembledReference, CompletionItem } from '../core/completions.js'; import { libraryControls, renderLibraryTitle } from './file-menu.js'; @@ -81,12 +67,18 @@ import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from './splitter import { flashToast } from './toast.js'; import type { App, ActionsRegistry, SchemaFocus } from './app.types.js'; import type { CreateAppEnv } from '../env.types.js'; -import type { SchemaGraphFocus, SchemaGraphNode, SchemaGraphEdge } from '../core/schema-graph.js'; -import type { LineageFocus } from '../net/ch-client.js'; import { createQueryExecutionService } from '../application/query-execution-service.js'; import { createConnectionSession } from '../application/connection-session.js'; import { createSchemaCatalogService } from '../application/schema-catalog-service.js'; +import { createWorkbenchParameterSession } from '../application/workbench-parameter-session.js'; +import { createExportService } from '../application/export-service.js'; +import type { ExportSink, FileHandleLike, DirectoryHandleLike } from '../application/export-service.js'; +import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../application/schema-graph-session.js'; +import { createAppPreferences } from '../application/app-preferences.js'; +import type { PreferenceKey } from '../application/app-preferences.js'; import { createWorkbenchSession } from './workbench/workbench-session.js'; +import { createQueryDocumentSession } from '../application/query-document-session.js'; +import { createSavedQueryService } from '../application/saved-query-service.js'; /** Optional globals a plain browser page (or the CM6/Chart/dagre UMD bundles a * `