You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of #68 (Roadmap to 1.0.0) and a follow-up to #87.
Problem
#87 adds a direct Export path for one editor query: stream a full uncapped result to disk, bypassing the grid. But the editor also runs multi-statement scripts (runScript, src/ui/app.js:605). Pressing Export on a script needs different behavior:
execute statements sequentially (CH's HTTP interface runs one statement per request), not one blob;
preserve session semantics for SET / CREATE TEMPORARY TABLE;
stream each row-returning statement to its own file, uncapped, without buffering;
run non-row statements for effect and log OK/error;
show a running log pane; stop safely on first failure; cancel cleanly.
This is its own UX, not something to bury inside the single-query flow.
This issue lands after #87 (in active implementation separately) and reuses its helpers. Two of #87's deliverables need extending — these belong to #99's scope, applied to whatever #87 ships (don't expect #87 to include them):
asyncfunctionexportEntry(){if(app.state.exporting.value)return;constta=app.dom.editorTextarea;constsel=ta ? ta.value.slice(ta.selectionStart,ta.selectionEnd) : '';constinput=sel.trim()!=='' ? sel : app.activeTab().sql;conststatements=splitStatements(input);if(!statements.length){flashToast('Nothing to export',{document: doc});return;}if(statements.length===1)returnexportDirect(statements[0]);// #87 single-filereturnexportScriptEntry(statements);}
Reused verbatim from #87: prepareExportSql ({ sql, format }), formatFileMeta, streamToFile (hold-back writer, returns the mid-stream message or null), findExceptionFrame, the showSaveFilePicker seam, and app.state.exporting.
Feature detection & button gating
showSaveFilePicker and showDirectoryPicker are the same File System Access family — every browser that has one has the other (Chromium ≥ 86, secure context). So the button's enabled/tooltip state stays exactly as #87 (app.canExport()); no new visible gate. A separate canExportScript() guards the script path defensively for the theoretical split:
constshowDirectoryPicker=env.showDirectoryPicker||(win.showDirectoryPicker ? win.showDirectoryPicker.bind(win) : null);app.canExportScript=()=>!!showDirectoryPicker&&!!secureCtx;// secureCtx from #87
If it's ever false while single-export is enabled, the script path degrades to a toast (not a silent no-op): Script export requires Chrome/Edge directory access over HTTPS.
Entry: exportScriptEntry
Directory picker first (transient-activation rule from #87), and skip the prompt entirely when there's nothing to export:
asyncfunctionexportScriptEntry(statements){if(!app.canExportScript()){flashToast('Script export requires Chrome/Edge directory access over HTTPS',{document: doc});return;}if(!statements.some(isRowReturning)){// sync — check before promptingflashToast('Nothing to export — script has no result-producing statements.',{document: doc});return;}// Picker FIRST — before any await (activation). mode:'readwrite' or createWritable fails.letdir;try{dir=awaitshowDirectoryPicker({mode: 'readwrite'});}catch(e){if(e&&e.name==='AbortError')return;// dismissed → silent no-opflashToast('Folder dialog failed: '+errMsg(e),{document: doc});return;}awaitensureConfig();if(!(awaitgetToken())){chCtx.onSignedOut();return;}awaitexportScript(statements,dir);}
Writing several files may trigger a one-time per-directory write-permission prompt — expected.
/** * Deterministic per-statement export filename: `<NNN>-<slug>.<ext>` (e.g. * `001-select.tsv`). `index` is the statement's 0-based position in the script; * the prefix is `index+1` zero-padded to 3, so it matches the log pane's `#` * column (non-row statements consume a number, leaving intentional gaps). `slug` * comes from inferQueryName → sanitized, lowercased, ≤ 24 chars (empty → 'query'). * `taken` (Set of names already used this run) de-dupes with `-2`, `-3`, … * Pure — the caller adds the returned name to `taken`. */exportfunctionscriptExportName(index,stmt,ext,taken){constnum=String(index+1).padStart(3,'0');constslug=(inferQueryName(stmt).replace(/^Query·/,'')||stmt).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,24)||'query';letname=`${num}-${slug}.${ext}`;for(letn=2;taken&&taken.has(name);n++)name=`${num}-${slug}-${n}.${ext}`;returnname;}
Execution model (src/ui/app.js) — the corrected loop
Sequential, one shared session for the whole script, its own abort/query-id state (never app.state.run*, and distinct from #87's single-export state) so Cancel can reach the in-flight stream. The log lives in a tab.resultvariant that holds only per-statement metadata — status/file/bytes/time — never the exported rows (that's the memory guarantee + the "grid not populated with rows" criterion).
// export-script state — reassigned each iteration so Cancel targets the active streamletexportScriptAbort=null;letexportScriptQueryId=null;letexportScriptCancelled=false;letexportScriptTick=null;asyncfunctionexportScript(statements,dir){consttab=app.activeTab();constsp=sessionParamsFor(tab,statements);// one session for all statementsconstentries=statements.map((sql,i)=>({
i, sql,type: isRowReturning(sql) ? 'rows' : 'effect',status: 'pending',file: null,bytes: 0,startedAt: null,ms: 0,error: null,}));tab.result={scriptExport: entries};// log pane — metadata only, no rowsapp.state.resultSort={col: null,dir: 'asc'};exportScriptCancelled=false;app.state.exporting.value=true;consttaken=newSet();// Live elapsed for the running row (bytes tick via onProgress; this ticks time).exportScriptTick=setInterval(()=>renderResults(app),200);renderResults(app);try{for(consteofentries){if(exportScriptCancelled){e.status='skipped';continue;}const{ sql, format }=prepareExportSql(e.sql);exportScriptQueryId='export-'+uid('');// real prefix (uid drops it under randomUUID)exportScriptAbort=newAbortController();constsignal=exportScriptAbort.signal;e.startedAt=now();e.status=e.type==='rows' ? 'exporting' : 'running';renderResults(app);try{if(e.type!=='rows'){// Run for effect (reuses runQuery; it returns { error }, doesn't throw on a CH error).constout=awaitch.runQuery(chCtx,e.sql,{format: 'TSV', signal,queryId: exportScriptQueryId,params: sp});if(out.error!=null)thrownewError(out.error);e.status='ok';}else{const{ ext, mime }=formatFileMeta(format);constname=scriptExportName(e.i,e.sql,ext,taken);taken.add(name);e.file=name;constfileHandle=awaitdir.getFileHandle(name,{create: true});constresp=awaitch.exportQuery(chCtx,sql,{queryId: exportScriptQueryId, signal, format,params: sp});consttag=resp.headers.get('X-ClickHouse-Exception-Tag');constmidErr=awaitstreamToFile(resp,fileHandle,{ signal, tag,onProgress: (b)=>{e.bytes=b;}});// streamToFile does NOT throw mid-streamif(midErr){// ← must check the return (was ignored in draft)e.status='failed';e.error='File may be incomplete; server failed after streaming started. '+midErr;e.ms=now()-e.startedAt;break;// stop-on-first-failure}e.status='ok';}e.ms=now()-e.startedAt;renderResults(app);}catch(ex){// pre-header CH error / network / aborte.ms=now()-e.startedAt;if(ex&&ex.name==='AbortError'){e.status='cancelled';exportScriptCancelled=true;}else{e.status='failed';e.error=String((ex&&ex.message)||ex);}break;// stop-on-first-failure}}for(consteofentries)if(e.status==='pending')e.status='skipped';// after a stop}finally{clearInterval(exportScriptTick);exportScriptTick=null;exportScriptAbort=null;exportScriptQueryId=null;app.state.exporting.value=false;// A schema-mutating effect statement that actually ran refreshes the tree (mirrors runScript).if(entries.some((e)=>e.status==='ok'&&isSchemaMutatingSql(e.sql)))app.loadSchema();renderResults(app);}}functioncancelExportScript(){exportScriptCancelled=true;// stops the loop from starting the nextif(exportScriptAbort)exportScriptAbort.abort();// aborts the in-flight stream → AbortErrorch.killQuery(chCtx,exportScriptQueryId,sqlString);// best-effort, never throws}
Wire exportEntry (button) and cancelExportScript (pane) into app.actions.
No retry. Unlike runScript (which retries once on SESSION_IS_LOCKED / transient network for read-only statements, app.js:644-653), export is strictly stop-on-first-failure with no retry: statements run one-at-a-time in a single session so the session lock can't self-collide, and a partially-written file shouldn't be silently re-attempted. (If a SESSION_IS_LOCKED ever surfaces it's reported like any other error.)
Log pane (src/ui/results.js)
r.scriptExport needs an early branch in both places that special-case r.script, before the normal-result paths:
buildToolbar (results.js:316) — add if (r && r.scriptExport) { … return toolbar; }before the normal toolbar, mirroring the r.script branch. It shows "Export script · N statements", live total elapsed/progress, and a Cancel export button (res-act cancel-act → app.actions.cancelExportScript()), and returns early so the log gets no view tabs, no row-limit selector, no Copy, no Export. (Falling through would render all of those and a re-entrant Export button — the bug this guards.)
renderResults body (results.js:135) — add the r.scriptExport body branch alongside r.script.
Status: pending · running · exporting · ok · failed · cancelled · skipped (colored like the script grid's ok/error).
File: the target name for row statements; blank for effect statements. Bytes: formatBytes(e.bytes), live. Time: e.ms (live for the running row via the 200 ms ticker).
On a failed row, show e.error (incl. the "File may be incomplete…" note) inline.
The log is metadata only — it never holds result rows, so tab.result carries no exported data.
Output format (per statement)
Same rule as #87, applied per statement via prepareExportSql: keep an explicit trailing FORMAT <x>, else append FORMAT TabSeparatedWithNames; extension from formatFileMeta(format). Non-row statements produce no file. (A row-returning statement that yields zero rows still gets a header-only file — expected, matches isRowReturning's keyword classification.)
Error handling
Stop on first failure; already-completed files stay on disk.
Pre-header CH error (exportQuery throws; or runQuery returns { error } which we rethrow) → row failed with the parsed exception; stop.
User cancel → active row cancelled, remaining skipped, completed files kept.
Browser support
Same stance as #87: Chromium + secure context only; Firefox/Safari and plain-HTTP Chromium are unavailable (button aria-disabled + tooltip from #87). Script path additionally needs showDirectoryPicker (ships with showSaveFilePicker, so effectively co-available).
Script export asks for a directory once, before any async auth/config work, with mode: 'readwrite'; dismissing it is a silent no-op.
A script with no row-returning statements shows "Nothing to export — …" and does not prompt for a directory.
Statements execute sequentially in one shared session (sessionParamsFor); SET / CREATE TEMPORARY TABLE visible to later statements; never concurrent.
Non-row statements run for effect, log ok/failed, and produce no file.
Row-returning statements stream uncapped to separate files; explicit trailing FORMAT <x> respected per statement, else TabSeparatedWithNames; extension matches the format; names are NNN-slug.ext, de-duped, prefixed by script position.
The log pane updates status, filename, bytes, and elapsed live.
Cancel aborts the active request, issues KILL QUERY for the active export id, marks the current statement cancelled and the rest skipped, and keeps completed files.
A mid-stream error is detected (via the __exception__ frame), surfaced in the log as failed/incomplete — not reported as success — and excised from the file.
Stop-on-first-failure: a pre-header/network error halts the loop; remaining statements skipped; completed files remain.
tab.result is not populated with exported rows (log holds metadata only); memory stays flat across a multi-million-row script export.
net — exportQuery forwards params (session_id) andquery_id into the URL; runQuery non-row path returns { error } on a CH error (rethrown by the loop).
ui (app.js) — inject showDirectoryPicker (fake dir handle whose getFileHandle returns capturing file handles), showSaveFilePicker, fetch (ReadableStream), env.isSecureContext. Assert: 1-statement → exportDirect, N → exportScript; picker opens before ensureConfig/getToken; sequential order + one shared session_id on every request; non-row logs ok, no file created; row statements write to distinct files with correct names; streamToFile mid-stream return → row failed/incomplete, loop stops (regression guard for the ignored-return bug); pre-header throw → failed + stop + rest skipped; cancel mid-stream → active cancelled + rest skipped + killQuery(activeId) + completed files kept; tab.result never gains rows; exporting toggles and export state resets in finally; no-row-returning script → toast, no picker.
ui (results.js) — buildToolbar early-returns for r.scriptExport with "Export script · N statements" + Cancel and no view tabs / row-limit / Copy / Export; renderResults body renders the columns + statuses; Cancel calls cancelExportScript.
Out of scope
Firefox/Safari fallback · zipping results into one archive · parallel export of statements · background export surviving tab close · resume/retry of partial exports.
Part of #68 (Roadmap to 1.0.0) and a follow-up to #87.
Problem
#87 adds a direct Export path for one editor query: stream a full uncapped result to disk, bypassing the grid. But the editor also runs multi-statement scripts (
runScript,src/ui/app.js:605). Pressing Export on a script needs different behavior:SET/CREATE TEMPORARY TABLE;This is its own UX, not something to bury inside the single-query flow.
Depends on #87 (read this first)
This issue lands after #87 (in active implementation separately) and reuses its helpers. Two of #87's deliverables need extending — these belong to #99's scope, applied to whatever #87 ships (don't expect #87 to include them):
exportQuerymust acceptparams. Export button: stream full query result to disk as TSV (File System Access API, uncapped, bypasses the grid) #87's signature isexportQuery(ctx, sql, { queryId, signal, format }). Script export needssession_idto ride along, so threadparamsthrough into the URL alongsidequery_id— exactly likerunQueryalready does (ch-client.js:419):exportDirect. This issue refactors that into anexportEntry()that splits and branches — mirroringrunEntry(app.js:694) — and makesexportDirect(sql)take the chosen statement so a selection/single-split statement flows through:Reused verbatim from #87:
prepareExportSql({ sql, format }),formatFileMeta,streamToFile(hold-back writer, returns the mid-stream message or null),findExceptionFrame, theshowSaveFilePickerseam, andapp.state.exporting.Feature detection & button gating
showSaveFilePickerandshowDirectoryPickerare the same File System Access family — every browser that has one has the other (Chromium ≥ 86, secure context). So the button's enabled/tooltip state stays exactly as #87 (app.canExport()); no new visible gate. A separatecanExportScript()guards the script path defensively for the theoretical split:If it's ever false while single-export is enabled, the script path degrades to a toast (not a silent no-op):
Script export requires Chrome/Edge directory access over HTTPS.Entry:
exportScriptEntryDirectory picker first (transient-activation rule from #87), and skip the prompt entirely when there's nothing to export:
Writing several files may trigger a one-time per-directory write-permission prompt — expected.
Core helper — per-statement filename (
src/core/export.js, 100% covered)Execution model (
src/ui/app.js) — the corrected loopSequential, one shared session for the whole script, its own abort/query-id state (never
app.state.run*, and distinct from #87's single-export state) so Cancel can reach the in-flight stream. The log lives in atab.resultvariant that holds only per-statement metadata — status/file/bytes/time — never the exported rows (that's the memory guarantee + the "grid not populated with rows" criterion).Wire
exportEntry(button) andcancelExportScript(pane) intoapp.actions.No retry. Unlike
runScript(which retries once onSESSION_IS_LOCKED/ transient network for read-only statements,app.js:644-653), export is strictly stop-on-first-failure with no retry: statements run one-at-a-time in a single session so the session lock can't self-collide, and a partially-written file shouldn't be silently re-attempted. (If aSESSION_IS_LOCKEDever surfaces it's reported like any other error.)Log pane (
src/ui/results.js)r.scriptExportneeds an early branch in both places that special-caser.script, before the normal-result paths:buildToolbar(results.js:316) — addif (r && r.scriptExport) { … return toolbar; }before the normal toolbar, mirroring ther.scriptbranch. It shows "Export script · N statements", live total elapsed/progress, and a Cancel export button (res-act cancel-act→app.actions.cancelExportScript()), and returns early so the log gets no view tabs, no row-limit selector, no Copy, no Export. (Falling through would render all of those and a re-entrant Export button — the bug this guards.)renderResultsbody (results.js:135) — add ther.scriptExportbody branch alongsider.script.Body columns:
rows/effect.pending·running·exporting·ok·failed·cancelled·skipped(colored like the script grid's ok/error).formatBytes(e.bytes), live. Time:e.ms(live for the running row via the 200 ms ticker).failedrow, showe.error(incl. the "File may be incomplete…" note) inline.The log is metadata only — it never holds result rows, so
tab.resultcarries no exported data.Output format (per statement)
Same rule as #87, applied per statement via
prepareExportSql: keep an explicit trailingFORMAT <x>, else appendFORMAT TabSeparatedWithNames; extension fromformatFileMeta(format). Non-row statements produce no file. (A row-returning statement that yields zero rows still gets a header-only file — expected, matchesisRowReturning's keyword classification.)Error handling
Stop on first failure; already-completed files stay on disk.
exportQuerythrows; orrunQueryreturns{ error }which we rethrow) → rowfailedwith the parsed exception; stop.streamToFilereturns a message via the__exception__frame +X-ClickHouse-Exception-Tag, format-independent — see Export button: stream full query result to disk as TSV (File System Access API, uncapped, bypasses the grid) #87) → rowfailed, file marked incomplete (File may be incomplete; server failed after streaming started.), the exception is excised from the file by the hold-back writer; stop.TypeError) → rowfailed; stop.cancelled, remainingskipped, completed files kept.Browser support
Same stance as #87: Chromium + secure context only; Firefox/Safari and plain-HTTP Chromium are unavailable (button
aria-disabled+ tooltip from #87). Script path additionally needsshowDirectoryPicker(ships withshowSaveFilePicker, so effectively co-available).Acceptance criteria
mode: 'readwrite'; dismissing it is a silent no-op.sessionParamsFor);SET/CREATE TEMPORARY TABLEvisible to later statements; never concurrent.ok/failed, and produce no file.FORMAT <x>respected per statement, elseTabSeparatedWithNames; extension matches the format; names areNNN-slug.ext, de-duped, prefixed by script position.KILL QUERYfor the active export id, marks the current statementcancelledand the restskipped, and keeps completed files.__exception__frame), surfaced in the log asfailed/incomplete — not reported as success — and excised from the file.skipped; completed files remain.tab.resultis not populated with exported rows (log holds metadata only); memory stays flat across a multi-million-row script export.exportQuery(Export button: stream full query result to disk as TSV (File System Access API, uncapped, bypasses the grid) #87) forwardsparamssosession_idreaches CH; the Export button dispatches viaexportEntry.npm testgreen at the per-file gate.Test plan (per layer)
scriptExportName:001-select.tsvshape, zero-pad, slug frominferQueryName, fallback to'query',-2/-3dedup againsttaken, extension honored, index = script position. (prepareExportSql/formatFileMeta/findExceptionFramealready covered by Export button: stream full query result to disk as TSV (File System Access API, uncapped, bypasses the grid) #87.)exportQueryforwardsparams(session_id) andquery_idinto the URL;runQuerynon-row path returns{ error }on a CH error (rethrown by the loop).app.js) — injectshowDirectoryPicker(fake dir handle whosegetFileHandlereturns capturing file handles),showSaveFilePicker,fetch(ReadableStream),env.isSecureContext. Assert: 1-statement →exportDirect, N →exportScript; picker opens beforeensureConfig/getToken; sequential order + one sharedsession_idon every request; non-row logsok, no file created; row statements write to distinct files with correct names;streamToFilemid-stream return → rowfailed/incomplete, loop stops (regression guard for the ignored-return bug); pre-header throw →failed+ stop + restskipped; cancel mid-stream → activecancelled+ restskipped+killQuery(activeId)+ completed files kept;tab.resultnever gainsrows;exportingtoggles and export state resets infinally; no-row-returning script → toast, no picker.results.js) —buildToolbarearly-returns forr.scriptExportwith "Export script · N statements" + Cancel and no view tabs / row-limit / Copy / Export;renderResultsbody renders the columns + statuses; Cancel callscancelExportScript.Out of scope
Firefox/Safari fallback · zipping results into one archive · parallel export of statements · background export surviving tab close · resume/retry of partial exports.