diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb30cf0..f8dca2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Temporary ClickHouse authentication loss now suspends only authenticated + execution, not the in-memory document session** (#512 phase 2, absorbing + #502/#520/#522). A disposable, epoch-fenced execution scope coordinates + Workbench and Dashboard queries, exports, schema graphs, catalog/reference + loads, formatting, SHOW CREATE, and detached-result refreshes. Closing it + aborts local work before best-effort server cancellation with an immutable + origin and `Authorization` lease, and stale completions cannot publish into + the next authenticated epoch; stale preflight work is also rejected before + it can send a replacement epoch's credentials. The mounted shell, exact + editor/tab/result objects, drafts, dirty state, route, and unload guard survive while the + header turns red and the existing authentication controls appear inline. + Successful Basic reauthentication installs a fresh scope and reloads + connection metadata in place; its reusable inline form clears the password, + resets submission state, and hides password visibility after success, ready + for a later recovery. Inline OAuth deliberately remains unavailable until the + Phase 3 checkpoint; explicit Log out remains destructive. + Async error-body classification and config discovery are epoch-fenced; refresh + authority snapshots its epoch and rechecks it after config discovery before + token-endpoint I/O, so stale discovery cannot replace a newer auth-header + policy. Catalog, schema, reference, and docs transports share a + connection-generation abort signal: `invalidate()` aborts them synchronously, + while stale writes remain fenced. - **Connection state now has one explicit, epoch-fenced lifecycle** (#512 phase 1). OAuth/Basic credentials, single-flight token refresh, transport success/failure, auth loss, reauthentication, and explicit sign-out flow diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac7f5dd2..60f274c0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,9 +47,10 @@ module is tested with plain stubs at the per-file coverage gate. | Module | Owns | |---|---| +| `authenticated-execution-scope` (`app.executionScope`) | one disposable, epoch-fenced registry for authenticated operation owners; closes local work synchronously and performs best-effort remote cancellation from an immutable credential lease | | `query-execution-service` (`app.exec`) | the shared request/stream/normalize read core + the script transport loop (retry classification, stop-on-first-failure, per-attempt `query_id`); stateless `kill(queryId)` — cancellation is caller-owned (`AbortController`s live with the owning session) | | `connection-session` (`app.conn`) | authoritative auth + connection lifecycle (`starting` / `connected` / `refreshing` / `offline` / `auth-required` / `reauthenticating` / `signed-out`), OAuth PKCE login/refresh, Basic probing, IdP config, identity, token storage, sign-out, and **the single live `chCtx` object** (mutated in place — `authConfirmed` by `net/ch-client`, `origin` by sign-in — never reconstructed) | -| `schema-catalog-service` (`app.catalog`) | server version, schema tree, lazy columns, SQL reference/completions, entity-doc cache, `invalidate()` | +| `schema-catalog-service` (`app.catalog`) | server version, schema tree, lazy columns, SQL reference/completions, entity-doc cache; catalog/schema/reference/docs transports share a connection-generation abort signal, and `invalidate()` synchronously aborts them while generation fences reject stale writes | | `workbench-parameter-session` (`app.params`) | `{name:Type}` analysis/prepare/gate policy, input-vs-execute hardening, enum inference, recent values; reads the live shared `AppState` slices through accessors | | `export-service` (`app.exports`) | direct + script export behind an injectable `ExportSink` (`pickFile`/`pickDirectory`); hold-back exception inspection, `.partial` semantics, its own cancellation state | | `query-document-session` (`app.queryDoc`) | Spec evaluation/diagnostics/dirty flags over `QueryTab`s, editor-mode policy | @@ -86,17 +87,37 @@ module is tested with plain stubs at the per-file coverage gate. Lifecycle ownership: **cancellation state always lives with the session that owns the operation** (issue #276 rule 5) — never in the transport service. -`destroy()`/`invalidate()` are wired where a session really ends today: -`signOut` tears down the workbench session, cancels graph/export work, and -invalidates the catalog before the login screen renders. There is no -route-remount mechanism — the dashboard is its own browser tab whose closure -JS never observes — so no teardown theater beyond that. +While authentication is valid, those owners register with the current +`AuthenticatedExecutionScope`. An involuntary auth-loss transition closes that +scope exactly once: owner aborts happen synchronously, old registrations become +observationally stale, and query ids captured before each abort are cancelled +best-effort through a frozen lease containing the exact origin and complete +`Authorization` header. That cancellation path has no token refresh, retry, or +auth-loss callback. + +The application shell and document session are deliberately outside the +execution scope. Auth loss therefore preserves the mounted editor, tabs, +drafts, dirty flags, navigation, and completed results while inline login +controls replace authenticated actions. In-place Basic login creates a fresh +scope for the new credential epoch and reloads connection-scoped metadata +without rebuilding the shell. Its inline form is reusable: a successful +recovery resets submission state, clears the password, and hides password +visibility, so a later loss can be recovered the same way. Inline OAuth is +deliberately unavailable until the Phase 3 checkpoint. Explicit Log out is the +separate destructive policy: it closes the scope, tears down the workbench and +Dashboard, clears credentials, and renders the signed-out login surface. Connection lifecycle ownership follows the same rule. `ConnectionSession` publishes one read-only signal and assigns a monotonically increasing credential epoch whenever credentials are installed or invalidated. Refresh is single-flight within an epoch; a late refresh or transport response cannot -write tokens, report auth loss, or repaint the replacement epoch. +write tokens, report auth loss, or repaint the replacement epoch. The network +boundary rechecks that epoch after every credential await and immediately +before each fetch attempt, so old work cannot execute under a replacement +session's credentials. This includes async HTTP error-body classification and +IdP config discovery: refresh authority snapshots its epoch, then rechecks it +after discovery and before token-endpoint I/O. A stale discovery therefore +cannot rewrite the replacement epoch's auth-header policy. `net/ch-client` reports only successful 2xx transport settlement as connected and rejected, non-aborted `fetch` as offline. HTTP query failures — including a post-confirmation 401/403 — remain query outcomes, not connection state. The diff --git a/src/application/authenticated-execution-scope.ts b/src/application/authenticated-execution-scope.ts new file mode 100644 index 00000000..cbdb07ce --- /dev/null +++ b/src/application/authenticated-execution-scope.ts @@ -0,0 +1,152 @@ +import type { AuthenticatedCancellationLease } from '../net/ch-client.js'; + +export type { AuthenticatedCancellationLease } from '../net/ch-client.js'; + +// Per-authenticated-session coordination for cancellable work. Operation +// owners retain their own AbortControllers and query lifecycle; this scope only +// provides the common epoch fence and invokes an injected network cancellation +// seam with the credentials that were valid when closing began. + +/** Network-owned, best-effort KILL QUERY seam. */ +export type CancelRemoteQuery = ( + lease: AuthenticatedCancellationLease, + queryId: string, +) => Promise | void; + +/** A caller-owned unit of authenticated work. */ +export interface AuthenticatedExecutionOperation { + /** Diagnostic identity only; useful to an owner when retaining its handle. */ + readonly name: string; + /** Must stop local work synchronously (normally AbortController.abort()). */ + abort(): void; + /** Reads the operation's current server query id, if it has one. */ + getQueryId?(): string | null | undefined; +} + +/** A registration is current only while its scope remains open and retained. */ +export interface AuthenticatedExecutionRegistration { + readonly name: string; + release(): void; + isCurrent(): boolean; +} + +export interface AuthenticatedExecutionScope { + readonly epoch: number; + readonly closed: boolean; + isOpen(): boolean; + isCurrent(registration: AuthenticatedExecutionRegistration): boolean; + register(operation: AuthenticatedExecutionOperation): AuthenticatedExecutionRegistration; + /** Idempotently closes the epoch, aborting local work before remote cleanup. */ + close(lease?: AuthenticatedCancellationLease | null): void; +} + +export interface AuthenticatedExecutionScopeDeps { + /** Connection epoch this scope fences. Refresh stays within this epoch. */ + readonly epoch: number; + readonly cancelRemote: CancelRemoteQuery; +} + +interface RegisteredOperation { + readonly id: number; + readonly operation: AuthenticatedExecutionOperation; +} + +function hasQueryId(queryId: string | null | undefined): queryId is string { + return queryId !== null && queryId !== undefined && queryId !== ''; +} + +/** + * Creates one disposable scope for a single authenticated connection epoch. + * `close` sets the closed latch before it invokes any owner code, so an abort + * callback that reports authentication loss or calls `close` again cannot + * restart disposal or make stale completion code current. + */ +export function createAuthenticatedExecutionScope( + deps: AuthenticatedExecutionScopeDeps, +): AuthenticatedExecutionScope { + let closed = false; + let closeLease: AuthenticatedCancellationLease | null = null; + let nextOperationId = 0; + const operations = new Map(); + const registrations = new Set(); + + function isCurrent(registration: AuthenticatedExecutionRegistration): boolean { + return !closed && registrations.has(registration) && registration.isCurrent(); + } + + function cancelRemote(lease: AuthenticatedCancellationLease, queryId: string): void { + // Both a synchronous seam failure and its eventual rejection are explicitly + // non-fatal. Cancellation has already stopped the local owner, and neither + // case may trigger refresh/retry/auth callbacks from this coordination layer. + try { + void Promise.resolve(deps.cancelRemote(lease, queryId)).catch(() => {}); + } catch { /* best-effort remote cancellation */ } + } + + function stop(entry: RegisteredOperation, lease: AuthenticatedCancellationLease | null): void { + // Capture before abort: several owners clear their query id while stopping. + let queryId: string | null | undefined; + try { + queryId = entry.operation.getQueryId?.(); + } catch { /* an owner that cannot report its id is still locally aborted */ } + + try { + entry.operation.abort(); + } catch { /* one faulty owner cannot prevent other session cleanup */ } + + if (lease && hasQueryId(queryId)) cancelRemote(lease, queryId); + } + + function register(operation: AuthenticatedExecutionOperation): AuthenticatedExecutionRegistration { + const entry: RegisteredOperation = { id: nextOperationId++, operation }; + let released = false; + const registration: AuthenticatedExecutionRegistration = { + name: operation.name, + release() { + if (released) return; + released = true; + operations.delete(entry.id); + registrations.delete(registration); + }, + isCurrent() { + return !released && !closed && operations.get(entry.id) === entry; + }, + }; + + if (closed) { + // A race-free caller may still finish setup just as auth is lost. It must + // not escape cancellation merely because registration was late. + released = true; + stop(entry, closeLease); + } else { + operations.set(entry.id, entry); + registrations.add(registration); + } + return registration; + } + + function close(lease?: AuthenticatedCancellationLease | null): void { + if (closed) return; + // This assignment is intentionally first: owner abort callbacks may be + // re-entrant, but can only observe an already-stale, closed scope. + closed = true; + // The caller obtains this just before credentials are cleared. A refresh + // remains in the same epoch, so close—not scope construction—must retain + // the latest credential. A foreign epoch is never authorized to clean up + // this scope, though its local operations still need immediate abortion. + closeLease = lease?.epoch === deps.epoch ? lease : null; + const active = [...operations.values()]; + operations.clear(); + registrations.clear(); + for (const entry of active) stop(entry, closeLease); + } + + return { + epoch: deps.epoch, + get closed() { return closed; }, + isOpen: () => !closed, + isCurrent, + register, + close, + }; +} diff --git a/src/application/connection-session.ts b/src/application/connection-session.ts index ccb5e6ad..471e95fb 100644 --- a/src/application/connection-session.ts +++ b/src/application/connection-session.ts @@ -26,7 +26,11 @@ import { resolveTarget } from '../core/target.js'; import { buildAuthorizeUrl, refreshTokens, bearerFromTokens } from '../net/oauth.js'; import { memoizeConfig, loadConfigDoc, resolveIdp } from '../net/oauth-config.js'; import type { ConfigDoc, ResolvedIdpConfig, ChAuthKind } from '../net/oauth-config.js'; -import type { ChCtx as NetChCtx, queryJson } from '../net/ch-client.js'; +import type { + AuthenticatedCancellationLease, + ChCtx as NetChCtx, + queryJson, +} from '../net/ch-client.js'; // ── Injected dependency seam ───────────────────────────────────────────────── @@ -58,7 +62,7 @@ export interface ConnectionSessionDeps { /** Auth was lost (no token / expired-and-unrefreshable / CH rejected a * valid login). The session never renders — it calls this and lets the * shell decide how to show the login screen. */ - onAuthLost: (detail?: string) => void; + onAuthLost: (detail?: string, lease?: AuthenticatedCancellationLease) => void; } // ── The ClickHouse auth context ────────────────────────────────────────────── @@ -108,6 +112,10 @@ export interface ConnectionSession { idpId(): string | null; chAuth(): ChAuthKind; basicUserClaim(): string; + /** The exact Basic target retained for an in-place reauthentication after an + * involuntary credential loss. It is deliberately separate from `chCtx`: + * the live context resets to the serving origin once credentials are cleared. */ + basicRecoveryOrigin(): string | null; isSignedIn(): boolean; email(): string; host(): string; @@ -125,7 +133,10 @@ export interface ConnectionSession { connectBasic(input: { username: string; password: string; host?: string }): Promise; signOut(): void; ensureFreshToken(): Promise; - + /** Snapshot exact cancellation authority for the current credential epoch. + * The returned header already includes its scheme; consumers must treat it + * as opaque and never route it through normal auth/refresh code. */ + captureCancellationLease(): AuthenticatedCancellationLease | null; } export function createConnectionSession(deps: ConnectionSessionDeps): ConnectionSession { @@ -138,6 +149,12 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection let token: string | null = ss.getItem('oauth_id_token'); let refreshTok: string | null = ss.getItem('oauth_refresh_token'); let authMode: 'oauth' | 'basic' = ss.getItem('ch_basic_auth') ? 'basic' : 'oauth'; + // `requireAuthentication` clears storage and returns the live request ctx to + // the serving origin. Keep the previous Basic target separately so the + // in-shell recovery form cannot accidentally reconnect to that serving host. + let basicRecoveryTarget: string | null = authMode === 'basic' + ? (ss.getItem('ch_basic_origin') || loc.origin) + : null; const basicCreds = (): string | null => ss.getItem('ch_basic_auth'); const currentCredential = (): string | null => (authMode === 'basic' ? basicCreds() : token); const basicUser = (): string => ss.getItem('ch_basic_user') || ''; @@ -209,15 +226,17 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection } function setTokens(id: string, refresh?: string): void { authMode = 'oauth'; + basicRecoveryTarget = null; storeTokens(id, refresh); chCtx.authConfirmed = false; transition({ type: 'credentials-installed' }); } - function clearTokens(): void { + function clearTokens(preserveBasicRecovery = false): void { token = null; refreshTok = null; idpId = null; authMode = 'oauth'; + if (!preserveBasicRecovery) basicRecoveryTarget = null; chCtx.origin = loc.origin; chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_return_route', 'oauth_idp', 'oauth_origin', @@ -298,10 +317,15 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection } return; } + const lease = captureCancellationLease(expectedEpoch); const next = transition({ type: 'auth-required', epoch: expectedEpoch, detail: message }); - clearTokens(); authLossReportedEpoch = next.epoch; - deps.onAuthLost(message); + const preserveBasicRecovery = authMode === 'basic'; + try { + deps.onAuthLost(message, lease ?? undefined); + } finally { + clearTokens(preserveBasicRecovery); + } } function refresh(): Promise { @@ -310,13 +334,20 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection if (authMode === 'basic') return Promise.resolve(false); const epoch = connectionSignal.value.epoch; if (refreshSlot?.epoch === epoch) return refreshSlot.promise; + // Bind this refresh to the credential generation that started it. Never + // read a replacement epoch's rotated refresh token after config discovery. + const refreshTokenForEpoch = refreshTok; transition({ type: 'begin-refresh', epoch }); let promise!: Promise; promise = (async () => { try { const cfg = await resolveConfig(); - const tokens = await refreshTokens(fetchFn, cfg, refreshTok); + // Discovery/config loading is asynchronous. A newer login may have + // installed credentials while it was pending; the old refresh must not + // send either generation's token after that boundary. + if (connectionSignal.value.epoch !== epoch) return false; + const tokens = await refreshTokens(fetchFn, cfg, refreshTokenForEpoch); const bearer = bearerFromTokens(tokens, cfg.bearer); // A newer credential scope owns the session now. This settlement may // neither write tokens nor publish lifecycle state. @@ -373,6 +404,19 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection const user = chUsername(decodeJwtPayload(t)); return 'Basic ' + btoa(unescape(encodeURIComponent(user + ':' + t))); } + function captureCancellationLease( + expectedEpoch = connectionSignal.value.epoch, + ): AuthenticatedCancellationLease | null { + if (connectionSignal.value.epoch !== expectedEpoch) return null; + const credential = currentCredential(); + if (!credential) return null; + return Object.freeze({ + epoch: expectedEpoch, + origin: chCtx.origin, + authorization: authHeader(credential), + fetch: fetchFn, + }); + } const chCtx: SessionChCtx = { fetch: fetchFn, // Where queries POST: the serving origin for OAuth, or the (possibly @@ -408,8 +452,12 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection async function ensureConfig(): Promise { // Basic mode needs no OAuth config — the auth scheme is fixed. if (authMode === 'basic') return null; + const epoch = connectionSignal.value.epoch; try { const cfg = await resolveConfig(); + // A late IdP discovery from an old credential generation cannot rewrite + // the replacement session's ClickHouse auth-header policy. + if (connectionSignal.value.epoch !== epoch || authMode !== 'oauth') return null; chAuthVal = cfg.chAuth; basicUserClaimVal = cfg.basicUserClaim || ''; return cfg; @@ -451,6 +499,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection ss.setItem('ch_basic_auth', creds); ss.setItem('ch_basic_user', user); ss.setItem('ch_basic_origin', target); + basicRecoveryTarget = target; chCtx.origin = target; chCtx.authConfirmed = false; transition({ type: 'credentials-installed' }); @@ -478,6 +527,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection idpId: () => idpId, chAuth: () => chAuthVal, basicUserClaim: () => basicUserClaimVal, + basicRecoveryOrigin: () => basicRecoveryTarget, isSignedIn, email, // The host queries actually go to. chCtx.origin already resolves to the basic @@ -495,5 +545,6 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection connectBasic, signOut, ensureFreshToken, + captureCancellationLease, }; } diff --git a/src/application/export-service.ts b/src/application/export-service.ts index cdc82623..d1e9a804 100644 --- a/src/application/export-service.ts +++ b/src/application/export-service.ts @@ -59,6 +59,10 @@ import { variableDoc } 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'; +import type { + AuthenticatedExecutionRegistration, + AuthenticatedExecutionScope, +} from './authenticated-execution-scope.js'; // ── File System Access seam (moved from app.ts) ───────────────────────────── @@ -179,6 +183,10 @@ export interface ExportServiceDeps { runQuery: typeof runQuery; killQuery: typeof killQuery; ctx(): ChCtx; + /** The disposable authenticated epoch which owns newly-started export work. + * A null scope preserves this application's narrow unit-test seam; normal + * UI entry points only call export while a scope is available. */ + executionScope(): AuthenticatedExecutionScope | null; ensureConfig(): Promise; /** Resolves the live bearer/basic credential, or null when signed out / * unrefreshable — both export entry points call `ctx().onSignedOut()` and @@ -254,6 +262,32 @@ export function createExportService(deps: ExportServiceDeps): ExportService { let exportScriptQueryId: string | null = null; let exportScriptCancelled = false; let exportScriptTick: ReturnType | null = null; + let nextScriptWave = 0; + let activeScriptWave: number | null = null; + + function isCurrent(registration: AuthenticatedExecutionRegistration | null): boolean { + return registration === null || registration.isCurrent(); + } + + /** Clear only the direct-export wave which still owns the shared UI flag. + * A late finally from a lost epoch must never turn off a replacement + * export's spinner. */ + function settleExport(controller: AbortController): void { + if (exportAbort !== controller) return; + exportAbort = null; + exportQueryId = null; + deps.state.exporting.value = false; + } + + function settleScriptExport(wave: number): void { + if (activeScriptWave !== wave) return; + if (exportScriptTick != null) clearInterval(exportScriptTick); + exportScriptTick = null; + exportScriptAbort = null; + exportScriptQueryId = null; + activeScriptWave = null; + deps.state.exporting.value = false; + } // The Export button dispatches by statement count: one statement keeps the // rich single-file flow below; more than one opens the script-export flow @@ -302,8 +336,27 @@ export function createExportService(deps: ExportServiceDeps): ExportService { // 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. + const controller = new AbortController(); + let waveQueryId: string | null = null; + let progress: { update(bytes: number): void; remove(): void } | null = null; + // Register before the native picker. Auth can be lost while that modal is + // open, and a picker that eventually resolves must not proceed to config, + // token, transport, or a late toast in the next authenticated epoch. + exportAbort = controller; + exportQueryId = null; deps.state.exporting.value = true; + const registration = deps.executionScope()?.register({ + name: 'single-file export', + abort: () => { + controller.abort(); + progress?.remove(); + progress = null; + settleExport(controller); + }, + getQueryId: () => waveQueryId, + }) || null; try { + if (!isCurrent(registration)) return; // 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. @@ -314,29 +367,41 @@ export function createExportService(deps: ExportServiceDeps): ExportService { types: [{ description: format + ' data', accept: { [mime]: ['.' + ext] } }], }); } catch (e) { + if (!isCurrent(registration)) return; 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; } + if (!isCurrent(registration)) return; + // Now the awaits are safe — we already hold the file handle. await deps.ensureConfig(); - if (!(await deps.getToken())) { deps.ctx().onSignedOut(); return; } + if (!isCurrent(registration)) return; + if (!(await deps.getToken())) { + if (isCurrent(registration)) deps.ctx().onSignedOut(); + return; + } + if (!isCurrent(registration)) return; - exportQueryId = 'export-' + deps.uid(''); - exportAbort = new AbortController(); - const progress = deps.hooks.showExportProgress(cancelExport); + waveQueryId = 'export-' + deps.uid(''); + exportQueryId = waveQueryId; + progress = deps.hooks.showExportProgress(cancelExport); + if (!isCurrent(registration)) return; try { const resp = await deps.exportQuery(deps.ctx(), sql, { - queryId: exportQueryId, signal: exportAbort.signal, format, + queryId: waveQueryId, signal: controller.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 }, }); + if (!isCurrent(registration)) return; 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), + signal: controller.signal, tag, + onProgress: (bytes) => { if (isCurrent(registration)) progress?.update(bytes); }, }); + if (!isCurrent(registration)) return; if (err) deps.hooks.toast('Export incomplete — server error mid-stream: ' + err); else deps.hooks.toast('Export complete'); } catch (e) { @@ -344,16 +409,16 @@ export function createExportService(deps: ExportServiceDeps): ExportService { // 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') { + if (isCurrent(registration) && !(e instanceof Error && e.name === 'AbortError') && msg !== 'signed out') { deps.hooks.toast('Export failed: ' + msg); } } finally { - progress.remove(); - exportAbort = null; - exportQueryId = null; + progress?.remove(); + progress = null; } } finally { - deps.state.exporting.value = false; + registration?.release(); + settleExport(controller); } } @@ -371,6 +436,16 @@ export function createExportService(deps: ExportServiceDeps): ExportService { { signal, tag, onProgress }: { signal: AbortSignal; tag: string | null; onProgress: (bytes: number) => void }, ): Promise { const writable = await handle.createWritable(); + const retainPartial = async (): Promise => { + // Finalize whatever reached the browser's swap file, then label it as + // incomplete when the platform supports in-place move. + await writable.close().catch(() => {}); + if (typeof handle.move === 'function') await handle.move(handle.name + '.partial').catch(() => {}); + }; + if (signal.aborted) { + await retainPartial(); + throw new DOMException('aborted', 'AbortError'); + } const HOLDBACK = 32 * 1024; // >= ClickHouse's MAX_EXCEPTION_SIZE (16 KiB) + margin const reader = resp.body!.getReader(); let held = new Uint8Array(0); @@ -394,6 +469,7 @@ export function createExportService(deps: ExportServiceDeps): ExportService { // 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 (signal.aborted) throw new DOMException('aborted', 'AbortError'); if (clean.length) { await writable.write(clean); written += clean.length; @@ -412,8 +488,7 @@ export function createExportService(deps: ExportServiceDeps): ExportService { // 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(() => {}); + await retainPartial(); throw e; } finally { reader.releaseLock(); @@ -449,23 +524,42 @@ export function createExportService(deps: ExportServiceDeps): ExportService { // 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. + const wave = ++nextScriptWave; + activeScriptWave = wave; + exportScriptCancelled = false; deps.state.exporting.value = true; + const registration = deps.executionScope()?.register({ + name: 'script export', + abort: () => { + exportScriptCancelled = true; + exportScriptAbort?.abort(); + settleScriptExport(wave); + }, + getQueryId: () => activeScriptWave === wave ? exportScriptQueryId : null, + }) || null; try { + if (!isCurrent(registration) || activeScriptWave !== wave) return; let dir: DirectoryHandleLike; try { dir = await deps.sink.pickDirectory({ mode: 'readwrite' }); } catch (e) { + if (!isCurrent(registration) || activeScriptWave !== wave) return; 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; } + if (!isCurrent(registration) || activeScriptWave !== wave) return; await deps.ensureConfig(); - if (!(await deps.getToken())) { deps.ctx().onSignedOut(); return; } - await exportScript(statements, dir, paramSrc); + if (!isCurrent(registration) || activeScriptWave !== wave) return; + if (!(await deps.getToken())) { + if (isCurrent(registration) && activeScriptWave === wave) deps.ctx().onSignedOut(); + return; + } + if (!isCurrent(registration) || activeScriptWave !== wave) return; + await exportScript(statements, dir, paramSrc, registration, wave); } 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; + registration?.release(); + settleScriptExport(wave); } } @@ -479,7 +573,12 @@ export function createExportService(deps: ExportServiceDeps): ExportService { // (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 { + async function exportScript( + statements: string[], dir: DirectoryHandleLike, paramSrc: PreparedSource, + registration: AuthenticatedExecutionRegistration | null, wave: number, + ): Promise { + const current = () => isCurrent(registration) && activeScriptWave === wave; + if (!current()) return; const tab = deps.activeTab(); const t0 = deps.now(); const sp = deps.sessionParamsFor(tab, statements); @@ -492,16 +591,15 @@ export function createExportService(deps: ExportServiceDeps): ExportService { 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); + exportScriptTick = setInterval(() => { if (current()) deps.hooks.renderResults(); }, 200); deps.hooks.renderResults(); for (const e of entries) { + if (!current()) return; 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. @@ -521,6 +619,7 @@ export function createExportService(deps: ExportServiceDeps): ExportService { if (e.type !== 'rows') { const out = await deps.runQuery(deps.ctx(), execStmt, { format: 'TSV', signal, queryId: exportScriptQueryId, params }); + if (!current()) return; if (out.error != null) throw new Error(out.error); e.status = 'ok'; } else { @@ -529,11 +628,14 @@ export function createExportService(deps: ExportServiceDeps): ExportService { taken.add(name); e.file = name; const fileHandle = await dir.getFileHandle(name, { create: true }); + if (!current()) return; const resp = await deps.exportQuery(deps.ctx(), sql, { queryId: exportScriptQueryId, signal, format, params }); + if (!current()) return; const tag = resp.headers.get('X-ClickHouse-Exception-Tag'); const midErr = await streamToFile(resp, fileHandle, - { signal, tag, onProgress: (b) => { e.bytes = b; } }); + { signal, tag, onProgress: (b) => { if (current()) e.bytes = b; } }); + if (!current()) return; if (midErr) { e.status = 'failed'; e.error = 'File may be incomplete; server failed after streaming started. ' + midErr; @@ -545,23 +647,25 @@ export function createExportService(deps: ExportServiceDeps): ExportService { e.ms = deps.now() - e.startedAt!; deps.hooks.renderResults(); } catch (ex) { + if (!current()) return; 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 } } + if (!current()) return; 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(); + // Do not `return` from this finally: a stale scope skips publication, but + // it must not swallow an unexpected exception from owner code. + if (current()) { + 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(); + } } } diff --git a/src/application/query-execution-service.ts b/src/application/query-execution-service.ts index feb4845d..f3710638 100644 --- a/src/application/query-execution-service.ts +++ b/src/application/query-execution-service.ts @@ -68,6 +68,9 @@ export interface ExecuteReadRequest { params?: Record; signal?: AbortSignal; queryId?: string; + /** Epoch fence owned by the caller. When it turns false, this service must + * neither acquire a newer live auth context nor publish more stream data. */ + isCurrent?: () => boolean; /** Per-read repaint hook — called with no arguments on each streamed chunk * (the workbench repaints its pane; a tile/detached view repaints its own * surface). Absent entirely when the caller passes none — no wrapper @@ -102,6 +105,9 @@ export interface ScriptStatement { export interface ScriptExecutionRequest { statements: ScriptStatement[]; signal?: AbortSignal; + /** Prevents a stale script (especially its delayed retry) from drifting + * into a replacement authenticated context. */ + isCurrent?: () => boolean; onStatementStart: (index: number, info: { queryId: string; attempt: 1 | 2 }) => void; onStatementResult: (index: number, entry: ScriptEntry) => void; } @@ -141,7 +147,12 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec // Cancel → { aborted }; a connection-level fetch failure → { error:'Network // error', transient } (retryable); any other throw → { error }. Otherwise the // runQuery result itself ({ raw } | { error }). - async function attemptStatement(stmt: string, opts: RunQueryOptions): Promise { + async function attemptStatement( + stmt: string, + opts: RunQueryOptions, + isCurrent: () => boolean, + ): Promise { + if (!isCurrent()) return { aborted: true }; try { return await deps.runQuery(deps.ctx(), stmt, opts); } catch (e) { @@ -163,9 +174,10 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec async function executeRead( result: StreamResult, { - sql, format = 'Table', rowLimit = 0, params, signal, queryId, onChunk, + sql, format = 'Table', rowLimit = 0, params, signal, queryId, isCurrent = () => true, onChunk, }: ExecuteReadRequest, ): Promise { + if (!isCurrent()) return result; try { const out = await deps.runQuery(deps.ctx(), sql, { format, @@ -173,15 +185,21 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec queryId, signal, params, - onLine: (json) => applyStreamLine(json, result), - onChunk, + onLine: (json) => { + if (isCurrent()) applyStreamLine(json, result); + }, + onChunk: onChunk + ? () => { if (isCurrent()) onChunk(); } + : undefined, }); + if (!isCurrent()) return result; if (out.error != null) result.error = out.error; else if (out.raw != null) { result.rawText = out.raw; result.progress.bytes = out.raw.length; } } catch (e) { + if (!isCurrent()) return result; // Cancel = abort: keep whatever streamed in, flag it partial (no error). if (e instanceof Error && e.name === 'AbortError') result.cancelled = true; else if (e instanceof TypeError) result.error = 'Network error'; @@ -196,10 +214,14 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec // (SELECT/WITH/SHOW/…) are fetched as JSONCompact capped at // SELECT_ROW_CAP; everything else runs for effect and reports OK. async function executeScript(req: ScriptExecutionRequest): Promise { - const { statements, signal, onStatementStart, onStatementResult } = req; + const { + statements, signal, onStatementStart, onStatementResult, + isCurrent = () => true, + } = req; const entries: ScriptEntry[] = []; let aborted = false; for (let i = 0; i < statements.length; i++) { + if (!isCurrent()) { aborted = true; break; } const stmt = statements[i]; const rowReturning = isRowReturning(stmt.sql); // Over-fetch SELECTs by one past the display cap so a truncated result is @@ -213,8 +235,10 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec // Fresh query_id per attempt, published before the request so Cancel // issues KILL QUERY against the statement that's actually running. let queryId = deps.uid('q'); + if (!isCurrent()) { aborted = true; break; } onStatementStart(i, { queryId, attempt: 1 }); - let out = await attemptStatement(stmt.execSql, { ...opts, queryId }); + let out = await attemptStatement(stmt.execSql, { ...opts, queryId }, isCurrent); + if (!isCurrent()) { aborted = true; break; } // Retry ONLY when it's safe. SESSION_IS_LOCKED means the statement was // rejected before running → safe to retry (any statement). A connection // reset (fetch TypeError → "Network error") leaves it UNKNOWN whether the @@ -223,9 +247,11 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec const locked = out.error != null && SESSION_BUSY.test(out.error); if (!out.aborted && (locked || (out.transient && rowReturning))) { await deps.sleep(deps.retryMs); + if (!isCurrent()) { aborted = true; break; } queryId = deps.uid('q'); onStatementStart(i, { queryId, attempt: 2 }); - out = await attemptStatement(stmt.execSql, { ...opts, queryId }); + out = await attemptStatement(stmt.execSql, { ...opts, queryId }, isCurrent); + if (!isCurrent()) { aborted = true; break; } } if (out.aborted) { aborted = true; break; } // A connection reset on a non-idempotent statement: don't silently retry — @@ -236,7 +262,7 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec if (out.error != null) { entry = { sql: stmt.sql, status: 'error', error: out.error, ms }; entries.push(entry); - onStatementResult(i, entry); + if (isCurrent()) onStatementResult(i, entry); break; // stop-on-first-failure: skip the remaining statements } if (rowReturning) { @@ -248,7 +274,7 @@ export function createQueryExecutionService(deps: QueryExecutionDeps): QueryExec entry = { sql: stmt.sql, status: 'ok', ms }; } entries.push(entry); - onStatementResult(i, entry); + if (isCurrent()) onStatementResult(i, entry); } return { entries, aborted }; } diff --git a/src/application/schema-catalog-service.ts b/src/application/schema-catalog-service.ts index 84c01ddd..8b08848c 100644 --- a/src/application/schema-catalog-service.ts +++ b/src/application/schema-catalog-service.ts @@ -241,7 +241,7 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal // // Connection-scoped state, reset by `resetDocsState()` (called from both // `invalidate()` and the top of `loadReferenceImpl` — see those functions): - // - `docGeneration` — bumped on every reset; a lookup captures the + // - `docGeneration` — bumped on every documentation reset; a lookup captures the // generation it started under and, if that generation has moved on by // the time its async result settles, drops the result silently: no // cache write, no map entry left behind, and the caller gets @@ -278,6 +278,17 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal // cached under BOTH the requested key and the normalized `kind:name` key, // so a later lookup under either kind is served from cache without a // second fetch. + // `connectionGeneration` fences every non-documentation server result when + // invalidate() retires a connection. Documentation additionally keeps its + // existing `docGeneration`: loadReference() is a documentation/reference + // refresh that must retire doc probes/lookups, but it runs alongside the + // normal schema load during boot and must not retire that schema request. + let connectionGeneration = 0; + // Every metadata/reference/documentation transport for one authenticated + // connection shares this signal. Generation checks still fence state writes, + // while invalidation aborts the actual requests synchronously so a closed + // authenticated scope does not leave catalog work running in the background. + let connectionAbort = new AbortController(); let docGeneration = 0; let capability: FunctionsDocCapability | null = null; let capabilityProbe: Promise | null = null; @@ -299,6 +310,7 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal let docsCapability: DocumentationCapability | null = null; let docsCapabilityProbe: Promise | null = null; const documentationCache = new Map | Promise>>(); + const disambiguateInFlight = new Map>>(); function resetDocsState(): void { docGeneration++; @@ -310,15 +322,18 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal docsCapability = null; docsCapabilityProbe = null; documentationCache.clear(); + disambiguateInFlight.clear(); } function ensureCapability(): Promise { if (capability) return Promise.resolve(capability); if (capabilityProbe) return capabilityProbe; // dedupe concurrent probes const gen = docGeneration; + const signal = connectionAbort.signal; const probe = (async (): Promise => { await deps.ensureConfig(); - const cols = await deps.loadFunctionsDocColumns(deps.ctx()); + if (gen !== docGeneration) return null; + const cols = await deps.loadFunctionsDocColumns(deps.ctx(), signal); if (gen !== docGeneration) return null; // superseded by invalidate/reconnect — never write shared capability state capabilityProbe = null; // settle: clears the in-flight slot so a `null` result below lets the next lookup batch retry if (cols === null) return null; // transient/denied probe failure — NOT cached into `capability` @@ -339,9 +354,11 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal const inflight = structuredCapabilityProbe.get(kind); if (inflight) return inflight; // dedupe concurrent probes for the SAME kind const gen = docGeneration; + const signal = connectionAbort.signal; const probe = (async (): Promise => { await deps.ensureConfig(); - const cols = await deps.loadDocTableColumns(deps.ctx(), STRUCTURED_PROBE_TABLE[kind]); + if (gen !== docGeneration) return null; + const cols = await deps.loadDocTableColumns(deps.ctx(), STRUCTURED_PROBE_TABLE[kind], signal); if (gen !== docGeneration) return null; // superseded by invalidate/reconnect — never write shared capability state structuredCapabilityProbe.set(kind, null); // settle: clears the in-flight slot so a `null` result lets the next batch retry if (cols === null) return null; // transient/denied probe failure — NOT cached @@ -380,9 +397,11 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal return Promise.resolve(null); // unavailable NOW, no query — re-evaluated on the next lookup } const gen = docGeneration; + const signal = connectionAbort.signal; const probe = (async (): Promise => { await deps.ensureConfig(); - const cols = await deps.loadDocTableColumns(deps.ctx(), 'documentation'); + if (gen !== docGeneration) return null; + const cols = await deps.loadDocTableColumns(deps.ctx(), 'documentation', signal); if (gen !== docGeneration) return null; // superseded by invalidate/reconnect — never write shared capability state docsCapabilityProbe = null; // settle: clears the in-flight slot so a `null` result below lets the next lookup batch retry if (cols === null) return null; // transient/denied probe failure — NOT cached into `docsCapability` @@ -403,8 +422,10 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal if (!cap.available) return { lookup: { status: 'unavailable' }, cacheable: true }; // durably-confirmed absent/denied capability // `cap.available` guarantees `buildFunctionDocSelect` returns a SELECT, not null. const sql = buildFunctionDocSelect(cap, target.name, deps.sqlString)!; + const signal = connectionAbort.signal; await deps.ensureConfig(); - const rows = await deps.loadFunctionDocRow(deps.ctx(), sql); + if (gen !== docGeneration) return { lookup: { status: 'unavailable' }, cacheable: false }; + const rows = await deps.loadFunctionDocRow(deps.ctx(), sql, signal); if (gen !== docGeneration) return { lookup: { status: 'unavailable' }, cacheable: false }; if (rows === null) return { lookup: { status: 'unavailable' }, cacheable: false }; // transient row-fetch failure — not cached if (rows.length === 0) return { lookup: { status: 'missing' }, cacheable: true }; @@ -431,8 +452,10 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal if (!cap.available) return { lookup: { status: 'unavailable' }, cacheable: true }; // durably-confirmed absent/denied capability // `cap.available` guarantees `buildStructuredDocSelect` returns a SELECT, not null. const sql = buildStructuredDocSelect(kind, cap, target.name, deps.sqlString)!; + const signal = connectionAbort.signal; await deps.ensureConfig(); - const rows = await deps.loadDocRow(deps.ctx(), sql); + if (gen !== docGeneration) return { lookup: { status: 'unavailable' }, cacheable: false }; + const rows = await deps.loadDocRow(deps.ctx(), sql, signal); if (gen !== docGeneration) return { lookup: { status: 'unavailable' }, cacheable: false }; if (rows === null) return { lookup: { status: 'unavailable' }, cacheable: false }; // transient row-fetch failure — not cached if (rows.length === 0) return { lookup: { status: 'missing' }, cacheable: true }; @@ -458,8 +481,10 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal if (!cap.available) return { lookup: { status: 'unavailable' }, cacheable: true }; // durably-confirmed absent/denied/pre-26.6 const sql = buildDocumentationSelect(cap, target.kind, target.name, deps.sqlString); if (sql === null) return { lookup: { status: 'missing' }, cacheable: true }; // no server `type` label for this kind + const signal = connectionAbort.signal; await deps.ensureConfig(); - const rows = await deps.loadDocRow(deps.ctx(), sql); + if (gen !== docGeneration) return { lookup: { status: 'unavailable' }, cacheable: false }; + const rows = await deps.loadDocRow(deps.ctx(), sql, signal); if (gen !== docGeneration) return { lookup: { status: 'unavailable' }, cacheable: false }; if (rows === null) return { lookup: { status: 'unavailable' }, cacheable: false }; // transient row-fetch failure — not cached if (rows.length === 0) return { lookup: { status: 'missing' }, cacheable: true }; @@ -525,6 +550,12 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal entryCache.delete(key); // transient/stale — no durable entry, next call retries } return result.lookup; + }).catch(() => { + // Editor callbacks retain this promise; network cancellation (and any + // other transient loader failure) must stay best-effort, never become + // an unhandled/rejected hover or reference callback. + if (entryCache.get(key) === promise) entryCache.delete(key); + return { status: 'unavailable' }; }); entryCache.set(key, promise); // dedupe concurrent lookups of the same key return promise; @@ -600,6 +631,9 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal documentationCache.delete(key); } return result.lookup; + }).catch(() => { + if (documentationCache.get(key) === promise) documentationCache.delete(key); + return { status: 'unavailable' }; }); documentationCache.set(key, promise); return promise; @@ -611,8 +645,6 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal // settle) but is not itself a durable cache — disambiguation results are // cheap/rare enough (a user explicitly asking "what else is named X") that // there is no lasting cache to invalidate. - const disambiguateInFlight = new Map>>(); - async function resolveDisambiguate(name: string, gen: number): Promise> { const cap = await ensureDocumentationCapability(); if (gen !== docGeneration) return { status: 'unavailable' }; @@ -620,8 +652,10 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal if (!cap.available) return { status: 'unavailable' }; // durably-confirmed absent/denied/pre-26.6 // `cap.available` guarantees `buildDocumentationNameSelect` returns a SELECT, not null. const sql = buildDocumentationNameSelect(cap, name, deps.sqlString)!; + const signal = connectionAbort.signal; await deps.ensureConfig(); - const rows = await deps.loadDocRow(deps.ctx(), sql); + if (gen !== docGeneration) return { status: 'unavailable' }; + const rows = await deps.loadDocRow(deps.ctx(), sql, signal); if (gen !== docGeneration) return { status: 'unavailable' }; if (rows === null) return { status: 'unavailable' }; // transient row-fetch failure if (rows.length === 0) return { status: 'missing' }; @@ -637,30 +671,43 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal if (inflight) return inflight; const gen = docGeneration; const promise = resolveDisambiguate(name, gen).then((result) => { - disambiguateInFlight.delete(name); + if (disambiguateInFlight.get(name) === promise) disambiguateInFlight.delete(name); return result; + }).catch(() => { + if (disambiguateInFlight.get(name) === promise) disambiguateInFlight.delete(name); + return { status: 'unavailable' } as DocLookup; }); disambiguateInFlight.set(name, promise); return promise; } async function loadVersion(): Promise { + const gen = connectionGeneration; + const signal = connectionAbort.signal; try { await deps.ensureConfig(); - state.serverVersion = await deps.loadServerVersion(deps.ctx()); + if (gen !== connectionGeneration) return; + const version = await deps.loadServerVersion(deps.ctx(), signal); + if (gen !== connectionGeneration) return; + state.serverVersion = version; hooks.onServerVersionLoaded?.(state.serverVersion); } catch { /* Best-effort metadata; ch-client reports transport lifecycle. */ } } async function loadSchemaImpl(): Promise { + const gen = connectionGeneration; + const signal = connectionAbort.signal; try { await deps.ensureConfig(); - const schema = await deps.loadSchema(deps.ctx()); + if (gen !== connectionGeneration) return; + const schema = await deps.loadSchema(deps.ctx(), signal); + if (gen !== connectionGeneration) return; // One batched write → one repaint (app.ts's schema effect + banner // effect react to these signals; no manual renderSchema/updateBanner // needed here). batch(() => { state.schema.value = schema; state.schemaError.value = null; }); } catch (e) { + if (gen !== connectionGeneration) return; state.schemaError.value = String((e instanceof Error && e.message) || e); } rebuildCompletions(); @@ -672,6 +719,8 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal // paints the spinner immediately; the result/[] write repaints with the data. // `tb.columns` stays the completion cache that buildCompletions reads. async function loadColumnsImpl(db: string, table: string): Promise { + const gen = connectionGeneration; + const signal = connectionAbort.signal; const setCols = (cols: SchemaColumn[] | 'loading'): void => { // `.value` is asserted non-null (matches the original untyped behavior // verbatim: a null schema here throws, exactly as `null.map(...)` always @@ -685,8 +734,12 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal setCols('loading'); try { await deps.ensureConfig(); - setCols(await deps.loadColumns(deps.ctx(), db, table, deps.sqlString)); + if (gen !== connectionGeneration) return; + const cols = await deps.loadColumns(deps.ctx(), db, table, deps.sqlString, signal); + if (gen !== connectionGeneration) return; + setCols(cols); } catch { + if (gen !== connectionGeneration) return; setCols([]); } rebuildCompletions(); // newly-loaded columns become completion candidates (#26) @@ -708,16 +761,40 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal // flight against the OLD connection drops its result instead of // repopulating the cache for the new one. resetDocsState(); - await deps.ensureConfig(); - refData = assembleReferenceData(await deps.loadReferenceData(deps.ctx())); - rebuildCompletions(); - hooks.refreshEditorReference(); // re-highlight with server keywords + const gen = connectionGeneration; + const signal = connectionAbort.signal; + try { + await deps.ensureConfig(); + if (gen !== connectionGeneration) return; + const reference = await deps.loadReferenceData(deps.ctx(), signal); + if (gen !== connectionGeneration) return; + refData = assembleReferenceData(reference); + rebuildCompletions(); + hooks.refreshEditorReference(); // re-highlight with server keywords + } catch (error) { + // invalidate() deliberately aborts this best-effort background load. + // Preserve the pre-existing contract for unrelated programming errors. + if (gen !== connectionGeneration + && signal.aborted + && (error as { name?: unknown } | null)?.name === 'AbortError') return; + throw error; + } } function invalidate(): void { + // Abort before advancing the generations, so callers observing the close + // synchronously see every in-flight network signal become aborted. + connectionAbort.abort(); + connectionGeneration++; + connectionAbort = new AbortController(); resetDocsState(); refData = assembleReferenceData(null); - rebuildCompletions(); + batch(() => { + state.serverVersion = null; + state.schema.value = null; + state.schemaError.value = null; + rebuildCompletions(); + }); } return { diff --git a/src/application/schema-graph-session.ts b/src/application/schema-graph-session.ts index 23efec14..981622b1 100644 --- a/src/application/schema-graph-session.ts +++ b/src/application/schema-graph-session.ts @@ -34,6 +34,10 @@ import type { CardGraphNode, CardGraphEdge, CardModel, SchemaCardColumnRow } fro import type { PositionMap } from '../core/graph-layout.js'; import { newResult } from '../core/stream.js'; import type { StreamResult } from '../core/stream.js'; +import type { + AuthenticatedExecutionRegistration, + AuthenticatedExecutionScope, +} from './authenticated-execution-scope.js'; // ── Shapes redeclared locally rather than imported across the // application→ui boundary (rule 1) ────────────────────────────────────────── @@ -123,6 +127,9 @@ export interface SchemaGraphHooks { export interface SchemaGraphDeps { ensureConfig(): Promise; getToken(): Promise; + /** The disposable authenticated execution scope. A graph session survives + * authentication loss; its individual requests do not. */ + executionScope(): AuthenticatedExecutionScope | null; /** The live ClickHouse auth context — a *provider*, not a value (matches * `schema-catalog-service.ts`'s own `ctx` seam). */ ctx(): ChCtx; @@ -145,11 +152,14 @@ export interface SchemaGraphSession { * (marked `partial`) if one had already drawn, else drops back to the * empty placeholder. */ cancel(opts?: { clearResult?: boolean }): void; + /** Suspend authenticated graph work while retaining the visible graph/tab + * shell for an in-place sign-in resume. */ + suspend(): 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; + 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 @@ -168,6 +178,8 @@ export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSess // state lives in its owner" shape as WorkbenchSession's own // `abortController`. let abortController: AbortController | null = null; + let showRegistration: AuthenticatedExecutionRegistration | null = null; + let activeShow: { tab: SchemaGraphTab; result: SchemaGraphResult } | 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 @@ -175,9 +187,24 @@ export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSess // resolves. const latestDetailRequest = new WeakMap(); + function settleSuspendedShow(tab: SchemaGraphTab, result: SchemaGraphResult): void { + // Authentication loss must never tear down the tab/result shell. Unlike a + // user pressing Cancel, keep even the empty loading placeholder so resume + // can continue from the same document without a destructive UI reset. + if (tab.result !== (result as unknown)) return; + const sg = result.schemaGraph; + if (!sg || !sg.loading) return; + sg.loading = false; + sg.partial = true; + deps.hooks.renderResults(); + } + function cancel({ clearResult = false }: { clearResult?: boolean } = {}): void { if (abortController) abortController.abort(); abortController = null; + showRegistration?.release(); + showRegistration = null; + activeShow = null; if (!clearResult) return; const tab = deps.activeTab(); const result = tab.result as SchemaGraphResult | null; @@ -192,62 +219,97 @@ export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSess deps.hooks.renderResults(); } + function suspend(): void { + const active = activeShow; + if (abortController) abortController.abort(); + abortController = null; + showRegistration?.release(); + showRegistration = null; + activeShow = null; + if (active) settleSuspendedShow(active.tab, active.result); + } + 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; + let tab: SchemaGraphTab | null = null; + let result: SchemaGraphResult | null = null; + const scope = deps.executionScope(); + const registration = scope?.register({ + name: 'schema graph inline load', + abort: () => { + controller.abort(); + if (tab && result) settleSuspendedShow(tab, result); + }, + }) || null; + const current = (): boolean => !controller.signal.aborted && (registration === null || registration.isCurrent()); 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 }); + await deps.ensureConfig(); + if (!current()) return; + if (!(await deps.getToken())) { + if (current()) deps.hooks.onAuthFailed(); + return; + } + if (!current()) return; + cancel(); // a new click/drag replaces whatever graph was in flight + tab = deps.activeTab(); + // Show a loading placeholder first — even Phase A (system.tables + + // system.dictionaries) is a network round trip. + result = newResult('Table'); + result.schemaGraph = { focus, loading: true, nodes: [], edges: [] }; + Object.assign(tab, { result }); + activeShow = { 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 => !current() || tab!.result !== (result as unknown); + deps.hooks.renderResults(); + abortController = controller; + showRegistration = registration; + 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 { + const renderCurrent = !superseded(); + if (abortController === controller) abortController = null; + if (showRegistration === registration) showRegistration = null; + if (activeShow?.result === result) activeShow = null; + if (renderCurrent) deps.hooks.renderResults(); + } } finally { - if (abortController === controller) abortController = null; + registration?.release(); } - deps.hooks.renderResults(); } // `ch.CardColumnRow` (the real loader shape) has no index signature; @@ -261,7 +323,7 @@ export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSess return out; }; - async function expand(focus: SchemaGraphFocus): Promise { + 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 @@ -269,44 +331,80 @@ export function createSchemaGraphSession(deps: SchemaGraphDeps): SchemaGraphSess 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.'); + const controller = new AbortController(); + const scope = deps.executionScope(); + const registration = scope?.register({ + name: 'schema graph expand', + abort: () => controller.abort(), + }) || null; + const current = (): boolean => !controller.signal.aborted && (registration === null || registration.isCurrent()); + try { + await deps.ensureConfig(); + if (!current()) return null; + const token = await deps.getToken(); + if (!current()) return null; + if (!token) { + 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, { + signal: controller.signal, + }); + if (!current()) return null; + 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, controller.signal); + if (!current()) return null; + 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 && current()) sg.savedPositions = positions; + return { + nodes: cardGraph.nodes, + edges: cardGraph.edges, + focus, + truncated: lineage.truncated || ex.truncated, + savedPositions: positions, + }; + } finally { + registration?.release(); } - // 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; + const controller = new AbortController(); + const scope = deps.executionScope(); + const registration = scope?.register({ + name: 'schema graph node detail', + abort: () => controller.abort(), + }) || null; + const current = (): boolean => !controller.signal.aborted && (registration === null || registration.isCurrent()); 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; + try { + await deps.ensureConfig(); + if (!current()) return null; + if (!(await deps.getToken())) { + if (current()) deps.hooks.onAuthFailed(); + return null; + } + if (!current()) return null; + const detail = await deps.loadTableDetail(deps.ctx(), node.db, node.name, controller.signal); + if (!current() || latestDetailRequest.get(token) !== node) return null; // superseded by a later click + return detail; + } finally { + registration?.release(); + } } - return { show, cancel, expand, loadNodeDetail }; + return { show, cancel, suspend, expand, loadNodeDetail }; } diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 3d708733..221e2490 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -249,6 +249,9 @@ export interface ViewerReadRequest { rowLimit?: number; params?: Record; signal?: AbortSignal; + /** A caller-owned, unique server query id. The execution scope uses this + * value to cancel exactly the request that was live when auth was lost. */ + queryId?: string; onChunk?: () => void; } export interface ViewerExecutor { @@ -263,6 +266,28 @@ export interface ViewerConnection { ensureFreshToken(): Promise; } +/** + * Structural projection of `AuthenticatedExecutionScope`. + * + * This package is deliberately below `src/application/**` in the dependency + * graph, so importing the concrete coordinator would violate the Dashboard + * boundary. Keeping the small capability structural lets the UI pass the + * real scope without making this runtime depend on the app shell. + */ +export interface ViewerExecutionRegistration { + release(): void; + isCurrent(): boolean; +} + +export interface ViewerExecutionScope { + isOpen(): boolean; + register(operation: { + name: string; + abort(): void; + getQueryId?(): string | null | undefined; + }): ViewerExecutionRegistration; +} + export interface DashboardViewerDeps { /** The immutable Dashboard snapshot this session views. */ document: DashboardDocumentV2; @@ -272,6 +297,11 @@ export interface DashboardViewerDeps { queries: readonly SavedQueryV2[]; exec: ViewerExecutor; connection: ViewerConnection; + /** The disposable authenticated execution epoch. When supplied, a missing + * scope means the document remains viewable but no server work may begin. */ + executionScope?(): ViewerExecutionScope | null; + /** Creates a unique ClickHouse query id for each HTTP request. */ + mintQueryId?(): string; /** Resolves the active layout plugin + fallback. Defaults to none — the * viewer computes the flow model directly from the document either way; the * registry is used only to fail closed when the layout cannot load. */ @@ -812,6 +842,64 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // can yield — exactly like a tile's generation. Without that ordering a // superseded wave could still be the last one to publish its rows. let optionsGen = 0; + let optionAbortController: AbortController | null = null; + + /** A registration retained for one public execution entry point. It is + * created before token preflight so an auth-loss arriving while refresh is + * pending can synchronously make its continuation inert. */ + interface ScopeRun { + isCurrent(): boolean; + release(): void; + scope: ViewerExecutionScope | null; + } + + function settleAuthenticatedWork(): void { + // Preserve each tile's last committed result. Only the transient loading + // presentation is settled; a resumed scope can refresh the same mounted + // Dashboard session without a route rebuild. + optionsGen++; + optionAbortController?.abort(); + optionAbortController = null; + for (const runtime of tiles) { + runtime.gen++; + runtime.abortController?.abort(); + runtime.abortController = null; + if (runtime.state.status === 'loading') { + // A refresh paints over an already-committed result only transiently. + // Authentication loss restores that committed presentation instead of + // replacing it with the viewer's generic idle/loading placeholder. + runtime.state.status = runtime.state.rows === null ? 'idle' : 'ready'; + runtime.state.progressRows = 0; + } + } + for (const variable of variableRuntimes) { + if (variable.state.status !== 'loading') continue; + variable.state.status = variable.state.options === null ? 'idle' : 'ready'; + } + publish(false); + } + + function beginScopeRun(name: string): ScopeRun | null { + const scope = deps.executionScope?.(); + // Omitted is the backwards-compatible/testing seam; supplied-but-null is + // the real suspended-auth state and must never reach token preflight. + if (deps.executionScope && (!scope || !scope.isOpen())) return null; + if (!scope) return { isCurrent: () => !destroyed, release: () => {}, scope: null }; + const registration = scope.register({ name, abort: settleAuthenticatedWork }); + return { + isCurrent: () => !destroyed && registration.isCurrent(), + release: () => registration.release(), + scope, + }; + } + + function beginRequest( + run: ScopeRun, name: string, controller: AbortController, queryId: string, + ): ViewerExecutionRegistration | null { + if (!run.isCurrent()) return null; + if (!run.scope) return null; + return run.scope.register({ name, abort: () => controller.abort(), getQueryId: () => queryId }); + } // `unknown`, not `string`: a multi-select variable's committed value is a real // `string[]` and must reach `serializeParamValue` as one — stringifying it here @@ -934,18 +1022,20 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa }; } - async function runTile(runtime: TileRuntime, source: PreparedSource, generation: number): Promise { - if (runtime.gen !== generation) return; + async function runTile( + runtime: TileRuntime, source: PreparedSource, generation: number, run: ScopeRun, + ): Promise { + if (!run.isCurrent() || runtime.gen !== generation) return; if (source.missing.length || source.invalid.length) { runtime.state.status = 'unfilled'; runtime.state.unfilled = source.missing.concat(source.invalid); - publish(); + if (run.isCurrent()) publish(); return; } if (source.errors.length) { runtime.state.status = 'error'; runtime.state.error = source.errors[0]; - publish(); + if (run.isCurrent()) publish(); return; } // `!`: runtime.query is present for every runnable (non-error) tile. @@ -960,43 +1050,58 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa runtime.state.status = 'error'; runtime.state.error = execution.error || 'Dashboard panels require structured streaming results. Remove the explicit FORMAT clause.'; - publish(); + if (run.isCurrent()) publish(); return; } runtime.state.status = 'loading'; runtime.state.progressRows = 0; + if (!run.isCurrent()) return; publish(); const controller = new AbortController(); runtime.abortController = controller; + const queryId = deps.mintQueryId?.(); + const requestRegistration = queryId === undefined + ? null + : beginRequest(run, `dashboard tile ${runtime.tile.id}`, controller, queryId); + if (!run.isCurrent() || (requestRegistration !== null && !requestRegistration.isCurrent())) { + controller.abort(); + if (runtime.gen === generation && runtime.state.status === 'loading') runtime.state.status = 'idle'; + requestRegistration?.release(); + return; + } const startedAt = deps.now(); const rowCap = runtime.isKpi ? 2 : DASH_TILE_ROW_CAP; // `!`: panelExecution always resolves a concrete format ('Table' default or 'KPI'). const result = newResult(execution.format!, rowCap); - await deps.exec.executeRead(result, { - sql: execSql, format: execution.format, rowLimit: execution.rowLimit, - params: execution.params, signal: controller.signal, - onChunk: () => { - if (runtime.gen !== generation) return; - runtime.state.progressRows = result.progress.rows; + try { + await deps.exec.executeRead(result, { + sql: execSql, format: execution.format, rowLimit: execution.rowLimit, + params: execution.params, signal: controller.signal, queryId, + onChunk: () => { + if (!run.isCurrent() || runtime.gen !== generation) return; + runtime.state.progressRows = result.progress.rows; + publish(); + }, + }); + if (!run.isCurrent() || runtime.gen !== generation) return; // superseded mid-stream + runtime.abortController = null; + if (result.error != null || result.cancelled) { + runtime.state.status = 'error'; + runtime.state.error = result.error || 'Cancelled'; publish(); - }, - }); - if (runtime.gen !== generation) return; // superseded mid-stream - runtime.abortController = null; - if (result.error != null || result.cancelled) { - runtime.state.status = 'error'; - runtime.state.error = result.error || 'Cancelled'; + return; + } + runtime.state.status = 'ready'; + runtime.state.error = null; + runtime.state.unfilled = []; + runtime.state.columns = result.columns as unknown as Column[]; + runtime.state.rows = result.rows; + runtime.state.meta = tileResultMeta(result, startedAt, deps.now()); + deps.recordBoundParams?.(source.statements.flatMap((statement) => statement.boundParams)); publish(); - return; + } finally { + requestRegistration?.release(); } - runtime.state.status = 'ready'; - runtime.state.error = null; - runtime.state.unfilled = []; - runtime.state.columns = result.columns as unknown as Column[]; - runtime.state.rows = result.rows; - runtime.state.meta = tileResultMeta(result, startedAt, deps.now()); - deps.recordBoundParams?.(source.statements.flatMap((statement) => statement.boundParams)); - publish(); } // ── The option batch ────────────────────────────────────────────────────── @@ -1058,16 +1163,33 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa * opening ONE variable in its own main-editor tab and running it there (#457) * is the diagnostic path. */ - async function runOptionBatch(generation: number): Promise { + async function runOptionBatch(generation: number, run: ScopeRun): Promise { if (optionBatch === null) return NO_RECONCILED; const result = newResult('Table', optionBatch.rowLimit); - await deps.exec.executeRead(result, { - sql: optionBatch.sql, - format: 'Table', - rowLimit: optionBatch.rowLimit, - params: { readonly: 2, max_result_bytes: VARIABLE_OPTION_BYTE_CAP }, - }); - if (optionsGen !== generation || destroyed) return NO_RECONCILED; // superseded + const controller = new AbortController(); + optionAbortController = controller; + const queryId = deps.mintQueryId?.(); + const requestRegistration = queryId === undefined + ? null + : beginRequest(run, 'dashboard variable options', controller, queryId); + if (!run.isCurrent() || (requestRegistration !== null && !requestRegistration.isCurrent())) { + controller.abort(); + requestRegistration?.release(); + return NO_RECONCILED; + } + try { + await deps.exec.executeRead(result, { + sql: optionBatch.sql, + format: 'Table', + rowLimit: optionBatch.rowLimit, + params: { readonly: 2, max_result_bytes: VARIABLE_OPTION_BYTE_CAP }, + signal: controller.signal, queryId, + }); + } finally { + if (optionAbortController === controller) optionAbortController = null; + requestRegistration?.release(); + } + if (!run.isCurrent() || optionsGen !== generation || destroyed) return NO_RECONCILED; // superseded const failure = result.error != null || result.cancelled ? (result.error || 'Cancelled') : null; @@ -1159,9 +1281,8 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa * those tiles in `error` status is a `'failure'`: it still completes * (`updatedAt` advances via the caller's own `publish`, unblocking the next * refresh) but must never overwrite the last known-good time. Skipped - * entirely once destroyed — there is no UI left to reflect it. */ + * The caller's scope-current guard already excludes destroyed sessions. */ function recordRefreshOutcome(ranTiles: TileRuntime[], waveMs: number): void { - if (destroyed) return; const failed = ranTiles.some((runtime) => runtime.state.status === 'error'); lastRefreshOutcome = failed ? 'failure' : 'success'; if (!failed) lastSuccessWallMs = waveMs; @@ -1169,13 +1290,13 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // ── Waves ───────────────────────────────────────────────────────────────── - async function preflight(): Promise { - if (destroyed) return false; + async function preflight(run: ScopeRun): Promise { + if (!run.isCurrent()) return false; if (!(await deps.connection.ensureFreshToken())) { - if (!destroyed) deps.onAuthFailed?.(); + if (run.isCurrent()) deps.onAuthFailed?.(); return false; } - return !destroyed; + return run.isCurrent(); } function sourcesById(prepared: PreparedSource[]): Map { @@ -1183,7 +1304,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa } async function refresh(): Promise { - if (!(await preflight())) return; + const run = beginScopeRun('dashboard refresh'); + if (!run) return; + try { + if (!(await preflight(run))) return; // #335: ONE wall-clock snapshot for the WHOLE refresh — every tile in it // resolves its relative tokens against this single instant. const waveMs = deps.wallNow(); @@ -1209,10 +1333,11 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // every panel for a list nothing is waiting on. Both are inside the `running` // window, so the refresh control stays busy until the options have landed too. const [reconciled] = await Promise.all([ - runOptionBatch(optionsGeneration), + runOptionBatch(optionsGeneration, run), runPool(runnable, VIEWER_TILE_CONCURRENCY, - (runtime) => runTile(runtime, batch.get(runtime.tile.id)!, generations.get(runtime.tile.id)!)), + (runtime) => runTile(runtime, batch.get(runtime.tile.id)!, generations.get(runtime.tile.id)!, run)), ]); + if (!run.isCurrent()) return; // #437: classified from the TILES only. An options failure has its own // published diagnostic and must not overwrite the last known-good tile // timestamp — the panels did refresh successfully. @@ -1222,8 +1347,11 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // every reconciled name, because `commitAndRerun` reserves generations across // all of their targets before issuing a single `runAffectedWave` (the same // coalescing clear-all uses). Runs only after both halves above have settled. - if (reconciled.length && !destroyed) await commitAndRerun(reconciled); - publish(false, destroyed ? null : deps.now()); + if (reconciled.length && run.isCurrent()) await commitAndRerun(reconciled); + if (run.isCurrent()) publish(false, deps.now()); + } finally { + run.release(); + } } const start = refresh; @@ -1231,7 +1359,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa async function refreshTile(tileId: string): Promise { const runtime = tiles.find((entry) => entry.tile.id === tileId); if (!runtime || !runtime.query || runtime.isText || runtime.presentationError) return; - if (!(await preflight())) return; + const run = beginScopeRun(`dashboard tile ${tileId} refresh`); + if (!run) return; + try { + if (!(await preflight(run))) return; // A single-tile refresh is a wave of one: it must publish its snapshot // like every other wave, or the tile's re-resolved relative bounds drift // from the closed time-range trigger label until the next full wave. @@ -1239,7 +1370,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa waveWallNowMs = waveMs; const generation = supersede(runtime); const prepared = sourcesById(prepareBatch('execute', undefined, undefined, waveMs).sources); - await runTile(runtime, prepared.get(tileId)!, generation); + await runTile(runtime, prepared.get(tileId)!, generation, run); + } finally { + run.release(); + } } // Re-run only the tiles some committed variable feeds into. @@ -1263,7 +1397,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // `preflight()` is the destroyed guard on this path too — it returns false // once the session is torn down, so no generation is reserved and no // request is issued after `destroy()`. - if (!(await preflight())) { publish(); return; } + const run = beginScopeRun('dashboard variable refresh'); + if (!run) { publish(); return; } + try { + if (!(await preflight(run))) { if (run.isCurrent()) publish(); return; } // Consult each committed variable's RESOLVED targets (`targetsByParameter`, // built once at construction from `resolveVariableTargets`) rather than // blindly rerunning every tile that merely declares the name — the two must @@ -1277,7 +1414,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const prepared = sourcesById(prepareBatch('execute', undefined, undefined, waveMs).sources); publish(); await runPool(targets, VIEWER_TILE_CONCURRENCY, - (runtime) => runTile(runtime, prepared.get(runtime.tile.id)!, reservations.get(runtime.tile.id)!)); + (runtime) => runTile(runtime, prepared.get(runtime.tile.id)!, reservations.get(runtime.tile.id)!, run)); + } finally { + run.release(); + } } // After committing a value (these four are commit paths only — never @@ -1468,6 +1608,8 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // Supersede any in-flight options request too, so its response can never // publish onto a torn-down session. optionsGen++; + optionAbortController?.abort(); + optionAbortController = null; for (const runtime of tiles) { runtime.gen++; if (runtime.abortController) { diff --git a/src/main.ts b/src/main.ts index 0f41f205..5251678f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,6 +47,9 @@ export interface BootstrapApp { * main.ts's own `string | null` sentinel (`null` means "no callback * error"), so this contract states what's actually passed here. */ showLogin(msg?: string | null): void; + /** Create the disposable execution scope for the signed-in credential epoch + * before any shell/catalog operation is allowed to run. */ + resumeAuthenticatedExecution(): void; /** Resolve the explicit or last-used StoredWorkspaceV5 and project it onto * `app.state` before the first `renderApp()` — see * `App.loadWorkspaceOnBoot`'s own doc comment (app.types.ts). The real @@ -203,6 +206,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) await app.conn.ensureConfig(); + app.resumeAuthenticatedExecution(); await app.loadWorkspaceOnBoot(); app.renderCurrentSurface(); void app.catalog.loadVersion(); diff --git a/src/net/ch-client.ts b/src/net/ch-client.ts index 58688e23..08add363 100644 --- a/src/net/ch-client.ts +++ b/src/net/ch-client.ts @@ -42,6 +42,17 @@ export interface ChCtx { onTransportOffline?: (error?: unknown) => void; } +/** Immutable authority retained only long enough to cancel work owned by a + * closing authenticated execution scope. `authorization` is already complete + * (scheme + credential): cleanup must never consult mutable auth mode, refresh, + * token storage, or normal auth-loss callbacks. */ +export interface AuthenticatedCancellationLease { + readonly epoch: number; + readonly origin: string; + readonly authorization: string; + readonly fetch: typeof fetch; +} + /** The injected SQL-string-quoting function a few call sites take as a * parameter (matching core/format.js's `sqlString`) instead of using the * module-level import directly. */ @@ -73,6 +84,16 @@ function isCurrentEpoch(ctx: ChCtx, requestEpoch: number | undefined): boolean { return requestEpoch === undefined || ctx.currentEpoch?.() === requestEpoch; } +// A request that was superseded before it can start (or retry) is cancellation, +// not an authentication failure. Keep the shape callers already treat as a +// silent cancellation without coupling this network module to a DOMException +// implementation. +function staleEpochAbort(): Error { + const error = new Error('request superseded by a newer authentication session'); + error.name = 'AbortError'; + return error; +} + /** Generic ClickHouse `FORMAT JSON` response shape — only `.data` is ever * read here; every other field (meta, statistics, rows_before_limit_at_least…) * is ignored by this module. */ @@ -108,8 +129,11 @@ export function chUrl(origin: string, opts: ChUrlOpts = {}): string { export async function authedFetch(ctx: ChCtx, url: string, sql: string, signal?: AbortSignal): Promise { const requestEpoch = ctx.currentEpoch?.(); const token = await ctx.getToken(); + // getToken may have awaited a sign-in/sign-out replacement. Its credential + // belongs to that replacement and this request must not send it. + if (!isCurrentEpoch(ctx, requestEpoch)) throw staleEpochAbort(); if (!token) { - if (isCurrentEpoch(ctx, requestEpoch)) ctx.onSignedOut(undefined, requestEpoch); + ctx.onSignedOut(undefined, requestEpoch); throw new Error('not signed in'); } let bearer = token; @@ -120,10 +144,14 @@ export async function authedFetch(ctx: ChCtx, url: string, sql: string, signal?: for (;;) { let resp: Response; try { + // Fence every attempt immediately before the injected side effect. A + // retry must never send a replacement session's newly-read credential. + const authorization = authHeader(bearer); + if (!isCurrentEpoch(ctx, requestEpoch)) throw staleEpochAbort(); resp = await ctx.fetch(url, { method: 'POST', body: sql, - headers: { Authorization: authHeader(bearer) }, + headers: { Authorization: authorization }, signal, }); } catch (e) { @@ -145,6 +173,10 @@ export async function authedFetch(ctx: ChCtx, url: string, sql: string, signal?: let authExpired = resp.status === 401 || resp.status === 403; if (!authExpired && !resp.ok) { const peek = await resp.clone().text(); + // Reading an error body is another async boundary. If this request was + // superseded while it was pending, its expiry marker must not start a + // refresh against the replacement session's credentials. + if (!isCurrentEpoch(ctx, requestEpoch)) return resp; if (isAuthExpiredBody(peek)) authExpired = true; } if (authExpired) { @@ -155,11 +187,11 @@ export async function authedFetch(ctx: ChCtx, url: string, sql: string, signal?: // caller shows it as a normal query error instead of force-logging-out. if (ctx.authConfirmed) return resp; if (attempt === 0 && (await ctx.refresh())) { - if (!isCurrentEpoch(ctx, requestEpoch)) return resp; + if (!isCurrentEpoch(ctx, requestEpoch)) throw staleEpochAbort(); // A successful refresh always yields a fresh, usable token — the // refresh() contract this seam relies on. bearer = (await ctx.getToken())!; - if (!isCurrentEpoch(ctx, requestEpoch)) return resp; + if (!isCurrentEpoch(ctx, requestEpoch)) throw staleEpochAbort(); attempt++; continue; } @@ -262,11 +294,14 @@ async function querySystemAware>(ctx: ChCtx, sqlBody * schema load or, via `ctx.dataLakeCatalogSettingUnsupported`, hiding every * other catalog too. */ -async function loadDataLakeCatalogTableNames(ctx: ChCtx, db: string): Promise { +async function loadDataLakeCatalogTableNames(ctx: ChCtx, db: string, signal?: AbortSignal): Promise { try { - const json = await querySystemAware<{ name: string }>(ctx, `SELECT database, name FROM system.tables WHERE database = ${sqlString(db)}`); + const json = await querySystemAware<{ name: string }>(ctx, `SELECT database, name FROM system.tables WHERE database = ${sqlString(db)}`, signal); return (json.data || []).map((r) => r.name); - } catch { + } catch (e) { + // A catalog database is best-effort, but cancellation belongs to the + // whole schema load and must stop its sibling fan-out too. + if (isAbort(e, signal)) throw e; return []; } } @@ -283,9 +318,28 @@ export async function killQuery(ctx: ChCtx, queryId: string | null | undefined, } catch { /* best-effort */ } } +/** Best-effort server cancellation through a frozen execution-scope lease. + * Unlike `killQuery`, this deliberately bypasses `authedFetch`: no token read, + * refresh, retry, lifecycle callback, or mutable auth-scheme lookup is allowed + * while a dead scope is closing. */ +export async function killQueryWithLease( + lease: AuthenticatedCancellationLease, + queryId: string | null | undefined, + sqlString: SqlStringFn, +): Promise { + if (!queryId) return; + try { + await lease.fetch(chUrl(lease.origin, { format: 'JSON' }), { + method: 'POST', + body: 'KILL QUERY WHERE query_id = ' + sqlString(queryId) + ' ASYNC', + headers: { Authorization: lease.authorization }, + }); + } catch { /* best-effort */ } +} + /** Fetch `version()` + `uptime()`. Returns the version string ('' on shape miss). */ -export async function loadServerVersion(ctx: ChCtx): Promise { - const json = await queryJson<{ v?: string; u?: number }>(ctx, 'SELECT version() AS v, uptime() AS u FORMAT JSON'); +export async function loadServerVersion(ctx: ChCtx, signal?: AbortSignal): Promise { + const json = await queryJson<{ v?: string; u?: number }>(ctx, 'SELECT version() AS v, uptime() AS u FORMAT JSON', signal); const row = (json.data && json.data[0]) || {}; return row.v || ''; } @@ -335,12 +389,12 @@ interface TableStatsRow { database: string; name: string; total_rows: number | s * * Returns [{ db, comment, expanded, tables: [{name,total_rows,total_bytes,comment,columns:null}] }]. */ -export async function loadSchema(ctx: ChCtx): Promise { +export async function loadSchema(ctx: ChCtx, signal?: AbortSignal): Promise { const dbJson = await queryJson(ctx, "SELECT name, comment, engine FROM system.databases\n" + "WHERE name NOT IN ('INFORMATION_SCHEMA','information_schema')\n" + 'ORDER BY name\n' + - 'FORMAT JSON'); + 'FORMAT JSON', signal); const dbRows = dbJson.data || []; const catalogDbs = dbRows.filter((r) => r.engine === 'DataLakeCatalog').map((r) => r.name); const exclude = ['INFORMATION_SCHEMA', 'information_schema', ...catalogDbs].map(sqlString).join(', '); @@ -352,8 +406,8 @@ export async function loadSchema(ctx: ChCtx): Promise { 'FROM system.tables\n' + `WHERE database NOT IN (${exclude})\n` + "ORDER BY database, startsWith(name, '_'), name\n" + - 'FORMAT JSON'), - Promise.all(catalogDbs.map(async (db) => ({ db, names: await loadDataLakeCatalogTableNames(ctx, db) }))), + 'FORMAT JSON', signal), + Promise.all(catalogDbs.map(async (db) => ({ db, names: await loadDataLakeCatalogTableNames(ctx, db, signal) }))), ]); const byDb = new Map(); for (const r of dbRows) byDb.set(r.name, { comment: r.comment || '', tables: [] }); @@ -499,12 +553,12 @@ export async function loadSchemaLineage(ctx: ChCtx, focus: LineageFocus | null | } /** Load the columns of one table. Returns [{name,type,comment}]. */ -export async function loadColumns(ctx: ChCtx, db: string, table: string, sqlString: SqlStringFn): Promise<{ name: string; type: string; comment: string }[]> { +export async function loadColumns(ctx: ChCtx, db: string, table: string, sqlString: SqlStringFn, signal?: AbortSignal): Promise<{ name: string; type: string; comment: string }[]> { const sql = 'SELECT name, type, comment FROM system.columns ' + 'WHERE database = ' + sqlString(db) + ' AND table = ' + sqlString(table) + ' ' + 'ORDER BY position'; - const json = await querySystemAware<{ name: string; type: string; comment?: string }>(ctx, sql); + const json = await querySystemAware<{ name: string; type: string; comment?: string }>(ctx, sql, signal); return (json.data || []).map((r) => ({ name: r.name, type: r.type, comment: r.comment || '' })); } @@ -536,14 +590,14 @@ export interface SchemaCardsResult { * (#179) — they're detail-drawer metadata (ch.loadTableDetail), not card * geometry, so pulling them on graph load was a dead read. */ -export async function loadSchemaCards(ctx: ChCtx, dbs: readonly string[] | null | undefined): Promise { +export async function loadSchemaCards(ctx: ChCtx, dbs: readonly string[] | null | undefined, signal?: AbortSignal): Promise { const columnsByKey: Record = {}; const list = (dbs || []).map((d) => sqlString(d)).join(', '); if (!list) return { columnsByKey }; const colRows = await trySystemAwareQueryData(ctx, 'SELECT database, table, name, type, is_in_partition_key, is_in_sorting_key, ' + 'is_in_primary_key, is_in_sampling_key, compression_codec, position ' - + 'FROM system.columns WHERE database IN (' + list + ') ORDER BY database, table, position'); + + 'FROM system.columns WHERE database IN (' + list + ') ORDER BY database, table, position', signal); for (const r of colRows || []) { const key = r.database + '.' + r.table; (columnsByKey[key] = columnsByKey[key] || []).push(r); @@ -555,6 +609,9 @@ export async function loadSchemaCards(ctx: ChCtx, dbs: readonly string[] | null export interface LoadLineageTransitiveOpts { nodeCap?: number; dbCap?: number; + /** Cancels the current frontier and prevents a stale traversal from starting + * another database round under a replacement authentication scope. */ + signal?: AbortSignal; } /** `loadLineageTransitive`'s result. */ @@ -568,7 +625,8 @@ export interface LineageTransitiveResult { * then BFS into every database referenced by the graph built so far, merging rows, * until no new database is referenced or a cap is hit. `opts.dbCap` bounds the * number of databases fetched and `opts.nodeCap` the graph size — either tripping - * sets `truncated` (the caller shows a banner). Returns `{ rows, truncated }`; + * sets `truncated` (the caller shows a banner); `opts.signal` cancels every + * frontier request and prevents a later round. Returns `{ rows, truncated }`; * `rows` is the merged `{ tables, dictionaries }` for buildSchemaGraph + expandLineage. */ export async function loadLineageTransitive(ctx: ChCtx, focus: LineageFocus | null | undefined, opts: LoadLineageTransitiveOpts = {}): Promise { @@ -587,7 +645,7 @@ export async function loadLineageTransitive(ctx: ChCtx, focus: LineageFocus | nu // next frontier. Far fewer round-trips than fetching one db at a time. const batch = frontier.slice(0, dbCap - loaded.size); batch.forEach((db) => loaded.add(db)); - const parts = await Promise.all(batch.map((db) => loadSchemaLineage(ctx, { db }))); + const parts = await Promise.all(batch.map((db) => loadSchemaLineage(ctx, { db }, { signal: opts.signal }))); for (const part of parts) { tables = tables.concat(part.tables); dictionaries = dictionaries.concat(part.dictionaries); @@ -673,7 +731,7 @@ export interface TableDetail { * drawer needs `type_full` + `granularity` besides. `data_skipping_indices` is a * MergeTree-only view (no DataLakeCatalog tables), so the plain client suffices. */ -export async function loadTableDetail(ctx: ChCtx, db: string, table: string): Promise { +export async function loadTableDetail(ctx: ChCtx, db: string, table: string, signal?: AbortSignal): Promise { const byCol = 'database = ' + sqlString(db) + ' AND table = ' + sqlString(table); const byName = 'database = ' + sqlString(db) + ' AND name = ' + sqlString(table); const [columns, indexes, partitions, tableRows] = await Promise.all([ @@ -682,16 +740,16 @@ export async function loadTableDetail(ctx: ChCtx, db: string, table: string): Pr + 'is_in_partition_key, is_in_sorting_key, is_in_primary_key, is_in_sampling_key, ' + 'toUInt64(data_compressed_bytes) AS compressed, toUInt64(data_uncompressed_bytes) AS uncompressed, ' + 'toUInt64(marks_bytes) AS marks, position ' - + 'FROM system.columns WHERE ' + byCol + ' ORDER BY position'), + + 'FROM system.columns WHERE ' + byCol + ' ORDER BY position', signal), tryQueryData(ctx, 'SELECT name, expr, type, type_full, granularity, ' + 'toUInt64(data_compressed_bytes) AS compressed, toUInt64(data_uncompressed_bytes) AS uncompressed, ' + 'toUInt64(marks_bytes) AS marks ' - + 'FROM system.data_skipping_indices WHERE ' + byCol + ' ORDER BY name FORMAT JSON'), + + 'FROM system.data_skipping_indices WHERE ' + byCol + ' ORDER BY name FORMAT JSON', signal), tryQueryData(ctx, 'SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytes ' - + 'FROM system.parts WHERE ' + byCol + ' AND active GROUP BY partition ORDER BY partition FORMAT JSON'), - trySystemAwareQueryData<{ ddl?: string; comment?: string; engine?: string }>(ctx, 'SELECT create_table_query AS ddl, comment, engine FROM system.tables WHERE ' + byName), + + 'FROM system.parts WHERE ' + byCol + ' AND active GROUP BY partition ORDER BY partition FORMAT JSON', signal), + trySystemAwareQueryData<{ ddl?: string; comment?: string; engine?: string }>(ctx, 'SELECT create_table_query AS ddl, comment, engine FROM system.tables WHERE ' + byName, signal), ]); return { columns: columns || [], @@ -710,10 +768,9 @@ export async function loadTableDetail(ctx: ChCtx, db: string, table: string): Pr // `signal` and aborted it, that means the caller's whole operation was // cancelled, not that this particular sub-query failed, so it must propagate // rather than be swallowed into "no data, continue" (#124). Gated on -// `signal.aborted` (not just the error's name) so a caller that never passed -// a signal — every site except `loadSchemaLineage` — keeps today's -// unconditional swallow, even if the underlying fetch happens to throw an -// AbortError-shaped error for some unrelated reason. +// `signal.aborted` (not just the error's name) so any caller that omits the +// optional signal keeps today's unconditional swallow, even if the underlying +// fetch happens to throw an AbortError-shaped error for some unrelated reason. async function tryRun( runner: (ctx: ChCtx, sql: string, signal?: AbortSignal) => Promise>, ctx: ChCtx, sql: string, signal?: AbortSignal, @@ -787,14 +844,14 @@ interface FormatRow { name: string } * Returns { keywords, functions, formats } — each null when its source is * missing/denied (the caller falls back to a built-in set). */ -export async function loadReferenceData(ctx: ChCtx): Promise { - const kw = await tryQueryData(ctx, 'SELECT keyword FROM system.keywords FORMAT JSON'); +export async function loadReferenceData(ctx: ChCtx, signal?: AbortSignal): Promise { + const kw = await tryQueryData(ctx, 'SELECT keyword FROM system.keywords FORMAT JSON', signal); const keywords = kw ? kw.map((r) => r.keyword) : null; // Prefer the `syntax` column (modern ClickHouse) for signature help; fall back // to the minimal shape when it doesn't exist (older servers) so we still get // names for highlighting + completion. - const fn = await tryQueryData(ctx, 'SELECT name, is_aggregate, syntax FROM system.functions FORMAT JSON') - || await tryQueryData(ctx, 'SELECT name, is_aggregate FROM system.functions FORMAT JSON'); + const fn = await tryQueryData(ctx, 'SELECT name, is_aggregate, syntax FROM system.functions FORMAT JSON', signal) + || await tryQueryData(ctx, 'SELECT name, is_aggregate FROM system.functions FORMAT JSON', signal); let functions: Record | null = null; if (fn) { functions = {}; @@ -809,7 +866,7 @@ export async function loadReferenceData(ctx: ChCtx): Promise { } // Output format names for FORMAT-clause completion (system.formats); a separate // catalog from keywords/functions, so it needs its own fetch. - const fmts = await tryQueryData(ctx, 'SELECT name FROM system.formats WHERE is_output ORDER BY name FORMAT JSON'); + const fmts = await tryQueryData(ctx, 'SELECT name FROM system.formats WHERE is_output ORDER BY name FORMAT JSON', signal); const formats = fmts ? fmts.map((r) => r.name) : null; return { keywords, functions, formats }; } @@ -850,11 +907,11 @@ const DOC_PROBE_TABLE_NAMES: Record = { documentation: 'documentation', }; -export function loadDocTableColumns(ctx: ChCtx, table: DocProbeTable): Promise { +export function loadDocTableColumns(ctx: ChCtx, table: DocProbeTable, signal?: AbortSignal): Promise { const tableName = DOC_PROBE_TABLE_NAMES[table]; return tryQueryData<{ name: string }>( ctx, - "SELECT name FROM system.columns WHERE database = 'system' AND table = " + sqlString(tableName) + ' FORMAT JSON', + "SELECT name FROM system.columns WHERE database = 'system' AND table = " + sqlString(tableName) + ' FORMAT JSON', signal, ).then((rows) => (rows === null ? null : rows.map((r) => r.name))); } @@ -865,22 +922,22 @@ export function loadDocTableColumns(ctx: ChCtx, table: DocProbeTable): Promise[] | null> { - return tryQueryData>(ctx, sql); +export function loadDocRow(ctx: ChCtx, sql: string, signal?: AbortSignal): Promise[] | null> { + return tryQueryData>(ctx, sql, signal); } /** #313's `system.functions`-specific capability probe — a thin wrapper over * the generalized `loadDocTableColumns` (#314). Kept as its own export so * `SchemaCatalogDeps`/existing call sites and tests don't need to change. */ -export function loadFunctionsDocColumns(ctx: ChCtx): Promise { - return loadDocTableColumns(ctx, 'functions'); +export function loadFunctionsDocColumns(ctx: ChCtx, signal?: AbortSignal): Promise { + return loadDocTableColumns(ctx, 'functions', signal); } /** #313's `system.functions`-specific row loader — a thin wrapper over the * generalized `loadDocRow` (#314). Kept as its own export for the same * reason as `loadFunctionsDocColumns` above. */ -export function loadFunctionDocRow(ctx: ChCtx, sql: string): Promise[] | null> { - return loadDocRow(ctx, sql); +export function loadFunctionDocRow(ctx: ChCtx, sql: string, signal?: AbortSignal): Promise[] | null> { + return loadDocRow(ctx, sql, signal); } /** `exportQuery`'s options. */ diff --git a/src/styles.css b/src/styles.css index fd3f1fb5..48c26641 100644 --- a/src/styles.css +++ b/src/styles.css @@ -431,6 +431,24 @@ h1, h2, h3, h4, h5, h6 { var(--bg); } .login-screen .mono { font-family: var(--mono); } +.auth-host { + flex: 0 1 auto; + max-height: 48vh; + overflow: auto; + padding: 14px; + border-bottom: 1px solid var(--border); + background: + radial-gradient(620px 220px at 50% -30%, color-mix(in oklab, var(--accent) 12%, transparent), transparent 72%), + var(--bg); +} +.auth-host[hidden] { display: none; } +.login-inline { + display: flex; + justify-content: center; +} +.login-inline .login-card { + box-shadow: var(--shadow-popover); +} .login-card { width: 380px; padding: 40px 36px 36px; background: var(--bg-header); @@ -464,6 +482,12 @@ h1, h2, h3, h4, h5, h6 { margin-top: 7px; font-size: var(--text-label); color: var(--fg-faint); } .login-sso-note .mono { color: var(--fg-mute); } +.login-inline-oauth-unavailable { + padding: 10px 12px; + background: var(--warn-bg); border: 1px solid var(--warn-bd); + border-radius: var(--r-md); color: var(--warn-fg); + font-size: var(--text-body); line-height: var(--lh-body); +} /* Buttons (primary / ghost) */ .login-btn { @@ -592,6 +616,13 @@ h1, h2, h3, h4, h5, h6 { font-family: var(--mono); color: var(--fg); } .connection-state { display: none; } +.connection-chip.is-auth-required { + background: var(--error-bg); border-color: var(--error-bd); +} +.connection-chip.is-auth-required .connection-host { display: none; } +.connection-chip.is-auth-required .connection-state { + display: inline; color: var(--error-fg); font-weight: var(--fw-semibold); +} .conn-status::before { content: ''; width: 7px; height: 7px; border-radius: var(--r-xs); background: var(--fg-faint); box-shadow: none; diff --git a/src/ui/app-header.ts b/src/ui/app-header.ts index 1a363e79..90db6ce8 100644 --- a/src/ui/app-header.ts +++ b/src/ui/app-header.ts @@ -48,6 +48,15 @@ export function applyConnectionStatus(app: Pick): void { chip.title = app.conn.host(); chip.querySelector('.connection-host')!.textContent = app.conn.host(); chip.querySelector('.connection-state')!.textContent = presentation.label; + // An in-place Basic reauthentication keeps this exact header DOM mounted. + // Refresh identity alongside the lifecycle chip so a different user/target + // never leaves the old session's label behind. + const userButton = app.dom.userBtn; + if (userButton) { + userButton.title = app.conn.email(); + const short = userButton.querySelector('.user-short'); + if (short) short.textContent = userShortName(app.conn.email()); + } } /** The one application header used by both Workbench and Dashboard. */ diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts index ffff32a4..506f7074 100644 --- a/src/ui/app-shell.ts +++ b/src/ui/app-shell.ts @@ -72,6 +72,12 @@ export type SurfaceHostKind = 'query' | 'dashboard'; export interface AppShellHandle { /** Replace the header slot's content (each surface builds its own header). */ setHeader(header: Element): void; + /** + * Stable host for in-shell authentication controls. It sits immediately + * below the header and starts hidden, so lifecycle wiring can reveal it + * without replacing either work surface. + */ + authHost: HTMLElement; /** Host the workbench column (SQL editor + result/data drawer) mounts into. */ queryHost: HTMLElement; /** Host a Dashboard mounts into. */ @@ -108,6 +114,12 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { // spliced in via `setHeader()` below — this slot is the stable mount point // so the header can be replaced without rebuilding the sidebar around it. const headerSlot = h('div', { class: 'app-header-slot' }); + const authHost = app.dom.authHost = h('div', { + class: 'auth-host', + hidden: true, + role: 'region', + 'aria-label': 'Authentication required', + }); app.dom.schemaSearchInput = h('input', { type: 'text', placeholder: 'Search tables, columns…', @@ -204,7 +216,7 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { navBtn('editor', Icon.code(), 'Editor'), navBtn('results', Icon.table2(), 'Results', app.dom.mobileBadge)); - root!.replaceChildren(headerSlot, app.dom.banner, mainRow, app.dom.mobileNav); + root!.replaceChildren(headerSlot, authHost, app.dom.banner, mainRow, app.dom.mobileNav); const disposers: (() => void)[] = []; // Reactive repaint of the schema tree — replaces the scattered renderSchema() @@ -305,6 +317,7 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { headerSlot.replaceChildren(header); applyConnectionStatus(app); }, + authHost, queryHost, dashboardHost, showHost: (kind) => { diff --git a/src/ui/app.ts b/src/ui/app.ts index 0bf062ec..07ce2489 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -65,7 +65,8 @@ import type { ComboField } from './combobox.js'; import { recentOptions } from '../core/recent-values.js'; import { paramComparisonColumns } from '../core/param-comparison.js'; import type { SchemaDb } from '../core/from-scope.js'; -import { renderLogin } from './login.js'; +import { mountInlineLogin, renderLogin } from './login.js'; +import type { InlineLoginHandle } from './login.js'; import { openShortcuts, resetShortcutChord } from './shortcuts.js'; import { startDrag } from './splitters.js'; import { flashToast } from './toast.js'; @@ -73,6 +74,10 @@ import type { App, ActionsRegistry, KeyboardOwner, SchemaFocus, WorkspaceChanged import type { CreateAppEnv, BroadcastChannelPort } from '../env.types.js'; import { createQueryExecutionService } from '../application/query-execution-service.js'; import { createConnectionSession } from '../application/connection-session.js'; +import { + createAuthenticatedExecutionScope, + type AuthenticatedExecutionScope, +} from '../application/authenticated-execution-scope.js'; import { createSchemaCatalogService } from '../application/schema-catalog-service.js'; import { createWorkbenchParameterSession } from '../application/workbench-parameter-session.js'; import { createChSessionParams } from '../application/ch-session-params.js'; @@ -349,7 +354,10 @@ export function createApp(env: CreateAppEnv = {}): App { // action (never by importing ui/doc-pane itself — the editor stays a leaf // layer, enforced by build/check-boundaries.mjs). Bound before Editor(app) // only for tidiness; the adapter reads it lazily at click/F1 time. - app.openDocEntry = (target) => openDocEntry(app, target); + app.openDocEntry = (target) => { + if (!app.requireAuthenticatedExecution()) return; + openDocEntry(app, target); + }; // #60 — the global Escape shortcut closes the pane from anywhere (layered // before cancel-query in shortcuts.ts's handleKeydown). app.closeDocPane = () => { @@ -359,7 +367,10 @@ export function createApp(env: CreateAppEnv = {}): App { }; // #315 — the F1 name-only disambiguation fallback's injected action, bound // the same way and for the same "editor never imports UI" reason. - app.openDocDisambiguation = (name) => openDocDisambiguation(app, name); + app.openDocDisambiguation = (name) => { + if (!app.requireAuthenticatedExecution()) return; + openDocDisambiguation(app, name); + }; app.sqlEditor = Editor(app); app.specEditor = SpecEditor(app); // The Spec-evaluation/document lifecycle (#276 Phase 4C) — @@ -434,6 +445,11 @@ export function createApp(env: CreateAppEnv = {}): App { // structural-only reinterpretation, not a new runtime assumption (a null // root would already throw inside login.ts's own `app.root.replaceChildren` // either way). + // Declared before the auth callbacks so they can retain or dispose the same + // persistent document shell. Construction completes before any callback can + // run; the actual mount helpers are installed further below. + let shell: AppShellHandle | null = null; + let disposeWorkbenchMount: (() => void) | null = null; const renderLoginApp = (msg?: string): void => { app.closeShortcutDialog(); resetShortcutChord(app); @@ -442,19 +458,31 @@ export function createApp(env: CreateAppEnv = {}): App { // detached sidebar, and the next sign-in would skip re-mounting a shell that // is no longer in the document, leaving a blank page. // - // The Dashboard surface goes here too, not just in `signOut`: this is the ONE - // place that knows the login screen is now showing, and `onAuthLost` (a 401 or - // an expired token) arrives here without passing through `signOut`. Left - // alive, a mounted Dashboard keeps its window listeners, its viewer session, - // and a generation-matching `surfaceCommands` — so its refresh and style - // shortcuts stay dispatchable FROM the login screen, executing tile queries - // against a dead session. Advancing the generation is what closes that port. + // The Dashboard surface goes here too: this is the explicit end-of-session + // renderer, so no route-scoped listeners or generation-matching command + // port may remain dispatchable from the full-screen login. Involuntary auth + // loss does not call this path; it retains the Dashboard/document shell and + // exposes the inline authentication host instead. disposeDashboardSurface(); advanceSurfaceGeneration(); app.mainSurface = QUERY_SURFACE; disposeShell(); renderLogin(app as App & { root: Element }, msg); }; + // Temporary auth loss suspends only this disposable scope. The document + // session (tabs/editors/results/workspace/shell) stays mounted; the two UI + // callbacks are installed once the persistent shell seam is defined below. + let activeExecutionScope: AuthenticatedExecutionScope | null = null; + let inlineLogin: InlineLoginHandle | null = null; + const revealAuthenticationRequired = (detail?: string): void => { + if (!shell) { + renderLoginApp(detail); + return; + } + inlineLogin ??= mountInlineLogin(app as App & { root: Element }, shell.authHost); + inlineLogin.show(detail); + }; + const hideAuthenticationRequired = (): void => inlineLogin?.hide(); // The auth + config + ClickHouse connection lifecycle (#276 Phase 2) — OAuth // PKCE login/refresh, Basic probing, and IdP config resolution live in // `application/connection-session.ts`, @@ -464,9 +492,49 @@ export function createApp(env: CreateAppEnv = {}): App { const conn = createConnectionSession({ fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, queryJson: ch.queryJson, - onAuthLost: (detail) => renderLoginApp(detail), + onAuthLost: (detail, lease) => { + const closing = activeExecutionScope; + activeExecutionScope = null; + closing?.close(lease); + revealAuthenticationRequired(detail); + }, }); app.conn = conn; + app.executionScope = () => activeExecutionScope; + app.resumeAuthenticatedExecution = () => { + const epoch = conn.connection.value.epoch; + if (activeExecutionScope?.epoch === epoch && activeExecutionScope.isOpen()) { + hideAuthenticationRequired(); + return; + } + activeExecutionScope?.close(); + const scope = createAuthenticatedExecutionScope({ + epoch, + cancelRemote: (lease, queryId) => ch.killQueryWithLease(lease, queryId, sqlString), + }); + activeExecutionScope = scope; + // Connection-scoped caches/panes are owners even when they have no live + // server query id. Their own invalidation/generation guards make late + // completion inert; query-bearing owners register their current ids. + scope.register({ name: 'schema catalog', abort: () => catalog.invalidate() }); + scope.register({ name: 'schema graph', abort: () => graph.suspend() }); + scope.register({ name: 'documentation pane', abort: () => closeDocPane(app) }); + hideAuthenticationRequired(); + }; + app.requireAuthenticatedExecution = () => { + let scope = activeExecutionScope; + // Production bootstrap establishes the first scope explicitly, but + // controller entry points are also valid before a surface is mounted + // (and tests exercise that contract). An already-authenticated session can + // therefore materialize its scope lazily; an auth-required session cannot. + if (!scope && conn.isSignedIn()) { + app.resumeAuthenticatedExecution(); + scope = activeExecutionScope; + } + if (scope?.isOpen()) return scope; + revealAuthenticationRequired(conn.connection.value.detail); + return null; + }; // THE single live ClickHouse context — owned by the session, aliased locally // so every existing ch.* call site below keeps referencing the same mutated // object (chCtx.origin/authConfirmed are mutated in place, never replaced). @@ -490,6 +558,9 @@ export function createApp(env: CreateAppEnv = {}): App { app.signOut = () => { app.closeShortcutDialog(); resetShortcutChord(app); + const closing = activeExecutionScope; + activeExecutionScope = null; + closing?.close(conn.captureCancellationLease()); workbench.destroy(); // Plain abort (no clearResult settle) — the login render replaces the // whole DOM next, so settling the visible result would be a wasted paint. @@ -501,9 +572,8 @@ export function createApp(env: CreateAppEnv = {}): App { // alongside the catalog reset, before the login screen renders. closeDocPane(app); conn.signOut(); - // #425: the Dashboard teardown, the surface-generation bump and the - // main-surface reset live in `renderLoginApp` — the one place that knows the - // login screen is showing — so `onAuthLost` gets them as well. + // #425: explicit logout owns Dashboard teardown, the surface-generation + // bump, and the main-surface reset through the full-screen login renderer. renderLoginApp(); }; app.showLogin = (msg) => renderLoginApp(msg); @@ -690,6 +760,7 @@ export function createApp(env: CreateAppEnv = {}): App { const exportService = createExportService({ exportQuery: ch.exportQuery, runQuery: ch.runQuery, killQuery: ch.killQuery, ctx: () => chCtx, ensureConfig, getToken, sqlString, now, wallNow, uid, + executionScope: () => app.executionScope(), canExport: () => app.canExport(), canExportScript: () => app.canExportScript(), sink: exportSink, state: app.state, // AppState structurally satisfies ExportStateSlice @@ -716,6 +787,7 @@ export function createApp(env: CreateAppEnv = {}): App { // effects (results repaint / Run button / mobile badge) the session owns. const workbench = createWorkbenchSession({ exec, ensureConfig, getToken, now, wallNow, uid, + executionScope: () => app.executionScope(), state: app.state, // AppState structurally satisfies WorkbenchStateSlice activeTab: () => app.activeTab(), hooks: { @@ -730,7 +802,7 @@ export function createApp(env: CreateAppEnv = {}): App { getSelectionText: () => app.sqlEditor.getSelection().text, tickElapsed, saveJSON, - onAuthFailed: () => chCtx.onSignedOut(), + onAuthFailed: chCtx.onSignedOut, }, }); app.workbench = workbench; @@ -991,12 +1063,6 @@ export function createApp(env: CreateAppEnv = {}): App { const tab = app.activeTab(); if (tab.result && tab.result.formatError) { tab.result = null; renderResults(app); } } - // Format one statement via ClickHouse's formatQuery(); returns the formatted text. - const formatOne = async (s: string): Promise => { - const json = await ch.queryJson<{ q: string }>(chCtx, 'SELECT formatQuery(' + sqlString(s) + ') AS q FORMAT JSON'); - return (json.data && json.data[0] && json.data[0].q) || ''; - }; - async function formatQuery(): Promise { if (app.activeTab().editorMode !== 'sql') return; const raw = app.activeTab().sqlDraft || ''; @@ -1010,17 +1076,58 @@ export function createApp(env: CreateAppEnv = {}): App { flashToast('Statement contains optional blocks — not formatted', { document: doc }); return; } - await ensureConfig(); - if (!(await getToken())) { chCtx.onSignedOut(); return; } - const tab = app.activeTab(); - setFmtBtn(true); // formatting a script is one request per statement — show busy + // `actions.formatQuery` enters through withAuthenticatedExecution(), so a + // private invocation only exists while its scope is present. + const scope = app.executionScope()!; + const controller = new AbortController(); + const registration = scope.register({ + name: 'format query', + abort: () => { + controller.abort(); + setFmtBtn(false); + }, + }); + const formatOne = async (s: string): Promise => { + const queryId = uid('q'); + const requestRegistration = scope.register({ + name: 'format statement', + abort: () => controller.abort(), + getQueryId: () => queryId, + }); + try { + if (!requestRegistration.isCurrent()) return ''; + const json = await ch.queryJson<{ q: string }>( + chCtx, + 'SELECT formatQuery(' + sqlString(s) + ') AS q FORMAT JSON', + controller.signal, + { query_id: queryId }, + ); + return requestRegistration.isCurrent() + ? (json.data && json.data[0] && json.data[0].q) || '' + : ''; + } finally { + requestRegistration.release(); + } + }; try { + await ensureConfig(); + if (!registration.isCurrent()) return; + if (!(await getToken())) { + // Scope epoch, rather than the mutable current lifecycle, makes a + // late old credential failure harmless after a successful resume. + chCtx.onSignedOut(undefined, scope.epoch); + return; + } + if (!registration.isCurrent()) return; + const tab = app.activeTab(); + setFmtBtn(true); // formatting a script is one request per statement — show busy if (stmts.length > 1) { // Multi-statement: format each (best-effort — keep the original text for any // statement that won't format, like insertCreate; skip a template, #165), // then reassemble with a `;` and a blank line between statements. const skipped = stmts.filter((s) => hasOptionalBlocks(s)).length; const formatted = await Promise.all(stmts.map((s) => (hasOptionalBlocks(s) ? s : formatOne(s).catch(() => s)))); + if (!registration.isCurrent()) return; app.sqlEditor.replaceDocument(withStatementBreak(formatted.map((q, i) => q || stmts[i]).join(';\n\n'))); clearFormatError(); if (skipped) { @@ -1033,11 +1140,13 @@ export function createApp(env: CreateAppEnv = {}): App { // position maps 1:1 onto the editor text; show it persistently + jump the caret. try { const q = await formatOne(raw); + if (!registration.isCurrent()) return; // Terminate so the caret lands past the last token — otherwise the input // event from the replace re-opens autocomplete on the trailing word. if (q) app.sqlEditor.replaceDocument(withStatementBreak(q)); clearFormatError(); } catch (e) { + if (!registration.isCurrent()) return; const msg = String((e instanceof Error && e.message) || e); // `formatError` (not a run result, so a later successful format can // clear just this — see clearFormatError) is app.ts/test-only, not @@ -1050,7 +1159,8 @@ export function createApp(env: CreateAppEnv = {}): App { if (pos != null) app.sqlEditor.revealOffset(pos); } } finally { - setFmtBtn(false); + if (registration.isCurrent()) setFmtBtn(false); + registration.release(); } } @@ -1067,6 +1177,7 @@ export function createApp(env: CreateAppEnv = {}): App { // detail-pane mount. const graph = createSchemaGraphSession({ ensureConfig, getToken, ctx: () => chCtx, + executionScope: () => app.executionScope(), loadSchemaLineage: ch.loadSchemaLineage, loadLineageTransitive: ch.loadLineageTransitive, loadSchemaCards: ch.loadSchemaCards, @@ -1074,7 +1185,7 @@ export function createApp(env: CreateAppEnv = {}): App { activeTab: () => app.activeTab(), hooks: { renderResults: () => renderResults(app), - onAuthFailed: () => chCtx.onSignedOut(), + onAuthFailed: chCtx.onSignedOut, }, }); app.graph = graph; @@ -1096,6 +1207,7 @@ export function createApp(env: CreateAppEnv = {}): App { const view = openSchemaView(app as DetachedGraphApp); try { const data = await graph.expand(focus); + if (!data) return; // Every real lineage/expansion node always carries `id`/`label` // (schema-graph.ts's `SchemaGraphNode`/`ExpandLineageNode`, both // required there); schema-cards.ts's own `CardGraphNode` widens them to @@ -1171,7 +1283,8 @@ export function createApp(env: CreateAppEnv = {}): App { function setResultRowLimit(n: number): Promise | undefined { app.state.resultRowLimit = normalizeRowLimit(n); prefs.save('resultRowLimit', app.state.resultRowLimit); - return app.activeTab().editorMode === 'sql' ? workbench.run() : undefined; + if (app.activeTab().editorMode !== 'sql') return undefined; + return app.requireAuthenticatedExecution() ? workbench.run() : undefined; } // Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with @@ -1180,32 +1293,71 @@ export function createApp(env: CreateAppEnv = {}): App { // failure or an empty statement (having already surfaced the toast), so // callers can no-op without inspecting the error themselves. async function fetchCreateSql(target: string): Promise { - await ensureConfig(); - if (!(await getToken())) { chCtx.onSignedOut(); return null; } + // Both callers are action-gated, so this private helper has a scope for + // its whole setup window. + const scope = app.executionScope()!; + const controller = new AbortController(); + let queryId: string | null = null; + const registration = scope.register({ + name: 'show create', + abort: () => controller.abort(), + getQueryId: () => queryId, + }); + const current = (): boolean => registration.isCurrent(); try { - const show = await ch.queryJson<{ statement: string }>(chCtx, 'SHOW CREATE ' + target + ' FORMAT JSON'); + await ensureConfig(); + if (!current()) return null; + if (!(await getToken())) { + chCtx.onSignedOut(undefined, scope.epoch); + return null; + } + if (!current()) return null; + queryId = uid('q'); + const show = await ch.queryJson<{ statement: string }>( + chCtx, + 'SHOW CREATE ' + target + ' FORMAT JSON', + controller.signal, + { query_id: queryId }, + ); + if (!current()) return null; const stmt = (show.data && show.data[0] && show.data[0].statement) || ''; if (!stmt) return null; try { - const fmt = await ch.queryJson<{ q: string }>(chCtx, 'SELECT formatQuery(' + sqlString(stmt) + ') AS q FORMAT JSON'); - return (fmt.data && fmt.data[0] && fmt.data[0].q) || stmt; - } catch { return stmt; /* formatting is best-effort — fall back to the raw DDL */ } + queryId = uid('q'); + const fmt = await ch.queryJson<{ q: string }>( + chCtx, + 'SELECT formatQuery(' + sqlString(stmt) + ') AS q FORMAT JSON', + controller.signal, + { query_id: queryId }, + ); + return current() ? (fmt.data && fmt.data[0] && fmt.data[0].q) || stmt : null; + } catch { + return current() ? stmt : null; // formatting is best-effort — fall back to the raw DDL + } } catch (e) { - flashToast('SHOW CREATE failed: ' + String((e instanceof Error && e.message) || e), { document: doc }); + if (current()) { + flashToast('SHOW CREATE failed: ' + String((e instanceof Error && e.message) || e), { document: doc }); + } return null; + } finally { + registration.release(); } } // Replaces the active editor's content (undo restores the prior query). async function insertCreate(target: string): Promise { + const scope = app.executionScope(); const sql = await fetchCreateSql(target); - if (sql != null) app.sqlEditor.replaceDocument(sql); + if (sql != null && scope?.isOpen() && app.executionScope() === scope) { + app.sqlEditor.replaceDocument(sql); + } } // Opens the DDL in a new tab, leaving the active tab untouched. async function openCreateInNewTab(target: string, name?: string): Promise { + const scope = app.executionScope(); const sql = await fetchCreateSql(target); - if (sql == null) return; + if (sql == null || !scope?.isOpen() || app.executionScope() !== scope) return; loadIntoNewTab(app, name || '', sql); // falsy → loadIntoNewTab's own 'Untitled' fallback, same as omitting name toEditorOnMobile(); } @@ -1746,8 +1898,6 @@ export function createApp(env: CreateAppEnv = {}): App { // sign-out) tears them down; `shell === null` is then the signal to rebuild, // and it MUST be nulled by everything that replaces `#root` wholesale, or the // next render would skip a mount that is no longer in the document. - let shell: AppShellHandle | null = null; - let disposeWorkbenchMount: (() => void) | null = null; const ignoreExternalWorkspaceChange = (): void => {}; const ensureShell = (): AppShellHandle => { shell ??= mountAppShell({ @@ -1761,12 +1911,21 @@ export function createApp(env: CreateAppEnv = {}): App { updateBanner: app.updateBanner, startDrag, }); + if (!inlineLogin) { + inlineLogin = mountInlineLogin( + app as App & { root: Element }, + shell.authHost, + ); + if (activeExecutionScope?.isOpen()) inlineLogin.hide(); + } if (!disposeWorkbenchMount) disposeWorkbenchMount = renderApp(app, { startDrag }, shell.queryHost); return shell; }; const disposeShell = (): void => { disposeWorkbenchMount?.(); disposeWorkbenchMount = null; + inlineLogin?.dispose(); + inlineLogin = null; shell?.dispose(); shell = null; }; @@ -1815,6 +1974,7 @@ export function createApp(env: CreateAppEnv = {}): App { closeDocPane(app); }; app.renderDashboard = () => { + if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); beginSurfaceTransition(); const mounted = ensureShell(); // Exposed BEFORE rendering: the grafana-grid engine measures its host's real @@ -2625,8 +2785,10 @@ export function createApp(env: CreateAppEnv = {}): App { }; // --- actions registry -------------------------------------------------- + const withAuthenticatedExecution = (operation: () => T): T | undefined => + (app.requireAuthenticatedExecution() ? operation() : undefined); app.actions = { - run: (opts) => workbench.runEntry(opts), + run: (opts) => withAuthenticatedExecution(() => workbench.runEntry(opts)), cancel: () => workbench.cancel(), newTab: () => newTab(app), selectTab: (id) => selectTab(app, id), @@ -2647,7 +2809,16 @@ export function createApp(env: CreateAppEnv = {}): App { // basic auth would keep rendering the placeholder workspace instead of the // requested or last-used persisted workspace. connect: async (input) => { + const resumeMountedDocument = shell !== null && activeExecutionScope === null; await conn.connectBasic(input); + app.resumeAuthenticatedExecution(); + if (resumeMountedDocument) { + // Preserve the exact mounted document/editor/result objects. Only + // connection-scoped metadata and execution owners are refreshed. + await Promise.allSettled([catalog.loadSchema(), catalog.loadReference()]); + void catalog.loadVersion(); + return; + } await app.loadWorkspaceOnBoot(); app.renderCurrentSurface(); void app.catalog.loadVersion(); @@ -2662,24 +2833,33 @@ export function createApp(env: CreateAppEnv = {}): App { // sides of the cast keeps it a single legal step (same pattern as // `recordHistory`'s above). copySnapshot: (result, targetDoc) => copySnapshot(result as QueryResult | null, targetDoc), - exportEntry, - exportDirect, + exportEntry: () => withAuthenticatedExecution(exportEntry), + exportDirect: (sqlInput, waveMs) => + withAuthenticatedExecution(() => exportDirect(sqlInput, waveMs)) ?? Promise.resolve(), cancelExport, cancelExportScript, save: saveActiveQuery, openUserMenu, - formatQuery, + formatQuery: () => withAuthenticatedExecution(formatQuery) ?? Promise.resolve(), formatSpec, setEditorMode, - explainQuery, - setExplainView, + explainQuery: () => withAuthenticatedExecution(explainQuery), + setExplainView: (id) => withAuthenticatedExecution(() => setExplainView(id)), setResultRowLimit, - showSchemaGraph, + showSchemaGraph: (focus) => + withAuthenticatedExecution(() => showSchemaGraph(focus)) ?? Promise.resolve(), cancelSchemaGraph, - expandSchemaGraph, - openNodeDetail, - insertCreate: async (target) => { await insertCreate(target); toEditorOnMobile(); }, - openCreateInNewTab: (target, name) => openCreateInNewTab(target, name), + expandSchemaGraph: (focus) => + withAuthenticatedExecution(() => expandSchemaGraph(focus)) ?? Promise.resolve(), + openNodeDetail: (node, targetDoc) => + withAuthenticatedExecution(() => openNodeDetail(node, targetDoc)) ?? Promise.resolve(), + insertCreate: async (target) => { + if (!app.requireAuthenticatedExecution()) return; + await insertCreate(target); + toEditorOnMobile(); + }, + openCreateInNewTab: (target, name) => + withAuthenticatedExecution(() => openCreateInNewTab(target, name)) ?? Promise.resolve(), openShortcuts: () => { const dialog = openShortcuts(app, () => { app.shortcutDialog = null; }); if (dialog) app.shortcutDialog = dialog; @@ -2688,7 +2868,8 @@ export function createApp(env: CreateAppEnv = {}): App { // (#126) so a schema tap / SHOW CREATE lands where the user can see it. insertAtCursor: (text) => { app.sqlEditor.insertAtCursor(text); toEditorOnMobile(); }, replaceEditor: (text) => { app.sqlEditor.replaceDocument(text); toEditorOnMobile(); }, - loadColumns, + loadColumns: (db, table) => + withAuthenticatedExecution(() => loadColumns(db, table)) ?? Promise.resolve(), // #466/#501-review: `renderTabs` alone repaints the strip; an in-place // `dirtySql`/`dirtySpec` mutation never touches the `tabs` SIGNAL itself // (no new array), so this is also the one place that re-syncs the @@ -2701,6 +2882,7 @@ export function createApp(env: CreateAppEnv = {}): App { }; app.renderApp = () => { + if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); beginSurfaceTransition(); // The Dashboard's own route-scoped resources go; the query column does NOT // (it is mounted once and preserved — see `ensureShell`). diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 905a84f1..2cabeb9c 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -19,6 +19,7 @@ import type { import type { DocTarget } from '../core/doc-types.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; import type { ConnectionSession, SessionChCtx } from '../application/connection-session.js'; +import type { AuthenticatedExecutionScope } from '../application/authenticated-execution-scope.js'; import type { SchemaCatalogService } from '../application/schema-catalog-service.js'; import type { SchemaGraphSession } from '../application/schema-graph-session.js'; import type { AppPreferences } from '../application/app-preferences.js'; @@ -110,6 +111,8 @@ export interface AppDom { // app.ts-internal only (renderApp()'s own mounted chrome + renderVarStrip()'s // rebuild bookkeeping) — not read by any other module. banner?: HTMLElement; + /** Stable in-shell mount for temporary authentication recovery controls. */ + authHost?: HTMLElement; connStatus?: HTMLElement; editorModeSwitch?: HTMLElement; editorRegion?: HTMLElement; @@ -242,6 +245,15 @@ export interface App { * shells/bootstrap consume those; a future phase re-points them to * `app.conn` directly. */ conn: ConnectionSession; + /** Current disposable authenticated execution scope, or null while the + * mounted document session is suspended for reauthentication. */ + executionScope(): AuthenticatedExecutionScope | null; + /** Start a fresh scope for the session's current credential epoch. Bootstrap + * and successful in-place Basic authentication call this before server work. */ + resumeAuthenticatedExecution(): void; + /** Shared gate for server-dependent commands. A null return also reveals and + * focuses the mounted recovery controls when the document shell is alive. */ + requireAuthenticatedExecution(): AuthenticatedExecutionScope | null; // Editor ports (injected seams — #143/#212). sqlEditor: EditorPort; diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 7c646aac..e31801ab 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -105,6 +105,7 @@ import type { SqlRoute } from '../core/sql-route.js'; import type { AppState } from '../state.js'; import type { ConnectionSession } from '../application/connection-session.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; +import type { AuthenticatedExecutionScope } from '../application/authenticated-execution-scope.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; import type { WorkspaceCommitResult, WorkspaceRepository } from '../workspace/workspace-repository.js'; import type { AppPreferences } from '../application/app-preferences.js'; @@ -162,6 +163,10 @@ export interface DashboardApp { toggleTheme(): void; conn: Pick; exec: Pick; + /** Server work belongs to the disposable authenticated epoch; the Dashboard + * stays mounted when this becomes unavailable. */ + executionScope?(): AuthenticatedExecutionScope | null; + requireAuthenticatedExecution?(): AuthenticatedExecutionScope | null; now(): number; wallNow(): number; params: Pick; @@ -600,6 +605,8 @@ export async function renderDashboard( queries, exec: app.exec, connection: { ensureFreshToken: () => app.conn.ensureFreshToken() }, + executionScope: () => app.requireAuthenticatedExecution?.() ?? app.executionScope?.() ?? null, + mintQueryId: () => app.genId(), registry: defaultLayoutRegistry, now: () => app.now(), wallNow: () => app.wallNow(), diff --git a/src/ui/login.ts b/src/ui/login.ts index 605a8402..2c072c7e 100644 --- a/src/ui/login.ts +++ b/src/ui/login.ts @@ -30,11 +30,35 @@ import type { ConnectionSession } from '../application/connection-session.js'; * composes rendering, not a pure forward. */ export interface LoginApp { root: Element; - conn: Pick; + conn: Pick; actions: Pick; showLogin(msg?: string): void; } +/** A reusable in-shell authentication mount. The host is stable for the life + * of the app shell; showing, hiding, and reporting an authentication error + * therefore never replaces the editor/results document tree around it. */ +export interface InlineLoginHandle { + /** Reveal the controls, replace any previous local error, and focus them. */ + show(errorMsg?: string): void; + /** Hide the controls without discarding field values or async config. */ + hide(): void; + /** Focus the first usable authentication control. */ + focus(): void; + /** Fence pending config work and remove only this mount from its host. */ + dispose(): void; +} + +interface LoginMount extends InlineLoginHandle { + container: HTMLElement; +} + +// `renderLogin()` historically has no disposer in its public API. Remember its +// private mount so a re-render can still fence a late `loadIdps()` completion +// from the screen it just replaced. +const fullMounts = new WeakMap(); +const inlineMounts = new WeakMap(); + // `err` is `unknown` under strict catch typing; only a thrown `Error` has a // `.message` worth surfacing (every throw site in this module and its tests // is either `new Error(...)` or a raw primitive) — anything else stringifies @@ -52,13 +76,54 @@ function errMsg(err: unknown): string { * showLogin(msg) — re-render with an error message */ export function renderLogin(app: LoginApp, errorMsg?: string): void { + fullMounts.get(app.root)?.dispose(); + const mount = mountLoginControls(app, app.root, 'full', errorMsg); + fullMounts.set(app.root, mount); +} + +/** + * Mount the same authentication card used by `renderLogin()` into a stable + * in-shell host. Unlike the full-screen renderer, failures are painted into + * this mount and neither `app.root` nor the surrounding shell is replaced. + * + * The mount starts visible and focused. Callers may retain it across lifecycle + * changes with `hide()`/`show()`, or tear it down permanently with `dispose()`. + */ +export function mountInlineLogin( + app: LoginApp, + host: HTMLElement, + errorMsg?: string, +): InlineLoginHandle { + inlineMounts.get(host)?.dispose(); + const mount = mountLoginControls(app, host, 'inline', errorMsg); + inlineMounts.set(host, mount); + return mount; +} + +function mountLoginControls( + app: LoginApp, + target: Element, + mode: 'full' | 'inline', + initialError?: string, +): LoginMount { const cur = app.conn.host(); + let disposed = false; let busy: 'sso' | 'creds' | null = null; // guards against double-submit + // An inline mount can be retained while a credential sign-in still awaits + // its post-connect work. A second auth loss calls `show()` on that same + // mount, which must make the old attempt unable to repaint the new recovery + // presentation when it eventually settles. + let presentationGeneration = 0; let showPw = false; // A `?host=` URL param pre-fills the credential server address. A non-empty // host means credential-only (SSO can only target the serving host), so // Advanced opens and the SSO buttons disable. - const hostHint = app.conn.hostHint || ''; + // During in-shell Basic recovery use the exact prior target. `chCtx.origin` + // is intentionally reset once the loss is reported, so `host()` alone is + // insufficient (and drops the scheme/port the reconnect must reuse). + const hostHint = mode === 'inline' + ? (app.conn.basicRecoveryOrigin() || app.conn.hostHint || '') + : (app.conn.hostHint || ''); let advOpen = !!hostHint; let ssoBtns: HTMLButtonElement[] = []; @@ -133,6 +198,7 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { // --- saved-connection picker (populated async; shown only when config lists hosts) --- let pickHosts: HostDescriptor[] = []; + let inlineOAuthConfigured = false; const hostPicker = h('select', { class: 'login-picker mono', onchange: onPickHost }); // Shown only for an `insecure` (accept-invalid-certificate) connection — the // browser can't be reached until its cert is trusted (see showCertWarn). @@ -153,23 +219,51 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { h('div', { class: 'login-brand-sub mono' }, 'ClickHouse® query console'))), pickerSection, ssoSection, - credSection, - errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null); + credSection); + let errorEl: HTMLElement | null = null; + const setError = (msg?: string): void => { + if (!msg) { + errorEl?.remove(); + errorEl = null; + return; + } + if (!errorEl) { + errorEl = h('div', { + class: 'login-error', + role: 'alert', + 'aria-live': 'polite', + }); + card.append(errorEl); + } + errorEl.textContent = msg; + }; + setError(initialError); - app.root.replaceChildren(h('div', { class: 'login-screen' }, card)); + const container = mode === 'full' + ? h('div', { class: 'login-screen' }, card) + : h('div', { + class: 'login-inline', + role: 'group', + 'aria-label': 'Authentication required', + }, card); + target.replaceChildren(container); + if (mode === 'inline') (target as HTMLElement).hidden = false; update(); // Resolve the configured IdPs (and the basic_login flag) and reconcile which // sections are shown. On failure keep credentials visible (fail-open — OAuth // can't work without config anyway) and show no SSO. app.conn.loadIdps().then(({ idps, basicLogin, hosts }) => { + if (disposed) return; const credsShown = basicLogin !== false; if (!credsShown) credSection.remove(); populateHosts(hosts); populateSso(idps); applyChrome(ssoBtns.length > 0, credsShown); update(); - }).catch(() => applyChrome(false, true)); // no config → credentials only + }).catch(() => { + if (!disposed) applyChrome(false, true); + }); // no config → credentials only // Show the "or use credentials" divider only when both sign-in methods // are actually offered. @@ -183,9 +277,22 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { // targets that host's origin); don't also offer it as a serving-host SSO button — // that would query the serving origin (e.g. localhost), not the chosen cluster. const standalone = (idps || []).filter((i) => !pickHosts.some((hh) => hh.auth === 'oauth' && hh.idp === i.id)); + // Phase 2 keeps the document shell mounted during recovery, but does not + // yet have the redirect-resume checkpoint needed to make an OAuth round + // trip safe. Full-screen login retains the normal OAuth controls. + if (mode === 'inline') { + if (standalone.length || inlineOAuthConfigured) { + ssoSection.replaceChildren(h('div', { + class: 'login-inline-oauth-unavailable', + role: 'status', + 'aria-live': 'polite', + }, 'Single sign-on is temporarily unavailable while this session is paused. Your work remains open.')); + } + return; + } if (!standalone.length) return; const mk = (idpId: string, label: string): HTMLButtonElement => { - const b = h('button', { class: 'login-btn btn-primary', onclick: () => doSso(idpId, b) }, + const b = h('button', { class: 'login-btn btn-primary', onclick: () => doSso(idpId, b, label) }, Icon.shield(), h('span', null, label)); ssoBtns.push(b); return b; @@ -202,7 +309,10 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { // Fill the picker from config.json's `hosts` (npm run local supplies them from // ~/.clickhouse-client/config.xml). Hidden when none are configured. function populateHosts(hosts: HostDescriptor[] | undefined): void { - pickHosts = hosts || []; + // OAuth saved connections also redirect away from the mounted document. + // Only credential targets are safe to offer in Phase 2 inline recovery. + inlineOAuthConfigured = mode === 'inline' && (hosts || []).some((hh) => hh.auth === 'oauth'); + pickHosts = (hosts || []).filter((hh) => mode === 'full' || hh.auth === 'basic'); if (!pickHosts.length) return; hostPicker.replaceChildren( h('option', { value: '' }, 'Choose a connection…'), @@ -266,15 +376,20 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { } async function pickOAuth(hh: HostDescriptor): Promise { - if (busy) return; // reachable from the cert panel's Continue button — guard re-entry like doSso + if (disposed || busy) return; // reachable from the cert panel's Continue button — guard re-entry like doSso + const generation = presentationGeneration; busy = 'sso'; hostPicker.disabled = true; try { await app.actions.login(hh.idp, hh.url); } catch (err) { + if (!ownsPresentation(generation)) return; busy = null; hostPicker.disabled = false; - app.showLogin(errMsg(err)); + const certGo = certWarn.querySelector('.login-cert-go'); + if (certGo) certGo.disabled = false; + reportError(errMsg(err)); + if (ownsPresentation(generation)) update(); } } @@ -301,30 +416,104 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { function onCredsKey(e: KeyboardEvent): void { if (e.key === 'Enter' && hasCreds()) doConnect(); } + function resetCredentialsAfterSuccess(): void { + busy = null; + passInput.value = ''; + showPw = false; + passInput.type = 'password'; + eyeBtn.title = 'Show password'; + eyeBtn.replaceChildren(Icon.eye()); + connectBtn.replaceChildren(h('span', null, 'Connect'), Icon.arrow()); + setError(); + update(); + } + + function ownsPresentation(generation: number): boolean { + return !disposed && (mode === 'full' || generation === presentationGeneration); + } + + function beginInlinePresentation(): void { + presentationGeneration += 1; + // The previous recovery action may still be awaiting metadata, but this + // presentation is a fresh opportunity to authenticate. Its completion is + // fenced by `presentationGeneration`, so release only the local UI here. + busy = null; + connectBtn.replaceChildren(h('span', null, 'Connect'), Icon.arrow()); + hostPicker.disabled = false; + update(); + } + async function doConnect(): Promise { - if (busy || !hasCreds()) return; + if (disposed || busy || !hasCreds()) return; + const generation = presentationGeneration; busy = 'creds'; connectBtn.disabled = true; connectBtn.replaceChildren(h('span', null, 'Connecting…')); try { await app.actions.connect({ username: userInput.value, password: passInput.value, host: hostInput.value }); - // success → connect() renders the workbench, replacing this screen. + // Full login mounts the workbench; inline recovery hides this host while + // retaining the already-mounted document session. + if (ownsPresentation(generation)) resetCredentialsAfterSuccess(); } catch (err) { + if (!ownsPresentation(generation)) return; busy = null; - app.showLogin(errMsg(err)); + connectBtn.replaceChildren(h('span', null, 'Connect'), Icon.arrow()); + reportError(errMsg(err)); + if (ownsPresentation(generation)) update(); } } - async function doSso(idpId: string, btn: HTMLButtonElement): Promise { - if (busy) return; + async function doSso(idpId: string, btn: HTMLButtonElement, label: string): Promise { + if (disposed || busy) return; + const generation = presentationGeneration; busy = 'sso'; btn.disabled = true; btn.replaceChildren(h('span', null, 'Redirecting…')); try { await app.actions.login(idpId); } catch (err) { + if (!ownsPresentation(generation)) return; busy = null; - app.showLogin(errMsg(err)); + btn.replaceChildren(Icon.shield(), h('span', null, label)); + reportError(errMsg(err)); + if (ownsPresentation(generation)) update(); } } + + function reportError(msg: string): void { + if (mode === 'full') app.showLogin(msg); + else setError(msg); + } + + function focus(): void { + if (disposed || mode === 'inline' && (target as HTMLElement).hidden) return; + const control = container.querySelector('input:not([disabled])') + ?? container.querySelector('.login-sso button:not([disabled])') + ?? container.querySelector('select:not([disabled])') + ?? container.querySelector('button:not([disabled])'); + control?.focus(); + } + + const mount: LoginMount = { + container, + show: (msg?: string) => { + if (disposed) return; + if (mode === 'inline') beginInlinePresentation(); + setError(msg); + if (mode === 'inline') (target as HTMLElement).hidden = false; + focus(); + }, + hide: () => { + if (!disposed && mode === 'inline') (target as HTMLElement).hidden = true; + }, + focus, + dispose: () => { + if (disposed) return; + disposed = true; + if (container.parentNode === target) container.remove(); + if (mode === 'inline') (target as HTMLElement).hidden = true; + }, + }; + if (mode === 'inline') focus(); + return mount; } diff --git a/src/ui/results.ts b/src/ui/results.ts index 024f4950..ea5a6754 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -38,6 +38,10 @@ import type { ResultSource } from '../core/query-source.js'; import type { SchemaGraphFocus } from '../core/schema-graph.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; import type { ConnectionSession } from '../application/connection-session.js'; +import type { + AuthenticatedExecutionRegistration, + AuthenticatedExecutionScope, +} from '../application/authenticated-execution-scope.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; import type { AppPreferences, PreferenceKey } from '../application/app-preferences.js'; @@ -161,7 +165,11 @@ export interface ResultsApp { now(): number; elapsedMs(): number; wallNow(): number; - conn: Pick; + conn: Pick & { + chCtx: Pick; + }; + executionScope(): AuthenticatedExecutionScope | null; + requireAuthenticatedExecution(): AuthenticatedExecutionScope | null; /** The shared request/stream/normalize service (#276 Phase 1) — this module * only ever needs `executeRead` (the detached Data view's own re-run). */ exec: Pick; @@ -931,6 +939,7 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { let gen = 0; let running = false; let ac: AbortController | null = null; + let activeRegistration: AuthenticatedExecutionRegistration | null = null; let closed = false; let statEl: HTMLElement | null = null; let refreshBtn: HTMLButtonElement | null = null; @@ -989,6 +998,8 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { if (closed) return; const myGen = ++gen; if (ac) ac.abort(); // supersede any in-flight detached request + activeRegistration?.release(); + activeRegistration = null; ac = new AbortController(); const { signal } = ac; const batch = prepareParameterizedBatch(analysis, { @@ -1003,18 +1014,39 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { // and re-enables Refresh even if it just superseded an in-flight run. if (blockers.length) { settle('Enter a value for: ' + blockers.join(', ')); return; } if (src.errors.length) { settle(src.errors[0]); return; } + const scope = app.requireAuthenticatedExecution(); + if (!scope) { settle('Sign in required'); return; } + const queryId = `detached-${source.tabId}-${myGen}-${Math.round(app.now())}`; + const registration = scope.register({ + name: 'detached result refresh', + abort: () => { + if (myGen !== gen || closed) return; + gen += 1; + ac?.abort(); + settle('Sign in required'); + }, + getQueryId: () => queryId, + }); + activeRegistration = registration; + const releaseRegistration = (): void => { + registration.release(); + if (activeRegistration === registration) activeRegistration = null; + }; running = true; setStatus('Running…'); if (refreshBtn) refreshBtn.disabled = true; if (!(await app.conn.ensureFreshToken())) { - if (myGen === gen && !closed) settle('Not signed in'); + if (registration.isCurrent()) app.conn.chCtx.onSignedOut(); + if (myGen === gen && !closed) settle('Sign in required'); + releaseRegistration(); return; } + if (!registration.isCurrent() || myGen !== gen || closed) { releaseRegistration(); return; } const execution = panelExecution(savedPanel, mergedSourceSql(src, source.sql), { format: 'Table', rowLimit: source.rowLimit, params: { ...(sessionId ? { session_id: sessionId } : {}), ...mergedSourceArgs(src) }, }); - if (execution.error) { settle(execution.error); return; } + if (execution.error) { settle(execution.error); releaseRegistration(); return; } // `!`: `defaults.format: 'Table'` is always supplied above, so // `execution.format` is always populated whether or not the KPI arm // ends up owning transport (it overrides to 'KPI' rather than clearing it). @@ -1026,26 +1058,32 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { // Native param_ bindings + the captured session (when any). params: execution.params, signal, + queryId, + isCurrent: () => registration.isCurrent() && myGen === gen && !closed, // Progress-only streaming (#198): update the lightweight status text as // rows arrive, but NEVER paint the in-flight result and NEVER touch the // committed `current` / stat / view / chart — only the winning // completion below commits and repaints. A stale/closed chunk is dropped. onChunk: () => { - if (myGen !== gen || closed) return; + if (!registration.isCurrent() || myGen !== gen || closed) return; const rowsRead = Number(result.progress?.rows) || 0; setStatus(rowsRead > 0 ? `Running… ${formatRows(rowsRead)} rows read` : 'Running…'); }, }); - if (myGen !== gen || closed) return; // superseded or closed → discard silently + if (!registration.isCurrent() || myGen !== gen || closed) { + releaseRegistration(); + return; // superseded or closed → discard silently + } // The in-flight result was never painted, so failure/cancel needs no // restore repaint — the committed `current` is still on screen (#198). - if (result.cancelled) { settle(''); return; } - if (result.error) { settle(result.error); return; } + if (result.cancelled) { settle(''); releaseRegistration(); return; } + if (result.error) { settle(result.error); releaseRegistration(); return; } current = result; // #171: record the winning run's bound params via the shared recorder. app.params.recordBoundParams(src.statements.flatMap((s) => s.boundParams)); settle(''); paint(); + releaseRegistration(); } const toolbar = h('div', { class: 'res-toolbar' }, @@ -1100,6 +1138,8 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { // and destroy the live chart instance. return () => { closed = true; + activeRegistration?.release(); + activeRegistration = null; if (ac) ac.abort(); if (chartInstance) { chartInstance.destroy(); chartInstance = null; } variableBarDispose?.(); diff --git a/src/ui/shortcuts.ts b/src/ui/shortcuts.ts index 949f46f2..e7a67d7e 100644 --- a/src/ui/shortcuts.ts +++ b/src/ui/shortcuts.ts @@ -87,7 +87,7 @@ export interface SurfaceCommandPort { export interface ShortcutsApp { document?: Document; state: Pick; - conn: Pick; + conn: Pick; sqlRoute: Pick & { mode?: 'view' | 'edit' }; workspaceRouteStatus: 'loading' | 'ready' | 'not-found' | 'error'; surfaceCommands?: SurfaceCommandPort | null; @@ -262,7 +262,14 @@ export function handleKeydown(e: ShortcutKeydownEvent, app: ShortcutsApp): strin if (!ready(app)) { resetShortcutChord(app); return null; } const mod = modKey(e); if (ownsKeyboard(app)) { resetShortcutChord(app); return null; } - if (!app.conn.isSignedIn()) { resetShortcutChord(app); return null; } + // A mounted auth-required document session keeps local commands alive; + // server-backed actions dispatch into their shared execution-scope gate, + // which reveals the in-shell auth controls. Only terminal explicit sign-out + // disables the entire application shortcut surface. + if (!app.conn.isSignedIn() && app.conn.connection.value.kind === 'signed-out') { + resetShortcutChord(app); + return null; + } if (e.key === 'Escape') resetShortcutChord(app); const command = visibleDefinitions(app).find((definition) => definition.matches?.(e, app)); if (command?.run) return command.run(e, app); diff --git a/src/ui/workbench/workbench-session.ts b/src/ui/workbench/workbench-session.ts index 94b9d3cb..1d262831 100644 --- a/src/ui/workbench/workbench-session.ts +++ b/src/ui/workbench/workbench-session.ts @@ -38,6 +38,10 @@ import { } from '../../core/variable-options.js'; import type { QueryResult, ScriptResult, ScriptEntry } from '../results.js'; import type { QueryExecutionService } from '../../application/query-execution-service.js'; +import type { + AuthenticatedExecutionRegistration, + AuthenticatedExecutionScope, +} from '../../application/authenticated-execution-scope.js'; // ── The state slice run()/runScript()/runEntry()/cancel() touch ──────────── // Pick-shaped, structurally satisfied by the real `AppState` (state.ts) — a @@ -147,6 +151,10 @@ export interface WorkbenchSessionDeps { state: WorkbenchStateSlice; activeTab(): QueryTab; hooks: WorkbenchHooks; + /** The authenticated epoch currently permitted to own new work. A null + * provider preserves the unauthenticated/test seam; a returned scope fences + * every async workbench wave through auth loss. */ + executionScope(): AuthenticatedExecutionScope | null; } // ── attachShell: the 3 run-coupled effects, verbatim from renderApp ───────── @@ -225,6 +233,65 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes return runT0 != null ? deps.now() - runT0 : 0; } + function isCurrent(registration: AuthenticatedExecutionRegistration | null): boolean { + return registration === null || registration.isCurrent(); + } + + /** Clears only the bookkeeping still owned by this wave. A scope close can + * make a new epoch runnable before an old transport settles, so its finally + * block must never clear the replacement wave's state. */ + function settleWave(controller: AbortController): void { + if (abortController !== controller) return; + if (runTick != null) { + clearInterval(runTick); + runTick = null; + } + abortController = null; + runQueryId = null; + runT0 = null; + state.running.value = false; + } + + function registerWave( + name: string, + controller: AbortController, + queryId: () => string | null, + ): AuthenticatedExecutionRegistration | null { + const scope = deps.executionScope(); + return scope?.register({ + name, + abort: () => { + controller.abort(); + // Scope close must settle the shell synchronously, but intentionally + // leaves the tab's last result in place for the login transition. + settleWave(controller); + }, + getQueryId: queryId, + }) || null; + } + + /** Complete the auth/config preflight for a registered wave. A rejected + * preflight must remain visible to the caller, but it has no transport + * finally block to release the registration for it. */ + async function preflightWave(registration: AuthenticatedExecutionRegistration | null): Promise { + let continueWave = false; + try { + await deps.ensureConfig(); + if (!isCurrent(registration)) return false; + if (!(await deps.getToken())) { + if (isCurrent(registration)) hooks.onAuthFailed(); + return false; + } + if (!isCurrent(registration)) return false; + continueWave = true; + return true; + } finally { + // The transport finally owns a successfully preflighted registration. + // Every early return and rejected preflight is released here instead. + if (!continueWave) registration?.release(); + } + } + async function run(opts?: RunOpts): Promise { if (state.running.value) return; // already running — cancel via cancel()/Esc const tab = deps.activeTab(); @@ -249,8 +316,10 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes // recent-value recording (#171), so it reads exactly the boundParams that // were sent. const src = hooks.prepareTabSource(srcSql, waveMs); - await deps.ensureConfig(); - if (!(await deps.getToken())) { hooks.onAuthFailed(); return; } + const controller = new AbortController(); + let waveQueryId: string | null = null; + const registration = registerWave('workbench run', controller, () => waveQueryId); + if (!(await preflightWave(registration))) return; hooks.cancelSchemaGraph(); // a Run/Explain takes over the result — don't leave a lineage fetch running @@ -275,6 +344,7 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes Object.assign(tab, { result: kpiErrorResult }); state.resultView.value = 'panel'; hooks.renderResults(); + registration?.release(); return; } @@ -318,8 +388,9 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes if (explainView) result.explainView = explainView; state.resultSort = { col: null, dir: 'asc' }; runT0 = t0; - runQueryId = deps.uid('q'); - abortController = new AbortController(); + waveQueryId = deps.uid('q'); + runQueryId = waveQueryId; + abortController = controller; runTick = setInterval(hooks.tickElapsed, 100); // Keep the current Table/JSON/Panel tab across re-runs (#34); a saved-query // open passes its remembered view in opts.view to restore that instead @@ -340,20 +411,23 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes sql: runSql, format: fmt, rowLimit, - queryId: runQueryId, - signal: abortController.signal, + queryId: waveQueryId, + signal: controller.signal, + isCurrent: () => isCurrent(registration), // Native ClickHouse query parameters (#134/#173): pass prepared values // as param_ so the server substitutes them (only row-returning // statements bind — a CREATE VIEW / DDL source stays verbatim). params: { ...hooks.sessionParamsFor(tab, [srcSql]), ...mergedSourceArgs(src), ...kpiExecution.params }, - onChunk: () => hooks.renderResults(), + onChunk: () => { + if (isCurrent(registration)) hooks.renderResults(); + }, }); } finally { - clearInterval(runTick as ReturnType); - runTick = null; - abortController = null; - runQueryId = null; - runT0 = null; + if (!isCurrent(registration)) { + settleWave(controller); + registration?.release(); + return; + } result.progress.elapsed_ns = (deps.now() - t0) * 1e6; // #185: capture the source that produced a normal, row-returning // structured result (fmt 'Table', so raw FORMAT / EXPLAIN are excluded; @@ -396,6 +470,8 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes hooks.recordBoundParams(src.statements.flatMap((s) => s.boundParams) as BoundParamSnapshot[]); if (isSchemaMutatingSql(runSql)) hooks.loadSchema(); // not awaited — fire and forget } + settleWave(controller); + registration?.release(); } } @@ -446,8 +522,10 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes hooks.renderResults(); return; } - await deps.ensureConfig(); - if (!(await deps.getToken())) { hooks.onAuthFailed(); return; } + const controller = new AbortController(); + let waveQueryId: string | null = null; + const registration = registerWave('workbench variable option', controller, () => waveQueryId); + if (!(await preflightWave(registration))) return; hooks.cancelSchemaGraph(); state.forceExplain = false; @@ -465,8 +543,9 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes Object.assign(tab, { result }); state.resultSort = { col: null, dir: 'asc' }; runT0 = t0; - runQueryId = deps.uid('q'); - abortController = new AbortController(); + waveQueryId = deps.uid('q'); + runQueryId = waveQueryId; + abortController = controller; runTick = setInterval(hooks.tickElapsed, 100); state.running.value = true; @@ -475,25 +554,28 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes sql: compileOptionProbe(sql), format: 'Table', rowLimit, - queryId: runQueryId, - signal: abortController.signal, + queryId: waveQueryId, + signal: controller.signal, + isCurrent: () => isCurrent(registration), params: { readonly: 2, max_result_bytes: VARIABLE_OPTION_BYTE_CAP }, - onChunk: () => hooks.renderResults(), + onChunk: () => { + if (isCurrent(registration)) hooks.renderResults(); + }, }); // Only the probe's own transport succeeding makes its response metadata // meaningful — a transport error or a cancellation already has its own // story and must not be overwritten by a shape verdict about a response // that never fully arrived. - if (!result.error && !result.cancelled) { + if (isCurrent(registration) && !result.error && !result.cancelled) { const shape = validateOptionColumns(result.columns); if (shape) result.error = shape.message; } } finally { - clearInterval(runTick as ReturnType); - runTick = null; - abortController = null; - runQueryId = null; - runT0 = null; + if (!isCurrent(registration)) { + settleWave(controller); + registration?.release(); + return; + } result.progress.elapsed_ns = (deps.now() - t0) * 1e6; // #185, same ordering `run()` uses and for the same reason: this MUST // run before the `running` flip below, which fires the results effect @@ -505,6 +587,8 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes } state.running.value = false; if (!result.error && !result.cancelled) hooks.recordHistory(tab, sql); + settleWave(controller); + registration?.release(); } } @@ -526,8 +610,10 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes // gate and args see the same varValues snapshot; edits during the auth // await apply to the next run. const paramSrc = hooks.prepareTabSource(originalInput, waveMs); - await deps.ensureConfig(); - if (!(await deps.getToken())) { hooks.onAuthFailed(); return; } + const controller = new AbortController(); + let waveQueryId: string | null = null; + const registration = registerWave('workbench script', controller, () => waveQueryId); + if (!(await preflightWave(registration))) return; hooks.cancelSchemaGraph(); // a script run takes over the result — don't leave a lineage fetch running state.forceExplain = false; @@ -538,7 +624,7 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes Object.assign(tab, { result: scriptResult }); state.resultSort = { col: null, dir: 'asc' }; runT0 = t0; - abortController = new AbortController(); + abortController = controller; runTick = setInterval(hooks.tickElapsed, 100); let aborted = false; // Attach a session only if the script needs one (TEMPORARY / SET) or the tab @@ -565,11 +651,17 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes // script is sent with its {name:Type} placeholders intact. params: { ...sp, ...paramSrc.statements[i].args }, })), - signal: abortController.signal, + signal: controller.signal, + isCurrent: () => isCurrent(registration), // Fresh query_id per attempt, published before the request so Cancel // issues KILL QUERY against the statement that's actually running. - onStatementStart: (_i, { queryId }) => { runQueryId = queryId; }, + onStatementStart: (_i, { queryId }) => { + if (!isCurrent(registration)) return; + waveQueryId = queryId; + runQueryId = queryId; + }, onStatementResult: (i, entry) => { + if (!isCurrent(registration)) return; entries.push(entry); // #171: THIS statement succeeded — record its own boundParams (per // statement, not per script: statement 1 of a later-failing script @@ -578,13 +670,13 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes hooks.renderResults(); }, }); - aborted = res.aborted; + if (isCurrent(registration)) aborted = res.aborted; } finally { - clearInterval(runTick as ReturnType); - runTick = null; - abortController = null; - runQueryId = null; - runT0 = null; + if (!isCurrent(registration)) { + settleWave(controller); + registration?.release(); + return; + } scriptResult.elapsedMs = deps.now() - t0; if (aborted) scriptResult.cancelled = true; state.running.value = false; @@ -598,6 +690,8 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes recordScriptHistory(state, originalInput, scriptResult.elapsedMs!, hooks.saveJSON); if (state.sidePanel.value === 'history') hooks.renderSavedHistory(); } + settleWave(controller); + registration?.release(); } } diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index b239d303..3297ffb1 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -39,6 +39,7 @@ import type { ChartJsConfigResult } from '../../src/core/chart-data.js'; import type { ChartInstance } from '../../src/ui/chart-render.js'; import type { QueryExecutionService } from '../../src/application/query-execution-service.js'; import type { ConnectionSession } from '../../src/application/connection-session.js'; +import { createAuthenticatedExecutionScope } from '../../src/application/authenticated-execution-scope.js'; import type { SchemaCatalogService } from '../../src/application/schema-catalog-service.js'; import type { SchemaGraphSession } from '../../src/application/schema-graph-session.js'; import type { AppPreferences } from '../../src/application/app-preferences.js'; @@ -232,6 +233,7 @@ const connDefaults: ConnectionSession = { idpId: () => null, chAuth: () => 'basic', basicUserClaim: () => 'sub', + basicRecoveryOrigin: () => null, isSignedIn: () => true, email: () => '', host: () => '', @@ -245,6 +247,7 @@ const connDefaults: ConnectionSession = { connectBasic: async () => {}, signOut: () => {}, ensureFreshToken: async () => true, + captureCancellationLease: () => null, }; // A stand-in for the Chart.js constructor: records its canvas + config and @@ -377,6 +380,7 @@ const savedDefaults: SavedQueryService = { const graphDefaults: SchemaGraphSession = { show: vi.fn(async () => {}), cancel: vi.fn(), + suspend: vi.fn(), expand: vi.fn(async () => ({ nodes: [], edges: [], focus: {}, truncated: false, savedPositions: {} })), loadNodeDetail: vi.fn(async () => null), }; @@ -403,6 +407,9 @@ const appDefaults: App = { root: null, document, conn: connDefaults, + executionScope: () => null, + resumeAuthenticatedExecution: () => {}, + requireAuthenticatedExecution: () => null, catalog: catalogDefaults, params: paramsDefaults, graph: graphDefaults, @@ -660,6 +667,10 @@ type AppOverrides = Partial>(overrides: O = {} as O) { const state = createState({ loadStr: (k, d) => d, loadJSON: (k, d) => d }); const root = document.createElement('div'); + const executionScope = createAuthenticatedExecutionScope({ + epoch: 1, + cancelRemote: async () => {}, + }); // Resolved BEFORE `base` so the `mutateWorkspace` queue below (which needs to // call `.loadById()`/`.commit()` on the SAME repository the rest of the // fake app sees) can close over it directly — the final `workspace` field is @@ -818,6 +829,8 @@ export function makeApp>(override elapsedMs: () => 0, now: () => 0, wallNow: () => 0, // the #173 wave wall clock (epoch ms; fixed in tests) + executionScope: () => executionScope, + requireAuthenticatedExecution: () => executionScope, showLogin: vi.fn(), signOut: vi.fn(), // Concrete (non-optional) elements — most consumers read these diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts new file mode 100644 index 00000000..2d77f7b6 --- /dev/null +++ b/tests/unit/app-shell.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mountAppShell } from '../../src/ui/app-shell.js'; +import { startDrag } from '../../src/ui/splitters.js'; +import { makeApp } from '../helpers/fake-app.js'; + +function mount() { + const loadSchema = vi.fn(async () => {}); + const loadReference = vi.fn(async () => {}); + const save = vi.fn(); + const app = makeApp({ + catalog: { loadSchema, loadReference }, + prefs: { save }, + }); + const handle = mountAppShell({ + app, + root: app.root, + document, + state: app.state, + catalog: app.catalog, + prefs: app.prefs, + matchMedia: null, + updateBanner: vi.fn(), + startDrag, + }); + return { app, handle, loadSchema, loadReference }; +} + +describe('mountAppShell authentication host', () => { + it('exposes one stable, hidden, labelled host immediately below the header', () => { + const { app, handle, loadSchema, loadReference } = mount(); + const children = [...app.root.children]; + + expect(children[0].classList.contains('app-header-slot')).toBe(true); + expect(children[1]).toBe(handle.authHost); + expect(app.dom.authHost).toBe(handle.authHost); + expect(handle.authHost.className).toBe('auth-host'); + expect(handle.authHost.hidden).toBe(true); + expect(handle.authHost.getAttribute('role')).toBe('region'); + expect(handle.authHost.getAttribute('aria-label')).toBe('Authentication required'); + expect(app.root.querySelectorAll('.auth-host')).toHaveLength(1); + expect(loadSchema).toHaveBeenCalledTimes(1); + expect(loadReference).toHaveBeenCalledTimes(1); + + handle.dispose(); + }); + + it('keeps the authentication host mounted while headers and work surfaces switch', () => { + const { app, handle } = mount(); + const host = handle.authHost; + const controls = document.createElement('div'); + controls.className = 'login-inline'; + host.replaceChildren(controls); + host.hidden = false; + const header = document.createElement('header'); + + handle.setHeader(header); + handle.showHost('dashboard'); + expect(handle.authHost).toBe(host); + expect(app.root.children[1]).toBe(host); + expect(host.firstElementChild).toBe(controls); + expect(host.hidden).toBe(false); + expect(handle.queryHost.hidden).toBe(true); + expect(handle.dashboardHost.hidden).toBe(false); + + handle.showHost('query'); + expect(app.root.children[1]).toBe(host); + expect(host.firstElementChild).toBe(controls); + expect(handle.queryHost.hidden).toBe(false); + expect(handle.dashboardHost.hidden).toBe(true); + + handle.dispose(); + expect(app.root.children[1]).toBe(host); + expect(host.firstElementChild).toBe(controls); + }); +}); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 545004da..7a9d7545 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -25,6 +25,7 @@ import { fakeIndexedDbFactory } from '../helpers/fake-idb.js'; import { fakeBroadcastBus } from '../helpers/fake-broadcast.js'; import { decodeShare } from '../../src/core/share.js'; import { flashToast } from '../../src/ui/toast.js'; +import { applyConnectionStatus } from '../../src/ui/app-header.js'; import type { CreateAppEnv, BroadcastChannelPort } from '../../src/env.types.js'; import type { WorkspaceCommitResult, WorkspaceLoadResult, WorkspaceMarkOpenedResult, @@ -38,6 +39,9 @@ import type { } from '../../src/generated/json-schema.types.js'; import type { CompletionItem, AssembledReference } from '../../src/core/completions.js'; import type { ConnectionLifecycleState } from '../../src/core/connection-lifecycle.js'; +import type { + AuthenticatedExecutionRegistration, AuthenticatedExecutionScope, +} from '../../src/application/authenticated-execution-scope.js'; import type { QueryResult, ScriptResult, ScriptExportResult, ScriptEntry, ScriptExportEntry, ResultSchemaGraph, } from '../../src/ui/results.js'; @@ -220,6 +224,31 @@ const asFetch = (v: object): typeof globalThis.fetch => v as typeof globalThis.f // this reads back the mock-inspection surface (`.mock.calls`, `.mockClear()`) // the real `fetch` type doesn't carry. const asMock = (fn: typeof fetch): Mock => fn as Mock; + +/** A deterministic execution-scope double for controller race tests. Each + * registered operation gets its own scripted `isCurrent()` sequence, so tests + * can model a scope closing at a precise await boundary without exposing app + * internals merely for coverage. */ +function scriptedExecutionScope(...sequences: boolean[][]): AuthenticatedExecutionScope { + let registrationIndex = 0; + return { + epoch: 1, + closed: false, + isOpen: () => true, + isCurrent: (registration) => registration.isCurrent(), + register: ({ name }) => { + const sequence = sequences[registrationIndex++] ?? [true]; + let call = 0; + const registration: AuthenticatedExecutionRegistration = { + name, + release: () => {}, + isCurrent: () => sequence[Math.min(call++, sequence.length - 1)] ?? true, + }; + return registration; + }, + close: () => {}, + }; +} // A general-purpose variant for a mock hiding behind a plain function-typed // interface member (e.g. `FakeWritable.write`, declared as a bare // `(chunk: Uint8Array) => Promise` so the object literal satisfies the @@ -849,6 +878,19 @@ describe('app workspace refresh + conflict UI (#343)', () => { expect(spy).not.toHaveBeenCalled(); }); + it('projects an externally deleted workspace as not-found during queued refresh', async () => { + const app = createApp(env()); + await seedActiveWorkspace(app, q1ws()); + app.renderCurrentSurface = vi.fn(); + app.workspace.loadById = vi.fn(async () => ({ status: 'empty' as const })); + + await app.refreshWorkspaceFromStore(); + + expect(app.currentWorkspace).toBeNull(); + expect(app.workspaceRouteStatus).toBe('not-found'); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + // #466/#501-review: `syncBeforeUnload` installs/removes the `beforeunload` // listener as the aggregate dirty state flips, rather than registering it // once and checking inside — Firefox (and older Chromium) disqualify a page @@ -1196,6 +1238,131 @@ describe('renderApp shell', () => { expect(chip.title).toBe('ch.example'); } }); + it('suspends and resumes authentication without replacing the in-memory document session', async () => { + const { app } = rendered(); + const addEventListener = vi.spyOn(window, 'addEventListener'); + const firstTab = app.activeTab(); + firstTab.sqlDraft = 'SELECT 42'; + firstTab.dirtySql = true; + firstTab.result = { marker: 'first retained result' }; + app.actions.newTab(); + const secondTab = app.activeTab(); + app.sqlEditor.replaceDocument('SELECT 84'); + app.dom.sqlEditorView!.dispatch({ selection: { anchor: 4 } }); + await Promise.resolve(); // let CodeMirror's selection observer settle + secondTab.specText = '{"name":'; + secondTab.specParsed = null; + secondTab.dirtySpec = true; + secondTab.result = { marker: 'second retained result' }; + app.syncBeforeUnload(); + + const tabsRef = app.state.tabs.value; + const firstResultRef = firstTab.result; + const secondResultRef = secondTab.result; + const activeTabId = app.state.activeTabId.value; + const nextTabId = app.state.nextTabId; + const routeRef = app.sqlRoute; + const editorView = app.dom.sqlEditorView; + const selection = { + anchor: editorView!.state.selection.main.anchor, + head: editorView!.state.selection.main.head, + }; + const beforeUnloadListener = addEventListener.mock.calls + .find(([type]) => type === 'beforeunload')?.[1] as EventListener | undefined; + expect(beforeUnloadListener).toBeTypeOf('function'); + const editorHost = app.dom.sqlEditorHost; + const resultsRegion = app.dom.resultsRegion; + const workbench = qs(app.root, '.workbench'); + const oldScope = app.executionScope(); + + app.conn.chCtx.onSignedOut('Session expired'); + + expect(oldScope?.closed).toBe(true); + expect(app.executionScope()).toBeNull(); + expect(qs(app.root, '.login-screen')).toBeNull(); + expect(qs(app.root, '.auth-host:not([hidden]) .login-inline')).not.toBeNull(); + expect(qs(app.root, '.conn-status').classList.contains('tone-error')).toBe(true); + expect(app.state.tabs.value).toBe(tabsRef); + expect(app.state.tabs.value).toEqual([firstTab, secondTab]); + expect(app.state.activeTabId.value).toBe(activeTabId); + expect(app.state.nextTabId).toBe(nextTabId); + expect(app.sqlRoute).toBe(routeRef); + expect(app.activeTab()).toBe(secondTab); + expect(firstTab.sqlDraft).toBe('SELECT 42'); + expect(firstTab.dirtySql).toBe(true); + expect(firstTab.result).toBe(firstResultRef); + expect(secondTab.sqlDraft).toBe('SELECT 84'); + expect(secondTab.specText).toBe('{"name":'); + expect(secondTab.dirtySql).toBe(true); + expect(secondTab.dirtySpec).toBe(true); + expect(secondTab.result).toBe(secondResultRef); + expect(app.dom.sqlEditorView).toBe(editorView); + expect(app.sqlEditor.getValue()).toBe('SELECT 84'); + expect(editorView!.state.selection.main).toMatchObject(selection); + expect(app.dom.sqlEditorHost).toBe(editorHost); + expect(app.dom.resultsRegion).toBe(resultsRegion); + expect(qs(app.root, '.workbench')).toBe(workbench); + const suspendedUnload = new Event('beforeunload', { cancelable: true }); + beforeUnloadListener!(suspendedUnload); + expect(suspendedUnload.defaultPrevented).toBe(true); + + // Server-backed documentation actions share the same auth gate while the + // local document shell remains usable. + expect(app.openDocEntry({ kind: 'function', name: 'sum' })).toBeUndefined(); + expect(app.openDocDisambiguation('sum')).toBeUndefined(); + expect(qs(app.root, '[role="complementary"]')).toBeNull(); + + await app.actions.connect({ username: 'demo', password: 'secret', host: '' }); + + expect(app.executionScope()).not.toBe(oldScope); + expect(app.executionScope()?.isOpen()).toBe(true); + expect(qs(app.root, '.auth-host').hidden).toBe(true); + expect(qs(app.root, '.conn-status').classList.contains('tone-success')).toBe(true); + expect(app.state.tabs.value).toBe(tabsRef); + expect(app.activeTab()).toBe(secondTab); + expect(firstTab.result).toBe(firstResultRef); + expect(secondTab.result).toBe(secondResultRef); + expect(secondTab.dirtySql).toBe(true); + expect(secondTab.dirtySpec).toBe(true); + expect(app.sqlRoute).toBe(routeRef); + expect(app.dom.sqlEditorView).toBe(editorView); + expect(app.sqlEditor.getValue()).toBe('SELECT 84'); + expect(editorView!.state.selection.main).toMatchObject(selection); + expect(app.dom.sqlEditorHost).toBe(editorHost); + expect(app.dom.resultsRegion).toBe(resultsRegion); + expect(qs(app.root, '.workbench')).toBe(workbench); + expect(qs(app.dom.userBtn!, '.user-short').textContent).toBe('demo'); + + // Leave no real beforeunload listener behind for later tests. + for (const tab of tabsRef) { tab.dirtySql = false; tab.dirtySpec = false; } + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + it('reuses an already-open execution scope when resume is requested again', () => { + const { app } = rendered(); + const scope = app.executionScope(); + app.resumeAuthenticatedExecution(); + expect(app.executionScope()).toBe(scope); + expect(scope?.isOpen()).toBe(true); + }); + it('refreshes the mounted header identity defensively when Basic resume changes users', () => { + const { app } = rendered(); + // The status projection can run while a shell is being torn down, before + // either optional identity node exists. It must still update the chip. + app.dom.userBtn = undefined; + expect(() => applyConnectionStatus(app)).not.toThrow(); + + const bareUserButton = document.createElement('button'); + app.dom.userBtn = bareUserButton; + applyConnectionStatus(app); + expect(bareUserButton.title).toBe('me@example.com'); + }); + it('builds the dark header icon when the saved theme is dark', () => { + const app = createApp(env()); + app.state.theme = 'dark'; + app.renderApp(); + expect(app.dom.themeBtn?.querySelector('svg')).not.toBeNull(); + }); it('toggles theme via the header button', () => { const { app } = rendered(); app.dom.themeBtn!.dispatchEvent(new Event('click')); // default light → dark @@ -3117,11 +3284,25 @@ describe('formatQuery', () => { await app.actions.formatQuery(); expect(e.fetch).not.toHaveBeenCalled(); }); - it('signs out when there is no usable token', async () => { + it('suspends in place when there is no usable token', async () => { const { app } = appFor([], { sessionStorage: memSession({}) }); // no token app.activeTab().sqlDraft = 'select 1'; await app.actions.formatQuery(); - expect(qs(app.root, '.login-screen')).not.toBeNull(); + expect(qs(app.root, '.login-screen')).toBeNull(); + expect(qs(app.root, '.auth-host:not([hidden]) .login-inline')).not.toBeNull(); + }); + it('suspends an already-started formatting scope when its token is no longer usable', async () => { + const { app, e } = appFor([], { sessionStorage: memSession({}) }); + // This deliberately creates the disposable execution scope before the + // action reaches its token check, exercising the mid-operation path (not + // merely the outer action gate used by a fresh signed-out screen). + app.resumeAuthenticatedExecution(); + app.activeTab().sqlDraft = 'select 1'; + asMock(e.fetch!).mockClear(); + await app.actions.formatQuery(); + expect(app.executionScope()).toBeNull(); + expect(e.fetch).not.toHaveBeenCalled(); + expect(qs(app.root, '.auth-host:not([hidden]) .login-inline')).not.toBeNull(); }); it('shows a format error persistently in the results panel and moves the caret to it', async () => { const { app } = appFor([ @@ -3218,6 +3399,67 @@ describe('formatQuery', () => { const noRender = createApp(env()); // no renderApp → no fmtBtn expect(() => noRender.setFmtBtn(true)).not.toThrow(); }); + it('drops a late format completion after authentication closes its execution scope', async () => { + let resolveFormat: ((value: FakeResponse) => void) | undefined; + const { app } = appFor([ + [(u, sql) => /formatQuery/.test(sql), () => new Promise((resolve) => { resolveFormat = resolve; })], + ]); + app.activeTab().sqlDraft = 'select 1'; + app.sqlEditor.replaceDocument('select 1'); + + const formatting = app.actions.formatQuery(); + await new Promise((resolve) => setTimeout(resolve)); + expect(resolveFormat).toBeTypeOf('function'); + app.conn.chCtx.onSignedOut('expired'); + expect(app.dom.fmtBtn!.disabled).toBe(false); + + resolveFormat?.(resp({ json: { data: [{ q: 'SELECT 1' }] } })); + await formatting; + expect(app.sqlEditor.getValue()).toBe('select 1'); + expect(qs(app.root, '.auth-host:not([hidden]) .login-inline')).not.toBeNull(); + }); + it('fences every formatter await boundary when its execution scope turns stale', async () => { + const staleBeforeToken = appFor([]).app; + staleBeforeToken.activeTab().sqlDraft = 'select 1'; + const firstScope = scriptedExecutionScope([false]); + staleBeforeToken.executionScope = () => firstScope; + staleBeforeToken.requireAuthenticatedExecution = () => firstScope; + await staleBeforeToken.actions.formatQuery(); // stale after config + + const staleStatement = appFor([]).app; + staleStatement.activeTab().sqlDraft = 'select 1'; + const secondScope = scriptedExecutionScope([true, true], [false]); + staleStatement.executionScope = () => secondScope; + staleStatement.requireAuthenticatedExecution = () => secondScope; + await staleStatement.actions.formatQuery(); // statement registers after closure + + const staleScript = appFor([ + [(u, sql) => /formatQuery/.test(sql), resp({ json: { data: [{ q: 'SELECT 1' }] } })], + ]).app; + staleScript.activeTab().sqlDraft = 'select 1; select 2'; + const thirdScope = scriptedExecutionScope([true, true, false], [true], [true]); + staleScript.executionScope = () => thirdScope; + staleScript.requireAuthenticatedExecution = () => thirdScope; + await staleScript.actions.formatQuery(); // stale after all statement requests settle + expect(staleScript.sqlEditor.getValue()).not.toContain('SELECT 1;\n\nSELECT 1'); + + const staleFailure = appFor([ + [(u, sql) => /formatQuery/.test(sql), resp({ ok: false, status: 500, text: '{"exception":"format fails"}' })], + ]).app; + staleFailure.activeTab().sqlDraft = 'select 1'; + const fourthScope = scriptedExecutionScope([true, true, false], [true]); + staleFailure.executionScope = () => fourthScope; + staleFailure.requireAuthenticatedExecution = () => fourthScope; + await staleFailure.actions.formatQuery(); // a stale rejection must not paint a format error + expect(staleFailure.activeTab().result).toBeNull(); + + const staleAfterToken = appFor([]).app; + staleAfterToken.activeTab().sqlDraft = 'select 1'; + const fifthScope = scriptedExecutionScope([true, false]); + staleAfterToken.executionScope = () => fifthScope; + staleAfterToken.requireAuthenticatedExecution = () => fifthScope; + await staleAfterToken.actions.formatQuery(); // stale after token acquisition + }); }); describe('insertCreate', () => { @@ -3260,10 +3502,49 @@ describe('insertCreate', () => { expect(app.sqlEditor.getValue()).toBe('keep'); expect(qs(document.body, '.share-toast')).not.toBeNull(); }); - it('signs out when there is no usable token', async () => { + it('suspends in place when there is no usable token', async () => { const { app } = appFor([], { sessionStorage: memSession({}) }); await app.actions.insertCreate('db.t'); - expect(qs(app.root, '.login-screen')).not.toBeNull(); + expect(qs(app.root, '.login-screen')).toBeNull(); + expect(qs(app.root, '.auth-host:not([hidden]) .login-inline')).not.toBeNull(); + }); + it('suspends an already-started SHOW CREATE scope when its token is no longer usable', async () => { + const { app, e } = appFor([], { sessionStorage: memSession({}) }); + app.resumeAuthenticatedExecution(); + app.sqlEditor.replaceDocument('keep'); + asMock(e.fetch!).mockClear(); + await app.actions.insertCreate('db.t'); + expect(app.executionScope()).toBeNull(); + expect(e.fetch).not.toHaveBeenCalled(); + expect(app.sqlEditor.getValue()).toBe('keep'); + }); + it('discards a SHOW CREATE response that arrives after the scope closes', async () => { + let resolveShow: ((value: FakeResponse) => void) | undefined; + const { app } = appFor([ + [(u, sql) => /SHOW CREATE/.test(sql), () => new Promise((resolve) => { resolveShow = resolve; })], + ]); + app.sqlEditor.replaceDocument('keep'); + const inserting = app.actions.insertCreate('db.t'); + await new Promise((resolve) => setTimeout(resolve)); + expect(resolveShow).toBeTypeOf('function'); + app.conn.chCtx.onSignedOut('expired'); + resolveShow?.(resp({ json: { data: [{ statement: 'CREATE TABLE db.t (a Int)' }] } })); + await inserting; + expect(app.sqlEditor.getValue()).toBe('keep'); + }); + it('fences SHOW CREATE before and after its token check when the scope turns stale', async () => { + const staleBeforeToken = appFor([]).app; + const firstScope = scriptedExecutionScope([false]); + staleBeforeToken.executionScope = () => firstScope; + staleBeforeToken.requireAuthenticatedExecution = () => firstScope; + await staleBeforeToken.actions.insertCreate('db.t'); + + const staleAfterToken = appFor([]).app; + const secondScope = scriptedExecutionScope([true, false]); + staleAfterToken.executionScope = () => secondScope; + staleAfterToken.requireAuthenticatedExecution = () => secondScope; + await staleAfterToken.actions.insertCreate('db.t'); + expect(staleAfterToken.sqlEditor.getValue()).toBe(''); }); }); @@ -3320,11 +3601,12 @@ describe('openCreateInNewTab (#180)', () => { expect(app.sqlEditor.getValue()).toBe('keep'); expect(qs(document.body, '.share-toast')).not.toBeNull(); }); - it('signs out when there is no usable token, creating no tab', async () => { + it('suspends in place when there is no usable token, creating no tab', async () => { const { app } = appFor([], { sessionStorage: memSession({}) }); const priorCount = app.state.tabs.value.length; await app.actions.openCreateInNewTab('db1.events', 'db1.events'); - expect(qs(app.root, '.login-screen')).not.toBeNull(); + expect(qs(app.root, '.login-screen')).toBeNull(); + expect(qs(app.root, '.auth-host:not([hidden]) .login-inline')).not.toBeNull(); expect(app.state.tabs.value.length).toBe(priorCount); }); }); @@ -4900,6 +5182,15 @@ describe('schema lineage graph (drag a db/table onto the results pane)', () => { const { app: app3 } = appForRun([[(u, sql) => /system\.tables/.test(sql), resp({ ok: false, status: 500, text: 'boom' })]]); await app3.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); expect(qs(document.body, '.graph-overlay')).toBeNull(); + // Auth can close after the action gate but before expand's first async + // continuation. The shell opened synchronously remains inert rather than + // rendering stale card data. + const { app: app4 } = appForRun(lineageRoutes, { openWindow: () => null }); + const staleScope = scriptedExecutionScope([false]); + app4.executionScope = () => staleScope; + app4.requireAuthenticatedExecution = () => staleScope; + await app4.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); + expect(qs(document.body, '.graph-overlay')).not.toBeNull(); }); it('openNodeDetail mounts the detail pane in the open overlay (and guards an incomplete node)', async () => { @@ -6070,8 +6361,10 @@ describe('unified /sql routing', () => { const { app } = liveApp(['a'], '?ws=ops&surface=dashboard'); app.renderCurrentSurface(); expect(app.surfaceCommands).not.toBeNull(); - // A 401 / expired token reaches `showLogin`, not `signOut` — the Dashboard's - // refresh and style shortcuts must not stay dispatchable from Login. + // This is the explicit full-screen `showLogin()` teardown API, not the + // ordinary onAuthLost path (which preserves the mounted document and + // reveals the inline reauthentication controls). Its Dashboard commands + // still must not remain dispatchable from the replacement Login screen. app.showLogin('Session expired'); expect(app.surfaceCommands).toBeNull(); expect(app.mainSurface).toEqual({ kind: 'query' }); @@ -6686,7 +6979,7 @@ describe('unified /sql routing', () => { expect(document.activeElement).not.toBe(oldFocus); }); - it('authentication loss tears down shortcut help and leaves only Login', () => { + it('explicit full-screen login teardown closes shortcut help and leaves only Login', () => { const app = createApp(env()); app.renderApp(); app.actions.openShortcuts(); diff --git a/tests/unit/authenticated-execution-scope.test.ts b/tests/unit/authenticated-execution-scope.test.ts new file mode 100644 index 00000000..666cbefe --- /dev/null +++ b/tests/unit/authenticated-execution-scope.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createAuthenticatedExecutionScope, + type AuthenticatedCancellationLease, +} from '../../src/application/authenticated-execution-scope.js'; + +const asFetch = (value: object): typeof fetch => value as typeof fetch; + +function makeLease(overrides: Partial = {}): AuthenticatedCancellationLease { + return { + epoch: 17, + origin: 'https://old-cluster.example', + authorization: 'Bearer opaque.old.token', + fetch: asFetch(vi.fn()), + ...overrides, + }; +} + +function makeScope(epoch = 17, cancelRemote = vi.fn()) { + return { cancelRemote, scope: createAuthenticatedExecutionScope({ epoch, cancelRemote }) }; +} + +describe('AuthenticatedExecutionScope', () => { + it('aborts registered work, passes the original lease and query id unchanged, and fences its handle', () => { + const lease = makeLease(); + const { scope, cancelRemote } = makeScope(); + const abort = vi.fn(() => { + expect(scope.closed).toBe(true); + expect(scope.isOpen()).toBe(false); + }); + const registration = scope.register({ name: 'workbench query', abort, getQueryId: () => 'query-123' }); + + expect(scope.epoch).toBe(17); + expect(registration.isCurrent()).toBe(true); + expect(scope.isCurrent(registration)).toBe(true); + + scope.close(lease); + + expect(abort).toHaveBeenCalledOnce(); + expect(cancelRemote).toHaveBeenCalledOnce(); + expect(cancelRemote).toHaveBeenCalledWith(lease, 'query-123'); + expect(registration.isCurrent()).toBe(false); + expect(scope.isCurrent(registration)).toBe(false); + }); + + it('is idempotent and re-entrant, even when an abort closes the scope again', () => { + const { scope, cancelRemote } = makeScope(); + const firstAbort = vi.fn(() => scope.close()); + const secondAbort = vi.fn(); + scope.register({ name: 'first', abort: firstAbort, getQueryId: () => 'q-first' }); + scope.register({ name: 'second', abort: secondAbort, getQueryId: () => 'q-second' }); + + scope.close(makeLease()); + scope.close(makeLease({ authorization: 'Bearer ignored-after-close' })); + + expect(firstAbort).toHaveBeenCalledOnce(); + expect(secondAbort).toHaveBeenCalledOnce(); + expect(cancelRemote.mock.calls).toEqual([ + [expect.any(Object), 'q-first'], + [expect.any(Object), 'q-second'], + ]); + }); + + it('allows released work to settle without later cancellation', () => { + const { scope, cancelRemote } = makeScope(); + const abort = vi.fn(); + const registration = scope.register({ name: 'finished export', abort, getQueryId: () => 'export-q' }); + + registration.release(); + registration.release(); + scope.close(makeLease()); + + expect(registration.isCurrent()).toBe(false); + expect(abort).not.toHaveBeenCalled(); + expect(cancelRemote).not.toHaveBeenCalled(); + }); + + it('does not treat a registration from another open scope as current', () => { + const first = makeScope().scope; + const second = makeScope().scope; + const foreign = second.register({ name: 'other session', abort: vi.fn() }); + + expect(foreign.isCurrent()).toBe(true); + expect(first.isCurrent(foreign)).toBe(false); + }); + + it('stops a late registration immediately and ignores absent, empty, and throwing query-id providers', () => { + const { scope, cancelRemote } = makeScope(); + const closeLease = makeLease({ authorization: 'Bearer refreshed-before-close' }); + scope.close(closeLease); + const lateAbort = vi.fn(); + const late = scope.register({ name: 'late', abort: lateAbort, getQueryId: () => 'late-q' }); + const noIdAbort = vi.fn(); + const noIdScope = makeScope(17, cancelRemote).scope; + noIdScope.register({ name: 'none', abort: noIdAbort }); + noIdScope.register({ name: 'empty', abort: vi.fn(), getQueryId: () => '' }); + noIdScope.register({ name: 'broken provider', abort: vi.fn(), getQueryId: () => { throw new Error('no id'); } }); + + noIdScope.close(makeLease()); + + expect(late.isCurrent()).toBe(false); + expect(lateAbort).toHaveBeenCalledOnce(); + expect(noIdAbort).toHaveBeenCalledOnce(); + expect(cancelRemote.mock.calls.map((call) => call[1])).toEqual(['late-q']); + expect(cancelRemote).toHaveBeenCalledWith(closeLease, 'late-q'); + }); + + it('uses the latest close lease, keeps Basic and Bearer authorization opaque, and swallows remote failures without retrying', async () => { + const basicLease = makeLease({ + origin: 'https://basic-cluster.example/base', + authorization: 'Basic ZGVtbzpwQHNzdzByZA==', + }); + const bearerLease = makeLease({ authorization: 'Bearer not-parsed-or-refreshed' }); + const basicCancel = vi.fn(async () => { throw new Error('network down'); }); + const bearerCancel = vi.fn(() => { throw new Error('synchronous failure'); }); + const basicScope = makeScope(17, basicCancel).scope; + const bearerScope = makeScope(17, bearerCancel).scope; + basicScope.register({ name: 'basic', abort: vi.fn(), getQueryId: () => 'basic-q' }); + bearerScope.register({ name: 'bearer', abort: vi.fn(), getQueryId: () => 'bearer-q' }); + + expect(() => basicScope.close(basicLease)).not.toThrow(); + expect(() => bearerScope.close(bearerLease)).not.toThrow(); + await Promise.resolve(); + + expect(basicCancel).toHaveBeenCalledTimes(1); + expect(basicCancel).toHaveBeenCalledWith(basicLease, 'basic-q'); + expect(bearerCancel).toHaveBeenCalledTimes(1); + expect(bearerCancel).toHaveBeenCalledWith(bearerLease, 'bearer-q'); + expect(basicLease.origin).toBe('https://basic-cluster.example/base'); + expect(basicLease.authorization).toBe('Basic ZGVtbzpwQHNzdzByZA=='); + expect(bearerLease.authorization).toBe('Bearer not-parsed-or-refreshed'); + }); + + it('continues cancelling other work when an owner abort throws', () => { + const { scope, cancelRemote } = makeScope(); + scope.register({ name: 'broken abort', abort: () => { throw new Error('broken'); }, getQueryId: () => 'broken-q' }); + const healthyAbort = vi.fn(); + scope.register({ name: 'healthy', abort: healthyAbort, getQueryId: () => 'healthy-q' }); + + expect(() => scope.close(makeLease())).not.toThrow(); + expect(healthyAbort).toHaveBeenCalledOnce(); + expect(cancelRemote.mock.calls.map((call) => call[1])).toEqual(['broken-q', 'healthy-q']); + }); + + it('still aborts locally but skips remote cancellation for a missing or foreign close lease', () => { + const missing = makeScope(); + const foreign = makeScope(); + const missingAbort = vi.fn(); + const foreignAbort = vi.fn(); + missing.scope.register({ name: 'missing lease', abort: missingAbort, getQueryId: () => 'missing-q' }); + foreign.scope.register({ name: 'foreign lease', abort: foreignAbort, getQueryId: () => 'foreign-q' }); + + missing.scope.close(); + foreign.scope.close(makeLease({ epoch: 18 })); + + expect(missingAbort).toHaveBeenCalledOnce(); + expect(foreignAbort).toHaveBeenCalledOnce(); + expect(missing.cancelRemote).not.toHaveBeenCalled(); + expect(foreign.cancelRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ch-client.test.ts b/tests/unit/ch-client.test.ts index cc2eb2b1..721551f3 100644 --- a/tests/unit/ch-client.test.ts +++ b/tests/unit/ch-client.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; import type { Mock } from 'vitest'; import { - chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadFunctionsDocColumns, loadFunctionDocRow, loadDocTableColumns, loadDocRow, runQuery, killQuery, exportQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, AST_PROGRESSIVE_THRESHOLD, byUnderscoreThenName, + chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadFunctionsDocColumns, loadFunctionDocRow, loadDocTableColumns, loadDocRow, runQuery, killQuery, killQueryWithLease, exportQuery, loadSchemaLineage, loadSchemaCards, loadLineageTransitive, loadTableDetail, AST_PROGRESSIVE_THRESHOLD, byUnderscoreThenName, } from '../../src/net/ch-client.js'; -import type { ChCtx, DocProbeTable } from '../../src/net/ch-client.js'; +import type { AuthenticatedCancellationLease, ChCtx, DocProbeTable } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; import type { StreamLine } from '../../src/core/stream.js'; @@ -116,14 +116,47 @@ describe('authedFetch', () => { await expect(authedFetch(ctx, 'u', 'sql')).rejects.toThrow('not signed in'); expect(ctx.onSignedOut).toHaveBeenCalled(); }); - it('does not signal auth loss when a missing-token request becomes stale', async () => { + it('cancels without signaling auth loss when a missing-token request becomes stale', async () => { let epoch = 1; const token = deferred(); const ctx = ctxWith(() => jsonResp({}), { currentEpoch: () => epoch, getToken: () => token.promise }); const pending = authedFetch(ctx, 'u', 'sql'); epoch = 2; token.resolve(null); - await expect(pending).rejects.toThrow('not signed in'); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(ctx.onSignedOut).not.toHaveBeenCalled(); + }); + it('does not fetch a replacement credential when the initial token lookup crosses an epoch', async () => { + let epoch = 1; + const token = deferred(); + const onTransportConnected = vi.fn(); + const onTransportOffline = vi.fn(); + const ctx = ctxWith(() => jsonResp({}), { + currentEpoch: () => epoch, + getToken: () => token.promise, + onTransportConnected, + onTransportOffline, + }); + const pending = authedFetch(ctx, 'u', 'sql'); + epoch = 2; + token.resolve('replacement-token'); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(ctx.fetchMock).not.toHaveBeenCalled(); + expect(ctx.onSignedOut).not.toHaveBeenCalled(); + expect(onTransportConnected).not.toHaveBeenCalled(); + expect(onTransportOffline).not.toHaveBeenCalled(); + }); + it('rechecks the epoch after constructing authorization and immediately before fetch', async () => { + let epoch = 1; + const ctx = ctxWith(() => jsonResp({}), { + currentEpoch: () => epoch, + authHeader: (token) => { + epoch = 2; + return 'Bearer replacement-' + token; + }, + }); + await expect(authedFetch(ctx, 'u', 'sql')).rejects.toMatchObject({ name: 'AbortError' }); + expect(ctx.fetchMock).not.toHaveBeenCalled(); expect(ctx.onSignedOut).not.toHaveBeenCalled(); }); it('returns the response on success', async () => { @@ -149,9 +182,14 @@ describe('authedFetch', () => { let epoch = 1; const failure = new Error('network unavailable'); const rejectedFetch = deferred(); + const fetchStarted = deferred(); const onTransportOffline = vi.fn(); - const ctx = ctxWith(async () => rejectedFetch.promise, { currentEpoch: () => epoch, onTransportOffline }); + const ctx = ctxWith(async () => { + fetchStarted.resolve(); + return rejectedFetch.promise; + }, { currentEpoch: () => epoch, onTransportOffline }); const pending = authedFetch(ctx, 'u', 'sql'); + await fetchStarted.promise; epoch = 2; rejectedFetch.reject(failure); await expect(pending).rejects.toBe(failure); @@ -207,9 +245,14 @@ describe('authedFetch', () => { it('fences a stale successful response from lifecycle and authentication state', async () => { let epoch = 1; const response = deferred(); + const fetchStarted = deferred(); const onTransportConnected = vi.fn(); - const ctx = ctxWith(async () => response.promise, { currentEpoch: () => epoch, onTransportConnected }); + const ctx = ctxWith(async () => { + fetchStarted.resolve(); + return response.promise; + }, { currentEpoch: () => epoch, onTransportConnected }); const pending = authedFetch(ctx, 'u', 'sql'); + await fetchStarted.promise; epoch = 2; response.resolve(jsonResp({ ok: 1 })); expect((await pending).ok).toBe(true); @@ -219,18 +262,51 @@ describe('authedFetch', () => { it('fences a stale auth-failure response from refresh and sign-out', async () => { let epoch = 1; const onTransportOffline = vi.fn(); - const ctx = ctxWith(async () => jsonResp({}, false, 401), { + const response = deferred(); + const fetchStarted = deferred(); + const ctx = ctxWith(async () => { + fetchStarted.resolve(); + return response.promise; + }, { currentEpoch: () => epoch, refresh: vi.fn(async () => true), onTransportOffline, }); const pending = authedFetch(ctx, 'u', 'sql'); + await fetchStarted.promise; epoch = 2; + response.resolve(jsonResp({}, false, 401)); expect((await pending).status).toBe(401); expect(ctx.refresh).not.toHaveBeenCalled(); expect(ctx.onSignedOut).not.toHaveBeenCalled(); expect(onTransportOffline).not.toHaveBeenCalled(); }); + it('does not refresh a replacement epoch after delayed body classification', async () => { + let epoch = 1; + const body = deferred(); + const bodyStarted = deferred(); + const response: FakeResponse = { + ok: false, + status: 500, + text: async () => { + bodyStarted.resolve(); + return body.promise; + }, + clone() { return this; }, + }; + const ctx = ctxWith(async () => response, { + currentEpoch: () => epoch, + refresh: vi.fn(async () => true), + }); + const pending = authedFetch(ctx, 'u', 'sql'); + await bodyStarted.promise; + epoch = 2; + body.resolve('jwt::token_verification_exception'); + + expect((await pending).status).toBe(500); + expect(ctx.refresh).not.toHaveBeenCalled(); + expect(ctx.onSignedOut).not.toHaveBeenCalled(); + }); it('does not retry or sign out when refresh settles after its request becomes stale', async () => { let epoch = 1; const refresh = deferred(); @@ -247,7 +323,7 @@ describe('authedFetch', () => { expect(ctx.refresh).toHaveBeenCalledTimes(1); epoch = 2; refresh.resolve(true); - expect((await pending).status).toBe(401); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); expect(ctx.fetchMock).toHaveBeenCalledTimes(1); expect(ctx.onSignedOut).not.toHaveBeenCalled(); }); @@ -270,7 +346,7 @@ describe('authedFetch', () => { await freshTokenStarted.promise; epoch = 2; freshToken.resolve('new'); - expect((await pending).status).toBe(401); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); expect(ctx.fetchMock).toHaveBeenCalledTimes(1); expect(ctx.onSignedOut).not.toHaveBeenCalled(); }); @@ -377,6 +453,37 @@ describe('loadServerVersion', () => { }); }); +describe('catalog loader cancellation signals', () => { + it('forwards one caller signal through metadata, catalog fan-out, reference fallbacks, and documentation loaders', async () => { + const controller = new AbortController(); + const ctx = ctxWith(async (_url, init) => { + if (init.body.includes('FROM system.databases')) { + return jsonResp({ data: [{ name: 'ice', engine: 'DataLakeCatalog' }] }); + } + // Force loadReferenceData's syntax-query compatibility fallback. + if (init.body.includes('system.functions') && init.body.includes('syntax')) { + return textResp('Unknown identifier syntax', false, 500); + } + return jsonResp({ data: [] }); + }); + + await loadServerVersion(ctx, controller.signal); + await loadSchema(ctx, controller.signal); + await loadColumns(ctx, 'ice', 'orders', sqlString, controller.signal); + await loadReferenceData(ctx, controller.signal); + await loadDocTableColumns(ctx, 'documentation', controller.signal); + await loadDocRow(ctx, 'SELECT 1 FORMAT JSON', controller.signal); + await loadFunctionsDocColumns(ctx, controller.signal); + await loadFunctionDocRow(ctx, 'SELECT 1 FORMAT JSON', controller.signal); + + expect(ctx.fetchMock.mock.calls.every(([, init]) => init.signal === controller.signal)).toBe(true); + const referenceFunctionCalls = ctx.fetchMock.mock.calls.filter(([, init]) => init.body.includes('system.functions')); + expect(referenceFunctionCalls).toHaveLength(2); + expect(referenceFunctionCalls.every(([, init]) => init.signal === controller.signal)).toBe(true); + expect(ctx.fetchMock.mock.calls.some(([, init]) => init.body.includes("database = 'ice'"))).toBe(true); + }); +}); + describe('byUnderscoreThenName', () => { it('sorts underscore-prefixed names after regular ones, either argument order', () => { expect(byUnderscoreThenName('_hidden', 'orders')).toBe(1); @@ -521,6 +628,22 @@ describe('loadSchema', () => { expect(schema.find((d) => d.db === 'broken')!.tables).toEqual([]); expect(schema.find((d) => d.db === 'healthy')!.tables).toEqual([{ name: 't1', total_rows: 0, total_bytes: 0, comment: '', columns: null }]); }); + it('propagates an abort from a catalog-database fan-out instead of treating it as an empty database', async () => { + const controller = new AbortController(); + const ctx = ctxWith(async (_url, init) => { + if (init.body.includes('FROM system.databases')) { + return jsonResp({ data: [{ name: 'ice', engine: 'DataLakeCatalog' }] }); + } + if (init.body.includes("database = 'ice'")) { + controller.abort(); + const error = new Error('cancelled'); + error.name = 'AbortError'; + throw error; + } + return jsonResp({ data: [] }); + }); + await expect(loadSchema(ctx, controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + }); }); describe('loadColumns', () => { @@ -830,6 +953,56 @@ describe('killQuery', () => { }); }); +describe('killQueryWithLease', () => { + const lease = ( + fetchFn: typeof fetch, authorization = 'Basic frozen-value', + ): AuthenticatedCancellationLease => Object.freeze({ + epoch: 7, + origin: 'https://old-cluster.example:8443', + authorization, + fetch: fetchFn, + }); + const leaseFetch = ( + impl: FetchImpl, + authorization = 'Basic frozen-value', + ) => { + const fetchMock = vi.fn(impl); + return { fetchMock, lease: lease(asFetch(fetchMock), authorization) }; + }; + + it('uses the exact frozen origin and complete Authorization header', async () => { + const { fetchMock, lease: frozen } = leaseFetch(async () => jsonResp({ data: [] })); + await killQueryWithLease(frozen, 'scope-q', sqlString); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://old-cluster.example:8443?default_format=JSON&enable_http_compression=1'); + expect(init.headers).toEqual({ Authorization: 'Basic frozen-value' }); + expect(init.body).toBe("KILL QUERY WHERE query_id = 'scope-q' ASYNC"); + }); + + it('treats the frozen Authorization value as opaque for OAuth too', async () => { + const { fetchMock, lease: frozen } = leaseFetch( + async () => jsonResp({ data: [] }), 'Bearer old-token', + ); + await killQueryWithLease(frozen, 'q', sqlString); + expect(fetchMock.mock.calls[0][1].headers).toEqual({ Authorization: 'Bearer old-token' }); + }); + + it('does not retry and swallows cleanup transport failures', async () => { + const { fetchMock, lease: frozen } = leaseFetch(async () => { throw new Error('offline'); }); + await expect(killQueryWithLease( + frozen, 'q', sqlString, + )).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('no-ops without a query id', async () => { + const { fetchMock, lease: frozen } = leaseFetch(async () => jsonResp({ data: [] })); + await killQueryWithLease(frozen, null, sqlString); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + describe('exportQuery', () => { it('sets query_id + default_format, passes the signal, and returns the raw Response', async () => { const signal = new AbortController().signal; @@ -1129,6 +1302,12 @@ describe('loadSchemaCards', () => { const colSql = seen.find((s) => /FROM system\.columns/.test(s)); expect(colSql).toContain('SETTINGS show_data_lake_catalogs_in_system_tables = 1'); }); + it('forwards a cancellation signal to its system.columns request', async () => { + const controller = new AbortController(); + const ctx = ctxWith(() => jsonResp({ data: [] })); + await loadSchemaCards(ctx, ['ice'], controller.signal); + expect(ctx.fetchMock.mock.calls[0][1].signal).toBe(controller.signal); + }); it('falls back to the plain system.columns query when an older ClickHouse rejects the setting', async () => { const ctx = ctxWith((url, init) => { const sql = init.body; @@ -1164,6 +1343,16 @@ describe('loadLineageTransitive', () => { expect(out.truncated).toBe(false); }); + it('threads one cancellation signal through every transitive frontier request', async () => { + const controller = new AbortController(); + const ctx = lineageCtx(); + await loadLineageTransitive(ctx, { db: 'a' }, { signal: controller.signal }); + expect(ctx.fetchMock).toHaveBeenCalled(); + for (const [, init] of ctx.fetchMock.mock.calls) { + expect(init.signal).toBe(controller.signal); + } + }); + it('flags truncated when the database cap is hit', async () => { // a → b → c; dbCap 2 stops before loading c. const ctx = ctxWith((url, init) => { @@ -1267,6 +1456,13 @@ describe('loadTableDetail', () => { expect(colSql).toContain('SETTINGS show_data_lake_catalogs_in_system_tables = 1'); expect(ddlSql).toContain('SETTINGS show_data_lake_catalogs_in_system_tables = 1'); }); + it('forwards one cancellation signal to every detail request', async () => { + const controller = new AbortController(); + const ctx = ctxWith(() => jsonResp({ data: [] })); + await loadTableDetail(ctx, 'ice', 'orders', controller.signal); + expect(ctx.fetchMock).toHaveBeenCalledTimes(4); + for (const [, init] of ctx.fetchMock.mock.calls) expect(init.signal).toBe(controller.signal); + }); it('falls back to the plain system.columns/system.tables queries when an older ClickHouse rejects the setting', async () => { const ctx = ctxWith((url, init) => { const sql = init.body; diff --git a/tests/unit/connection-session.test.ts b/tests/unit/connection-session.test.ts index fc558465..6a421c60 100644 --- a/tests/unit/connection-session.test.ts +++ b/tests/unit/connection-session.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi } from 'vitest'; import { webcrypto } from 'node:crypto'; import { createConnectionSession } from '../../src/application/connection-session.js'; import type { ConnectionSessionDeps, SessionStorageLike } from '../../src/application/connection-session.js'; -import type { ChCtx, queryJson } from '../../src/net/ch-client.js'; +import type { + AuthenticatedCancellationLease, + ChCtx, + queryJson, +} from '../../src/net/ch-client.js'; import { jwt, memStorage } from '../helpers/auth-fixtures.js'; // ── Fakes / helpers ────────────────────────────────────────────────────────── @@ -74,7 +78,7 @@ interface SetupOpts { location?: { origin: string; pathname: string; search: string; href: string }; routes?: RouteFn[]; queryJson?: QueryJsonFn; - onAuthLost?: (detail?: string) => void; + onAuthLost?: ConnectionSessionDeps['onAuthLost']; } function setup(opts: SetupOpts = {}) { const fetchMock = makeFetch(opts.routes || []); @@ -159,6 +163,12 @@ describe('construction seeding', () => { }); describe('connection lifecycle ownership', () => { + it('uses empty Basic storage as an empty display identity and never refreshes Basic credentials', async () => { + const { session } = setup({ storage: memStorage({ ch_basic_auth: 'YWJj' }) }); + expect(session.email()).toBe(''); + await expect(session.chCtx.refresh()).resolves.toBe(false); + }); + it('publishes only transport settlements as connected/offline', () => { const { session } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); session.chCtx.onTransportConnected(); @@ -416,6 +426,31 @@ describe('refresh (via getToken)', () => { expect(onAuthLost).not.toHaveBeenCalled(); }); + it('does not send a replacement refresh token when config resolves after an epoch change', async () => { + const configResponse = deferred(); + let tokenCalls = 0; + const { session, storage } = setup({ + storage: memStorage({ oauth_id_token: expiredToken, oauth_refresh_token: 'old-refresh' }), + routes: [ + (url) => (url.endsWith('/config.json') ? configResponse.promise : null), + (url) => { + if (!url.endsWith('/token')) return null; + tokenCalls += 1; + return jsonResponse(200, { id_token: 'must-not-be-used' }); + }, + ], + }); + const oldGet = session.getToken(); + await vi.waitFor(() => expect(session.connection.value.kind).toBe('refreshing')); + session.setTokens('replacement-token', 'replacement-refresh'); + configResponse.resolve(jsonResponse(200, CONFIG_DOC_RAW)); + + await expect(oldGet).resolves.toBe('replacement-token'); + expect(tokenCalls).toBe(0); + expect(storage.getItem('oauth_id_token')).toBe('replacement-token'); + expect(storage.getItem('oauth_refresh_token')).toBe('replacement-refresh'); + }); + it('does not let an old finally clear a newer epoch refresh slot', async () => { const oldResponse = deferred(); const newResponse = deferred(); @@ -556,6 +591,27 @@ describe('ensureConfig', () => { await expect(session.ensureConfig()).resolves.toBeNull(); expect(fetchMock.calls.length).toBe(0); }); + + it('does not let old discovery rewrite a replacement epoch auth-header policy', async () => { + const discovery = deferred(); + const { session, fetchMock } = setup({ + storage: memStorage({ oauth_id_token: validToken, oauth_idp: 'basicidp' }), + routes: [(url) => (url.includes('issuer2.example/.well-known/') ? discovery.promise : null)], + }); + const oldEnsure = session.ensureConfig(); + await vi.waitFor(() => expect(fetchMock.calls.some((url) => url.includes('issuer2.example/.well-known/'))).toBe(true)); + + session.selectIdp('g'); + session.setTokens('replacement-token', 'replacement-refresh'); + discovery.resolve(jsonResponse(200, { + authorization_endpoint: 'https://issuer2.example/authorize', + token_endpoint: 'https://issuer2.example/token', + })); + + await expect(oldEnsure).resolves.toBeNull(); + expect(session.chAuth()).toBe('bearer'); + expect(session.basicUserClaim()).toBe(''); + }); }); // ── connectBasic ───────────────────────────────────────────────────────────── @@ -673,22 +729,142 @@ describe('signOut', () => { }); describe('chCtx.onSignedOut', () => { + it('reports a discovered empty session once without converting explicit signed-out state', () => { + const { session, onAuthLost } = setup(); + session.chCtx.onSignedOut('no credentials'); + session.chCtx.onSignedOut('duplicate'); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 0 }); + expect(onAuthLost).toHaveBeenCalledTimes(1); + expect(onAuthLost).toHaveBeenCalledWith('no credentials'); + }); + + it('captures the in-memory credential even if sessionStorage changes before a failure settles', () => { + const storage = memStorage({ oauth_id_token: validToken }); + const { session, onAuthLost } = setup({ storage }); + // OAuth's token is deliberately held in memory; a storage mutation must + // not weaken the exact cancellation authority captured for an in-flight + // operation. + storage.removeItem('oauth_id_token'); + session.chCtx.onSignedOut('credential disappeared'); + expect(session.connection.value).toMatchObject({ kind: 'auth-required', detail: 'credential disappeared' }); + expect(onAuthLost).toHaveBeenCalledWith('credential disappeared', expect.objectContaining({ authorization: `Bearer ${validToken}` })); + }); + it('clears tokens and reports the given detail', () => { const { session, onAuthLost } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); session.chCtx.onSignedOut('you are not welcome'); expect(session.token()).toBeNull(); - expect(onAuthLost).toHaveBeenCalledWith('you are not welcome'); + expect(onAuthLost).toHaveBeenCalledWith( + 'you are not welcome', + expect.objectContaining({ authorization: `Bearer ${validToken}`, epoch: 0 }), + ); + }); + it('retains the exact prior Basic target for inline recovery, but not explicit sign-out', () => { + const { session } = setup({ storage: memStorage({ + ch_basic_auth: 'YWJj', ch_basic_user: 'bob', ch_basic_origin: 'https://db.example:9440', + }) }); + session.chCtx.onSignedOut('credentials rejected'); + expect(session.chCtx.origin).toBe('https://ch.example'); + expect(session.basicRecoveryOrigin()).toBe('https://db.example:9440'); + + session.signOut(); + expect(session.basicRecoveryOrigin()).toBeNull(); }); it('falls back to the default expired-session message', () => { const { session, onAuthLost } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); session.chCtx.onSignedOut(); - expect(onAuthLost).toHaveBeenCalledWith('Your session expired — please sign in again.'); + expect(onAuthLost).toHaveBeenCalledWith( + 'Your session expired — please sign in again.', + expect.objectContaining({ authorization: `Bearer ${validToken}` }), + ); + }); + it('supplies an immutable latest-credential cancellation lease before clearing storage', () => { + const storage = memStorage({ oauth_id_token: validToken }); + let captured: AuthenticatedCancellationLease | undefined; + let tokenDuringCallback: string | null = null; + const { session, fetchMock } = setup({ + storage, + onAuthLost: (_detail, lease) => { + captured = lease; + tokenDuringCallback = storage.getItem('oauth_id_token'); + }, + }); + session.setTokens(expiringSoonToken, 'rotated-refresh'); + const closingEpoch = session.connection.value.epoch; + session.chCtx.origin = 'https://rotated-cluster.example:9440'; + session.chCtx.onSignedOut(undefined, closingEpoch); + expect(tokenDuringCallback).toBe(expiringSoonToken); + expect(captured).toEqual({ + epoch: closingEpoch, + origin: 'https://rotated-cluster.example:9440', + authorization: `Bearer ${expiringSoonToken}`, + fetch: session.chCtx.fetch, + }); + expect(Object.isFrozen(captured)).toBe(true); + expect(captured?.fetch).toBe(session.chCtx.fetch); + expect(fetchMock.calls).toEqual([]); + expect(storage.getItem('oauth_id_token')).toBeNull(); + }); + it('prebuilds the Basic header and exact target origin into the lease', async () => { + const { session } = setup(); + await session.connectBasic({ username: 'alice', password: 'secret', host: 'db.example:8443' }); + const lease = session.captureCancellationLease(); + expect(lease).toEqual({ + epoch: session.connection.value.epoch, + origin: 'https://db.example:8443', + authorization: `Basic ${btoa('alice:secret')}`, + fetch: session.chCtx.fetch, + }); + }); + it('returns no cancellation lease for a superseded epoch or an empty current credential', () => { + const { session } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); + const captureAtEpoch = session.captureCancellationLease as (expectedEpoch?: number) => + AuthenticatedCancellationLease | null; + expect(captureAtEpoch(session.connection.value.epoch + 1)).toBeNull(); + session.signOut(); + expect(session.captureCancellationLease()).toBeNull(); + }); + it('probes an empty Basic password as an intentional empty credential', async () => { + let auth = ''; + const { session } = setup({ + queryJson: fakeQueryJson(async (ctx) => { + const credential = await ctx.getToken(); + if (credential === null || !ctx.authHeader) throw new Error('missing Basic credential'); + auth = ctx.authHeader(credential); + return {}; + }), + }); + await session.connectBasic({ username: 'alice', password: '' }); + expect(auth).toBe(`Basic ${btoa('alice:')}`); + }); + it('clears credentials even when the auth-loss consumer throws', () => { + const storage = memStorage({ oauth_id_token: validToken, oauth_refresh_token: 'refresh' }); + const { session } = setup({ + storage, + onAuthLost: () => { throw new Error('scope teardown failed'); }, + }); + expect(() => session.chCtx.onSignedOut()).toThrow('scope teardown failed'); + expect(session.token()).toBeNull(); + expect(session.refreshToken()).toBeNull(); + expect(storage.getItem('oauth_id_token')).toBeNull(); + expect(storage.getItem('oauth_refresh_token')).toBeNull(); }); }); // ── ensureFreshToken ───────────────────────────────────────────────────────── describe('ensureFreshToken', () => { + it('returns null rather than invalidating a newer credential when a failed refresh is deliberately kept in its old epoch', async () => { + let session!: ReturnType; + const { session: created } = setup({ + storage: memStorage({ oauth_id_token: expiredToken }), + routes: [(url) => (url.endsWith('/token') ? jsonResponse(200, {}) : null)], + onAuthLost: () => session.signOut(), + }); + session = created; + await expect(session.getToken()).resolves.toBeNull(); + }); + it('resolves true when a valid token is available', async () => { const { session } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); await expect(session.ensureFreshToken()).resolves.toBe(true); diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index f9a36d75..c994b857 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -9,6 +9,7 @@ import type { DashboardDocumentV2, DashboardTileV1, SavedQueryV2, } from '../../src/generated/json-schema.types.js'; import { VARIABLE_OPTION_CAP } from '../../src/core/variable-options.js'; +import { createAuthenticatedExecutionScope } from '../../src/application/authenticated-execution-scope.js'; // ── Fixtures ──────────────────────────────────────────────────────────────── @@ -788,6 +789,252 @@ describe('per-tile control and lifecycle', () => { expect(session.state.value.tiles[0].columns).toEqual([{ name: 'fresh' }]); }); + it('suspends an in-flight tile through its execution scope, keeps committed data, and permits a replacement scope to refresh', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let call = 0; + let request: ViewerReadRequest | null = null; + const cancelled: string[] = []; + let scope = createAuthenticatedExecutionScope({ + epoch: 7, + cancelRemote: (_lease, queryId) => { cancelled.push(queryId); }, + }); + const exec: ViewerExecutor = { + async executeRead(result, req) { + call++; + if (call === 1) { + result.columns = [{ name: 'old' }] as never; + result.rows = [[1]]; + return; + } + if (call === 2) { + request = req; + await gate; + result.columns = [{ name: 'stale' }] as never; + result.rows = [[9]]; + return; + } + result.columns = [{ name: 'fresh' }] as never; + result.rows = [[2]]; + }, + }; + let queryNumber = 0; + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT 1')], + executionScope: () => scope, + mintQueryId: () => `dash-${++queryNumber}`, + })); + await session.start(); + expect(session.state.value.tiles[0].columns).toEqual([{ name: 'old' }]); + + const suspended = session.refresh(); + await flush(); + expect(session.state.value.tiles[0].status).toBe('loading'); + scope.close({ epoch: 7 } as never); + const activeRequest = request as ViewerReadRequest | null; + expect(activeRequest?.signal?.aborted).toBe(true); + expect(session.state.value.running).toBe(false); + // An auth suspension is not a manual cancel: the already committed tile + // remains visibly ready while its stale refresh request is aborted. + expect(session.state.value.tiles[0].status).toBe('ready'); + expect(session.state.value.tiles[0].columns).toEqual([{ name: 'old' }]); + expect(cancelled).toContain('dash-2'); + release(); + await suspended; + expect(session.state.value.tiles[0].columns).toEqual([{ name: 'old' }]); + + scope = createAuthenticatedExecutionScope({ epoch: 8, cancelRemote: () => {} }); + await session.refresh(); + expect(session.state.value.tiles[0].status).toBe('ready'); + expect(session.state.value.tiles[0].columns).toEqual([{ name: 'fresh' }]); + }); + + it('registers before token preflight, so scope closure prevents a deferred preflight from issuing a tile request', async () => { + let releaseToken!: (ok: boolean) => void; + const token = new Promise((resolve) => { releaseToken = resolve; }); + const scope = createAuthenticatedExecutionScope({ epoch: 7, cancelRemote: () => {} }); + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT 1')], + connection: { ensureFreshToken: () => token }, executionScope: () => scope, + mintQueryId: () => 'dash-preflight', + })); + const started = session.start(); + scope.close({ epoch: 7 } as never); + releaseToken(true); + await started; + expect(calls).toEqual([]); + expect(session.state.value.running).toBe(false); + }); + + it('settles a configured variable that was waiting for options when scope closure lands in preflight', async () => { + let releaseToken!: (ok: boolean) => void; + const token = new Promise((resolve) => { releaseToken = resolve; }); + const scope = createAuthenticatedExecutionScope({ epoch: 8, cancelRemote: () => {} }); + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('t1', 'q1')], + variableConfigs: { country: { sql: 'SELECT code, label FROM countries' } }, + }), + exec, + queries: [query('q1', 'SELECT 1 WHERE country = {country:String}')], + connection: { ensureFreshToken: () => token }, executionScope: () => scope, + })); + const started = session.start(); + expect(session.state.value.variableStates[0].status).toBe('loading'); + scope.close({ epoch: 8 } as never); + expect(session.state.value.variableStates[0].status).toBe('idle'); + releaseToken(true); + await started; + expect(calls).toEqual([]); + }); + + it('treats a supplied-but-missing execution scope as suspended across full, tile, and variable waves', async () => { + const ensureFreshToken = vi.fn(async () => true); + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), + exec, + queries: [query('q1', 'SELECT 1 WHERE country = {country:String}')], + connection: { ensureFreshToken }, executionScope: () => null, + })); + await session.start(); + await session.refreshTile('t1'); + await session.applyVariable('country', 'de', true); + expect(ensureFreshToken).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + expect(session.state.value.variableStates[0]).toMatchObject({ value: 'de', active: true }); + }); + + it('keeps a no-scope query-id seam backward-compatible by executing without a request registration', async () => { + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT 1')], + mintQueryId: () => 'legacy-query-id', + })); + await session.start(); + expect(calls).toHaveLength(1); + expect(session.state.value.tiles[0].status).toBe('ready'); + }); + + it('fences a run that becomes stale between loading-state publication and request registration', async () => { + let checks = 0; + const scope = { + isOpen: () => true, + register: vi.fn(() => ({ + // preflight checks twice, runTile checks once at entry, then the + // loading-state fence sees this fourth read as stale. + isCurrent: () => ++checks < 4, + release: vi.fn(), + })), + }; + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT 1')], + executionScope: () => scope, mintQueryId: () => 'late-scope-check', + })); + await session.start(); + expect(calls).toEqual([]); + expect(scope.register).toHaveBeenCalledTimes(1); + }); + + it('fences a run that becomes stale immediately before beginRequest', async () => { + let checks = 0; + const scope = { + isOpen: () => true, + register: vi.fn(() => ({ + // The fourth read is the post-loading guard; the fifth is beginRequest. + isCurrent: () => ++checks < 5, + release: vi.fn(), + })), + }; + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT 1')], + executionScope: () => scope, mintQueryId: () => 'stale-before-request', + })); + await session.start(); + expect(calls).toEqual([]); + expect(scope.register).toHaveBeenCalledTimes(1); + }); + + it('settles a tile without issuing it when the request registration becomes stale during setup', async () => { + let registrations = 0; + const scope = { + isOpen: () => true, + register: vi.fn(() => { + const current = ++registrations === 1; + return { isCurrent: () => current, release: vi.fn() }; + }), + }; + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT 1')], + executionScope: () => scope, + mintQueryId: () => 'stale-during-registration', + })); + await session.start(); + expect(calls).toEqual([]); + expect(session.state.value.tiles[0].status).toBe('idle'); + }); + + it('does not start the option batch when its query registration becomes stale during setup', async () => { + let registrations = 0; + const scope = { + isOpen: () => true, + register: vi.fn(() => { + const current = ++registrations === 1; + return { isCurrent: () => current, release: vi.fn() }; + }), + }; + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('t1', 'q1')], + variableConfigs: { country: { sql: 'SELECT code, label FROM countries' } }, + }), + exec, + queries: [query('q1', 'SELECT 1 WHERE country = {country:String}')], + executionScope: () => scope, + mintQueryId: () => 'stale-option-registration', + })); + await session.start(); + expect(calls).toEqual([]); + expect(session.state.value.variableStates[0].status).toBe('loading'); + }); + + it('aborts a variable-triggered affected-tile wave through the same scope fence', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let request: ViewerReadRequest | null = null; + const scope = createAuthenticatedExecutionScope({ epoch: 7, cancelRemote: () => {} }); + const exec: ViewerExecutor = { + async executeRead(result, req) { + request = req; + await gate; + result.columns = [{ name: 'late' }] as never; + result.rows = [[1]]; + }, + }; + const session = createDashboardViewerSession(makeDeps({ + document: doc({ tiles: [tile('t1', 'q1')] }), exec, queries: [query('q1', 'SELECT {country:String}')], + executionScope: () => scope, mintQueryId: () => 'dash-variable', + })); + await session.start(); + expect(session.state.value.tiles[0].status).toBe('unfilled'); + + const applied = session.applyVariable('country', 'de', true); + await flush(); + expect(session.state.value.tiles[0].status).toBe('loading'); + scope.close({ epoch: 7 } as never); + expect((request as ViewerReadRequest | null)?.signal?.aborted).toBe(true); + expect(session.state.value.tiles[0].status).toBe('idle'); + release(); + await applied; + expect(session.state.value.tiles[0].columns).toBeNull(); + }); + it('a superseded seventh queued tile worker exits before mutating state or issuing a request', async () => { let releaseOldWorkers!: () => void; const oldWorkers = new Promise((resolve) => { releaseOldWorkers = resolve; }); diff --git a/tests/unit/export-service.test.ts b/tests/unit/export-service.test.ts index 6f174165..28cd04d0 100644 --- a/tests/unit/export-service.test.ts +++ b/tests/unit/export-service.test.ts @@ -13,6 +13,10 @@ import type { QueryTab } from '../../src/state.js'; import type { ChCtx, RunQueryResult } from '../../src/net/ch-client.js'; import type { PreparedSource, PreparedStatement } from '../../src/core/param-pipeline.js'; import type { WorkbenchParameterSession } from '../../src/application/workbench-parameter-session.js'; +import { + createAuthenticatedExecutionScope, +} from '../../src/application/authenticated-execution-scope.js'; +import type { AuthenticatedExecutionScope } from '../../src/application/authenticated-execution-scope.js'; // ── Small deferred/flush helpers (mirrors workbench-session.test.ts's own // convention for scripting async picker/fetch behaviors on a test's own @@ -30,6 +34,17 @@ function flush(): Promise { function abortError(): Error { return Object.assign(new Error('x'), { name: 'AbortError' }); } +function executionScope(epoch = 1): ReturnType { + return createAuthenticatedExecutionScope({ epoch, cancelRemote: vi.fn() }); +} +function scopeWithChecks(values: boolean[]): AuthenticatedExecutionScope { + return { + epoch: 1, + isOpen: () => true, + register: () => ({ release: vi.fn(), isCurrent: () => values.shift() ?? false }), + close: vi.fn(async () => {}), + } as unknown as AuthenticatedExecutionScope; +} function preparedStatement(over: Partial = {}): PreparedStatement { return { sql: 'SELECT 1', args: {}, boundParams: [], ...over }; @@ -179,6 +194,7 @@ function makeHarness(opts: { canExportScript?: () => boolean; ensureConfig?: () => Promise; getToken?: () => Promise; + executionScope?: () => AuthenticatedExecutionScope | null; sessionParamsFor?: (tab: QueryTab, sqls: string[]) => Record; } = {}): Harness { const state = makeState(opts.state); @@ -195,6 +211,7 @@ function makeHarness(opts: { const deps: ExportServiceDeps = { exportQuery: ch.exportQuery, runQuery: ch.runQuery, killQuery: ch.killQuery, ctx: () => ctx, + executionScope: opts.executionScope || (() => null), ensureConfig: opts.ensureConfig || vi.fn(async () => undefined), getToken: opts.getToken || vi.fn(async () => 'tok'), sqlString, @@ -564,6 +581,303 @@ describe('createExportService: exportDirect (issue #87)', () => { }); }); +describe('createExportService: authenticated execution scope', () => { + it('fences each direct-export continuation independently when its epoch has closed', async () => { + // The explicit sequences model a loss at: entry, picker settlement, + // configuration settlement, token settlement, progress construction, and + // request settlement. None may move work into a replacement scope. + const runs = [ + [false], [true, false], [true, true, false], + [true, true, true, false], [true, true, true, true, false], + [true, true, true, true, true, false], + ]; + for (const checks of runs) { + const { handle } = fakeFileHandle(); + const h = makeHarness({ + executionScope: () => scopeWithChecks([...checks]), + sink: { pickFile: vi.fn(async () => asFileHandleLike(handle)) }, + }); + await createExportService(h.deps).exportDirect('SELECT 1', 0); + expect(h.state.exporting.value).toBe(false); + } + }); + + it('fences script-export preflight and its internal loop at each epoch boundary', async () => { + const runs = [ + [false], [true, false], [true, true, false], [true, true, true, false], + [true, true, true, true, false], [true, true, true, true, true, false], + [true, true, true, true, true, true, false], + [true, true, true, true, true, true, true, false], + [true, true, true, true, true, true, true, true, false], + [true, true, true, true, true, true, true, true, true, false], + ]; + for (const checks of runs) { + const { dir } = fakeDirHandle(); + const h = makeHarness({ + executionScope: () => scopeWithChecks([...checks]), + sink: { pickDirectory: vi.fn(async () => dir) }, + tab: { sqlDraft: 'SELECT 1; SELECT 2' }, + }); + await createExportService(h.deps).exportEntry(); + expect(h.state.exporting.value).toBe(false); + } + }); + + it('suppresses stale picker failures and a stale signed-out callback', async () => { + const pickerFailure = makeHarness({ + executionScope: () => scopeWithChecks([true, false]), + sink: { pickFile: vi.fn(async () => { throw new Error('late picker'); }) }, + }); + await createExportService(pickerFailure.deps).exportDirect('SELECT 1', 0); + expect(pickerFailure.hooks.toast).not.toHaveBeenCalled(); + + const tokenLoss = makeHarness({ + executionScope: () => scopeWithChecks([true, true, true, false]), + getToken: async () => null, + }); + await createExportService(tokenLoss.deps).exportDirect('SELECT 1', 0); + expect(tokenLoss.ctx.onSignedOut).not.toHaveBeenCalled(); + }); + + it('fences stale direct-export progress and final completion independently', async () => { + const many = 'x'.repeat(33 * 1024); + const progress = makeHarness({ executionScope: () => scopeWithChecks([true, true, true, true, true, true, false, true]) }); + progress.ch.exportQuery.mockResolvedValue(asResponse(fakeExportResponse({ body: streamBody([many]) }))); + await createExportService(progress.deps).exportDirect('SELECT 1', 0); + expect(progress.hooks.toast).not.toHaveBeenCalled(); + + const final = makeHarness({ executionScope: () => scopeWithChecks([true, true, true, true, true, true, true, false]) }); + final.ch.exportQuery.mockResolvedValue(asResponse(fakeExportResponse({ body: streamBody([many]) }))); + await createExportService(final.deps).exportDirect('SELECT 1', 0); + expect(final.hooks.toast).not.toHaveBeenCalled(); + }); + + it('suppresses stale script picker and per-statement settlements', async () => { + const pickerFailure = makeHarness({ + executionScope: () => scopeWithChecks([true, false]), + tab: { sqlDraft: 'SELECT 1; SELECT 2' }, + sink: { pickDirectory: vi.fn(async () => { throw new Error('late picker'); }) }, + }); + await createExportService(pickerFailure.deps).exportEntry(); + expect(pickerFailure.hooks.toast).not.toHaveBeenCalled(); + + const afterEffect = makeHarness({ + executionScope: () => scopeWithChecks([true, true, true, true, true, true, true, true, false]), + tab: { sqlDraft: 'CREATE TABLE t (x Int8); SELECT 1' }, + }); + await createExportService(afterEffect.deps).exportEntry(); + expect(afterEffect.hooks.renderResults).toHaveBeenCalled(); + + const failedEffect = makeHarness({ + executionScope: () => scopeWithChecks([true, true, true, true, true, true, true, true, false]), + tab: { sqlDraft: 'CREATE TABLE t (x Int8); SELECT 1' }, + }); + failedEffect.ch.runQuery.mockRejectedValue(new Error('late failure')); + await createExportService(failedEffect.deps).exportEntry(); + expect(failedEffect.hooks.renderResults).toHaveBeenCalled(); + }); + + it('stops effect-script settlement and final bookkeeping when its owning scope closes', async () => { + const afterTransport = deferred(); + const transportScope = executionScope(); + const transport = makeHarness({ + executionScope: () => transportScope, + tab: { sqlDraft: 'CREATE TABLE t (x Int8); SELECT 1' }, + }); + transport.ch.runQuery.mockImplementation(() => afterTransport.promise); + const pending = createExportService(transport.deps).exportEntry(); + await flush(); + transportScope.close(); + afterTransport.resolve({}); + await pending; + expect(transport.hooks.loadSchema).not.toHaveBeenCalled(); + + const failedTransport = deferred(); + const failedScope = executionScope(); + const failed = makeHarness({ + executionScope: () => failedScope, + tab: { sqlDraft: 'CREATE TABLE t (x Int8); SELECT 1' }, + }); + failed.ch.runQuery.mockImplementation(() => failedTransport.promise); + const failedPending = createExportService(failed.deps).exportEntry(); + await flush(); + failedScope.close(); + failedTransport.reject(new Error('late failure')); + await failedPending; + + const finalScope = executionScope(); + let renders = 0; + const final = makeHarness({ + executionScope: () => finalScope, + tab: { sqlDraft: 'SELECT 1; SELECT 2' }, + hooks: { renderResults: vi.fn(() => { renders += 1; if (renders === 5) finalScope.close(); }) }, + }); + await createExportService(final.deps).exportEntry(); + expect(final.state.exporting.value).toBe(false); + }); + + it('retains a partial file when cancellation races exactly with end-of-stream', async () => { + const scope = executionScope(); + let reads = 0; + const body: FakeBody = { + getReader: () => ({ + read: async () => { + reads += 1; + if (reads === 1) return { done: false, value: new TextEncoder().encode('tail') }; + scope.close(); + return { done: true }; + }, + releaseLock: () => {}, + }), + }; + const { handle, writable } = fakeFileHandle(); + const h = makeHarness({ + executionScope: () => scope, + sink: { pickFile: vi.fn(async () => asFileHandleLike(handle)) }, + }); + h.ch.exportQuery.mockResolvedValue(asResponse(fakeExportResponse({ body }))); + await createExportService(h.deps).exportDirect('SELECT 1', 0); + expect(writable.close).toHaveBeenCalled(); + }); + + it('stops a direct export during the picker preflight; its late resolution cannot configure, authenticate, or toast', async () => { + const picker = deferred(); + const scope = executionScope(); + const h = makeHarness({ executionScope: () => scope, sink: { pickFile: vi.fn(() => picker.promise) } }); + const run = createExportService(h.deps).exportDirect('SELECT 1', 0); + await flush(); + expect(h.state.exporting.value).toBe(true); + + scope.close(); + expect(h.state.exporting.value).toBe(false); + picker.resolve(asFileHandleLike(fakeFileHandle().handle)); + await run; + + expect(h.deps.ensureConfig).not.toHaveBeenCalled(); + expect(h.deps.getToken).not.toHaveBeenCalled(); + expect(h.ch.exportQuery).not.toHaveBeenCalled(); + expect(h.hooks.toast).not.toHaveBeenCalled(); + }); + + it('closes a direct request with its captured query id and fences its late success/progress settlement', async () => { + const cancelRemote = vi.fn(); + const scope = createAuthenticatedExecutionScope({ epoch: 1, cancelRemote }); + const pending = deferred(); + const progress = { update: vi.fn(), remove: vi.fn() }; + const { handle } = fakeFileHandle(); + const h = makeHarness({ + executionScope: () => scope, + sink: { pickFile: vi.fn(async () => asFileHandleLike(handle)) }, + hooks: { showExportProgress: vi.fn(() => progress) }, + }); + h.ch.exportQuery.mockImplementation(async () => pending.promise); + const run = createExportService(h.deps).exportDirect('SELECT 1', 0); + await flush(); + const queryId = h.ch.exportQuery.mock.calls[0][2].queryId as string; + + scope.close({ epoch: 1, origin: 'https://ch.example', authorization: 'Bearer old', fetch: h.ctx.fetch }); + expect(h.state.exporting.value).toBe(false); + expect(progress.remove).toHaveBeenCalledTimes(1); + expect(cancelRemote).toHaveBeenCalledWith(expect.objectContaining({ authorization: 'Bearer old' }), queryId); + pending.resolve(asResponse(fakeExportResponse({ body: streamBody(['late']) }))); + await run; + + expect(progress.update).not.toHaveBeenCalled(); + expect(h.hooks.toast).not.toHaveBeenCalled(); + }); + + it('fences a direct export that loses auth while its writable is still opening', async () => { + const scope = executionScope(); + const writableDeferred = deferred(); + const { writable } = fakeFileHandle(); + const handle: FakeFileHandle = { + name: 'late.tsv', + createWritable: vi.fn(() => writableDeferred.promise), + }; + const h = makeHarness({ + executionScope: () => scope, + sink: { pickFile: vi.fn(async () => asFileHandleLike(handle)) }, + }); + h.ch.exportQuery.mockResolvedValue(asResponse(fakeExportResponse({ body: streamBody(['late']) }))); + const run = createExportService(h.deps).exportDirect('SELECT 1', 0); + await flush(); + scope.close(); + writableDeferred.resolve(writable); + await run; + + expect(writable.write).not.toHaveBeenCalled(); + expect(h.hooks.toast).not.toHaveBeenCalled(); + expect(h.state.exporting.value).toBe(false); + }); + + it('allows a replacement scope to start a fresh export after a lost picker wave settles', async () => { + const picker = deferred(); + let scope = executionScope(); + const h = makeHarness({ executionScope: () => scope, sink: { pickFile: vi.fn(() => picker.promise) } }); + const service = createExportService(h.deps); + const oldRun = service.exportDirect('SELECT 1', 0); + await flush(); + scope.close(); + scope = executionScope(2); + picker.resolve(asFileHandleLike(fakeFileHandle().handle)); + await oldRun; + + const { handle } = fakeFileHandle(); + (h.sink.pickFile as Mock).mockResolvedValueOnce(asFileHandleLike(handle)); + await service.exportDirect('SELECT 2', 0); + expect(h.ch.exportQuery).toHaveBeenCalledTimes(1); + expect(h.ch.exportQuery.mock.calls[0][1]).toContain('SELECT 2'); + expect(h.state.exporting.value).toBe(false); + }); + + it('aborts a script row with the current query id and cannot paint its late completion', async () => { + const cancelRemote = vi.fn(); + const scope = createAuthenticatedExecutionScope({ epoch: 1, cancelRemote }); + const pending = deferred(); + const { dir } = fakeDirHandle(); + const h = makeHarness({ + executionScope: () => scope, + tab: { sqlDraft: 'SELECT 1; SELECT 2' }, + sink: { pickDirectory: vi.fn(async () => dir) }, + }); + h.ch.exportQuery.mockImplementation(async () => pending.promise); + const run = createExportService(h.deps).exportEntry(); + await flush(); + const queryId = h.ch.exportQuery.mock.calls[0][2].queryId as string; + const rendersBeforeClose = (h.hooks.renderResults as Mock).mock.calls.length; + + scope.close({ epoch: 1, origin: 'https://ch.example', authorization: 'Bearer old', fetch: h.ctx.fetch }); + expect(h.state.exporting.value).toBe(false); + expect(cancelRemote).toHaveBeenCalledWith(expect.anything(), queryId); + pending.resolve(asResponse(fakeExportResponse({ body: streamBody(['late']) }))); + await run; + + expect((h.hooks.renderResults as Mock).mock.calls.length).toBe(rendersBeforeClose); + expect(h.ch.exportQuery).toHaveBeenCalledTimes(1); + }); + + it('stops a script export in preflight and never lets the late directory picker start transport', async () => { + const directory = deferred(); + const scope = executionScope(); + const h = makeHarness({ + executionScope: () => scope, + tab: { sqlDraft: 'SELECT 1; SELECT 2' }, + sink: { pickDirectory: vi.fn(() => directory.promise) }, + }); + const run = createExportService(h.deps).exportEntry(); + await flush(); + scope.close(); + expect(h.state.exporting.value).toBe(false); + directory.resolve(fakeDirHandle().dir); + await run; + + expect(h.deps.ensureConfig).not.toHaveBeenCalled(); + expect(h.ch.exportQuery).not.toHaveBeenCalled(); + expect(h.ch.runQuery).not.toHaveBeenCalled(); + expect(h.hooks.renderResults).not.toHaveBeenCalled(); + }); +}); + // ── exportScriptEntry / exportScript (issue #99) ──────────────────────────── describe('createExportService: exportScriptEntry / exportScript (issue #99)', () => { diff --git a/tests/unit/login.test.ts b/tests/unit/login.test.ts index 156b8de5..b78bb5c0 100644 --- a/tests/unit/login.test.ts +++ b/tests/unit/login.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderLogin } from '../../src/ui/login.js'; +import { mountInlineLogin, renderLogin } from '../../src/ui/login.js'; import { makeApp } from '../helpers/fake-app.js'; import type { ConfigDoc, HostDescriptor, IdpDescriptor } from '../../src/net/oauth-config.js'; @@ -50,6 +50,7 @@ interface AppOverrides extends Partial> { actions?: Partial; host?: () => string; loadIdps?: () => Promise; + basicRecoveryOrigin?: () => string | null; // Not a base fake-app.js field (only ever supplied as an override, matching // the real app's optional `conn.hostHint` — app.types.ts / login.ts's // `LoginApp`). @@ -58,13 +59,14 @@ interface AppOverrides extends Partial> { // makeApp defaults loadIdps → { idps: [], basicLogin: true }. Override per test. function appWith(over: AppOverrides = {}): FakeApp { const base = makeApp(); - const { loadIdps, host, hostHint, ...rest } = over; + const { loadIdps, host, hostHint, basicRecoveryOrigin, ...rest } = over; return makeApp({ ...rest, actions: { ...base.actions, ...(over.actions || {}) }, conn: { ...(host ? { host } : {}), ...(hostHint !== undefined ? { hostHint } : {}), + ...(basicRecoveryOrigin ? { basicRecoveryOrigin } : {}), ...(loadIdps ? { loadIdps: asLoadIdps(loadIdps) } : {}), }, }); @@ -219,6 +221,35 @@ describe('renderLogin — insecure (accept-invalid-certificate) hosts', () => { expect(login).toHaveBeenCalledTimes(1); }); + it('oauth insecure host: restores its Continue control after a live redirect failure', async () => { + const login = vi.fn(async () => { throw new Error('certificate redirect failed'); }); + const app = withInsecure({ actions: { login } }); + app.showLogin = vi.fn(); + renderLogin(app); await tick(); + selectHost(app.root, '1'); + const go = qs(app.root, '.login-cert-go'); + click(go); + expect(go.disabled).toBe(true); + await tick(); + + expect(go.disabled).toBe(false); + expect(app.showLogin).toHaveBeenCalledWith('certificate redirect failed'); + }); + + it('fences a certificate-gated OAuth failure after its full-screen mount is replaced', async () => { + let rejectLogin: ((reason?: unknown) => void) | undefined; + const login = vi.fn(() => new Promise((_resolve, reject) => { rejectLogin = reject; })); + const app = withInsecure({ actions: { login } }); + renderLogin(app); await tick(); + selectHost(app.root, '1'); + click(qs(app.root, '.login-cert-go')); + renderLogin(app); // disposes the redirecting mount + rejectLogin?.(new Error('late certificate redirect failure')); + await tick(); + + expect(app.root.querySelector('.login-error')).toBeNull(); + }); + it('clears the cert step when switching to the placeholder or a normal connection', async () => { const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [ ...insecureHosts, @@ -484,6 +515,18 @@ describe('renderLogin — SSO flow', () => { await tick(); expect(showLogin).toHaveBeenCalledWith('sso-raw'); }); + it('fences an SSO failure after its full-screen mount is replaced', async () => { + let rejectLogin: ((reason?: unknown) => void) | undefined; + const login = vi.fn(() => new Promise((_resolve, reject) => { rejectLogin = reject; })); + const app = ssoApp({ actions: { login } }); + renderLogin(app); await tick(); + click(qs(app.root, '.login-sso .login-btn')); + renderLogin(app); // disposes the redirecting mount + rejectLogin?.(new Error('late redirect failure')); + await tick(); + + expect(app.root.querySelector('.login-error')).toBeNull(); + }); it('ignores a second SSO click while one is in flight', async () => { let resolve: (() => void) | undefined; const login = vi.fn(() => new Promise((r) => { resolve = r; })); @@ -498,3 +541,342 @@ describe('renderLogin — SSO flow', () => { await tick(); }); }); + +describe('mountInlineLogin', () => { + const idps: Pick[] = [{ id: 'g', label: 'Google' }]; + const hosts: HostDescriptor[] = [{ + label: 'demo', + url: 'http://localhost:8123', + auth: 'basic', + user: 'default', + password: 'pw', + idp: '', + insecure: false, + }]; + + it('mounts Basic recovery controls without replacing the app root', async () => { + const loadIdps = async () => ({ idps, basicLogin: true, hosts }); + const full = appWith({ loadIdps }); + const inline = appWith({ loadIdps }); + const shell = document.createElement('div'); + const sentinel = document.createElement('div'); + sentinel.className = 'editor-sentinel'; + inline.root.replaceChildren(sentinel); + const host = document.createElement('div'); + shell.append(host); + + renderLogin(full); + mountInlineLogin(inline, host); + await tick(); + + expect(inline.root.firstElementChild).toBe(sentinel); + expect(inline.root.querySelector('.login-card')).toBeNull(); + expect(host.querySelector('.login-screen')).toBeNull(); + expect(host.querySelector('.login-inline')).not.toBeNull(); + expect(qsa(host, '.login-input')).toHaveLength(qsa(full.root, '.login-input').length); + expect(host.querySelector('.login-sso .login-btn')).toBeNull(); + expect(qs(host, '.login-inline-oauth-unavailable').textContent).toContain('Single sign-on'); + expect([...qs(host, '.login-picker').options].map((o) => o.textContent)) + .toEqual([...qs(full.root, '.login-picker').options].map((o) => o.textContent)); + expect(qs(host, '.login-brand-name').textContent).toBe(qs(full.root, '.login-brand-name').textContent); + }); + + it('keeps Basic failures local, accessible, and preserves the surrounding root and field values', async () => { + const showLogin = vi.fn(); + const connect = vi.fn(async () => { throw new Error('wrong password'); }); + const app = appWith({ showLogin, actions: { connect } }); + const sentinel = document.createElement('div'); + app.root.replaceChildren(sentinel); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + const [user, pass] = qsa(host, '.login-input'); + type(user, 'alice'); + type(pass, 'bad'); + + click(qs(host, '.login-creds .login-btn')); + await tick(); + + expect(app.root.firstElementChild).toBe(sentinel); + expect(showLogin).not.toHaveBeenCalled(); + expect([user.value, pass.value]).toEqual(['alice', 'bad']); + const error = qs(host, '.login-error'); + expect(error.textContent).toBe('wrong password'); + expect(error.getAttribute('role')).toBe('alert'); + expect(error.getAttribute('aria-live')).toBe('polite'); + const button = qs(host, '.login-creds .login-btn'); + expect(button.textContent).toContain('Connect'); + expect(button.disabled).toBe(false); + click(button); // a current failure leaves the same values available to retry + await tick(); + expect(connect).toHaveBeenCalledTimes(2); + + handle.show(); + expect(host.querySelector('.login-error')).toBeNull(); + }); + + it('keeps inline OAuth unavailable without replacing the surrounding root', async () => { + const showLogin = vi.fn(); + const login = vi.fn(async () => { throw new Error('redirect blocked'); }); + const app = appWith({ + showLogin, + actions: { login }, + loadIdps: async () => ({ idps, basicLogin: true }), + }); + const sentinel = document.createElement('div'); + app.root.replaceChildren(sentinel); + const host = document.createElement('div'); + mountInlineLogin(app, host); + await tick(); + expect(app.root.firstElementChild).toBe(sentinel); + expect(showLogin).not.toHaveBeenCalled(); + expect(host.querySelector('.login-sso .login-btn')).toBeNull(); + expect(qs(host, '.login-inline-oauth-unavailable').getAttribute('role')).toBe('status'); + expect(login).not.toHaveBeenCalled(); + }); + + it('prefills and reuses the exact prior Basic target during inline recovery', async () => { + const connect = vi.fn(async () => {}); + const app = appWith({ + actions: { connect }, + basicRecoveryOrigin: () => 'https://db.example:9440', + }); + const host = document.createElement('div'); + mountInlineLogin(app, host); + const [user, pass, target] = qsa(host, '.login-input'); + + expect(target.value).toBe('https://db.example:9440'); + expect(qs(host, '.login-target .lt-host').textContent).toBe('https://db.example:9440'); + type(user, 'alice'); + type(pass, 'fresh-secret'); + click(qs(host, '.login-creds .login-btn')); + await tick(); + + expect(connect).toHaveBeenCalledWith({ + username: 'alice', password: 'fresh-secret', host: 'https://db.example:9440', + }); + }); + + it('resets a successful Basic submission so the retained mount supports a second recovery cycle', async () => { + const connect = vi.fn(async () => {}); + const app = appWith({ actions: { connect } }); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + const [user, pass] = qsa(host, '.login-input'); + const eye = qs(host, '.login-eye'); + const button = qs(host, '.login-creds .login-btn'); + + type(user, 'alice'); + type(pass, 'first-secret'); + click(eye); // prove a successful cycle also resets password visibility + expect(pass.type).toBe('text'); + click(button); + await tick(); + + expect(connect).toHaveBeenCalledTimes(1); + expect(pass.value).toBe(''); + expect(pass.type).toBe('password'); + expect(eye.title).toBe('Show password'); + expect(button.disabled).toBe(false); + expect(button.textContent).toContain('Connect'); + + handle.hide(); + handle.show('Expired again'); + expect(button.disabled).toBe(false); + type(pass, 'second-secret'); + click(button); + await tick(); + + expect(connect).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenLastCalledWith({ + username: 'alice', password: 'second-secret', host: '', + }); + expect(pass.value).toBe(''); + expect(button.disabled).toBe(false); + }); + + it('does not let a stale successful recovery clear a newer recovery presentation', async () => { + let resolveFirst: (() => void) | undefined; + const connect = vi.fn(() => new Promise((resolve) => { resolveFirst = resolve; })); + const app = appWith({ actions: { connect } }); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + const [user, pass] = qsa(host, '.login-input'); + const button = qs(host, '.login-creds .login-btn'); + + type(user, 'alice'); type(pass, 'old-secret'); + click(button); + expect(button.disabled).toBe(true); + handle.hide(); // actions.connect normally hides the retained recovery host + handle.show('Credentials expired again'); + type(pass, 'new-secret'); + expect(button.disabled).toBe(false); // a new recovery cycle is immediately usable + + resolveFirst?.(); + await tick(); + + expect(pass.value).toBe('new-secret'); + expect(qs(host, '.login-error').textContent).toBe('Credentials expired again'); + expect(button.disabled).toBe(false); + expect(button.textContent).toContain('Connect'); + }); + + it('does not let a stale failed recovery overwrite or block a newer recovery cycle', async () => { + let rejectFirst: ((reason?: unknown) => void) | undefined; + let attempts = 0; + const connect = vi.fn(() => { + attempts += 1; + return attempts === 1 + ? new Promise((_resolve, reject) => { rejectFirst = reject; }) + : Promise.resolve(); + }); + const app = appWith({ actions: { connect } }); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + const [user, pass] = qsa(host, '.login-input'); + const button = qs(host, '.login-creds .login-btn'); + + type(user, 'alice'); type(pass, 'old-secret'); + click(button); + handle.hide(); + handle.show('Credentials expired again'); + type(pass, 'fresh-secret'); + + rejectFirst?.(new Error('old password rejected')); + await tick(); + + expect([user.value, pass.value]).toEqual(['alice', 'fresh-secret']); + expect(qs(host, '.login-error').textContent).toBe('Credentials expired again'); + expect(button.disabled).toBe(false); + expect(button.textContent).toContain('Connect'); + + click(button); // the current presentation can retry even after old failure settles + await tick(); + expect(connect).toHaveBeenCalledTimes(2); + expect(pass.value).toBe(''); + expect(button.disabled).toBe(false); + }); + + it('does not expose OAuth navigation inline before the recovery checkpoint exists', async () => { + const login = vi.fn(async () => {}); + const app = appWith({ + actions: { login }, + loadIdps: async () => ({ + idps: [{ id: 'g', label: 'Google' }], basicLogin: false, + hosts: [{ + label: 'oauth-only', url: 'https://db.example', auth: 'oauth', + user: '', password: '', idp: 'g', insecure: false, + }], + }), + }); + const host = document.createElement('div'); + mountInlineLogin(app, host); + await tick(); + + expect(host.querySelector('.login-sso button')).toBeNull(); + expect(qs(host, '.login-picker-field').style.display).toBe('none'); + expect(host.querySelector('.login-creds')).toBeNull(); + const message = qs(host, '.login-inline-oauth-unavailable'); + expect(message.getAttribute('role')).toBe('status'); + expect(message.textContent).toContain('Single sign-on'); + expect(login).not.toHaveBeenCalled(); + }); + + it('hides, shows, and focuses the existing mount without rebuilding it', () => { + const app = appWith(); + const host = document.createElement('div'); + host.hidden = true; + document.body.append(host); + const handle = mountInlineLogin(app, host, 'Sign in again'); + const card = qs(host, '.login-card'); + const user = qsa(host, '.login-input')[0]; + + expect(host.hidden).toBe(false); + expect(document.activeElement).toBe(user); + handle.hide(); + expect(host.hidden).toBe(true); + handle.show('Session expired'); + expect(host.hidden).toBe(false); + expect(qs(host, '.login-card')).toBe(card); + expect(qs(host, '.login-error').textContent).toBe('Session expired'); + expect(document.activeElement).toBe(user); + host.remove(); + }); + + it('dispose removes only its own mount and fences a late IdP load', async () => { + let resolveConfig: ((value: TestIdpsResult) => void) | undefined; + const loadIdps = () => new Promise((resolve) => { resolveConfig = resolve; }); + const app = appWith({ loadIdps }); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + const detachedContainer = qs(host, '.login-inline'); + + handle.dispose(); + const replacement = document.createElement('div'); + replacement.className = 'new-owner'; + host.append(replacement); + resolveConfig?.({ idps, basicLogin: false, hosts }); + await tick(); + + expect(host.hidden).toBe(true); + expect(host.firstElementChild).toBe(replacement); + expect(detachedContainer.querySelector('.login-sso .login-btn')).toBeNull(); + expect(detachedContainer.querySelector('.login-creds')).not.toBeNull(); + handle.show('ignored after disposal'); + expect(host.firstElementChild).toBe(replacement); + }); + + it('fences a late credential failure after an inline mount is disposed', async () => { + let rejectConnect: ((reason?: unknown) => void) | undefined; + const connect = vi.fn(() => new Promise((_resolve, reject) => { rejectConnect = reject; })); + const app = appWith({ + actions: { connect }, + loadIdps: async () => ({ idps, basicLogin: true }), + }); + const host = document.createElement('div'); + const basic = mountInlineLogin(app, host); + const [user, pass] = qsa(host, '.login-input'); + type(user, 'alice'); type(pass, 'wrong'); + click(qs(host, '.login-creds .login-btn')); + await tick(); + basic.dispose(); + rejectConnect?.(new Error('late credential failure')); + await tick(); + expect(host.querySelector('.login-error')).toBeNull(); + }); + + it('makes hidden and disposed inline handle methods inert', () => { + const app = appWith(); + const host = document.createElement('div'); + document.body.append(host); + const handle = mountInlineLogin(app, host); + handle.hide(); + handle.focus(); // hidden controls must not steal focus + expect(host.hidden).toBe(true); + host.replaceChildren(); // mount was independently removed by a shell owner + handle.dispose(); + handle.dispose(); + handle.show('ignored'); + handle.focus(); + expect(host.hidden).toBe(true); + host.remove(); + }); + + it('a full-screen re-render fences the superseded mount config load', async () => { + let resolveFirst: ((value: TestIdpsResult) => void) | undefined; + const app = appWith({ + loadIdps: () => new Promise((resolve) => { resolveFirst = resolve; }), + }); + renderLogin(app); + const oldCard = qs(app.root, '.login-card'); + app.conn.loadIdps = asLoadIdps(async () => ({ idps: [], basicLogin: true })); + renderLogin(app, 'new screen'); + + resolveFirst?.({ idps, basicLogin: false, hosts }); + await tick(); + + expect(app.root.contains(oldCard)).toBe(false); + expect(oldCard.querySelector('.login-sso .login-btn')).toBeNull(); + expect(oldCard.querySelector('.login-creds')).not.toBeNull(); + expect(qs(app.root, '.login-error').textContent).toBe('new screen'); + }); +}); diff --git a/tests/unit/main.test.ts b/tests/unit/main.test.ts index a48fca24..ff67bcee 100644 --- a/tests/unit/main.test.ts +++ b/tests/unit/main.test.ts @@ -51,6 +51,7 @@ function fakeApp(over: Partial> & { conn?: Partial {}) }, renderCurrentSurface: vi.fn(), + resumeAuthenticatedExecution: vi.fn(), syncSqlRoute: vi.fn(), showLogin: vi.fn(), // #287 W4: bootstrap awaits this before the first renderApp() on the @@ -109,6 +110,7 @@ describe('bootstrap', () => { it('renders the app when already signed in', async () => { const app = fakeApp({ token: valid, conn: { isSignedIn: () => true } }); await bootstrap(app, fakeEnv()); + expect(app.resumeAuthenticatedExecution).toHaveBeenCalledOnce(); expect(app.catalog.loadVersion).toHaveBeenCalledOnce(); expect(app.renderCurrentSurface).toHaveBeenCalled(); }); diff --git a/tests/unit/query-execution-service.test.ts b/tests/unit/query-execution-service.test.ts index 468837b1..f1619c33 100644 --- a/tests/unit/query-execution-service.test.ts +++ b/tests/unit/query-execution-service.test.ts @@ -171,6 +171,38 @@ describe('executeRead', () => { expect(calls[0].opts.onChunk).toBeUndefined(); }); + it('does not acquire auth or mutate a result when the caller epoch is already stale', async () => { + const { fn } = fakeRunQuery([() => ({ error: 'must not run' })]); + const ctx = vi.fn(() => fakeCtx); + const svc = createQueryExecutionService(makeDeps({ runQuery: fn, ctx })); + const result = newResult('Table'); + await svc.executeRead(result, { sql: 'SELECT 1', isCurrent: () => false }); + expect(ctx).not.toHaveBeenCalled(); + expect(fn).not.toHaveBeenCalled(); + expect(result.error).toBeNull(); + }); + + it('fences late stream chunks and settlement after the caller epoch closes', async () => { + let current = true; + const onChunk = vi.fn(); + const { fn } = fakeRunQuery([ + (opts) => { + opts.onLine!({ meta: [{ name: 'x', type: 'Int32' }] }); + opts.onLine!({ row: { x: 1 } }); + current = false; + opts.onLine!({ row: { x: 2 } }); + opts.onChunk!(); + return { error: 'late error' }; + }, + ]); + const svc = createQueryExecutionService(makeDeps({ runQuery: fn })); + const result = newResult('Table'); + await svc.executeRead(result, { sql: 'SELECT 1', isCurrent: () => current, onChunk }); + expect(result.rows).toEqual([[1]]); + expect(result.error).toBeNull(); + expect(onChunk).not.toHaveBeenCalled(); + }); + it('marks cancelled (not error) and keeps partial rows on AbortError', async () => { const { fn } = fakeRunQuery([ (opts) => { @@ -227,6 +259,99 @@ const ddlStmt = (params: Record = {}): ScriptStatement }); describe('executeScript', () => { + it('fences late errors and callback publication after an authenticated epoch closes', async () => { + let current = true; + const rejected = fakeRunQuery([() => { current = false; throw new Error('late'); }]); + const service = createQueryExecutionService(makeDeps({ runQuery: rejected.fn })); + await expect(service.executeRead(newResult('Table'), { sql: 'SELECT 1', isCurrent: () => current })) + .resolves.toMatchObject({ error: null }); + + // The entry is local bookkeeping, but its callback is a UI publication and + // must be fenced independently for both error and success entries. + for (const outcome of [{ error: 'bad' } as RunQueryResult, { raw: '' } as RunQueryResult]) { + let checks = 0; + const transport = fakeRunQuery([() => outcome]); + const onStatementResult = vi.fn(); + const scoped = createQueryExecutionService(makeDeps({ runQuery: transport.fn })); + const result = await scoped.executeScript({ + statements: [ddlStmt()], + isCurrent: () => (++checks < 5), + onStatementStart: vi.fn(), + onStatementResult, + }); + expect(result.entries).toHaveLength(1); + expect(onStatementResult).not.toHaveBeenCalled(); + } + }); + + it('stringifies a non-Error script transport failure', async () => { + const { fn } = fakeRunQuery([() => { throw 'opaque transport failure'; }]); + const svc = createQueryExecutionService(makeDeps({ runQuery: fn })); + const { entries } = await svc.executeScript({ statements: [ddlStmt()], onStatementStart: vi.fn(), onStatementResult: vi.fn() }); + expect(entries).toEqual([expect.objectContaining({ status: 'error', error: 'opaque transport failure' })]); + }); + + it('treats every lifecycle fence as a local abort, without publishing an entry or acquiring a replacement context', async () => { + // These four placements deliberately exercise the distinct fences around a + // script attempt: before the loop, after minting its id, after transport, + // and after a retry. They are separate auth-loss interleavings in the UI. + const before = createQueryExecutionService(makeDeps({ + runQuery: fakeRunQuery([]).fn, + })); + await expect(before.executeScript({ statements: [ddlStmt()], isCurrent: () => false, onStatementStart: vi.fn(), onStatementResult: vi.fn() })) + .resolves.toEqual({ entries: [], aborted: true }); + + let checks = 0; + const afterId = createQueryExecutionService(makeDeps({ + runQuery: fakeRunQuery([]).fn, + })); + await expect(afterId.executeScript({ + statements: [ddlStmt()], + isCurrent: () => (++checks < 2), + onStatementStart: vi.fn(), onStatementResult: vi.fn(), + })).resolves.toEqual({ entries: [], aborted: true }); + + let postTransport = true; + const transport = fakeRunQuery([() => { postTransport = false; return { raw: '' }; }]); + const afterTransport = createQueryExecutionService(makeDeps({ runQuery: transport.fn })); + await expect(afterTransport.executeScript({ + statements: [ddlStmt()], isCurrent: () => postTransport, + onStatementStart: vi.fn(), onStatementResult: vi.fn(), + })).resolves.toEqual({ entries: [], aborted: true }); + + let retryTransportCalls = 0; + const retryTransport = fakeRunQuery([ + () => ({ error: 'SESSION_IS_LOCKED' }), + () => { retryTransportCalls += 1; return { raw: '' }; }, + ]); + let retryChecks = 0; + const afterRetry = createQueryExecutionService(makeDeps({ + runQuery: retryTransport.fn, + sleep: async () => {}, + })); + await expect(afterRetry.executeScript({ + statements: [ddlStmt()], + // loop / after-id / after-transport / after-sleep all pass; the fence + // immediately after the retry transport rejects its late result. + isCurrent: () => (++retryChecks <= 6), + onStatementStart: vi.fn(), onStatementResult: vi.fn(), + })).resolves.toEqual({ entries: [], aborted: true }); + expect(retryTransportCalls).toBe(1); + }); + + it('does not enter transport when the scope closes between publishing the id and the attempt', async () => { + let checks = 0; + const run = fakeRunQuery([]); + const svc = createQueryExecutionService(makeDeps({ runQuery: run.fn })); + await expect(svc.executeScript({ + statements: [ddlStmt()], + // loop and id fence pass; attemptStatement itself observes the close. + isCurrent: () => (++checks < 3), + onStatementStart: vi.fn(), onStatementResult: vi.fn(), + })).resolves.toEqual({ entries: [], aborted: true }); + expect(run.calls).toHaveLength(0); + }); + it('runs one runQuery per statement, wire text vs authored sql, in order', async () => { const { fn, calls } = fakeRunQuery([ () => ({ raw: JSON.stringify({ meta: [{ name: 'x', type: 'Int32' }], data: [[1]] }) }), @@ -321,6 +446,28 @@ describe('executeScript', () => { expect(entries[0].status).toBe('ok'); }); + it('does not let a delayed retry acquire a replacement auth context', async () => { + let current = true; + const { fn, calls } = fakeRunQuery([ + () => ({ error: 'SESSION_IS_LOCKED: locked' }), + () => ({ raw: '' }), + ]); + const ctx = vi.fn(() => fakeCtx); + const sleep = vi.fn(async () => { current = false; }); + const onStatementStart = vi.fn(); + const svc = createQueryExecutionService(makeDeps({ runQuery: fn, ctx, sleep })); + const result = await svc.executeScript({ + statements: [ddlStmt()], + isCurrent: () => current, + onStatementStart, + onStatementResult: vi.fn(), + }); + expect(result).toEqual({ entries: [], aborted: true }); + expect(calls).toHaveLength(1); + expect(ctx).toHaveBeenCalledTimes(1); + expect(onStatementStart).toHaveBeenCalledTimes(1); + }); + it('retries a transient (TypeError) failure only for a row-returning statement', async () => { const { fn, calls } = fakeRunQuery([ () => { throw new TypeError('reset'); }, diff --git a/tests/unit/results.test.ts b/tests/unit/results.test.ts index ad4cb7b2..92965a15 100644 --- a/tests/unit/results.test.ts +++ b/tests/unit/results.test.ts @@ -14,6 +14,7 @@ import { formatRows } from '../../src/core/format.js'; import { queryPanel } from '../../src/core/saved-query.js'; import type { AppState, ResultSort } from '../../src/state.js'; import type { App } from '../../src/ui/app.types.js'; +import type { AuthenticatedExecutionScope, AuthenticatedExecutionOperation } from '../../src/application/authenticated-execution-scope.js'; // tests/helpers/fake-app.js's `makeApp()` is a long-standing untyped test // double implementing exactly the members results.ts's own narrow `ResultsApp` @@ -169,6 +170,21 @@ describe('renderResults states', () => { expect(qsa(app.dom.resultsRegion, '.res-table tbody tr')).toHaveLength(2); expect(qs(app.dom.resultsRegion, '.stream-strip')).not.toBeNull(); }); + it('destroys a prior live chart before rebuilding the results surface', () => { + const app = appWithResult(tableResult()); + const chart = { destroy: vi.fn() }; + app.chart = chart; + renderResults(app); + expect(chart.destroy).toHaveBeenCalledTimes(1); + expect(app.chart).toBeNull(); + }); + it('normalizes a persisted table panel to the ordinary Table result view', () => { + const app = appWithResult(tableResult(), { resultView: 'panel' }); + app.activeTab().specParsed!.panel = { cfg: { type: 'table' } }; + renderResults(app); + expect(app.state.resultView.value).toBe('table'); + expect(qs(app.dom.resultsRegion, '.res-table')).not.toBeNull(); + }); it('clicking a live result cell opens its detail drawer through the result-view callback', () => { const app = appWithResult(tableResult(), { resultView: 'table' }); renderResults(app); @@ -212,6 +228,15 @@ describe('renderResults states', () => { expect(qs(region, '.chart-view canvas')).not.toBeNull(); // autoPanel picked a chart expect(queryPanel(app.activeTab())).toBeUndefined(); // preview never writes the tab spec }); + it('uses the panel-picker rerender callback after choosing a presentation from Table view', () => { + const app = appWithResult(tableResult(), { resultView: 'table' }); + renderResults(app); + const picker = qs(app.dom.resultsRegion, '.result-panel-select'); + picker.value = [...picker.options].find((option) => option.value !== '' && option.value !== 'panel:auto')!.value; + picker.dispatchEvent(new Event('change', { bubbles: true })); + expect(app.state.resultView.value).toBe('panel'); + expect(qs(app.dom.resultsRegion, '.panel-view')).not.toBeNull(); + }); it('panel view with no result shows the run hint (query-backed types need a Run)', () => { const app = appWithResult(null, { resultView: 'panel' }); renderResults(app); @@ -1046,10 +1071,116 @@ describe('expandDataPane', () => { click(refreshBtn(overlay)); await tick(); expect(app.exec.executeRead).not.toHaveBeenCalled(); - expect(qs(overlay, '.detached-status').textContent).toBe('Not signed in'); + expect(app.conn.chCtx.onSignedOut).toHaveBeenCalledTimes(1); + expect(qs(overlay, '.detached-status').textContent).toBe('Sign in required'); expect(refreshBtn(overlay)!.disabled).toBe(false); // re-enabled after the blocked attempt }); + it('routes a detached refresh through the authentication gate before it registers transport', async () => { + const executeRead = vi.fn(async (result: QueryResult) => result); + const app = makeApp({ + requireAuthenticatedExecution: () => null, + exec: { executeRead }, + }); + app.state.varValues.level = 'X'; + expandDataPane(app, paramResult()); + const overlay = qs(document, '.graph-overlay'); + click(refreshBtn(overlay)); + await tick(); + expect(executeRead).not.toHaveBeenCalled(); + expect(qs(overlay, '.detached-status').textContent).toBe('Sign in required'); + expect(refreshBtn(overlay)!.disabled).toBe(false); + }); + + it('aborts a detached refresh and preserves its committed snapshot when the execution scope closes', async () => { + let resolveRead: ((result: ExecuteReadResult) => void) | undefined; + let signal: AbortSignal | undefined; + let opts: ExecuteReadOpts | undefined; + const executeRead = vi.fn((result: ExecuteReadResult, request: ExecuteReadOpts = {} as ExecuteReadOpts) => { + signal = request.signal; + opts = request; + return new Promise((resolve) => { resolveRead = resolve; }); + }); + const app = makeApp({ exec: { executeRead } }); + app.state.varValues.level = 'X'; + expandDataPane(app, paramResult()); + const overlay = qs(document, '.graph-overlay'); + click(refreshBtn(overlay)); + await tick(); + + expect(opts?.queryId).toMatch(/^detached-t1-1-/); + expect(opts?.isCurrent?.()).toBe(true); + expect(signal?.aborted).toBe(false); + app.executionScope()!.close(); + expect(opts?.isCurrent?.()).toBe(false); + expect(signal?.aborted).toBe(true); + expect(qs(overlay, '.detached-status').textContent).toBe('Sign in required'); + expect(refreshBtn(overlay)!.disabled).toBe(false); + + // A late executor completion and progress callback must not replace the + // data pane's already committed snapshot after auth loss. + opts?.onChunk?.(); + const late = newResult('Table'); + late.columns = [{ name: 'late', type: 'String' }]; + late.rows = [['discarded']]; + resolveRead?.(late); + await tick(); + expect(qsa(overlay, '.res-table tbody tr')).toHaveLength(2); + }); + + it('ignores an obsolete detached-scope abort callback after a newer refresh owns the pane', async () => { + const operations: AuthenticatedExecutionOperation[] = []; + const scope = { + register: vi.fn((operation: AuthenticatedExecutionOperation) => { + operations.push(operation); + return { name: operation.name, release: vi.fn(), isCurrent: () => true }; + }), + } as unknown as AuthenticatedExecutionScope; + const requests: ExecuteReadOpts[] = []; + const executeRead = vi.fn((_result: ExecuteReadResult, request: ExecuteReadOpts = {} as ExecuteReadOpts) => { + requests.push(request); + return new Promise(() => {}); + }); + const app = makeApp({ + executionScope: () => scope, + requireAuthenticatedExecution: () => scope, + exec: { executeRead }, + }); + app.state.varValues.level = 'X'; + expandDataPane(app, paramResult()); + const overlay = qs(document, '.graph-overlay'); + click(refreshBtn(overlay)); + await tick(); + click(refreshBtn(overlay)); + await tick(); + expect(operations).toHaveLength(2); + expect(requests).toHaveLength(2); + expect(requests[0].signal?.aborted).toBe(true); + expect(requests[1].signal?.aborted).toBe(false); + + operations[0].abort(); + expect(requests[1].signal?.aborted).toBe(false); + expect(qs(overlay, '.detached-status').textContent).toBe('Running…'); + expect(refreshBtn(overlay)!.disabled).toBe(true); + }); + + it('does not start executor transport when auth closes during the detached token check', async () => { + let resolveToken: ((value: boolean) => void) | undefined; + const ensureFreshToken = vi.fn(() => new Promise((resolve) => { resolveToken = resolve; })); + const executeRead = vi.fn(async (result: QueryResult) => result); + const app = makeApp({ conn: { ensureFreshToken }, exec: { executeRead } }); + app.state.varValues.level = 'X'; + expandDataPane(app, paramResult()); + const overlay = qs(document, '.graph-overlay'); + click(refreshBtn(overlay)); + await tick(); + app.executionScope()!.close(); + resolveToken?.(true); + await tick(); + expect(executeRead).not.toHaveBeenCalled(); + expect(qs(overlay, '.detached-status').textContent).toBe('Sign in required'); + }); + it('shows a status and keeps the previous result when the rerun errors', async () => { const executeRead = vi.fn(async (result: QueryResult, _opts: ExecuteReadOpts = {} as ExecuteReadOpts) => { result.error = 'Boom'; return result; }); const app = makeApp({ exec: { executeRead } }); diff --git a/tests/unit/schema-catalog-service.test.ts b/tests/unit/schema-catalog-service.test.ts index 3d06074f..c69821b6 100644 --- a/tests/unit/schema-catalog-service.test.ts +++ b/tests/unit/schema-catalog-service.test.ts @@ -51,6 +51,13 @@ function makeHooks(): Mocked> { }; } +function deferred(): { promise: Promise; resolve(value: T): void; reject(reason?: unknown): void } { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +} + function makeDeps(over: Partial = {}): SchemaCatalogDeps { return { loadServerVersion: vi.fn(async () => '24.3.1.2603'), @@ -197,6 +204,29 @@ describe('loadReference', () => { await svc.docEntry({ kind: 'function', name: 'count' }); // cache was cleared by loadReference → refetches expect(loadFunctionDocRow).toHaveBeenCalledTimes(2); }); + + it('swallows an AbortError only after invalidation, while unrelated reference failures reject', async () => { + const abortingLoad = vi.fn((_ctx: ChCtx, signal?: AbortSignal) => new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => { + const error = new Error('connection closed'); + error.name = 'AbortError'; + reject(error); + }, { once: true }); + })); + const abortingSvc = createSchemaCatalogService(makeDeps({ + loadReferenceData: abortingLoad as unknown as SchemaCatalogDeps['loadReferenceData'], + })); + const aborting = abortingSvc.loadReference(); + await Promise.resolve(); + abortingSvc.invalidate(); + await expect(aborting).resolves.toBeUndefined(); + + const transportFailure = new Error('reference transport failed'); + const failingSvc = createSchemaCatalogService(makeDeps({ + loadReferenceData: vi.fn(async () => { throw transportFailure; }) as unknown as SchemaCatalogDeps['loadReferenceData'], + })); + await expect(failingSvc.loadReference()).rejects.toBe(transportFailure); + }); }); describe('rebuildCompletions', () => { @@ -240,6 +270,296 @@ describe('refData / completions setters', () => { // ── invalidate ─────────────────────────────────────────────────────────────── +describe('connection invalidation generation', () => { + it('stops before every metadata transport when invalidated during configuration, and never rebuilds stale data', async () => { + const config = deferred(); + const state = makeState(baseSchema()); + const hooks = makeHooks(); + const deps = makeDeps({ state, hooks, ensureConfig: vi.fn(() => config.promise) }); + const svc = createSchemaCatalogService(deps); + const version = svc.loadVersion(); + const schema = svc.loadSchema(); + const columns = svc.loadColumns('d1', 't1'); + const reference = svc.loadReference(); + svc.invalidate(); + config.resolve(null); + await Promise.all([version, schema, columns, reference]); + expect(deps.loadServerVersion).not.toHaveBeenCalled(); + expect(deps.loadSchema).not.toHaveBeenCalled(); + expect(deps.loadColumns).not.toHaveBeenCalled(); + expect(deps.loadReferenceData).not.toHaveBeenCalled(); + expect(hooks.renderVarStrip).not.toHaveBeenCalled(); + }); + + it('synchronously aborts active schema, reference, and documentation work and gives the next connection a fresh signal', async () => { + const firstSchema = deferred(); + const secondSchema = deferred(); + const schemaLoads = [firstSchema, secondSchema]; + const schemaSignals: AbortSignal[] = []; + const referenceSignals: AbortSignal[] = []; + const docSignals: AbortSignal[] = []; + const deps = makeDeps({ + loadSchema: vi.fn((_ctx: ChCtx, signal?: AbortSignal) => { + schemaSignals.push(signal!); + return schemaLoads.shift()!.promise; + }) as unknown as SchemaCatalogDeps['loadSchema'], + loadReferenceData: vi.fn((_ctx: ChCtx, signal?: AbortSignal) => { + referenceSignals.push(signal!); + return new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => { + const error = new Error('cancelled'); + error.name = 'AbortError'; + reject(error); + }, { once: true }); + }); + }) as unknown as SchemaCatalogDeps['loadReferenceData'], + loadDocTableColumns: vi.fn((_ctx: ChCtx, _table, signal?: AbortSignal) => { + docSignals.push(signal!); + return new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => { + const error = new Error('cancelled'); + error.name = 'AbortError'; + reject(error); + }, { once: true }); + }); + }) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + }); + const svc = createSchemaCatalogService(deps); + + const schema = svc.loadSchema(); + const ref = svc.loadReference(); + const doc = svc.docEntry({ kind: 'setting', name: 'max_threads' }); + await Promise.resolve(); + svc.invalidate(); + + expect(schemaSignals[0].aborted).toBe(true); + expect(referenceSignals[0].aborted).toBe(true); + expect(docSignals[0].aborted).toBe(true); + + firstSchema.resolve([]); + await Promise.all([schema, ref]); + await expect(doc).resolves.toEqual({ status: 'unavailable' }); + + const freshSchema = svc.loadSchema(); + await Promise.resolve(); + expect(schemaSignals[1]).not.toBe(schemaSignals[0]); + expect(schemaSignals[1].aborted).toBe(false); + secondSchema.resolve([]); + await freshSchema; + }); + + it('does not rebuild schema/columns after a loader itself invalidates the connection', async () => { + const state = makeState(baseSchema()); + const hooks = makeHooks(); + let svc!: ReturnType; + const deps = makeDeps({ + state, + hooks, + loadSchema: vi.fn(async () => { svc.invalidate(); return baseSchema(); }) as unknown as SchemaCatalogDeps['loadSchema'], + loadColumns: vi.fn(async () => { svc.invalidate(); throw new Error('connection replaced'); }), + }); + svc = createSchemaCatalogService(deps); + await svc.loadSchema(); + // invalidate() intentionally clears the whole connection projection; + // columns are normally only requested from a newly-rendered schema row. + state.schema.value = baseSchema(); + await svc.loadColumns('d1', 't1'); + expect(hooks.renderVarStrip).not.toHaveBeenCalled(); + }); + + it('drops capability probes which complete after reconnect for function, structured, and Markdown documentation sources', async () => { + const functionColumns = deferred(); + const functionSvc = createSchemaCatalogService(makeDeps({ + loadFunctionsDocColumns: vi.fn(() => functionColumns.promise), + })); + const functionLookup = functionSvc.docEntry({ kind: 'function', name: 'late' }); + await Promise.resolve(); + functionSvc.invalidate(); + functionColumns.resolve(['name']); + await expect(functionLookup).resolves.toEqual({ status: 'unavailable' }); + + const structuredColumns = deferred(); + const structuredSvc = createSchemaCatalogService(makeDeps({ + loadDocTableColumns: vi.fn(() => structuredColumns.promise) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + })); + const structuredLookup = structuredSvc.docEntry({ kind: 'table-engine', name: 'late' }); + await Promise.resolve(); + structuredSvc.invalidate(); + structuredColumns.resolve(['name']); + await expect(structuredLookup).resolves.toEqual({ status: 'unavailable' }); + + const markdownColumns = deferred(); + const markdownSvc = createSchemaCatalogService(makeDeps({ + loadDocTableColumns: vi.fn(() => markdownColumns.promise) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + })); + const markdownLookup = markdownSvc.docEntry({ kind: 'setting', name: 'late' }); + await Promise.resolve(); + markdownSvc.invalidate(); + markdownColumns.resolve(['name', 'type', 'description']); + await expect(markdownLookup).resolves.toEqual({ status: 'unavailable' }); + }); + + it('fences a reconnect that happens during the post-capability configuration await of every documentation lookup', async () => { + const exercise = async ( + warm: () => Promise, + lookup: () => Promise, + invalidate: () => void, + release: () => void, + ): Promise => { + await warm(); + const pending = lookup(); + await Promise.resolve(); + invalidate(); + release(); + await expect(pending).resolves.toEqual({ status: 'unavailable' }); + }; + + let hold = false; + let config = deferred(); + const functionSvc = createSchemaCatalogService(makeDeps({ + ensureConfig: vi.fn(() => hold ? config.promise : Promise.resolve(null)), + loadFunctionsDocColumns: vi.fn(async () => ['name']), + })); + await exercise(() => functionSvc.docEntry({ kind: 'function', name: 'warm' }), + () => { hold = true; return functionSvc.docEntry({ kind: 'function', name: 'next' }); }, + () => functionSvc.invalidate(), () => config.resolve(null)); + + hold = false; config = deferred(); + const structuredSvc = createSchemaCatalogService(makeDeps({ + ensureConfig: vi.fn(() => hold ? config.promise : Promise.resolve(null)), + loadDocTableColumns: vi.fn(async () => ['name']) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + })); + await exercise(() => structuredSvc.docEntry({ kind: 'table-engine', name: 'warm' }), + () => { hold = true; return structuredSvc.docEntry({ kind: 'table-engine', name: 'next' }); }, + () => structuredSvc.invalidate(), () => config.resolve(null)); + + hold = false; config = deferred(); + const markdownSvc = createSchemaCatalogService(makeDeps({ + ensureConfig: vi.fn(() => hold ? config.promise : Promise.resolve(null)), + loadDocTableColumns: vi.fn(async () => ['name', 'type', 'description']) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + })); + await exercise(() => markdownSvc.docEntry({ kind: 'setting', name: 'warm' }), + () => { hold = true; return markdownSvc.docEntry({ kind: 'setting', name: 'next' }); }, + () => markdownSvc.invalidate(), () => config.resolve(null)); + + // Name disambiguation has the same post-config fence but a separate + // in-flight map, so cover it as its own consumer of the cached capability. + hold = false; config = deferred(); + const disambiguateSvc = createSchemaCatalogService(makeDeps({ + ensureConfig: vi.fn(() => hold ? config.promise : Promise.resolve(null)), + loadDocTableColumns: vi.fn(async () => ['name', 'type', 'description']) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + })); + await disambiguateSvc.docEntry({ kind: 'setting', name: 'warm' }); + hold = true; + const pending = disambiguateSvc.docDisambiguate('next'); + await Promise.resolve(); + disambiguateSvc.invalidate(); + config.resolve(null); + await expect(pending).resolves.toEqual({ status: 'unavailable' }); + }); + + it('falls back from a durably unavailable structured source and aliases a canonical Markdown entry', async () => { + const fallback = createSchemaCatalogService(makeDeps({ + loadDocTableColumns: vi.fn(async (_ctx: ChCtx, table: string) => ( + table === 'table_engines' ? [] : ['name', 'type', 'description'] + )) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + loadDocRow: vi.fn(async () => [{ name: 'MergeTree', type: 'Table engine', description: 'fallback' }]), + })); + await expect(fallback.docEntry({ kind: 'table-engine', name: 'MergeTree' })).resolves.toMatchObject({ status: 'found' }); + + const markdown = createSchemaCatalogService(makeDeps({ + loadDocTableColumns: vi.fn(async () => ['name', 'type', 'description']) as unknown as SchemaCatalogDeps['loadDocTableColumns'], + loadDocRow: vi.fn(async () => [{ name: 'MAX_THREADS', type: 'Setting', description: 'canonical' }]), + })); + await expect(markdown.docMarkdown({ kind: 'setting', name: 'max_threads' })).resolves.toMatchObject({ status: 'found' }); + }); + + it('drops every stale metadata result, clears connection state, and permits a fresh generation', async () => { + const oldVersion = deferred(); + const oldSchema = deferred(); + const oldColumns = deferred<{ name: string; type: string; comment: string }[]>(); + const oldReference = deferred<{ keywords: string[]; functions: Record; formats: string[] }>(); + const freshSchema = [{ db: 'fresh', tables: [{ name: 'table' }] }]; + const freshColumns = [{ name: 'fresh_column', type: 'String', comment: '' }]; + const state = makeState(baseSchema()); + state.serverVersion = 'old-version'; + state.schemaError.value = 'old error'; + const hooks = makeHooks(); + const loadServerVersion = vi.fn().mockImplementationOnce(() => oldVersion.promise).mockResolvedValueOnce('fresh-version'); + const loadSchema = vi.fn().mockImplementationOnce(() => oldSchema.promise).mockResolvedValueOnce(freshSchema); + const loadColumns = vi.fn().mockImplementationOnce(() => oldColumns.promise).mockResolvedValueOnce(freshColumns); + const loadReferenceData = vi.fn().mockImplementationOnce(() => oldReference.promise).mockResolvedValueOnce({ keywords: ['FRESH'], functions: {}, formats: [] }); + const deps = makeDeps({ + state, + hooks, + loadServerVersion, + loadSchema: loadSchema as unknown as SchemaCatalogDeps['loadSchema'], + loadColumns, + loadReferenceData: loadReferenceData as unknown as SchemaCatalogDeps['loadReferenceData'], + }); + const svc = createSchemaCatalogService(deps); + + // loadReference resets only documentation state; all four loaders are now + // in the same connection generation and must be retired together by + // invalidate(), not by their own late result. + const reference = svc.loadReference(); + const version = svc.loadVersion(); + const schema = svc.loadSchema(); + const columns = svc.loadColumns('d1', 't1'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + expect(loadServerVersion).toHaveBeenCalledTimes(1); + expect(loadSchema).toHaveBeenCalledTimes(1); + expect(loadColumns).toHaveBeenCalledTimes(1); + expect(loadReferenceData).toHaveBeenCalledTimes(1); + + svc.invalidate(); + expect(state.serverVersion).toBeNull(); + expect(state.schema.value).toBeNull(); + expect(state.schemaError.value).toBeNull(); + expect(svc.refData.keywords).not.toContain('FRESH'); + + oldVersion.resolve('late-version'); + oldSchema.resolve(baseSchema()); + oldColumns.resolve([{ name: 'late_column', type: 'String', comment: '' }]); + oldReference.resolve({ keywords: ['LATE'], functions: {}, formats: [] }); + await Promise.all([version, schema, columns, reference]); + expect(state.serverVersion).toBeNull(); + expect(state.schema.value).toBeNull(); + expect(svc.refData.keywords).not.toContain('LATE'); + expect(hooks.onServerVersionLoaded).not.toHaveBeenCalled(); + expect(hooks.renderVarStrip).not.toHaveBeenCalled(); + expect(hooks.refreshEditorReference).not.toHaveBeenCalled(); + + await svc.loadVersion(); + await svc.loadSchema(); + await svc.loadColumns('fresh', 'table'); + await svc.loadReference(); + expect(state.serverVersion).toBe('fresh-version'); + expect(state.schema.value).toMatchObject(freshSchema); + expect((state.schema.value as SchemaDb[])[0].tables![0].columns).toEqual(freshColumns); + expect(svc.refData.keywords).toContain('FRESH'); + expect(hooks.onServerVersionLoaded).toHaveBeenCalledWith('fresh-version'); + expect(hooks.renderVarStrip).toHaveBeenCalledTimes(1); + expect(hooks.refreshEditorReference).toHaveBeenCalledTimes(1); + }); + + it('does not publish a stale schema failure after invalidation', async () => { + const schema = deferred(); + const state = makeState(); + const svc = createSchemaCatalogService(makeDeps({ + state, + loadSchema: vi.fn(() => schema.promise) as unknown as SchemaCatalogDeps['loadSchema'], + })); + + const pending = svc.loadSchema(); + await Promise.resolve(); + svc.invalidate(); + schema.reject(new Error('late schema failure')); + await pending; + expect(state.schemaError.value).toBeNull(); + }); +}); + // ── docSummary / docEntry (#313) ──────────────────────────────────────────── describe('docSummary / docEntry', () => { @@ -495,7 +815,7 @@ describe('docEntry — #314 structured-source routing', () => { status: 'found', value: expect.objectContaining({ target: { kind: 'table-engine', name: 'MergeTree' }, title: 'MergeTree' }), }); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines'); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines', expect.any(AbortSignal)); expect(loadDocRow).toHaveBeenCalledTimes(1); }); @@ -509,9 +829,9 @@ describe('docEntry — #314 structured-source routing', () => { await svc.docEntry({ kind: 'database-engine', name: 'Atomic' }); await svc.docEntry({ kind: 'data-type', name: 'Int32' }); - expect(loadDocTableColumns).toHaveBeenNthCalledWith(1, fakeCtx, 'formats'); - expect(loadDocTableColumns).toHaveBeenNthCalledWith(2, fakeCtx, 'database_engines'); - expect(loadDocTableColumns).toHaveBeenNthCalledWith(3, fakeCtx, 'data_type_families'); + expect(loadDocTableColumns).toHaveBeenNthCalledWith(1, fakeCtx, 'formats', expect.any(AbortSignal)); + expect(loadDocTableColumns).toHaveBeenNthCalledWith(2, fakeCtx, 'database_engines', expect.any(AbortSignal)); + expect(loadDocTableColumns).toHaveBeenNthCalledWith(3, fakeCtx, 'data_type_families', expect.any(AbortSignal)); }); it('probes each structured kind independently, once per kind, and dedupes concurrent probes for the SAME kind', async () => { @@ -941,7 +1261,7 @@ describe('#315 system.documentation capability + version policy', () => { value: expect.objectContaining({ target: { kind: 'setting', name: 'max_threads' }, renderMode: 'markdown-subset' }), }); expect(loadDocTableColumns).toHaveBeenCalledTimes(1); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'documentation'); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'documentation', expect.any(AbortSignal)); expect(loadDocRow).toHaveBeenCalledTimes(1); // A second lookup for a different name shares the cached capability. @@ -1129,7 +1449,7 @@ describe('#315 source preference — structured vs. system.documentation', () => if (result.status === 'found') expect(result.value.sourceTable).not.toBe('documentation'); // Only the table-engine probe/lookup ran — never a documentation probe. expect(loadDocTableColumns).toHaveBeenCalledTimes(1); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines'); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines', expect.any(AbortSignal)); expect(loadDocRow).toHaveBeenCalledTimes(1); }); @@ -1144,7 +1464,7 @@ describe('#315 source preference — structured vs. system.documentation', () => expect(await svc.docEntry({ kind: 'table-engine', name: 'NopeTree' })).toEqual({ status: 'missing' }); // Only the table-engine probe ran (once) — no documentation probe. expect(loadDocTableColumns).toHaveBeenCalledTimes(1); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines'); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines', expect.any(AbortSignal)); }); it('a durably-unavailable structured source falls back to system.documentation for the SAME target', async () => { @@ -1169,8 +1489,8 @@ describe('#315 source preference — structured vs. system.documentation', () => renderMode: 'markdown-subset', }), }); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines'); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'documentation'); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'table_engines', expect.any(AbortSignal)); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'documentation', expect.any(AbortSignal)); }); it('a kind with NO structured loader at all ("setting") goes straight to system.documentation', async () => { @@ -1184,7 +1504,24 @@ describe('#315 source preference — structured vs. system.documentation', () => const result = await svc.docEntry({ kind: 'setting', name: 'max_threads' }); expect(result.status).toBe('found'); expect(loadDocTableColumns).toHaveBeenCalledTimes(1); - expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'documentation'); + expect(loadDocTableColumns).toHaveBeenCalledWith(fakeCtx, 'documentation', expect.any(AbortSignal)); + }); + + it('converts a thrown compact-documentation transport failure to unavailable and clears the entry cache for a retry', async () => { + const state = makeState(); + state.serverVersion = '26.6.1'; + const loadDocRow = vi.fn() + .mockRejectedValueOnce(new Error('compact documentation transport failed')) + .mockResolvedValueOnce([{ name: 'max_threads', type: 'Setting', description: 'x' }]); + const svc = createSchemaCatalogService(makeDeps({ + state, + loadDocTableColumns: vi.fn(async () => ['name', 'type', 'description']), + loadDocRow: loadDocRow as unknown as SchemaCatalogDeps['loadDocRow'], + })); + + await expect(svc.docEntry({ kind: 'setting', name: 'max_threads' })).resolves.toEqual({ status: 'unavailable' }); + await expect(svc.docEntry({ kind: 'setting', name: 'max_threads' })).resolves.toMatchObject({ status: 'found' }); + expect(loadDocRow).toHaveBeenCalledTimes(2); }); it('docKindAvailable("setting") reflects the documentation capability once probed (no structured loader exists for it)', async () => { @@ -1267,7 +1604,7 @@ describe('#315 docMarkdown — explicit full-Markdown-depth lookup', () => { value: expect.objectContaining({ markdown: 'The full markdown body.', renderMode: 'markdown-subset' }), }); // The structured table-engine loader was never consulted by docMarkdown. - expect(loadDocTableColumns).not.toHaveBeenCalledWith(fakeCtx, 'table_engines'); + expect(loadDocTableColumns).not.toHaveBeenCalledWith(fakeCtx, 'table_engines', expect.any(AbortSignal)); }); it('caches found/missing and dedupes concurrent lookups, separately from docEntry', async () => { @@ -1319,6 +1656,23 @@ describe('#315 docMarkdown — explicit full-Markdown-depth lookup', () => { expect(loadDocRow).toHaveBeenCalledTimes(2); }); + it('converts a thrown transport failure to unavailable and clears the Markdown cache for a retry', async () => { + const state = makeState(); + state.serverVersion = '26.6.1'; + const loadDocRow = vi.fn() + .mockRejectedValueOnce(new Error('markdown transport failed')) + .mockResolvedValueOnce([{ name: 'max_threads', type: 'Setting', description: 'd' }]); + const svc = createSchemaCatalogService(makeDeps({ + state, + loadDocTableColumns: vi.fn(async () => ['name', 'type', 'description']), + loadDocRow: loadDocRow as unknown as SchemaCatalogDeps['loadDocRow'], + })); + + await expect(svc.docMarkdown({ kind: 'setting', name: 'max_threads' })).resolves.toEqual({ status: 'unavailable' }); + await expect(svc.docMarkdown({ kind: 'setting', name: 'max_threads' })).resolves.toMatchObject({ status: 'found' }); + expect(loadDocRow).toHaveBeenCalledTimes(2); + }); + it('invalidate() mid-flight drops a stale docMarkdown response (no cache write, resolves unavailable)', async () => { let resolveRow: (v: Record[]) => void; const rowPromise = new Promise[]>((res) => { resolveRow = res; }); @@ -1421,6 +1775,23 @@ describe('#315 docDisambiguate — name-only, all kinds', () => { expect(second.status).toBe('found'); }); + it('converts a thrown disambiguation transport failure to unavailable and releases the name for a retry', async () => { + const state = makeState(); + state.serverVersion = '26.6.1'; + const loadDocRow = vi.fn() + .mockRejectedValueOnce(new Error('disambiguation transport failed')) + .mockResolvedValueOnce([{ name: 'Log', type: 'Table Engine', description: 'x' }]); + const svc = createSchemaCatalogService(makeDeps({ + state, + loadDocTableColumns: vi.fn(async () => ['name', 'type', 'description']), + loadDocRow: loadDocRow as unknown as SchemaCatalogDeps['loadDocRow'], + })); + + await expect(svc.docDisambiguate('Log')).resolves.toEqual({ status: 'unavailable' }); + await expect(svc.docDisambiguate('Log')).resolves.toMatchObject({ status: 'found' }); + expect(loadDocRow).toHaveBeenCalledTimes(2); + }); + it('invalidate() mid-flight (during the capability probe) drops a stale docDisambiguate response', async () => { let resolveCols: (v: string[] | null) => void; const colsPromise = new Promise((res) => { resolveCols = res; }); diff --git a/tests/unit/schema-graph-session.test.ts b/tests/unit/schema-graph-session.test.ts index 47129728..b229e379 100644 --- a/tests/unit/schema-graph-session.test.ts +++ b/tests/unit/schema-graph-session.test.ts @@ -6,6 +6,8 @@ import type { SchemaGraphDeps, SchemaGraphHooks, SchemaGraphTab, SchemaGraphFocus, } from '../../src/application/schema-graph-session.js'; import type { ChCtx } from '../../src/net/ch-client.js'; +import { createAuthenticatedExecutionScope } from '../../src/application/authenticated-execution-scope.js'; +import type { AuthenticatedExecutionScope } from '../../src/application/authenticated-execution-scope.js'; // ── Small deferred + flush helpers (mirrors workbench-session.test.ts's own // pattern: a macrotask-boundary flush is simpler/more robust than counting @@ -105,6 +107,7 @@ function makeDeps(over: Partial = {}): SchemaGraphDeps { return { ensureConfig: vi.fn(async () => null), getToken: vi.fn(async () => 'tok'), + executionScope: () => null, ctx: () => fakeCtx, loadSchemaLineage: fakeLoadSchemaLineage(async () => ({ tables: [], dictionaries: [] })), loadLineageTransitive: fakeLoadLineageTransitive({ tables: [], dictionaries: [] }), @@ -123,6 +126,15 @@ function schemaGraphOf(tab: SchemaGraphTab): { return (tab.result as { schemaGraph: ReturnType }).schemaGraph; } +function expanded(data: Awaited['expand']>>): NonNullable { + expect(data).not.toBeNull(); + return data!; +} + +function scope(epoch = 1): AuthenticatedExecutionScope { + return createAuthenticatedExecutionScope({ epoch, cancelRemote: vi.fn() }); +} + // ── show() ─────────────────────────────────────────────────────────────────── describe('show()', () => { @@ -146,6 +158,26 @@ describe('show()', () => { expect(tab.result).toBeNull(); }); + it('registers before auth preflight; scope loss during it starts no lineage request or late auth transition', async () => { + const config = deferred(); + const hooks = makeHooks(); + const loadSchemaLineage = fakeLoadSchemaLineage(async () => ({ tables: [], dictionaries: [] })); + const active = scope(); + const deps = makeDeps({ + hooks, + executionScope: () => active, + ensureConfig: vi.fn(() => config.promise), + loadSchemaLineage, + }); + const svc = createSchemaGraphSession(deps); + const pending = svc.show({ db: 'd' }); + active.close(); + config.resolve(null); + await pending; + expect(loadSchemaLineage).not.toHaveBeenCalled(); + expect(hooks.onAuthFailed).not.toHaveBeenCalled(); + }); + it('draws a Phase-A-only graph (no progressive callbacks) and reports tableCount', async () => { const tab = makeTab(); const hooks = makeHooks(); @@ -240,6 +272,121 @@ describe('show()', () => { expect(schemaGraphOf(tab).loading).toBe(true); }); + it('scope loss during the request synchronously settles the graph and late callbacks cannot resurrect it', async () => { + const active = scope(); + const tab = makeTab(); + let onBase: ((base: FakeLineageResult) => void) | undefined; + const gate = deferred(); + const deps = makeDeps({ + activeTab: () => tab, + executionScope: () => active, + loadSchemaLineage: fakeLoadSchemaLineage((_focus, opts) => { + onBase = opts.onBase; + return gate.promise; + }), + }); + const svc = createSchemaGraphSession(deps); + const pending = svc.show({ db: 'd' }); + await flush(); + active.close(); + expect(schemaGraphOf(tab).loading).toBe(false); + expect(schemaGraphOf(tab).partial).toBe(true); + onBase?.({ tables: [{ database: 'd', name: 'late', engine: 'MergeTree' }], dictionaries: [] }); + gate.resolve({ tables: [{ database: 'd', name: 'late', engine: 'MergeTree' }], dictionaries: [] }); + await pending; + expect(schemaGraphOf(tab).nodes).toEqual([]); + expect(schemaGraphOf(tab).loading).toBe(false); + }); + + it('does not settle a graph that the tab has already replaced, or one which already finished loading', async () => { + const tab = makeTab(); + const gate = deferred(); + const svc = createSchemaGraphSession(makeDeps({ + activeTab: () => tab, + loadSchemaLineage: fakeLoadSchemaLineage(() => gate.promise), + })); + const pending = svc.show({ db: 'd' }); + await flush(); + const original = tab.result; + tab.result = { replacement: true }; + svc.suspend(); + expect(tab.result).toEqual({ replacement: true }); + gate.resolve({ tables: [], dictionaries: [] }); + await pending; + + const finished = makeTab(); + const again = createSchemaGraphSession(makeDeps({ + activeTab: () => finished, + loadSchemaLineage: hangsUntilAborted(), + })); + const second = again.show({ db: 'd' }); + await flush(); + const graph = schemaGraphOf(finished); + graph.loading = false; + again.suspend(); + expect(schemaGraphOf(finished).partial).toBeUndefined(); + await second; + expect(original).not.toBeNull(); + }); + + it('drops a true token that arrives after its execution scope has closed', async () => { + const active = scope(); + const token = deferred(); + const loadSchemaLineage = fakeLoadSchemaLineage(async () => ({ tables: [], dictionaries: [] })); + const svc = createSchemaGraphSession(makeDeps({ + executionScope: () => active, + getToken: () => token.promise, + loadSchemaLineage, + })); + const pending = svc.show({ db: 'd' }); + await flush(); + active.close(); + token.resolve('still-valid-but-stale'); + await pending; + expect(loadSchemaLineage).not.toHaveBeenCalled(); + }); + + it('suspend keeps the in-memory placeholder rather than applying manual-cancel clearing', async () => { + const tab = makeTab(); + const gate = deferred(); + const svc = createSchemaGraphSession(makeDeps({ + activeTab: () => tab, + // Deliberately ignore AbortSignal: even a poorly behaved seam must not + // publish after the session has suspended. + loadSchemaLineage: fakeLoadSchemaLineage(() => gate.promise), + })); + const pending = svc.show({ db: 'd' }); + await flush(); + svc.suspend(); + expect(schemaGraphOf(tab).loading).toBe(false); + expect(schemaGraphOf(tab).partial).toBe(true); + gate.resolve({ tables: [{ database: 'd', name: 'late', engine: 'MergeTree' }], dictionaries: [] }); + await pending; + expect(schemaGraphOf(tab).nodes).toEqual([]); + }); + + it('allows a replacement execution scope to start a fresh graph after the prior one closes', async () => { + let active = scope(1); + const tab = makeTab(); + const slow = deferred(); + const deps = makeDeps({ + activeTab: () => tab, + executionScope: () => active, + loadSchemaLineage: fakeLoadSchemaLineage((focus) => focus.db === 'old' + ? slow.promise + : Promise.resolve({ tables: [{ database: 'new', name: 't', engine: 'MergeTree' }], dictionaries: [] })), + }); + const svc = createSchemaGraphSession(deps); + const old = svc.show({ db: 'old' }); + await flush(); + active.close(); + active = scope(2); + await svc.show({ db: 'new' }); + slow.resolve({ tables: [{ database: 'old', name: 't', engine: 'MergeTree' }], dictionaries: [] }); + await old; + expect(schemaGraphOf(tab).focus?.db).toBe('new'); + }); + it('a genuine (non-abort) fetch failure sets an error result', async () => { const tab = makeTab(); const deps = makeDeps({ @@ -363,6 +510,82 @@ describe('expand()', () => { expect(hooks.onAuthFailed).toHaveBeenCalledTimes(2); }); + it('scope loss during expand preflight returns null without beginning a lineage request', async () => { + const config = deferred(); + const active = scope(); + const loadLineageTransitive = fakeLoadLineageTransitive({ tables: [], dictionaries: [] }); + const svc = createSchemaGraphSession(makeDeps({ + executionScope: () => active, + ensureConfig: vi.fn(() => config.promise), + loadLineageTransitive, + })); + const pending = svc.expand({ db: 'd' }); + active.close(); + config.resolve(null); + await expect(pending).resolves.toBeNull(); + expect(loadLineageTransitive).not.toHaveBeenCalled(); + }); + + it('scope loss during expand drops the late card payload and does not write saved positions', async () => { + const active = scope(); + const cards = deferred<{ columnsByKey: Record }>(); + let cardsSignal: AbortSignal | undefined; + const tab = makeTab({ schemaGraph: { nodes: [], edges: [] } }); + const svc = createSchemaGraphSession(makeDeps({ + activeTab: () => tab, + executionScope: () => active, + loadLineageTransitive: fakeLoadLineageTransitive({ tables: [], dictionaries: [] }), + loadSchemaCards: vi.fn((_ctx: unknown, _dbs: readonly string[], signal?: AbortSignal) => { + cardsSignal = signal; + return cards.promise; + }) as unknown as SchemaGraphDeps['loadSchemaCards'], + })); + const pending = svc.expand({ db: 'd' }); + await flush(); + active.close(); + expect(cardsSignal?.aborted).toBe(true); + cards.resolve({ columnsByKey: {} }); + await expect(pending).resolves.toBeNull(); + expect(schemaGraphOf(tab).savedPositions).toBeUndefined(); + }); + + it('drops expand after a token or lineage continuation belongs to a closed scope', async () => { + const tokenScope = scope(); + const token = deferred(); + const tokenLineage = fakeLoadLineageTransitive({ tables: [], dictionaries: [] }); + const tokenSession = createSchemaGraphSession(makeDeps({ + executionScope: () => tokenScope, + getToken: () => token.promise, + loadLineageTransitive: tokenLineage, + })); + const tokenPending = tokenSession.expand({ db: 'd' }); + await flush(); + tokenScope.close(); + token.resolve('stale'); + await expect(tokenPending).resolves.toBeNull(); + expect(tokenLineage).not.toHaveBeenCalled(); + + const lineageScope = scope(2); + const lineage = deferred(); + const cards = fakeLoadSchemaCards(); + let transitiveSignal: AbortSignal | undefined; + const lineageSession = createSchemaGraphSession(makeDeps({ + executionScope: () => lineageScope, + loadLineageTransitive: vi.fn((_ctx: unknown, _focus: unknown, opts: { signal?: AbortSignal }) => { + transitiveSignal = opts.signal; + return lineage.promise; + }) as unknown as SchemaGraphDeps['loadLineageTransitive'], + loadSchemaCards: cards, + })); + const lineagePending = lineageSession.expand({ db: 'd' }); + await flush(); + lineageScope.close(); + expect(transitiveSignal?.aborted).toBe(true); + lineage.resolve({ tables: [], dictionaries: [] }); + await expect(lineagePending).resolves.toBeNull(); + expect(cards).not.toHaveBeenCalled(); + }); + it('resolves the rich-card dataset (nodes/edges/focus/truncated/savedPositions)', async () => { const deps = makeDeps({ loadLineageTransitive: fakeLoadLineageTransitive( @@ -370,7 +593,7 @@ describe('expand()', () => { ), }); const svc = createSchemaGraphSession(deps); - const data = await svc.expand({ kind: 'db', db: 'd' }); + const data = expanded(await svc.expand({ kind: 'db', db: 'd' })); expect(data.focus).toEqual({ kind: 'db', db: 'd' }); expect(data.nodes.length).toBe(1); expect(data.nodes[0].card).toBeDefined(); @@ -378,12 +601,24 @@ describe('expand()', () => { expect(data.savedPositions).toEqual({}); }); + it('copies fetched card-column rows into the card graph without retaining the transport row object', async () => { + const transportRow = { name: 'id', type: 'UInt64' }; + const svc = createSchemaGraphSession(makeDeps({ + loadLineageTransitive: fakeLoadLineageTransitive( + { tables: [{ database: 'd', name: 't', engine: 'MergeTree' }], dictionaries: [] }, + ), + loadSchemaCards: fakeLoadSchemaCards({ 'd.t': [transportRow] }), + })); + const data = expanded(await svc.expand({ db: 'd' })); + expect(data.nodes[0].card.cols[0]).toEqual({ ...transportRow, fullType: 'UInt64', roles: [] }); + }); + it('truncated is true when either the transitive load or the expansion truncated', async () => { const deps = makeDeps({ loadLineageTransitive: fakeLoadLineageTransitive({ tables: [], dictionaries: [] }, true), }); const svc = createSchemaGraphSession(deps); - const data = await svc.expand({ db: 'd' }); + const data = expanded(await svc.expand({ db: 'd' })); expect(data.truncated).toBe(true); }); @@ -400,9 +635,9 @@ describe('expand()', () => { }); const svc = createSchemaGraphSession(deps); await svc.show({ db: 'd' }); // sets tab.result.schemaGraph - const first = await svc.expand({ db: 'd' }); + const first = expanded(await svc.expand({ db: 'd' })); expect(schemaGraphOf(tab).savedPositions).toBe(first.savedPositions); - const second = await svc.expand({ db: 'd' }); + const second = expanded(await svc.expand({ db: 'd' })); expect(second.savedPositions).toBe(first.savedPositions); // same map reused }); @@ -442,6 +677,65 @@ describe('loadNodeDetail()', () => { expect(result).toEqual(detail); }); + it('registers detail before auth preflight and drops a late detail after scope loss', async () => { + const active = scope(); + const config = deferred(); + const loadTableDetail = fakeLoadTableDetail(emptyDetail); + const svc = createSchemaGraphSession(makeDeps({ + executionScope: () => active, + ensureConfig: vi.fn(() => config.promise), + loadTableDetail, + })); + const pending = svc.loadNodeDetail({ db: 'd', name: 't' }, {}); + active.close(); + config.resolve(null); + await expect(pending).resolves.toBeNull(); + expect(loadTableDetail).not.toHaveBeenCalled(); + }); + + it('calls onAuthFailed for a current detail request whose token cannot be refreshed', async () => { + const hooks = makeHooks(); + const svc = createSchemaGraphSession(makeDeps({ hooks, getToken: vi.fn(async () => null) })); + await expect(svc.loadNodeDetail({ db: 'd', name: 't' }, {})).resolves.toBeNull(); + expect(hooks.onAuthFailed).toHaveBeenCalledTimes(1); + }); + + it('scope loss while detail is loading fences its late completion', async () => { + const active = scope(); + const gate = deferred(); + let detailSignal: AbortSignal | undefined; + const svc = createSchemaGraphSession(makeDeps({ + executionScope: () => active, + loadTableDetail: vi.fn((_ctx: unknown, _db: string, _table: string, signal?: AbortSignal) => { + detailSignal = signal; + return gate.promise; + }) as unknown as SchemaGraphDeps['loadTableDetail'], + })); + const pending = svc.loadNodeDetail({ db: 'd', name: 't' }, {}); + await flush(); + active.close(); + expect(detailSignal?.aborted).toBe(true); + gate.resolve(emptyDetail); + await expect(pending).resolves.toBeNull(); + }); + + it('does not fetch node detail when a true token resolves after scope closure', async () => { + const active = scope(); + const token = deferred(); + const loadTableDetail = fakeLoadTableDetail(emptyDetail); + const svc = createSchemaGraphSession(makeDeps({ + executionScope: () => active, + getToken: () => token.promise, + loadTableDetail, + })); + const pending = svc.loadNodeDetail({ db: 'd', name: 't' }, {}); + await flush(); + active.close(); + token.resolve('stale'); + await expect(pending).resolves.toBeNull(); + expect(loadTableDetail).not.toHaveBeenCalled(); + }); + it('last-clicked wins: a later click on the same token supersedes an earlier, slower one', async () => { const gate = deferred(); const loadTableDetail = vi.fn((_ctx: unknown, db: string) => ( diff --git a/tests/unit/shortcuts.test.ts b/tests/unit/shortcuts.test.ts index 5bdfdab2..20f953ac 100644 --- a/tests/unit/shortcuts.test.ts +++ b/tests/unit/shortcuts.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { signal } from '@preact/signals-core'; import { SHORTCUT_CATALOG, openShortcuts, handleKeydown, resetShortcutChord } from '../../src/ui/shortcuts.js'; import type { ShortcutKeydownEvent, SurfaceCommandPort } from '../../src/ui/shortcuts.js'; import { makeApp } from '../helpers/fake-app.js'; @@ -271,7 +272,10 @@ describe('handleKeydown', () => { }); it('fails closed for every application action when signed out', () => { - const app = makeApp({ conn: { isSignedIn: () => false } }); + const app = makeApp({ conn: { + isSignedIn: () => false, + connection: signal({ kind: 'signed-out' as const, epoch: 1 }), + } }); for (const event of [ { metaKey: true, key: 'Enter' }, { metaKey: true, key: 's' }, { metaKey: true, shiftKey: true, key: 'Enter' }, { key: 'g' }, { key: '?' }, @@ -322,7 +326,10 @@ describe('handleKeydown', () => { expect(app.actions.formatQuery).toHaveBeenCalled(); expect(app.actions.run).not.toHaveBeenCalled(); expect(e.preventDefault).toHaveBeenCalled(); - const out = makeApp({ conn: { isSignedIn: () => false } }); + const out = makeApp({ conn: { + isSignedIn: () => false, + connection: signal({ kind: 'signed-out' as const, epoch: 1 }), + } }); expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'Enter' }), out)).toBeNull(); }); it('Spec mode blocks Run and routes document formatting to the Spec editor', () => { @@ -345,7 +352,10 @@ describe('handleKeydown', () => { }); it('does not switch editor mode while signed out', () => { - const app = makeApp({ conn: { isSignedIn: () => false } }); + const app = makeApp({ conn: { + isSignedIn: () => false, + connection: signal({ kind: 'signed-out' as const, epoch: 1 }), + } }); const e = ev({ metaKey: true, altKey: true, key: '1' }); expect(handleKeydown(e, app)).toBeNull(); expect(e.preventDefault).not.toHaveBeenCalled(); @@ -362,7 +372,10 @@ describe('handleKeydown', () => { expect(app.actions.share).toHaveBeenCalledTimes(1); expect(handleKeydown(ev({ metaKey: true, key: 's' }), app)).toBe('save'); expect(app.actions.save).toHaveBeenCalledTimes(2); - const out = makeApp({ conn: { isSignedIn: () => false } }); + const out = makeApp({ conn: { + isSignedIn: () => false, + connection: signal({ kind: 'signed-out' as const, epoch: 1 }), + } }); expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 's' }), out)).toBeNull(); expect(handleKeydown(ev({ metaKey: true, key: 's' }), out)).toBeNull(); }); @@ -371,9 +384,29 @@ describe('handleKeydown', () => { expect(handleKeydown(ev({ key: '?' }), app)).toBe('shortcuts'); expect(handleKeydown(ev({ key: '?', target: { tagName: 'INPUT' } }), app)).toBeNull(); expect(handleKeydown(ev({ key: '?', target: { isContentEditable: true } }), app)).toBeNull(); - const out = makeApp({ conn: { isSignedIn: () => false } }); + const out = makeApp({ conn: { + isSignedIn: () => false, + connection: signal({ kind: 'signed-out' as const, epoch: 1 }), + } }); expect(handleKeydown(ev({ key: '?' }), out)).toBeNull(); }); + + it('keeps local commands and routes remote commands to their shared gate while auth is required', () => { + const app = makeApp({ conn: { + isSignedIn: () => false, + connection: signal({ kind: 'auth-required' as const, epoch: 2 }), + } }); + expect(handleKeydown(ev({ metaKey: true, key: 'Enter' }), app)).toBe('run'); + expect(app.actions.run).toHaveBeenCalledOnce(); + expect(handleKeydown(ev({ metaKey: true, key: 's' }), app)).toBe('save'); + expect(app.actions.save).toHaveBeenCalledOnce(); + app.activeTab().editorMode = 'spec'; + expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'Enter' }), app)).toBe('formatSpec'); + expect(app.actions.formatSpec).toHaveBeenCalledOnce(); + expect(handleKeydown(ev({ metaKey: true, altKey: true, key: '1' }), app)).toBe('sqlMode'); + expect(app.actions.setEditorMode).toHaveBeenCalledWith('sql'); + expect(handleKeydown(ev({ key: '?' }), app)).toBe('shortcuts'); + }); it('returns null for unhandled keys', () => { const app = makeApp(); expect(handleKeydown(ev({ key: 'x' }), app)).toBeNull(); diff --git a/tests/unit/workbench-session.test.ts b/tests/unit/workbench-session.test.ts index 1259d9ca..33607029 100644 --- a/tests/unit/workbench-session.test.ts +++ b/tests/unit/workbench-session.test.ts @@ -14,6 +14,11 @@ import type { import type { StreamResult } from '../../src/core/stream.js'; import type { PreparedSource, PreparedStatement, BoundParamSnapshot } from '../../src/core/param-pipeline.js'; import { VARIABLE_OPTION_BYTE_CAP, VARIABLE_OPTION_CAP } from '../../src/core/variable-options.js'; +import { + createAuthenticatedExecutionScope, +} from '../../src/application/authenticated-execution-scope.js'; +import type { AuthenticatedExecutionScope } from '../../src/application/authenticated-execution-scope.js'; +import type { AuthenticatedCancellationLease } from '../../src/application/authenticated-execution-scope.js'; // ── Small deferred helper (mirrors the pattern query-execution-service.test.ts // uses for scripting async runQuery behaviors, adapted to a single promise a @@ -113,6 +118,7 @@ interface Harness { tab: QueryTab; execFakes: ReturnType; nowSeq: { value: number }; + scopeRef: { current: AuthenticatedExecutionScope | null }; } function makeHarness(opts: { @@ -120,12 +126,14 @@ function makeHarness(opts: { hooks?: Partial; tab?: Partial; getToken?: () => Promise; + executionScope?: AuthenticatedExecutionScope | null; } = {}): Harness { const state = makeState(opts.state); const hooks = makeHooks(opts.hooks); const tab: QueryTab = { ...newTabObj('t1'), ...opts.tab }; const execFakes = makeExec(); const nowSeq = { value: 0 }; + const scopeRef = { current: opts.executionScope || null }; const deps: WorkbenchSessionDeps = { exec: execFakes.exec, ensureConfig: vi.fn(async () => undefined), @@ -136,8 +144,41 @@ function makeHarness(opts: { state, activeTab: () => tab, hooks, + executionScope: () => scopeRef.current, + }; + return { deps, state, hooks, tab, execFakes, nowSeq, scopeRef }; +} + +function executionScope(epoch = 1) { + return createAuthenticatedExecutionScope({ epoch, cancelRemote: vi.fn() }); +} + +/** Lets preflight tests prove that a wave releases its registration without + * closing the containing authenticated scope. */ +function trackingExecutionScope(epoch = 1) { + const scope = executionScope(epoch); + const register = scope.register; + const release = vi.fn(); + vi.spyOn(scope, 'register').mockImplementation((operation) => { + const registration = register(operation); + return { + ...registration, + release: () => { + release(); + registration.release(); + }, + }; + }); + return { scope, release }; +} + +function cancellationLease(epoch = 1): AuthenticatedCancellationLease { + return { + epoch, + origin: 'https://cluster.example', + authorization: 'Bearer fixed-at-close', + fetch: vi.fn() as typeof fetch, }; - return { deps, state, hooks, tab, execFakes, nowSeq }; } // ── run() ──────────────────────────────────────────────────────────────────── @@ -164,7 +205,7 @@ describe('createWorkbenchSession: run()', () => { // run/preview branch and a tab carries no `filterPreview`. it('blocks (no exec call) when the var gate is blocked', async () => { - const h = makeHarness({ hooks: { varGateBlocked: vi.fn(() => true) } }); + const h = makeHarness({ tab: { sqlDraft: 'SELECT 1' }, hooks: { varGateBlocked: vi.fn(() => true) } }); const session = createWorkbenchSession(h.deps); await session.run(); expect(h.execFakes.executeRead).not.toHaveBeenCalled(); @@ -180,6 +221,105 @@ describe('createWorkbenchSession: run()', () => { expect(h.hooks.onAuthFailed).toHaveBeenCalledTimes(1); }); + it.each(['ensureConfig', 'getToken'] as const)('releases its tracked scope registration when %s rejects', async (preflight) => { + const tracked = trackingExecutionScope(); + const h = makeHarness({ executionScope: tracked.scope, tab: { sqlDraft: 'SELECT 1' } }); + const failure = new Error(`${preflight} failed`); + if (preflight === 'ensureConfig') h.deps.ensureConfig = vi.fn(async () => { throw failure; }); + else h.deps.getToken = vi.fn(async () => { throw failure; }); + + await expect(createWorkbenchSession(h.deps).run()).rejects.toBe(failure); + expect(tracked.release).toHaveBeenCalledOnce(); + expect(tracked.scope.isOpen()).toBe(true); + }); + + it('auth loss during preflight leaves a completed result intact and never starts a request', async () => { + const scope = executionScope(); + const gate = deferred(); + const h = makeHarness({ executionScope: scope, tab: { sqlDraft: 'SELECT 1' } }); + h.deps.ensureConfig = vi.fn(() => gate.promise); + const completed = { format: 'Table', rows: [['old']] }; + h.tab.result = completed as QueryTab['result']; + const session = createWorkbenchSession(h.deps); + const pending = session.run(); + + scope.close(cancellationLease()); + expect(h.state.running.value).toBe(false); + gate.resolve(undefined); + await pending; + + expect(h.execFakes.executeRead).not.toHaveBeenCalled(); + expect(h.tab.result).toBe(completed); + expect(h.hooks.onAuthFailed).not.toHaveBeenCalled(); + }); + + it('does not cross a scope closure that occurs while the ordinary-run token awaits', async () => { + const scope = executionScope(); + const tokenGate = deferred(); + const h = makeHarness({ executionScope: scope, tab: { sqlDraft: 'SELECT 1' }, getToken: () => tokenGate.promise }); + const session = createWorkbenchSession(h.deps); + const pending = session.run(); + await flush(); + + scope.close(); + tokenGate.resolve('tok'); + await pending; + + expect(h.execFakes.executeRead).not.toHaveBeenCalled(); + expect(h.hooks.onAuthFailed).not.toHaveBeenCalled(); + }); + + it('scope close aborts the request, supplies its server id once, and makes its late completion inert', async () => { + const cancelRemote = vi.fn(); + const scope = createAuthenticatedExecutionScope({ epoch: 1, cancelRemote }); + const lease = cancellationLease(); + const gate = deferred(); + const h = makeHarness({ executionScope: scope, tab: { sqlDraft: 'CREATE TABLE stale (x Int32) ENGINE=Memory' } }); + h.execFakes.executeRead.mockImplementation((_result: StreamResult) => gate.promise); + const session = createWorkbenchSession(h.deps); + const pending = session.run(); + await flush(); + const req = h.execFakes.executeRead.mock.calls[0][1] as ExecuteReadRequest; + + scope.close(lease); + expect(req.signal?.aborted).toBe(true); + expect(h.state.running.value).toBe(false); + expect(cancelRemote).toHaveBeenCalledWith(lease, 'q-1'); + gate.resolve({} as StreamResult); + await pending; + + expect(h.hooks.recordHistory).not.toHaveBeenCalled(); + expect(h.hooks.recordBoundParams).not.toHaveBeenCalled(); + expect(h.hooks.loadSchema).not.toHaveBeenCalled(); + expect((h.tab.result as { source?: unknown } | null)?.source).toBeUndefined(); + }); + + it('a replacement scope can run while an old scoped completion settles inertly', async () => { + const oldScope = executionScope(); + const newScope = executionScope(2); + const oldGate = deferred(); + const h = makeHarness({ executionScope: oldScope, tab: { sqlDraft: 'SELECT 1' } }); + h.execFakes.executeRead.mockImplementationOnce(() => oldGate.promise); + h.execFakes.executeRead.mockImplementationOnce(async (result: StreamResult) => { + Object.assign(result, { columns: [{ name: 'fresh', type: 'String' }], rows: [['new']] }); + return result; + }); + const session = createWorkbenchSession(h.deps); + const stale = session.run(); + await flush(); + oldScope.close(cancellationLease()); + h.scopeRef.current = newScope; + await session.run(); + expect(h.execFakes.executeRead).toHaveBeenCalledTimes(2); + const fresh = h.tab.result; + oldGate.resolve({} as StreamResult); + await stale; + + expect(h.tab.result).toBe(fresh); + expect(h.hooks.recordHistory).toHaveBeenCalledTimes(1); + expect((fresh as { rows?: unknown[][] } | null)?.rows).toEqual([['new']]); + }); + it('KPI panel: an explicit FORMAT clash sets an owned error result and never executes', async () => { const h = makeHarness({ tab: { sqlDraft: 'SELECT 1 FORMAT JSON', specParsed: { name: 'k', favorite: false, panel: { cfg: { type: 'kpi' } } } }, @@ -382,6 +522,88 @@ describe('createWorkbenchSession: runScript()', () => { expect(h.hooks.onAuthFailed).toHaveBeenCalledTimes(1); }); + it.each(['ensureConfig', 'getToken'] as const)('releases its tracked scope registration when %s rejects', async (preflight) => { + const tracked = trackingExecutionScope(); + const h = makeHarness({ executionScope: tracked.scope }); + const failure = new Error(`${preflight} failed`); + if (preflight === 'ensureConfig') h.deps.ensureConfig = vi.fn(async () => { throw failure; }); + else h.deps.getToken = vi.fn(async () => { throw failure; }); + + await expect(createWorkbenchSession(h.deps).runScript(['SELECT 1'], 'SELECT 1')).rejects.toBe(failure); + expect(tracked.release).toHaveBeenCalledOnce(); + expect(tracked.scope.isOpen()).toBe(true); + }); + + it('reports a token failure while the captured script scope is still current', async () => { + const h = makeHarness({ executionScope: executionScope(), getToken: async () => null }); + const session = createWorkbenchSession(h.deps); + await session.runScript(['SELECT 1'], 'SELECT 1'); + + expect(h.execFakes.executeScript).not.toHaveBeenCalled(); + expect(h.hooks.onAuthFailed).toHaveBeenCalledOnce(); + }); + + it('scope close makes late script callbacks and final settlement inert', async () => { + const scope = executionScope(); + const gate = deferred(); + const h = makeHarness({ executionScope: scope }); + let req!: ScriptExecutionRequest; + h.execFakes.executeScript.mockImplementation((request: ScriptExecutionRequest) => { + req = request; + return gate.promise; + }); + const session = createWorkbenchSession(h.deps); + const pending = session.runScript(['CREATE TABLE t (x Int32) ENGINE=Memory'], 'CREATE TABLE t (x Int32) ENGINE=Memory'); + await flush(); + + scope.close(); + req.onStatementStart(0, { queryId: 'late-script-q', attempt: 1 }); + req.onStatementResult(0, { sql: 'CREATE TABLE t (x Int32) ENGINE=Memory', status: 'ok', ms: 1 }); + expect(h.state.running.value).toBe(false); + gate.resolve({ entries: [], aborted: false }); + await pending; + + expect((h.tab.result as { script: unknown[] } | null)?.script).toEqual([]); + expect(h.hooks.recordBoundParams).not.toHaveBeenCalled(); + expect(h.hooks.loadSchema).not.toHaveBeenCalled(); + expect(h.state.history).toEqual([]); + }); + + it('captures the script scope before config/token awaits and treats either auth-loss point as stale', async () => { + const beforeConfigScope = executionScope(); + const configGate = deferred(); + const beforeConfig = makeHarness({ executionScope: beforeConfigScope }); + beforeConfig.deps.ensureConfig = vi.fn(() => configGate.promise); + const beforeConfigSession = createWorkbenchSession(beforeConfig.deps); + const first = beforeConfigSession.runScript(['SELECT 1'], 'SELECT 1'); + beforeConfigScope.close(); + configGate.resolve(undefined); + await first; + + const duringTokenScope = executionScope(); + const tokenGate = deferred(); + const duringToken = makeHarness({ executionScope: duringTokenScope, getToken: () => tokenGate.promise }); + const duringTokenSession = createWorkbenchSession(duringToken.deps); + const second = duringTokenSession.runScript(['SELECT 1'], 'SELECT 1'); + await flush(); + duringTokenScope.close(); + tokenGate.resolve('tok'); + await second; + + expect(beforeConfig.execFakes.executeScript).not.toHaveBeenCalled(); + expect(duringToken.execFakes.executeScript).not.toHaveBeenCalled(); + expect(beforeConfig.hooks.onAuthFailed).not.toHaveBeenCalled(); + expect(duringToken.hooks.onAuthFailed).not.toHaveBeenCalled(); + }); + + it('keeps the ordinary explicit cancel path safe if only the running signal remains', () => { + const h = makeHarness({ state: { running: signal(true) } }); + const session = createWorkbenchSession(h.deps); + + expect(() => session.cancel()).not.toThrow(); + expect(h.execFakes.kill).toHaveBeenCalledWith(null); + }); + it('flips `running` true eagerly, before the transport resolves', async () => { const h = makeHarness(); const gate = deferred(); @@ -832,6 +1054,78 @@ describe('createWorkbenchSession: dashboard-variable Run (#465)', () => { expect(h.hooks.onAuthFailed).toHaveBeenCalledTimes(1); }); + it.each(['ensureConfig', 'getToken'] as const)('releases its tracked scope registration when %s rejects', async (preflight) => { + const tracked = trackingExecutionScope(); + const h = makeHarness({ + executionScope: tracked.scope, + tab: variableTab({ sqlDraft: 'SELECT a, b FROM t' }), + }); + const failure = new Error(`${preflight} failed`); + if (preflight === 'ensureConfig') h.deps.ensureConfig = vi.fn(async () => { throw failure; }); + else h.deps.getToken = vi.fn(async () => { throw failure; }); + + await expect(createWorkbenchSession(h.deps).run()).rejects.toBe(failure); + expect(tracked.release).toHaveBeenCalledOnce(); + expect(tracked.scope.isOpen()).toBe(true); + }); + + it('reports a current variable-scope token failure, but ignores a scope closed during token resolution', async () => { + const current = makeHarness({ executionScope: executionScope(), tab: variableTab({ sqlDraft: 'SELECT a, b FROM t' }), getToken: async () => null }); + await createWorkbenchSession(current.deps).run(); + + const closingScope = executionScope(); + const tokenGate = deferred(); + const stale = makeHarness({ executionScope: closingScope, tab: variableTab({ sqlDraft: 'SELECT a, b FROM t' }), getToken: () => tokenGate.promise }); + const pending = createWorkbenchSession(stale.deps).run(); + await flush(); + closingScope.close(); + tokenGate.resolve('tok'); + await pending; + + expect(current.hooks.onAuthFailed).toHaveBeenCalledOnce(); + expect(current.execFakes.executeRead).not.toHaveBeenCalled(); + expect(stale.hooks.onAuthFailed).not.toHaveBeenCalled(); + expect(stale.execFakes.executeRead).not.toHaveBeenCalled(); + }); + + it('scope close during a variable probe settles immediately and skips late validation, source, and History', async () => { + const scope = executionScope(); + const gate = deferred(); + const h = makeHarness({ executionScope: scope, tab: variableTab({ sqlDraft: 'SELECT a, b FROM t' }) }); + h.execFakes.executeRead.mockImplementation((_result: StreamResult) => gate.promise); + const session = createWorkbenchSession(h.deps); + const pending = session.run(); + await flush(); + const req = h.execFakes.executeRead.mock.calls[0][1] as ExecuteReadRequest; + + scope.close(); + req.onChunk?.(); + expect(h.state.running.value).toBe(false); + gate.resolve({} as StreamResult); + await pending; + + expect(h.hooks.renderResults).not.toHaveBeenCalled(); + expect(h.hooks.recordHistory).not.toHaveBeenCalled(); + expect((h.tab.result as { source?: unknown } | null)?.source).toBeUndefined(); + }); + + it('captures the variable scope before config awaits, preserving the pre-existing result on auth loss', async () => { + const scope = executionScope(); + const configGate = deferred(); + const h = makeHarness({ executionScope: scope, tab: variableTab({ sqlDraft: 'SELECT a, b FROM t' }) }); + h.deps.ensureConfig = vi.fn(() => configGate.promise); + const previous = { rows: [['completed']] }; + h.tab.result = previous as QueryTab['result']; + const pending = createWorkbenchSession(h.deps).run(); + + scope.close(); + configGate.resolve(undefined); + await pending; + + expect(h.execFakes.executeRead).not.toHaveBeenCalled(); + expect(h.tab.result).toBe(previous); + }); + it('never consults the ordinary {name:Type} var gate — optionSqlDiagnostics is its complete policy (#465 review)', async () => { const h = makeHarness({ tab: variableTab({ sqlDraft: 'SELECT a, b FROM t' }),