The file is not on disk.
';
@@ -457,6 +474,7 @@
return `${lk ? 'The run locks the settings.
' : ''}
Scripts
${txt('folder', 'Folder')}${abs ? '
⚠ Not portable: an absolute folder.
' : ''}
+
${txt('filters', 'File filters')}
Separate filters with ;. Only * and ? are wildcards.
${chk('recurse', 'Search subfolders')}
Run
@@ -552,6 +570,10 @@
Target
${txt('server', 'Server')}${txt('database', 'Database')}
saveAs: () => `
Save as
The batch name is the file name without .sqlrunboard.json.
`,
+ folder: () => `
Select a folder
${esc(d.path)}
+
+ ${d.parent ? `` : ''}${d.folders.map(f => ``).join('')}
+
`,
closeRun: () => `
Close SQLRunboard?
${esc(d.text)}
`,
}[d.kind]();
el.innerHTML = `
`;
@@ -560,6 +582,7 @@
Target
${txt('server', 'Server')}${txt('database', 'Database')}
function dialogOk() {
const d = app.dialog, v = document.getElementById('dlg-in')?.value ?? '';
if (d.kind === 'password') { if (!v) return; d.b.password = v; app.dialog = null; renderDialog(); logon(d.b, d.then); return; }
+ if (d.kind === 'folder') { d.b.settings.folder = d.path; saveSettings(d.b); closeDialog(); return; }
if (d.kind === 'confirm') { app.dialog = null; renderDialog(); d.then(); render(); return; }
if (d.kind === 'saveAs') {
if (!v.trim()) return;
@@ -611,6 +634,7 @@
Target
${txt('server', 'Server')}${txt('database', 'Database')}
case 'closeDrawer': b.drawer = false; render(); return document.querySelector(`[data-k="s:${CSS.escape(b.sel)}"]`)?.focus();
case 'runlog': return todo();
case 'test': return logon(b, login => { b.testMsg = `Connected. Server ${b.settings.server.split('.')[0].toUpperCase()}, database ${b.settings.database}, login ${login}.`; });
+ case 'browse': return browse(b, b.settings.folder);
case 'sortFiles': b.scripts.sort((x, y) => x.path.localeCompare(y.path, undefined, { numeric: true, sensitivity: 'base' })); b.dirty = true; return render();
}
}
@@ -620,6 +644,7 @@
Target
${txt('server', 'Server')}${txt('database', 'Database')}
if (app.menu && !e.target.closest('#menu')) closeMenu(false);
const t = e.target, b = cur();
const dlg = t.closest('[data-dlg]'); if (dlg) { e.preventDefault(); return dlg.dataset.dlg === 'ok' ? dialogOk() : closeDialog(); }
+ const fo = t.closest('[data-folder]'); if (fo) return browse(app.dialog.b, fo.dataset.folder);
const mc = t.closest('[data-mcmd]');
if (mc) {
const { b: mb, path } = app.menu, c = mc.dataset.mcmd; closeMenu();
@@ -651,11 +676,12 @@
Target
${txt('server', 'Server')}${txt('database', 'Database')}
if (t.id === 'theme') { app.theme = t.checked ? 'Dark' : 'Light'; store.set('theme', app.theme); return applyTheme(); }
if (t.dataset.tl) { app[t.dataset.tl] = t.checked; store.set(t.dataset.tl, t.checked); return render(); }
if (!b) return;
- if (t.dataset.include) { b.scripts.find(s => s.path === t.dataset.include).include = t.checked; b.dirty = true; return render(); }
+ if (t.dataset.include) return setInclude(b, b.scripts.find(s => s.path === t.dataset.include), t.checked);
if (t.dataset.set) {
const k = t.dataset.set; let v = t.type === 'checkbox' ? t.checked : t.value;
if (k === 'parallelLimit') { v = Math.min(64, Math.max(1, Math.round(+v || 1))); t.value = v; }
- b.settings[k] = v; b.dirty = true; b.testMsg = null;
+ if (k === 'timeoutSeconds' && v !== '') { v = Math.max(1, Math.round(+v) || 1); t.value = v; }
+ b.settings[k] = v; b.dirty = true; b.testMsg = null; saveSettings(b);
render({ tick: k !== 'auth' }); // the logon type changes which fields show
}
});
@@ -705,7 +731,7 @@
Target
${txt('server', 'Server')}${txt('database', 'Database')}
const list = b.scripts.map(s => s.path), i = list.indexOf(fr.dataset.file);
if (e.altKey && dirs[e.key]) { e.preventDefault(); if (!locked(b) && moveIn(b.scripts, b.scripts[i], dirs[e.key])) b.dirty = true; render(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); b.fileSel = list[Math.max(0, Math.min(list.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]; render(); }
- else if (e.key === ' ' && !locked(b) && b.scripts[i].state !== 'Missing') { e.preventDefault(); b.scripts[i].include = !b.scripts[i].include; b.dirty = true; render(); }
+ else if (e.key === ' ' && !locked(b) && b.scripts[i].state !== 'Missing') { e.preventDefault(); setInclude(b, b.scripts[i], !b.scripts[i].include); }
else return;
return document.querySelector(`[data-k="f:${CSS.escape(b.fileSel)}"]`)?.focus();
}
diff --git a/tests/SqlRunboard.Gui.Tests/HostTests.cs b/tests/SqlRunboard.Gui.Tests/HostTests.cs
index 1f6e363..25ad949 100644
--- a/tests/SqlRunboard.Gui.Tests/HostTests.cs
+++ b/tests/SqlRunboard.Gui.Tests/HostTests.cs
@@ -58,4 +58,20 @@ public async Task A_request_without_a_valid_token_or_cookie_is_rejected(string p
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(path, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await withBadCookie.GetAsync(path, Ct)).StatusCode);
}
+
+ [Theory]
+ [InlineData("""{"parallelLimit":1,"timeoutSeconds":1,"filters":"*.sql","target":{}}""", HttpStatusCode.OK)]
+ [InlineData("""{"parallelLimit":64,"filters":"*.sql","target":{}}""", HttpStatusCode.OK)]
+ [InlineData("""{"parallelLimit":0,"filters":"*.sql","target":{}}""", HttpStatusCode.BadRequest)]
+ [InlineData("""{"parallelLimit":65,"filters":"*.sql","target":{}}""", HttpStatusCode.BadRequest)]
+ [InlineData("""{"parallelLimit":4,"timeoutSeconds":0,"filters":"*.sql","target":{}}""", HttpStatusCode.BadRequest)]
+ public async Task The_server_checks_the_settings_range(string json, HttpStatusCode expected)
+ {
+ await using var app = await TestApp.StartAsync();
+ var batch = app.Host.Batches.NewBatch();
+
+ var res = await app.Client().PostAsync($"/api/batches/{batch.Id}/settings", new StringContent(json, System.Text.Encoding.UTF8, "application/json"), Ct);
+
+ Assert.Equal(expected, res.StatusCode);
+ }
}
diff --git a/tests/SqlRunboard.Gui.Tests/SettingsTests.cs b/tests/SqlRunboard.Gui.Tests/SettingsTests.cs
new file mode 100644
index 0000000..5f8af5b
--- /dev/null
+++ b/tests/SqlRunboard.Gui.Tests/SettingsTests.cs
@@ -0,0 +1,122 @@
+using Microsoft.Playwright;
+
+public class SettingsTests : AppPageTest
+{
+ readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName;
+
+ void Touch(params string[] paths)
+ {
+ foreach (var p in paths)
+ {
+ var full = Path.Combine(scripts, p);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ File.WriteAllText(full, "select 1");
+ }
+ }
+
+ ILocator Panel => Page.Locator("#panel");
+ ILocator Tab(string name) => Page.GetByRole(AriaRole.Tab, new() { Name = name });
+ ILocator FileRows => Panel.Locator("tbody tr");
+
+ async Task NewBatchAsync()
+ {
+ await Page.Keyboard.PressAsync("ControlOrMeta+n");
+ await Expect(Tab("Settings")).ToHaveAttributeAsync("aria-selected", "true");
+ }
+
+ async Task SetFolderAsync(string folder)
+ {
+ await Panel.GetByLabel("Folder", new() { Exact = true }).FillAsync(folder);
+ await Panel.GetByLabel("Folder", new() { Exact = true }).PressAsync("Enter");
+ }
+
+ [Fact]
+ public async Task The_Settings_tab_shows_the_spec_defaults()
+ {
+ await NewBatchAsync();
+
+ await Expect(Panel.GetByLabel("File filters")).ToHaveValueAsync("*.sql");
+ await Expect(Panel.GetByLabel("Search subfolders")).Not.ToBeCheckedAsync();
+ await Expect(Panel.GetByLabel("Parallel limit")).ToHaveValueAsync("4");
+ await Expect(Panel.GetByLabel("Parallel limit")).ToHaveAttributeAsync("min", "1");
+ await Expect(Panel.GetByLabel("Parallel limit")).ToHaveAttributeAsync("max", "64");
+ await Expect(Panel.GetByLabel("Stop the queue at the first failure")).Not.ToBeCheckedAsync();
+ await Expect(Panel.GetByLabel("Confirm before run")).Not.ToBeCheckedAsync();
+ await Expect(Panel.GetByLabel("Command timeout (s)")).ToHaveValueAsync("");
+ }
+
+ [Fact]
+ public async Task The_Files_tab_shows_the_matching_files_in_natural_order()
+ {
+ Touch("10_x.sql", "2_x.sql", "1_x.tsql", "notes.txt", "sub/3_x.sql");
+ await NewBatchAsync();
+
+ await Panel.GetByLabel("File filters").FillAsync("*.sql;*.tsql");
+ await Panel.GetByLabel("Search subfolders").CheckAsync();
+ await SetFolderAsync(scripts);
+ await Tab("Files").ClickAsync();
+
+ await Expect(FileRows.Locator("td.tnum")).ToHaveTextAsync(["1_x.tsql", "2_x.sql", "10_x.sql", "sub/3_x.sql"]);
+ }
+
+ [Fact]
+ public async Task A_cleared_check_box_is_kept_by_the_server()
+ {
+ Touch("a.sql", "b.sql");
+ await NewBatchAsync();
+ await SetFolderAsync(scripts);
+ await Tab("Files").ClickAsync();
+
+ await Panel.GetByLabel("Include a.sql").UncheckAsync();
+
+ var other = await Context.NewPageAsync();
+ await OpenAsync(other, App.Host.BaseUrl);
+ await other.GetByRole(AriaRole.Tab, new() { Name = "Files" }).ClickAsync();
+ await Expect(other.GetByLabel("Include a.sql")).Not.ToBeCheckedAsync();
+ await Expect(other.GetByLabel("Include b.sql")).ToBeCheckedAsync();
+
+ await Panel.GetByLabel("Include a.sql").CheckAsync();
+ await Expect(other.GetByLabel("Include a.sql")).ToBeCheckedAsync();
+ }
+
+ [Fact]
+ public async Task A_settings_change_goes_to_the_server()
+ {
+ await NewBatchAsync();
+
+ await Panel.GetByLabel("Parallel limit").FillAsync("100");
+ await Panel.GetByLabel("Parallel limit").PressAsync("Enter");
+ await Panel.GetByLabel("Command timeout (s)").FillAsync("30");
+ await Panel.GetByLabel("Command timeout (s)").PressAsync("Enter");
+
+ var other = await Context.NewPageAsync();
+ await OpenAsync(other, App.Host.BaseUrl);
+ await other.GetByRole(AriaRole.Tab, new() { Name = "Settings" }).ClickAsync();
+ await Expect(other.GetByLabel("Parallel limit")).ToHaveValueAsync("64");
+ await Expect(other.GetByLabel("Command timeout (s)")).ToHaveValueAsync("30");
+ }
+
+ [Fact]
+ public async Task The_folder_dialog_selects_a_folder_from_the_server_list()
+ {
+ Touch("deploy/1_a.sql");
+ await NewBatchAsync();
+ await SetFolderAsync(scripts);
+
+ await Panel.GetByRole(AriaRole.Button, new() { Name = "Browse" }).ClickAsync();
+ var dialog = Page.GetByRole(AriaRole.Dialog);
+ await dialog.GetByRole(AriaRole.Button, new() { Name = "deploy", Exact = true }).ClickAsync();
+ await dialog.GetByRole(AriaRole.Button, new() { Name = "Select this folder" }).ClickAsync();
+
+ await Expect(dialog).ToHaveCountAsync(0);
+ await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToHaveValueAsync(Path.Combine(scripts, "deploy"));
+ await Tab("Files").ClickAsync();
+ await Expect(FileRows.Locator("td.tnum")).ToHaveTextAsync(["1_a.sql"]);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await base.DisposeAsync();
+ Directory.Delete(scripts, recursive: true);
+ }
+}
From c27238f758d822f0d68b726750d206b422a9033c Mon Sep 17 00:00:00 2001
From: Hank <36976296+ravenbix@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:41:38 +0200
Subject: [PATCH 19/96] chore(ci): add release workflow that publishes
single-file archives on a v* tag
---
.github/workflows/release.yml | 77 +++++++++++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3457bba
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,77 @@
+name: Release
+
+# Push a tag such as v0.1.0 to publish a release.
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ name: publish (${{ matrix.rid }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { rid: win-x64, os: windows-latest }
+ # macOS runner: the SDK gives the apphost an ad-hoc signature, which Apple silicon needs.
+ - { rid: osx-arm64, os: macos-latest }
+ - { rid: linux-x64, os: ubuntu-latest }
+ - { rid: linux-arm64, os: ubuntu-latest }
+ runs-on: ${{ matrix.os }}
+ defaults:
+ run:
+ shell: pwsh
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+ - name: Publish and pack
+ run: |
+ $rid = '${{ matrix.rid }}'
+ $out = "publish/$rid"
+ foreach ($project in 'sqlrunboard', 'sqlrunboard-desktop') {
+ dotnet publish "src/$project" -c Release -r $rid --self-contained `
+ -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -o $out
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+ }
+ # Pack only the two executables (the win-x64 output also has native .pdb files).
+ $ext = $rid.StartsWith('win') ? '.exe' : ''
+ $files = "sqlrunboard$ext", "sqlrunboard-desktop$ext"
+ $name = "sqlrunboard-${{ github.ref_name }}-$rid"
+ # tar keeps the Unix execute bit. Windows users expect a zip.
+ if ($ext) { Compress-Archive -Path ($files | ForEach-Object { "$out/$_" }) -DestinationPath "$name.zip" }
+ else { tar -czf "$name.tar.gz" -C $out @files }
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.rid }}
+ path: sqlrunboard-*
+ if-no-files-found: error
+
+ release:
+ needs: publish
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v5
+ with:
+ path: dist
+ merge-multiple: true
+ - name: Create release
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $notes = @'
+ Each archive contains `sqlrunboard` (opens the GUI in your browser) and `sqlrunboard-desktop` (opens the GUI in its own window). The executables are self-contained: you do not need to install .NET.
+
+ The executables are not code signed.
+
+ - macOS: the system quarantines downloaded files. After you extract the archive, run `xattr -d com.apple.quarantine sqlrunboard sqlrunboard-desktop` in the folder.
+ - Windows: SmartScreen can show "Windows protected your PC". Click "More info", then "Run anyway".
+ - Linux: `sqlrunboard-desktop` needs WebKitGTK (for example `libwebkit2gtk-4.1-0`) on the computer.
+ '@
+ gh release create '${{ github.ref_name }}' dist/* --repo '${{ github.repository }}' --title '${{ github.ref_name }}' --notes $notes --generate-notes
From 898b192c4de48e550641ede32a020a64701cdd5d Mon Sep 17 00:00:00 2001
From: Hank <36976296+ravenbix@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:42:38 +0200
Subject: [PATCH 20/96] feat(core): save a batch to a version 1 batch file with
a relative folder and a change check
---
src/SqlRunboard.Core/Batch.cs | 3 +
src/SqlRunboard.Core/BatchFile.cs | 43 +++++++
src/SqlRunboard.Core/BatchList.cs | 38 ++++++
src/SqlRunboard.Gui/GuiHost.cs | 2 +-
.../SqlRunboard.Core.Tests/BatchFileTests.cs | 119 ++++++++++++++++++
5 files changed, 204 insertions(+), 1 deletion(-)
create mode 100644 src/SqlRunboard.Core/BatchFile.cs
create mode 100644 tests/SqlRunboard.Core.Tests/BatchFileTests.cs
diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs
index bc0cb44..ae06792 100644
--- a/src/SqlRunboard.Core/Batch.cs
+++ b/src/SqlRunboard.Core/Batch.cs
@@ -28,6 +28,9 @@ public sealed record BatchSettings
public bool ConfirmBeforeRun { get; init; }
public int? TimeoutSeconds { get; init; }
public BatchTarget Target { get; init; } = new();
+
+ ///
The ranges of docs/SPEC.md "Batch". JSON input can also give null for the non-null properties.
+ public bool IsValid => this is { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null };
}
///
One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box.
diff --git a/src/SqlRunboard.Core/BatchFile.cs b/src/SqlRunboard.Core/BatchFile.cs
new file mode 100644
index 0000000..4507b7b
--- /dev/null
+++ b/src/SqlRunboard.Core/BatchFile.cs
@@ -0,0 +1,43 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SqlRunboard.Core;
+
+///
The file on disk changed after the app loaded or saved it. The user must choose: overwrite it, or load it again.
+public sealed class BatchFileChangedException(string file) : IOException($"The file changed on disk: {file}");
+
+///
The batch file, schema version 1. See docs/SPEC.md "Batch". It holds no secrets: the password is never in BatchSettings.
+public static class BatchFile
+{
+ public const string Extension = ".sqlrunboard.json";
+
+ // A key that is not in the file gets the spec default.
+ sealed record Json(int Version, string? Folder = null, string Filters = "*.sql", bool Recurse = false, int ParallelLimit = 4, bool StopOnFirstFailure = false,
+ bool ConfirmBeforeRun = false, int? TimeoutSeconds = null, BatchTarget? Target = null, IReadOnlyList
? Scripts = null);
+
+ static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true, Converters = { new JsonStringEnumConverter() } };
+
+ public static bool HasExtension(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
+
+ /// The batch name: the file name without .sqlrunboard.json.
+ public static string NameOf(string file) => Path.GetFileName(file)[..^Extension.Length];
+
+ public static string Format(BatchSettings s, IReadOnlyList scripts) =>
+ JsonSerializer.Serialize(new Json(1, s.Folder, s.Filters, s.Recurse, s.ParallelLimit, s.StopOnFirstFailure, s.ConfirmBeforeRun, s.TimeoutSeconds, s.Target, scripts), Options);
+
+ /// Throws InvalidDataException for an incorrect file.
+ public static (BatchSettings Settings, IReadOnlyList Scripts) Parse(string text)
+ {
+ Json? j;
+ try { j = JsonSerializer.Deserialize(text, Options); }
+ catch (JsonException e) { throw new InvalidDataException($"Not a batch file: {e.Message}"); }
+ if (j is not { Version: 1 }) throw new InvalidDataException("Not a batch file of version 1.");
+ var settings = new BatchSettings
+ {
+ Folder = j.Folder, Filters = j.Filters, Recurse = j.Recurse, ParallelLimit = j.ParallelLimit, StopOnFirstFailure = j.StopOnFirstFailure,
+ ConfirmBeforeRun = j.ConfirmBeforeRun, TimeoutSeconds = j.TimeoutSeconds, Target = j.Target ?? new(),
+ };
+ if (!settings.IsValid || j.Scripts?.Any(s => s?.Path is null) == true) throw new InvalidDataException("The batch file has an incorrect setting.");
+ return (settings, j.Scripts ?? []);
+ }
+}
diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs
index 100f6c4..53130b4 100644
--- a/src/SqlRunboard.Core/BatchList.cs
+++ b/src/SqlRunboard.Core/BatchList.cs
@@ -14,6 +14,7 @@ public sealed class BatchList
readonly Lock gate = new();
readonly List batches = [];
readonly List> subscribers = [];
+ readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote
int lastId, lastUntitled;
public Batch NewBatch()
@@ -44,6 +45,43 @@ public Batch NewBatch()
return Replace(id, b => b with { Scripts = [.. b.Scripts.Select(s => s.Path == path ? s with { Include = include } : s)] });
}
+ ///
+ /// Writes the batch file. Path null: Save, to the current file. Otherwise Save as, to that full path; the app adds .sqlrunboard.json if it is not there.
+ /// Throws BatchFileChangedException if the file on disk is not the version that the app loaded, unless overwrite is true. Null if no batch has this id.
+ ///
+ public Batch? Save(int id, string? path, bool overwrite)
+ {
+ if (path is not null && !Path.IsPathFullyQualified(path)) throw new ArgumentException($"Type a full path: {path}");
+ lock (gate)
+ {
+ var i = batches.FindIndex(b => b.Id == id);
+ if (i < 0) return null;
+ var b = batches[i];
+ var file = path is null ? b.File ?? throw new ArgumentException("The batch has no file. Use Save as.")
+ : BatchFile.HasExtension(path) ? Path.GetFullPath(path) : Path.GetFullPath(path + BatchFile.Extension);
+ // ponytail: Save as over another existing file overwrites it without a question; ask first if users lose files that way.
+ if (!overwrite && file == b.File && File.Exists(file) && File.ReadAllText(file) != loaded.GetValueOrDefault(id)) throw new BatchFileChangedException(file);
+ var settings = b.Settings with { Folder = Rebase(b.File, file, b.Settings.Folder) };
+ var text = BatchFile.Format(settings, b.Scripts);
+ File.WriteAllText(file, text);
+ loaded[id] = text;
+ var batch = batches[i] = b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings };
+ Publish(new Change("batch", batch));
+ return batch;
+ }
+ }
+
+ ///
+ /// The folder for a batch file at newFile. A relative folder (or any folder of an unsaved batch) becomes relative to newFile, with /.
+ /// It stays absolute if no relative path is possible (another Windows drive). An absolute folder of a saved batch is your choice and stays.
+ ///
+ static string? Rebase(string? oldFile, string newFile, string? folder)
+ {
+ if (string.IsNullOrWhiteSpace(folder) || (oldFile is not null && Path.IsPathRooted(folder))) return folder;
+ var rel = Path.GetRelativePath(Path.GetDirectoryName(newFile)!, Path.GetFullPath(FolderPath(oldFile, folder)!));
+ return Path.IsPathRooted(rel) ? rel : rel.Replace('\\', '/');
+ }
+
// Call under the lock.
Batch? Replace(int id, Func change)
{
diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs
index 69a1cef..a1c927b 100644
--- a/src/SqlRunboard.Gui/GuiHost.cs
+++ b/src/SqlRunboard.Gui/GuiHost.cs
@@ -65,7 +65,7 @@ public static async Task StartAsync(GuiHostOptions options)
app.MapGet("/events", (BatchList list, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, ct)));
app.MapPost("/api/batches", (BatchList list) => list.NewBatch());
app.MapPost("/api/batches/{id:int}/settings", (int id, BatchSettings settings, BatchList list) =>
- settings is not { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null } ? Results.BadRequest()
+ !settings.IsValid ? Results.BadRequest()
: list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound());
app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) =>
list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound());
diff --git a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs
new file mode 100644
index 0000000..bbf1fa8
--- /dev/null
+++ b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs
@@ -0,0 +1,119 @@
+using SqlRunboard.Core;
+
+/// Save, Save as, Open, Load again and Remove on the side list, against real temp folders.
+public sealed class BatchFileTests : IDisposable
+{
+ readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-batch-").FullName;
+ readonly BatchList list = new();
+
+ void Touch(params string[] paths)
+ {
+ foreach (var p in paths)
+ {
+ var full = Path.Combine(root, p);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ File.WriteAllText(full, "select 1");
+ }
+ }
+
+ Batch NewBatchWithFolder(string folder)
+ {
+ var b = list.NewBatch();
+ return list.UpdateSettings(b.Id, new BatchSettings { Folder = folder })!;
+ }
+
+ [Fact]
+ public void Save_as_writes_schema_version_1_with_the_folder_relative_to_the_batch_file()
+ {
+ Touch("scripts/001_a.sql", "scripts/002_b.sql");
+ var b = NewBatchWithFolder(Path.Combine(root, "scripts"));
+ list.SetInclude(b.Id, "002_b.sql", false);
+ list.UpdateSettings(b.Id, b.Settings with { ConfirmBeforeRun = true, Target = new BatchTarget { Server = "sql01", Database = "Sales" } });
+
+ var saved = list.Save(b.Id, Path.Combine(root, "deploy"), overwrite: false)!;
+
+ Assert.Equal("deploy", saved.Name);
+ Assert.Equal(Path.Combine(root, "deploy.sqlrunboard.json"), saved.File);
+ Assert.False(saved.Dirty);
+ Assert.Equal("scripts", saved.Settings.Folder);
+ Assert.Equal("""
+ {
+ "version": 1,
+ "folder": "scripts",
+ "filters": "*.sql",
+ "recurse": false,
+ "parallelLimit": 4,
+ "stopOnFirstFailure": false,
+ "confirmBeforeRun": true,
+ "timeoutSeconds": null,
+ "target": {
+ "server": "sql01",
+ "database": "Sales",
+ "auth": "Integrated",
+ "user": null,
+ "passwordVariable": null,
+ "encrypt": "Mandatory",
+ "trustServerCertificate": false,
+ "extraConnectionString": ""
+ },
+ "scripts": [
+ {
+ "path": "001_a.sql",
+ "include": true
+ },
+ {
+ "path": "002_b.sql",
+ "include": false
+ }
+ ]
+ }
+ """.ReplaceLineEndings("\n"), File.ReadAllText(saved.File!).ReplaceLineEndings("\n").TrimEnd());
+ }
+
+ [Fact]
+ public void Save_asks_before_it_overwrites_a_file_that_changed_on_disk()
+ {
+ var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x.sqlrunboard.json"), overwrite: false)!;
+ Assert.NotNull(list.Save(b.Id, null, overwrite: false)); // unchanged on disk: no question
+ File.WriteAllText(b.File!, File.ReadAllText(b.File!).Replace("\"recurse\": false", "\"recurse\": true"));
+
+ Assert.Throws(() => list.Save(b.Id, null, overwrite: false));
+ list.Save(b.Id, null, overwrite: true);
+ Assert.Contains("\"recurse\": false", File.ReadAllText(b.File!));
+ }
+
+ [Fact]
+ public void Save_of_an_unsaved_batch_needs_a_path()
+ {
+ var b = list.NewBatch();
+
+ Assert.Throws(() => list.Save(b.Id, null, overwrite: false));
+ Assert.Throws(() => list.Save(b.Id, "relative/x", overwrite: false));
+ }
+
+ [Fact]
+ public void Save_as_to_another_folder_keeps_a_relative_folder_pointing_at_the_same_scripts()
+ {
+ Touch("scripts/a.sql");
+ var b = NewBatchWithFolder(Path.Combine(root, "scripts"));
+ list.Save(b.Id, Path.Combine(root, "x"), overwrite: false);
+ Directory.CreateDirectory(Path.Combine(root, "sub"));
+
+ var moved = list.Save(b.Id, Path.Combine(root, "sub", "y.sqlrunboard.json"), overwrite: false)!;
+
+ Assert.Equal("../scripts", moved.Settings.Folder);
+ Assert.Equal("y", moved.Name);
+ }
+
+ [Fact]
+ public void An_absolute_folder_stays_absolute_after_the_first_save()
+ {
+ var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!;
+ var other = Path.Combine(root, "elsewhere");
+ list.UpdateSettings(b.Id, b.Settings with { Folder = other });
+
+ Assert.Equal(other, list.Save(b.Id, null, overwrite: false)!.Settings.Folder);
+ }
+
+ public void Dispose() => Directory.Delete(root, recursive: true);
+}
From 5f27c976e9a4fbc4d49b45827a8b2fb247a4d735 Mon Sep 17 00:00:00 2001
From: Hank <36976296+ravenbix@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:43:26 +0200
Subject: [PATCH 21/96] feat(core): open, load again and remove batch files in
the side list
---
src/SqlRunboard.Core/BatchList.cs | 54 +++++++++++++
.../SqlRunboard.Core.Tests/BatchFileTests.cs | 78 +++++++++++++++++++
2 files changed, 132 insertions(+)
diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs
index 53130b4..7450331 100644
--- a/src/SqlRunboard.Core/BatchList.cs
+++ b/src/SqlRunboard.Core/BatchList.cs
@@ -71,6 +71,60 @@ public Batch NewBatch()
}
}
+ ///
+ /// Adds a batch file to the list, or gives the entry that already has this file. The path must be full and end with .sqlrunboard.json.
+ /// Throws FileNotFoundException, or InvalidDataException for an incorrect file.
+ ///
+ public Batch Open(string path)
+ {
+ if (!Path.IsPathFullyQualified(path) || !BatchFile.HasExtension(path)) throw new ArgumentException($"Select a full path to a {BatchFile.Extension} file: {path}");
+ var file = Path.GetFullPath(path);
+ lock (gate)
+ {
+ // ponytail: an ordinal path compare; the same file with another letter case on Windows or macOS gets a second entry.
+ if (batches.Find(b => b.File == file) is { } open) return open;
+ var batch = Load(new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []));
+ batches.Add(batch);
+ Publish(new Change("batch", batch));
+ return batch;
+ }
+ }
+
+ /// "Load again": replaces the batch with its file on disk. Null if no batch has this id.
+ public Batch? Reload(int id)
+ {
+ lock (gate)
+ {
+ var i = batches.FindIndex(b => b.Id == id);
+ if (i < 0) return null;
+ if (batches[i].File is null) throw new ArgumentException("The batch has no file.");
+ var batch = batches[i] = Load(batches[i]);
+ Publish(new Change("batch", batch));
+ return batch;
+ }
+ }
+
+ // Call under the lock. Reads b.File and scans its folder.
+ Batch Load(Batch b)
+ {
+ var text = File.ReadAllText(b.File!);
+ var (settings, scripts) = BatchFile.Parse(text);
+ loaded[b.Id] = text;
+ return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) };
+ }
+
+ /// "Remove from list". The file stays on disk. False if no batch has this id.
+ public bool Remove(int id)
+ {
+ lock (gate)
+ {
+ if (batches.RemoveAll(b => b.Id == id) == 0) return false;
+ loaded.Remove(id);
+ Publish(new Change("remove", new { id }));
+ return true;
+ }
+ }
+
///
/// The folder for a batch file at newFile. A relative folder (or any folder of an unsaved batch) becomes relative to newFile, with /.
/// It stays absolute if no relative path is possible (another Windows drive). An absolute folder of a saved batch is your choice and stays.
diff --git a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs
index bbf1fa8..de1874b 100644
--- a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs
+++ b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs
@@ -115,5 +115,83 @@ public void An_absolute_folder_stays_absolute_after_the_first_save()
Assert.Equal(other, list.Save(b.Id, null, overwrite: false)!.Settings.Folder);
}
+ [Fact]
+ public void Open_reads_the_settings_and_the_start_order_with_the_cleared_check_boxes()
+ {
+ Touch("scripts/a.sql", "scripts/b.sql", "scripts/c.sql");
+ File.WriteAllText(Path.Combine(root, "deploy.sqlrunboard.json"), """
+ {
+ "version": 1,
+ "folder": "scripts",
+ "parallelLimit": 2,
+ "target": { "server": "sql01", "auth": "SqlServer" },
+ "scripts": [
+ { "path": "c.sql", "include": true },
+ { "path": "a.sql", "include": false },
+ { "path": "b.sql", "include": true }
+ ]
+ }
+ """);
+
+ var b = list.Open(Path.Combine(root, "deploy.sqlrunboard.json"));
+
+ Assert.Equal("deploy", b.Name);
+ Assert.False(b.Dirty);
+ Assert.Equal(2, b.Settings.ParallelLimit);
+ Assert.Equal("*.sql", b.Settings.Filters);
+ Assert.Equal(Auth.SqlServer, b.Settings.Target.Auth);
+ Assert.Equal([new("c.sql", true), new("a.sql", false), new("b.sql", true)], b.Scripts);
+ }
+
+ [Fact]
+ public void Opening_a_file_that_is_in_the_list_gives_the_existing_entry()
+ {
+ var saved = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!;
+
+ var opened = list.Open(Path.Combine(root, "sub", "..", "x.sqlrunboard.json"));
+
+ Assert.Equal(saved.Id, opened.Id);
+ using var sub = list.Subscribe();
+ Assert.Single(sub.Snapshot);
+ }
+
+ [Fact]
+ public void Open_accepts_only_batch_files()
+ {
+ File.WriteAllText(Path.Combine(root, "x.json"), "{}");
+ File.WriteAllText(Path.Combine(root, "bad.sqlrunboard.json"), """{ "version": 2 }""");
+
+ Assert.Throws(() => list.Open(Path.Combine(root, "x.json")));
+ Assert.Throws(() => list.Open(Path.Combine(root, "bad.sqlrunboard.json")));
+ Assert.Throws(() => list.Open(Path.Combine(root, "none.sqlrunboard.json")));
+ }
+
+ [Fact]
+ public void Load_again_replaces_the_batch_with_the_file_on_disk()
+ {
+ var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!;
+ list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 9 });
+ File.WriteAllText(b.File!, File.ReadAllText(b.File!).Replace("\"parallelLimit\": 4", "\"parallelLimit\": 7"));
+
+ var reloaded = list.Reload(b.Id)!;
+
+ Assert.Equal(7, reloaded.Settings.ParallelLimit);
+ Assert.False(reloaded.Dirty);
+ Assert.NotNull(list.Save(b.Id, null, overwrite: false)); // the loaded version is now the disk version
+ }
+
+ [Fact]
+ public void Remove_takes_the_batch_off_the_list_and_keeps_the_file()
+ {
+ var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!;
+
+ Assert.True(list.Remove(b.Id));
+
+ using var sub = list.Subscribe();
+ Assert.Empty(sub.Snapshot);
+ Assert.True(File.Exists(b.File));
+ Assert.False(list.Remove(b.Id));
+ }
+
public void Dispose() => Directory.Delete(root, recursive: true);
}
From 345d715e3c8d71e2e09d1a27e57cff22d9d43f23 Mon Sep 17 00:00:00 2001
From: Hank <36976296+ravenbix@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:44:02 +0200
Subject: [PATCH 22/96] feat(core): run a batch: rescan, queue the included
scripts and obey the parallel limit
---
src/SqlRunboard.Core/Batch.cs | 4 +-
src/SqlRunboard.Core/BatchList.cs | 88 ++++++++++++-
src/SqlRunboard.Core/Run.cs | 16 +++
tests/SqlRunboard.Core.Tests/RunTests.cs | 151 +++++++++++++++++++++++
4 files changed, 256 insertions(+), 3 deletions(-)
create mode 100644 src/SqlRunboard.Core/Run.cs
create mode 100644 tests/SqlRunboard.Core.Tests/RunTests.cs
diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs
index bc0cb44..93350d9 100644
--- a/src/SqlRunboard.Core/Batch.cs
+++ b/src/SqlRunboard.Core/Batch.cs
@@ -33,5 +33,5 @@ public sealed record BatchSettings
/// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box.
public sealed record ScriptEntry(string Path, bool Include);
-/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order.
-public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts);
+/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null.
+public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null);
diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs
index 100f6c4..e01032d 100644
--- a/src/SqlRunboard.Core/BatchList.cs
+++ b/src/SqlRunboard.Core/BatchList.cs
@@ -8,11 +8,13 @@ public sealed record Change(string Kind, object Data);
///
/// The side list: all batches, in the order you added them. The server keeps this state; the page shows it.
/// Each subscriber gets the full state, then every change after it.
+/// It also runs the batches: each batch has its own queue and parallel limit, and many batches can run at once.
///
-public sealed class BatchList
+public sealed class BatchList(ISqlExecutor executor, TimeProvider time)
{
readonly Lock gate = new();
readonly List batches = [];
+ readonly Dictionary runs = [];
readonly List> subscribers = [];
int lastId, lastUntitled;
@@ -49,11 +51,95 @@ public Batch NewBatch()
{
var i = batches.FindIndex(b => b.Id == id);
if (i < 0) return null;
+ if (batches[i].Run?.Active == true) return batches[i]; // the run locks the settings and the check boxes
var batch = batches[i] = change(batches[i]) with { Dirty = true };
Publish(new Change("batch", batch));
return batch;
}
+ ///
+ /// Scans the folder again and queues all included scripts in the start order. Returns the batch with its new run,
+ /// the batch unchanged if its run still has Queued or Running scripts, or null if no batch has this id.
+ ///
+ public Batch? Run(int id)
+ {
+ lock (gate)
+ {
+ var i = batches.FindIndex(b => b.Id == id);
+ if (i < 0) return null;
+ var b = batches[i];
+ if (b.Run?.Active == true) return b;
+ var folder = FolderPath(b.File, b.Settings.Folder);
+ var scripts = Merge(b.Scripts, FileScan.Scan(folder, b.Settings.Filters, b.Settings.Recurse));
+ batches[i] = b with { Scripts = scripts, Dirty = b.Dirty || !scripts.SequenceEqual(b.Scripts) };
+ var run = runs[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings);
+ foreach (var s in scripts.Where(s => s.Include))
+ {
+ run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []);
+ run.Queue.Add(s.Path);
+ }
+ Pump(id, run);
+ return batches[i];
+ }
+ }
+
+ /// The mutable state of a run. Change it only under the lock.
+ sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings)
+ {
+ public DateTimeOffset Start => start;
+ public string Folder => folder;
+ public BatchSettings Settings => settings;
+ public OrderedDictionary Scripts { get; } = new(StringComparer.Ordinal); // the start order
+ public List Queue { get; } = [];
+ public int Running { get; set; }
+
+ public void Add(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; }
+ public void SetLast(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts.SkipLast(1), a] }; }
+ }
+
+ // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run.
+ void Pump(int id, ActiveRun run)
+ {
+ while (run.Running < run.Settings.ParallelLimit && run.Queue.Count > 0)
+ {
+ var path = run.Queue[0];
+ run.Queue.RemoveAt(0);
+ var start = time.GetUtcNow();
+ string text;
+ // Each script reads its file when it starts, so you can fix a Queued script.
+ // ponytail: the read runs under the lock; move it out if slow network folders block the page.
+ try { text = File.ReadAllText(Path.Combine(run.Folder, path)); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message));
+ continue;
+ }
+ run.Running++;
+ run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null));
+ var execution = new SqlExecution(path, [text], new SqlConnectionSettings(run.Settings.Target, Password: null));
+ // The continuation runs on the thread pool, never inline here under the lock.
+ _ = executor.ExecuteAsync(execution, _ => { }, CancellationToken.None)
+ .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default);
+ }
+ var i = batches.FindIndex(b => b.Id == id);
+ if (i < 0 || runs.GetValueOrDefault(id) != run) return;
+ var state = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]);
+ Publish(new Change("batch", batches[i] = batches[i] with { Run = state }));
+ }
+
+ void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task t)
+ {
+ lock (gate)
+ {
+ var end = time.GetUtcNow();
+ run.SetLast(path, t.IsCompletedSuccessfully ? new Attempt(start, end, t.Result.Status, t.Result.Rows, t.Result.Error)
+ : t.IsCanceled ? new Attempt(start, end, ScriptStatus.Cancelled, null, null)
+ : new Attempt(start, end, ScriptStatus.Failed, null, t.Exception!.GetBaseException().Message));
+ run.Running--;
+ Pump(id, run);
+ }
+ }
+
/// A relative folder is relative to the batch file. An unsaved batch has an absolute folder.
static string? FolderPath(string? batchFile, string? folder) =>
string.IsNullOrWhiteSpace(folder) || batchFile is null ? folder : Path.Combine(Path.GetDirectoryName(batchFile)!, folder);
diff --git a/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs
new file mode 100644
index 0000000..fd39a5a
--- /dev/null
+++ b/src/SqlRunboard.Core/Run.cs
@@ -0,0 +1,16 @@
+namespace SqlRunboard.Core;
+
+/// One attempt of one script. End is null while it runs.
+public sealed record Attempt(DateTimeOffset Start, DateTimeOffset? End, ScriptStatus Status, long? Rows, string? Error);
+
+/// One script of a run. Status is the status of the last attempt, or Queued.
+public sealed record ScriptRun(string Path, ScriptStatus Status, IReadOnlyList Attempts);
+
+///
+/// The current run of a batch, as the page sees it. Scripts are in the start order; Queue has the Queued paths in queue order.
+/// Now is the server clock when this state was made, so the page can show the elapsed time of Running scripts.
+///
+public sealed record RunState(DateTimeOffset Start, DateTimeOffset Now, IReadOnlyList Scripts, IReadOnlyList Queue)
+{
+ public bool Active => Scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running);
+}
diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs
new file mode 100644
index 0000000..b5a1570
--- /dev/null
+++ b/tests/SqlRunboard.Core.Tests/RunTests.cs
@@ -0,0 +1,151 @@
+using SqlRunboard.Core;
+
+/// The run engine, seen through BatchList: Run, then the published batch changes. Fake executor + fake clock.
+public sealed class RunTests : IDisposable
+{
+ readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-run-").FullName;
+ readonly FakeTime time = new();
+ readonly FakeSqlExecutor executor;
+ readonly BatchList list;
+ readonly BatchList.Subscription sub;
+
+ public RunTests()
+ {
+ executor = new FakeSqlExecutor(time);
+ list = new BatchList(executor, time);
+ sub = list.Subscribe();
+ }
+
+ void Write(string path, string text = "select 1") => File.WriteAllText(Path.Combine(root, path), text);
+
+ void Script(string path, int seconds, ScriptStatus status = ScriptStatus.Succeeded, long? rows = null) =>
+ executor.Scripts[path] = new FakeScript { Duration = TimeSpan.FromSeconds(seconds), Status = status, Rows = rows };
+
+ Batch NewBatch(int limit, params string[] files)
+ {
+ foreach (var f in files) Write(f);
+ var b = list.NewBatch();
+ return list.UpdateSettings(b.Id, new BatchSettings { Folder = root, ParallelLimit = limit })!;
+ }
+
+ /// Waits for the next published state of the batch that matches.
+ async Task Until(int id, Func ok)
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ while (true)
+ {
+ var change = await sub.Changes.ReadAsync(cts.Token);
+ if (change.Data is Batch { Run: { } run } b && b.Id == id && ok(run)) return run;
+ }
+ }
+
+ static ScriptStatus StatusOf(RunState run, string path) => run.Scripts.Single(s => s.Path == path).Status;
+
+ [Fact]
+ public async Task Run_queues_the_included_scripts_in_the_start_order_and_obeys_the_parallel_limit()
+ {
+ var b = NewBatch(2, "1.sql", "2.sql", "3.sql", "4.sql");
+ list.SetInclude(b.Id, "2.sql", false);
+ Script("1.sql", 10, rows: 5); Script("3.sql", 20); Script("4.sql", 5);
+
+ var run = list.Run(b.Id)!.Run!;
+
+ Assert.Equal(["1.sql", "3.sql", "4.sql"], run.Scripts.Select(s => s.Path));
+ Assert.Equal([ScriptStatus.Running, ScriptStatus.Running, ScriptStatus.Queued], run.Scripts.Select(s => s.Status));
+ Assert.Equal(["4.sql"], run.Queue);
+ Assert.Equal(["1.sql", "3.sql"], executor.Calls.Select(c => c.ScriptName));
+
+ time.Advance(TimeSpan.FromSeconds(10));
+ run = await Until(b.Id, r => StatusOf(r, "1.sql") == ScriptStatus.Succeeded);
+ Assert.Equal(ScriptStatus.Running, StatusOf(run, "4.sql"));
+ var a = Assert.Single(run.Scripts[0].Attempts);
+ Assert.Equal((time.GetUtcNow() - TimeSpan.FromSeconds(10), time.GetUtcNow(), 5L), (a.Start, a.End, a.Rows));
+
+ time.Advance(TimeSpan.FromSeconds(10));
+ run = await Until(b.Id, r => r.Scripts.All(s => s.Status == ScriptStatus.Succeeded));
+ Assert.Empty(run.Queue);
+ }
+
+ [Fact]
+ public async Task A_limit_of_1_gives_an_ordered_run()
+ {
+ var b = NewBatch(1, "a.sql", "b.sql", "c.sql");
+ Script("a.sql", 5); Script("b.sql", 5); Script("c.sql", 5);
+
+ list.Run(b.Id);
+ for (var n = 0; n < 3; n++)
+ {
+ var run = await Until(b.Id, r => r.Scripts.Count(s => s.Status == ScriptStatus.Running) == 1 && r.Scripts.Count(s => s.Status == ScriptStatus.Succeeded) == n);
+ Assert.Equal(1, run.Scripts.Count(s => s.Status == ScriptStatus.Running));
+ time.Advance(TimeSpan.FromSeconds(5));
+ }
+
+ await Until(b.Id, r => !r.Active);
+ Assert.Equal(["a.sql", "b.sql", "c.sql"], executor.Calls.Select(c => c.ScriptName));
+ }
+
+ [Fact]
+ public async Task Each_script_reads_its_file_when_it_starts_and_a_missing_file_fails()
+ {
+ var b = NewBatch(1, "a.sql", "b.sql", "c.sql");
+ Script("a.sql", 5);
+
+ list.Run(b.Id);
+ Write("b.sql", "select 'fixed'");
+ File.Delete(Path.Combine(root, "c.sql"));
+ time.Advance(TimeSpan.FromSeconds(5));
+
+ var run = await Until(b.Id, r => !r.Active);
+ Assert.Equal(["select 'fixed'"], executor.Calls.Single(c => c.ScriptName == "b.sql").Commands);
+ Assert.Equal(ScriptStatus.Failed, StatusOf(run, "c.sql"));
+ Assert.Equal("File not found", run.Scripts.Single(s => s.Path == "c.sql").Attempts.Single().Error);
+ Assert.DoesNotContain(executor.Calls, c => c.ScriptName == "c.sql");
+ }
+
+ [Fact]
+ public void Run_scans_again_and_a_new_file_goes_at_the_bottom_with_its_check_box_set()
+ {
+ var b = NewBatch(4, "2.sql", "3.sql");
+ list.SetInclude(b.Id, "3.sql", false);
+ Write("1.sql");
+
+ b = list.Run(b.Id)!;
+
+ Assert.Equal([new ScriptEntry("2.sql", true), new ScriptEntry("3.sql", false), new ScriptEntry("1.sql", true)], b.Scripts);
+ Assert.Equal(["2.sql", "1.sql"], b.Run!.Scripts.Select(s => s.Path));
+ }
+
+ [Fact]
+ public async Task The_run_locks_the_settings_and_the_check_boxes_and_Run_until_it_ends()
+ {
+ var b = NewBatch(4, "a.sql");
+ Script("a.sql", 5);
+ list.Run(b.Id);
+
+ Assert.Equal(4, list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 8 })!.Settings.ParallelLimit);
+ Assert.True(list.SetInclude(b.Id, "a.sql", false)!.Scripts.Single().Include);
+ list.Run(b.Id);
+ Assert.Single(executor.Calls);
+
+ time.Advance(TimeSpan.FromSeconds(5));
+ await Until(b.Id, r => !r.Active);
+ Assert.Equal(8, list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 8 })!.Settings.ParallelLimit);
+ }
+
+ [Fact]
+ public void Many_batches_run_at_the_same_time_each_with_its_own_limit()
+ {
+ var one = NewBatch(1, "a.sql", "b.sql");
+ var two = NewBatch(2, "a.sql", "b.sql");
+ Script("a.sql", 5); Script("b.sql", 5);
+
+ Assert.Equal([ScriptStatus.Running, ScriptStatus.Queued], list.Run(one.Id)!.Run!.Scripts.Select(s => s.Status));
+ Assert.Equal([ScriptStatus.Running, ScriptStatus.Running], list.Run(two.Id)!.Run!.Scripts.Select(s => s.Status));
+ }
+
+ public void Dispose()
+ {
+ sub.Dispose();
+ Directory.Delete(root, recursive: true);
+ }
+}
From 008123ddba8120fec587d31f5f98244e3684c21d Mon Sep 17 00:00:00 2001
From: Hank <36976296+ravenbix@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:46:22 +0200
Subject: [PATCH 23/96] feat(gui): run a batch from the header and show its
scripts on the board
---
src/SqlRunboard.Gui/GuiHost.cs | 2 +
src/SqlRunboard.Gui/wwwroot/index.html | 25 +++++-
tests/SqlRunboard.Gui.Tests/BoardTests.cs | 97 +++++++++++++++++++++++
3 files changed, 121 insertions(+), 3 deletions(-)
create mode 100644 tests/SqlRunboard.Gui.Tests/BoardTests.cs
diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs
index 69a1cef..6fdd02c 100644
--- a/src/SqlRunboard.Gui/GuiHost.cs
+++ b/src/SqlRunboard.Gui/GuiHost.cs
@@ -69,6 +69,8 @@ public static async Task StartAsync(GuiHostOptions options)
: list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound());
app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) =>
list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound());
+ app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) =>
+ list.Run(id) is { } b ? Results.Ok(b) : Results.NotFound());
// The browser has no folder picker that gives a path, so the page browses the folders here.
app.MapPost("/api/folders", (FolderRequest r) =>
{
diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html
index 3979b30..869aa58 100644
--- a/src/SqlRunboard.Gui/wwwroot/index.html
+++ b/src/SqlRunboard.Gui/wwwroot/index.html
@@ -211,7 +211,16 @@
// ---------- server state ----------
// The server keeps all batch state. Each server batch merges into a page object that also keeps the view state (tab, selection, drawer).
// The page uses the flat settings of the prototype; the server uses the batch file shape (target is nested).
-const toView = sb => ({ ...sb, settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } });
+const toView = sb => ({ ...sb, run: toRun(sb.run), settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } });
+// The server run (ISO times) in the prototype shape: times are seconds from the start of the run.
+// run.t is "now" on the server clock: the page clock plus the offset that the last state gave (the tests use a fake server clock).
+function toRun(r) {
+ if (!r) return null;
+ const t0 = Date.parse(r.start), sec = x => x == null ? null : (Date.parse(x) - t0) / 1000, skew = Date.parse(r.now) - Date.now(), rs = {};
+ for (const s of r.scripts) rs[s.path] = { status: s.status, msgs: [], attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) };
+ const ended = !r.scripts.some(s => s.status === 'Queued' || s.status === 'Running');
+ return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } };
+}
const fromView = s => ({ folder: s.folder || null, filters: s.filters, recurse: s.recurse, parallelLimit: +s.parallelLimit, stopOnFirstFailure: s.stopOnFirstFailure, confirmBeforeRun: s.confirmBeforeRun,
timeoutSeconds: s.timeoutSeconds === '' ? null : +s.timeoutSeconds,
target: { server: s.server, database: s.database, auth: s.auth, user: s.user, passwordVariable: s.passwordVariable, encrypt: s.encrypt, trustServerCertificate: s.trustServerCertificate, extraConnectionString: s.extra } });
@@ -257,7 +266,7 @@
// ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST.
const todo = () => toast('Not available yet.');
-const runBatch = todo, cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo;
+const cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo;
// Edits go to the server one at a time, in the order you make them.
let edits = Promise.resolve();
const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; });
@@ -265,6 +274,11 @@
b.saving = (b.saving ?? 0) + 1;
sendEdit(`/api/batches/${b.id}/settings`, fromView(b.settings)).then(sb => { if (--b.saving) return; if (sb) upsert(sb); render(); });
}
+async function runBatch(b) {
+ if (!b || b.missing || locked(b)) return;
+ const sb = await sendEdit(`/api/batches/${b.id}/run`, {});
+ if (sb) { upsert(sb); b.tab = 'run'; render(); }
+}
function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); }
async function browse(b, path) { // the folder dialog: the server lists the folders
try { openDialog({ kind: 'folder', b, ...await post('/api/folders', { path }) }); } catch { if (path) browse(b, null); else toast('The folder list is not available.'); }
@@ -609,7 +623,11 @@ Target
${txt('server', 'Server')}${txt('database', 'Database')}
function closeMenu(refocusBack = true) { const back = app.menu?.back; app.menu = null; document.getElementById('menu').innerHTML = ''; if (refocusBack) refocus(back); }
// ---------- commands ----------
-function selectBatch(id) { app.cur = id; closeMenu(false); render(); }
+function selectBatch(id) { // Files before the first run, Run after it
+ const b = app.batches.find(x => x.id === id);
+ if (b && id !== app.cur) b.tab = null;
+ app.cur = id; closeMenu(false); render();
+}
async function newBatch() { // Ctrl+N
let b;
try { b = upsert(await post('/api/batches')); } catch (e) { return toast(e.message); } // the SSE event can come before or after this
@@ -784,6 +802,7 @@ Target
${txt('server', 'Server')}${txt('database', 'Database')}
setDrawerWidth(app.drawerWidth);
render();
connect();
+setInterval(() => app.batches.some(b => b.run && !b.run.ended) && render({ tick: true }), 1000); // the elapsed time of Running scripts