Skip to content

Refactor app.ts into explicit application services and route sessions #276

Description

@BorisTyshkevich

Summary

Refactor src/ui/app.ts from a large application controller/service locator into a small composition root plus explicit, constructible application services and route-scoped sessions.

This is an architectural refactor. Preserve current behavior, build output, keyboard shortcuts, UI flows, persistence formats, authentication behavior, query semantics, cancellation semantics, and test coverage. Do not redesign the UI or introduce a framework as part of this issue.

The target is a modular monolith with clear boundaries for Workbench, Dashboard, future dashboard persistence, and an AI/MCP helper.

Why

src/ui/app.ts currently owns or coordinates all of the following:

  • OAuth and Basic authentication
  • ClickHouse connection context
  • query execution and script execution
  • parameter preparation and execution gating
  • schema/reference loading
  • editor integration and Spec validation
  • query history and saved-query persistence
  • single-query and script export
  • schema lineage graphs
  • sharing
  • dashboard bootstrap and auth handoff
  • route DOM construction
  • action registration and reactive effects

The file is difficult to change safely because unrelated features share the same mutable App object and global AppState. Future product blocks—Workbench, Dashboard, persisted dashboard documents, share links, and an AI helper—need reusable application capabilities that do not depend on DOM nodes or the full App contract.

Architectural rules

Every extraction in this issue must follow these rules:

  1. Extracted application services must not accept App or AppState as constructor arguments.
  2. Dependencies must be explicit and narrow interfaces.
  3. Non-view services must not import or call DOM helpers, renderResults, renderApp, flashToast, editor adapters, or UI modules.
  4. Services return typed results, throw typed/domain errors where appropriate, or publish typed state/events. The UI decides how to render messages.
  5. Cancellation state belongs to the session or operation that owns it.
  6. Route-specific state must not be added back to the global AppState as a shortcut.
  7. Existing core functions should be reused rather than wrapped in generic abstraction layers.
  8. Keep the application framework-free. This issue does not introduce React, Preact, Vue, or a generic dependency-injection container.
  9. Preserve the existing single-file production build. Code splitting is a separate concern.
  10. Each migration step must leave the application runnable and tests green.

Target module layout

Names may be adjusted slightly when implementation reveals a better fit, but preserve these responsibilities and boundaries.

src/
  application/
    connection-session.ts
    query-execution-service.ts
    export-service.ts
    schema-catalog-service.ts
    workbench-parameter-session.ts
    query-document-session.ts
    saved-query-service.ts
    schema-graph-session.ts
    app-preferences.ts
    errors.ts

  ui/
    workbench/
      workbench-session.ts
      workbench-shell.ts
      workbench-actions.ts
    dashboard/
      dashboard-session.ts
      dashboard-shell.ts
    app.ts                 # composition/bootstrap only

Existing pure modules under src/core, network code under src/net, editor ports, and focused UI render modules should remain where they are unless moving one is required to break an actual dependency cycle.

Required extractions

1. QueryExecutionService

Extract the query transport and execution policy that currently exists through runReadInto, run, runScript, query IDs, ClickHouse sessions, retry classification, parameter transport, and cancellation.

The service must be constructible without the UI and usable by:

  • Workbench single-statement execution
  • Workbench script execution
  • Dashboard tile execution
  • detached data views
  • future AI/MCP commands that execute approved queries

Suggested API shape:

export interface QueryExecutionService {
  executeRead(request: ReadExecutionRequest): Promise<ReadExecutionResult>;
  executeScript(request: ScriptExecutionRequest): Promise<ScriptExecutionResult>;
  cancel(operationId: string): Promise<void>;
}

Required behavior to preserve:

  • streamed structured results
  • explicit FORMAT/raw response handling
  • row limits
  • query IDs and server-side KILL QUERY
  • abort behavior preserving partial streamed results
  • per-tab ClickHouse HTTP session semantics for TEMPORARY and SET
  • script stop-on-first-failure behavior
  • retry only for SESSION_IS_LOCKED, or transient read-only statements where currently allowed
  • warning for ambiguous network failure after non-idempotent statements
  • schema-mutating result classification exposed to the caller

Do not make this service write into tabs, history, dashboard tiles, DOM, or global signals.

2. ConnectionSession

Extract authentication and ClickHouse connection lifecycle:

  • config and IdP loading/memoization
  • OAuth login, token exchange/refresh inputs, token storage hooks
  • Basic-auth connection probing
  • target origin
  • auth-header construction
  • current identity
  • sign-in/sign-out
  • ensureAuthenticated
  • construction of the ClickHouse request context
  • cross-tab auth snapshot import/export primitives

Suggested API:

export interface ConnectionSession {
  readonly state: ReadonlySignal<ConnectionState>;
  ensureAuthenticated(): Promise<boolean>;
  beginOAuth(input?: OAuthLoginInput): Promise<void>;
  connectBasic(input: BasicConnectionInput): Promise<void>;
  signOut(): void;
  identity(): ConnectionIdentity | null;
  createClickHouseContext(): ChCtx;
  snapshotAuth(): AuthSnapshot | null;
  restoreAuth(snapshot: AuthSnapshot): void;
}

The service must not render the login page or toast. Authentication failures must be represented as typed state or errors for the shell to handle.

3. WorkbenchSession

Create a route-scoped Workbench session that owns:

  • open query documents/tabs
  • active document
  • Workbench running operation
  • Workbench cancellation state
  • result view and sort state
  • editor mode coordination
  • Workbench parameter session
  • Workbench-only selection state
  • lifecycle cleanup

It may use signals internally. It must expose a narrow interface/view state to the Workbench shell.

It must provide destroy() and release:

  • reactive effects created by the session
  • timers
  • event listeners
  • in-flight Workbench operations

Do not move Dashboard state into this session.

4. DashboardSession

Create a separate route-scoped Dashboard session that owns:

  • dashboard loading/runtime state
  • tile execution queue and concurrency
  • per-tile cancellation state
  • filters and filter execution state
  • dashboard-specific errors and progress
  • dashboard lifecycle cleanup

The Dashboard session must depend on QueryExecutionService and dashboard/query repositories through narrow interfaces. It must not depend on WorkbenchSession, editor ports, Workbench DOM, or the complete App object.

It must provide destroy() and cancel all in-flight tile/filter work.

This issue does not require persistent DashboardDocument v1, but the session API must not assume that a dashboard is permanently derived from the favorites list. Accept a dashboard runtime input/model through an explicit interface so a stored document can replace the current source later.

5. ExportService

Extract:

  • direct query export
  • script export
  • streaming to disk
  • hold-back inspection for ClickHouse exception frames
  • partial-file behavior
  • file/directory naming
  • progress state
  • export-specific query IDs and cancellation

Separate application policy from browser filesystem access:

export interface ExportSink {
  pickFile(input: PickFileInput): Promise<FileTarget | null>;
  pickDirectory(input: PickDirectoryInput): Promise<DirectoryTarget | null>;
}

ExportService must be testable using an in-memory sink. It may depend on query/connection primitives, but must not use Workbench DOM or mutate AppState directly.

6. SchemaCatalogService

Extract server metadata and reference-data lifecycle:

  • server version
  • schema loading
  • lazy column loading
  • reference keywords/functions
  • completion snapshot construction
  • entity documentation cache
  • cache invalidation when the connection changes

Suggested API:

export interface SchemaCatalogService {
  loadVersion(): Promise<string>;
  refreshSchema(): Promise<SchemaCatalog>;
  loadColumns(ref: TableRef): Promise<SchemaColumn[]>;
  loadReferenceData(): Promise<ReferenceData>;
  getEntityDocumentation(name: string): Promise<string | null>;
  completionSnapshot(): CompletionSnapshot;
  invalidate(): void;
}

Keep rendering of the schema tree and connection banner in UI modules.

7. WorkbenchParameterSession

Extract Workbench parameter state and policy currently mixed into renderVarStrip and query execution:

  • parameter analysis
  • value and optional-block activation state
  • input-mode versus execute-mode validation
  • hardened invalid values
  • schema-inferred enum suggestions
  • recent-value recording and clearing
  • execution preparation snapshot
  • Run eligibility/gate result

Suggested API:

export interface WorkbenchParameterSession {
  analyze(sql: string): ParameterViewModel[];
  updateValue(name: string, value: string): void;
  commitValue(name: string): void;
  prepare(sql: string, wallNowMs: number): PreparedSource;
  gate(sql: string, mode: 'input' | 'execute'): ExecutionGate;
  recordBoundParams(params: readonly BoundParamSnapshot[]): void;
}

The session returns view models. A focused VarStripView remains responsible for DOM controls, focus preservation, combobox behavior, and painting diagnostics.

8. QueryDocumentSession and SavedQueryService

Separate an open document from its persisted representation.

QueryDocumentSession owns:

  • SQL draft
  • Spec text
  • parsed Spec and diagnostics
  • dirty flags
  • editor mode
  • linked saved-query ID
  • last successful result columns
  • optional ClickHouse HTTP session ID

SavedQueryService owns:

  • create/update saved query
  • validation before persistence
  • history recording
  • share serialization for the current URL format
  • repository/storage interaction

Do not let saved-query persistence call editor or rendering methods. The Workbench session coordinates persistence results with its document state.

9. SchemaGraphSession

Extract schema-lineage operation state:

  • progressive base/progress/final graph updates
  • stale-operation protection
  • abort/cancel behavior
  • expanded graph loading
  • table detail loading
  • saved node positions
  • latest-detail-request protection

Suggested API:

export interface SchemaGraphSession {
  load(focus: SchemaFocus): AsyncIterable<SchemaGraphUpdate>;
  expand(focus: SchemaFocus): Promise<ExpandedSchemaGraph>;
  loadNodeDetail(node: SchemaNodeRef): Promise<NodeDetail>;
  cancel(): void;
  destroy(): void;
}

The service/session returns graph models. Opening a child window/overlay and drawing SVG remain UI adapter responsibilities.

10. AppPreferences

Centralize typed access to persisted user preferences and local/session storage keys.

At minimum include:

  • theme and density
  • layout dimensions
  • result-row limit
  • variable values/activation/recents
  • library metadata currently treated as preferences

Do not force saved-query/library domain records into a generic key-value API if a dedicated repository is clearer.

app.ts target responsibility

At completion, src/ui/app.ts should be a composition/bootstrap module. It may:

  • adapt the browser environment
  • construct repositories, ports, sessions, and services
  • choose Workbench or Dashboard route
  • mount the relevant shell
  • coordinate initial authentication routing
  • expose a small top-level Application lifecycle

It should not contain business workflows such as query execution, export streaming, schema graph loading, saved-query commits, parameter validation, or authentication refresh logic.

Illustrative target:

export function createApplication(env: AppEnvironment): Application {
  const preferences = createAppPreferences(env.storage);
  const connection = createConnectionSession({ env, preferences });
  const execution = createQueryExecutionService({ connection, clock: env.clock });
  const catalog = createSchemaCatalogService({ execution, connection });
  const exports = createExportService({ execution, connection, sink: env.exportSink });
  const savedQueries = createSavedQueryService({ repository: env.savedQueryRepository });

  return {
    mountWorkbench(root) {
      const session = createWorkbenchSession({
        execution,
        connection,
        catalog,
        exports,
        savedQueries,
        preferences,
      });
      return mountWorkbenchShell(root, session);
    },

    mountDashboard(root, input) {
      const session = createDashboardSession({ execution, connection, input });
      return mountDashboardShell(root, session);
    },
  };
}

The final implementation does not need to match this code literally, but it must produce the same dependency direction.

Migration plan

Implement as incremental PRs or commits. Do not rewrite app.ts in one pass.

Phase 0: characterization and dependency map

  • Add or identify characterization tests for critical current workflows.
  • Record the current app.ts line count and App interface member count in the PR description, not as a permanent test.
  • Identify every import of App, AppState, runReadInto, auth helpers, and route-specific state.
  • Add no architecture-only abstractions in this phase.

Required characterization coverage:

  • single read execution and streaming
  • explicit FORMAT
  • cancellation and partial result
  • script execution/retry/stop-on-error
  • Basic and OAuth token refresh paths
  • dashboard tile execution
  • direct and script export cancellation
  • schema graph stale-write protection
  • save/share validation

Phase 1: QueryExecutionService

  • Extract transport and execution state first.
  • Keep thin compatibility wrappers on app temporarily where needed.
  • Migrate Workbench, Dashboard, and detached data execution to the service.
  • Delete app.runReadInto after all callers migrate.

Phase 2: ConnectionSession

  • Extract auth/config/ClickHouse context.
  • Migrate login UI to consume typed connection state.
  • Migrate dashboard auth handoff to session snapshot/restore methods.
  • Remove token/auth fields from App where no longer required by views.

Phase 3: route sessions

  • Add WorkbenchSession and DashboardSession.
  • Move cancellation and route-specific state into the owning session.
  • Add lifecycle teardown.
  • Keep current visual render modules initially; adapt them to narrower session/view interfaces.

Phase 4: supporting services

Extract in this order unless dependency analysis justifies another order:

  1. ExportService
  2. SchemaCatalogService
  3. WorkbenchParameterSession
  4. QueryDocumentSession and SavedQueryService
  5. SchemaGraphSession
  6. AppPreferences

Phase 5: composition cleanup

  • Reduce App/AppState to UI-only compatibility shapes, then remove them where possible.
  • Split renderApp into route shell mounting and focused view modules.
  • Ensure app.ts is composition/bootstrap only.
  • Update architecture documentation.

Compatibility strategy

Temporary adapters are allowed during migration, for example:

app.actions.run = () => workbenchSession.runActiveSelection();

But they must be deleted before closing this issue. Do not preserve the original giant App interface as a permanent facade over all new services.

Avoid parallel duplicate implementations. At each phase, route every caller through the new service before deleting the previous implementation.

Testing requirements

Unit tests

Each extracted service/session must have focused tests constructed without a real browser DOM unless it is explicitly a UI adapter.

Use injected dependencies for:

  • clock and wall clock
  • random/query ID generation
  • timers/sleep
  • fetch/ClickHouse client
  • persistence
  • filesystem/export sink

Test cancellation and stale-result behavior explicitly.

Integration tests

Keep or add integration coverage proving:

  • Workbench run updates the active document correctly
  • Dashboard tiles use the same read execution semantics
  • Workbench cancel does not cancel dashboard/export work
  • Dashboard destruction cancels tile/filter work
  • export cancellation does not cancel a grid run
  • connection refresh is not raced independently by dashboard tiles
  • session teardown removes listeners/timers/effects

Architecture tests

Add lightweight dependency guards, using ESLint/import rules or a small repository script, enforcing:

  • src/application/** cannot import src/ui/**
  • route sessions cannot import each other's implementation modules
  • QueryExecutionService cannot import editor or rendering modules
  • DashboardSession cannot import Workbench modules
  • non-view services cannot import DOM helpers

Do not add a large architecture framework solely for these checks.

Acceptance criteria

  • QueryExecutionService is constructible and tested without App, AppState, or DOM.
  • Workbench, Dashboard tiles, and detached data reads use the same execution service.
  • Authentication and ClickHouse connection lifecycle live in ConnectionSession without rendering dependencies.
  • Workbench and Dashboard have separate route-scoped sessions and cancellation state.
  • Both route sessions expose destroy() and tests prove cleanup.
  • Export operations live in ExportService with an injectable filesystem sink.
  • Schema/reference lifecycle lives in SchemaCatalogService.
  • Workbench parameter policy lives outside the variable-strip DOM renderer.
  • Open query document behavior is represented by QueryDocumentSession.
  • Saved-query/history/share persistence is accessed through a focused service/repository boundary.
  • Schema graph operation lifecycle lives outside app.ts.
  • Application services do not accept the full App object.
  • src/application/** has no imports from src/ui/**.
  • Workbench and Dashboard sessions do not import one another.
  • Existing production behavior and persisted formats remain compatible.
  • Existing production build remains a self-contained dist/sql.html.
  • Existing tests remain green and new focused tests cover extracted boundaries.
  • src/ui/app.ts becomes composition/bootstrap code rather than a business-workflow controller.
  • docs/ARCHITECTURE.md is updated with the new dependency graph and lifecycle ownership.

Definition of done metrics

These are review signals, not strict line-count goals:

  • app.ts should no longer be the largest implementation file because it contains application workflows.
  • The public application/bootstrap object should expose route mounting and lifecycle, not hundreds of mutable fields.
  • A unit test should be able to execute a query, run a dashboard tile, validate parameters, and export to an in-memory sink without constructing DOM nodes.
  • Adding a future MCP-backed AI command should be possible by depending on typed application services/commands rather than importing app.ts or mutating UI state.

Non-goals

  • UI redesign
  • changing the current saved-query JSON format
  • implementing DashboardDocument v1
  • adding ClickHouse dashboard persistence
  • implementing MCP or an AI helper
  • introducing code splitting or lazy loading
  • replacing signals
  • introducing a UI framework
  • converting the application to micro-frontends
  • changing query semantics or export formats

Agent execution notes

Before editing code:

  1. Read docs/ARCHITECTURE.md, src/ui/app.ts, src/ui/app.types.ts, src/state.ts, src/ui/dashboard.ts, src/ui/results.ts, and the ClickHouse client modules.
  2. Search all references to the specific functions being moved.
  3. Add characterization tests before moving behavior that is not already isolated.
  4. Move one responsibility at a time and run typecheck/tests after each move.
  5. Prefer preserving existing pure helpers and injecting them into services over copying logic.
  6. Do not weaken TypeScript types with broad any, Record<string, unknown>, or a new universal context object to make extraction easier.
  7. Do not create a replacement god object named Services, Context, or ApplicationState containing every dependency.
  8. Update import boundaries and delete obsolete compatibility members as each migration completes.

This issue may be implemented through a sequence of linked pull requests. Each PR should state which phase and acceptance criteria it completes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions