From e5466b32a1eae8727199eda205443b36d1b640e0 Mon Sep 17 00:00:00 2001 From: Brandur Date: Fri, 6 Nov 2026 12:46:57 -0600 Subject: [PATCH 1/2] Align SQLite CLI URL forms across languages Accept sqlite:path and sqlite://path consistently in Go, Java, JavaScript, Ruby, and Rust, including :memory: and offline SQL export. Normalize Java URLs before JDBC dispatch and select the Ruby adapter's native URL form while retaining sqlite3: and jdbc:sqlite: aliases. Cover relative and absolute files, in-memory databases, and export without opening a database. Document the common forms and native aliases. --- CHANGELOG.md | 4 ++ cmd/river/rivercli/command.go | 10 ++- cmd/river/rivercli/river_cli.go | 2 +- cmd/river/rivercli/river_cli_test.go | 66 +++++++++++++++++++ docs/sqlite-urls.md | 23 +++++++ java/CHANGELOG.md | 4 ++ java/DIFFERENCES.md | 3 + java/README.md | 4 ++ .../com/riverqueue/cli/MigrationCliTest.java | 24 +++++++ .../main/java/com/riverqueue/Database.java | 7 +- js/CHANGELOG.md | 4 ++ js/cli/README.md | 6 +- js/cli/src/database.ts | 9 ++- js/cli/src/migrate-commands.ts | 2 +- js/cli/src/run.test.ts | 54 ++++++++++++--- ruby/CHANGELOG.md | 4 ++ ruby/docs/migrations.md | 10 +-- ruby/lib/migration_cli.rb | 19 +++++- ruby/spec/migration_cli_shared_examples.rb | 19 ++++++ ruby/spec/migration_export_spec.rb | 7 ++ rust/CHANGELOG.md | 4 ++ rust/riverqueue-cli/README.md | 4 ++ rust/riverqueue-cli/src/migrate_get.rs | 14 ++-- rust/riverqueue-cli/tests/migrate.rs | 21 ++++++ rust/riverqueue-cli/tests/migrate_get.rs | 35 ++++++---- 25 files changed, 316 insertions(+), 43 deletions(-) create mode 100644 docs/sqlite-urls.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 318cdea10..10612b783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Accept `sqlite:path` and `sqlite::memory:` in the CLI alongside the existing `sqlite://` forms, including offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.51.1] - 2026-11-05 ### Fixed diff --git a/cmd/river/rivercli/command.go b/cmd/river/rivercli/command.go index 317e96075..0da631189 100644 --- a/cmd/river/rivercli/command.go +++ b/cmd/river/rivercli/command.go @@ -70,9 +70,13 @@ func RunCommand[TOpts CommandOpts](ctx context.Context, bundle *RunCommandBundle protocol = "postgres" } else if bundle.DatabaseURL != nil { databaseURL = bundle.DatabaseURL - var ok bool - protocol, urlWithoutProtocol, ok = strings.Cut(*databaseURL, "://") - if !ok { + var hasProtocol bool + if sqlitePath, sqlite := strings.CutPrefix(*databaseURL, "sqlite:"); sqlite { + protocol, urlWithoutProtocol, hasProtocol = "sqlite", strings.TrimPrefix(sqlitePath, "//"), true + } else { + protocol, urlWithoutProtocol, hasProtocol = strings.Cut(*databaseURL, "://") + } + if !hasProtocol { return false, fmt.Errorf("expected database URL (`%s`) to be formatted like `postgres://...`", *bundle.DatabaseURL) } } diff --git a/cmd/river/rivercli/river_cli.go b/cmd/river/rivercli/river_cli.go index 224822269..17b78e63a 100644 --- a/cmd/river/rivercli/river_cli.go +++ b/cmd/river/rivercli/river_cli.go @@ -151,7 +151,7 @@ default. } addDatabaseURLFlag := func(cmd *cobra.Command, databaseURL *string) { - cmd.Flags().StringVar(databaseURL, "database-url", "", "URL of the database (should look like `postgres://...`)") + cmd.Flags().StringVar(databaseURL, "database-url", "", "URL of the database (postgres://..., sqlite://path, or sqlite:path)") } addLineFlag := func(cmd *cobra.Command, line *string) { cmd.Flags().StringVar(line, "line", "", "migration line to operate on (default: main)") diff --git a/cmd/river/rivercli/river_cli_test.go b/cmd/river/rivercli/river_cli_test.go index f8ce43714..0037cf727 100644 --- a/cmd/river/rivercli/river_cli_test.go +++ b/cmd/river/rivercli/river_cli_test.go @@ -8,6 +8,8 @@ import ( "fmt" "maps" "net/url" + "os" + "path/filepath" "runtime/debug" "strconv" "strings" @@ -544,6 +546,70 @@ SELECT 'up 1' FROM river_table `), strings.TrimSpace(out.String())) } +func TestBaseCommandSetSQLiteURLs(t *testing.T) { + t.Parallel() + + runFunc := func(t *testing.T, databaseURL string, args ...string) string { + t.Helper() + + var out bytes.Buffer + cli := NewCLI(&Config{Name: "River"}) + cli.SetOut(&out) + cmd := cli.BaseCommandSet() + cmd.SetArgs(append(args, "--database-url", databaseURL)) + require.NoError(t, cmd.Execute()) + return out.String() + } + + t.Run("DialectHint", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite:", "sqlite://"} { + sql := runFunc(t, databaseURL, "migrate-get", "--version", "1", "--up") + require.Contains(t, sql, "id integer PRIMARY KEY") + } + }) + + t.Run("File", func(t *testing.T) { + t.Parallel() + + for _, prefix := range []string{"sqlite:", "sqlite://"} { + t.Run(prefix, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "river.db") + workingDir, err := os.Getwd() + require.NoError(t, err) + relativePath, err := filepath.Rel(workingDir, path) + require.NoError(t, err) + databaseURL := prefix + relativePath + + sql := runFunc(t, databaseURL, "migrate-get", "--version", "1", "--up") + require.Contains(t, sql, "CREATE TABLE") + require.NoFileExists(t, path) + runFunc(t, databaseURL, "migrate-up") + require.FileExists(t, path) + runFunc(t, "sqlite://"+path, "validate") + }) + } + }) + + t.Run("Memory", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite::memory:", "sqlite://:memory:"} { + t.Run(databaseURL, func(t *testing.T) { + t.Parallel() + + sql := runFunc(t, databaseURL, "migrate-get", "--version", "1", "--up") + require.Contains(t, sql, "CREATE TABLE") + out := runFunc(t, databaseURL, "migrate-up") + require.Contains(t, out, "applied migration 008") + }) + } + }) +} + func TestMigrateGet(t *testing.T) { t.Parallel() diff --git a/docs/sqlite-urls.md b/docs/sqlite-urls.md new file mode 100644 index 000000000..efca9a4b0 --- /dev/null +++ b/docs/sqlite-urls.md @@ -0,0 +1,23 @@ +# SQLite CLI URLs + +The Go, Java, JavaScript, Ruby, and Rust migration CLIs accept the same SQLite +prefixes. The `//` after `sqlite:` is optional: + +| Database | Long form | Short form | +| --- | --- | --- | +| Relative file | `sqlite://river.db` | `sqlite:river.db` | +| Absolute file | `sqlite:///var/lib/app/river.db` | `sqlite:/var/lib/app/river.db` | +| In memory | `sqlite://:memory:` | `sqlite::memory:` | + +Relative paths are resolved from the command's working directory. An in-memory +database lasts only for that invocation; use a file to share a database between +migration commands and application processes. + +For `migrate-get`, `--database-url` only selects the SQL dialect. Both `sqlite:` +and `sqlite://` work without a path, and export never opens or creates a database. + +Ruby also accepts `sqlite3:` and `sqlite3://` with either Active Record or Sequel. +Java also accepts native `jdbc:sqlite:` URLs, passed unchanged to the JDBC driver. +These aliases are specific to those languages. Driver-specific URI options and +query parameters are not portable between implementations; for example, Rust's +`?mode=rwc` creates a missing database file. diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 9e2227fc5..48f0b6143 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Normalize `sqlite://` URLs before opening JDBC connections so relative paths remain relative and `sqlite://:memory:` opens an in-memory database. Preserve `sqlite:` and native `jdbc:sqlite:` URLs. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.4.0] - 2026-11-06 ### Added diff --git a/java/DIFFERENCES.md b/java/DIFFERENCES.md index 123daf41c..49c295994 100644 --- a/java/DIFFERENCES.md +++ b/java/DIFFERENCES.md @@ -10,6 +10,9 @@ - A `DataSource` and its pool belong to the application. `Database.connect` uses DriverManager without pooling; callers wanting pooled connections supply a data source. River-owned JDBC connections are closed after each transaction. +- SQLite connections accept the shared `sqlite:` and `sqlite://` forms, plus + native `jdbc:sqlite:` URLs passed unchanged to JDBC. See the + [SQLite URL reference](../docs/sqlite-urls.md) for the portable forms. - Caller-owned transactions use JDBC savepoints. Hooks run within those savepoints, so a failed River operation leaves the outer transaction usable. - Cancellation is cooperative and followed by interruption after the configured diff --git a/java/README.md b/java/README.md index 947bbe6e6..0b47adc8c 100644 --- a/java/README.md +++ b/java/README.md @@ -88,6 +88,10 @@ for each operation. River never closes an application-owned data source. An ## Migration CLI +SQLite connections accept `sqlite://path` and `sqlite:path`, as well as native +`jdbc:sqlite:` URLs. See the shared [SQLite URL reference](../docs/sqlite-urls.md) +for relative paths and in-memory databases. + The CLI runs with Java 21 or newer. [JBang](https://www.jbang.dev/documentation/jbang/latest/installation.html) resolves the thin CLI JAR and its dependencies and installs a reusable command: diff --git a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java index f4fa310b7..042e2f9bc 100644 --- a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java +++ b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java @@ -117,6 +117,30 @@ void operationFailureHasNonzeroExit() { assertTrue(result.out().isEmpty()); } + @Test + void sqliteUrlForms() { + for (String prefix : List.of("sqlite:", "sqlite://", "jdbc:sqlite:")) { + var hint = run(Map.of(), "migrate-get", "--database-url", prefix, "--version", "1", "--up"); + assertEquals(0, hint.exit(), hint.err()); + assertTrue(hint.out().contains("id integer PRIMARY KEY")); + Path file = directory.resolve("forms-" + prefix.length() + ".db"); + String relative = Path.of("").toAbsolutePath().relativize(file).toString(); + String url = prefix + relative; + var exported = run(Map.of(), "migrate-get", "--database-url", url, "--version", "1", "--up"); + assertEquals(0, exported.exit(), exported.err()); + assertTrue(exported.out().contains("id integer PRIMARY KEY")); + assertFalse(java.nio.file.Files.exists(file)); + var up = run(Map.of(), "migrate-up", "--database-url", url); + assertEquals(0, up.exit(), up.err()); + assertTrue(java.nio.file.Files.exists(file)); + var listed = run(Map.of(), "migrate-list", "--database-url", "sqlite://" + file); + assertEquals(0, listed.exit(), listed.err()); + assertTrue(listed.out().contains("008 applied"), listed.out()); + var memory = run(Map.of(), "migrate-list", "--database-url", prefix + ":memory:"); + assertEquals(0, memory.exit(), memory.err()); + } + } + @Test void targetsLimitsAndDryRunsPreserveExpectedHistory() { var environment = Map.of("DATABASE_URL", "jdbc:sqlite:" + directory.resolve("river.db")); diff --git a/java/river/src/main/java/com/riverqueue/Database.java b/java/river/src/main/java/com/riverqueue/Database.java index a7fc047bd..380193e16 100644 --- a/java/river/src/main/java/com/riverqueue/Database.java +++ b/java/river/src/main/java/com/riverqueue/Database.java @@ -41,7 +41,12 @@ public static Database connect(String url) { public static Database connect(String url, String applicationName) { if (url.startsWith("sqlite:") || url.startsWith("jdbc:sqlite:")) { - String jdbc = url.startsWith("jdbc:") ? url : "jdbc:" + url; + String jdbc; + if (url.startsWith("jdbc:")) jdbc = url; + else { + String path = url.substring("sqlite:".length()); + jdbc = "jdbc:sqlite:" + (path.startsWith("//") ? path.substring(2) : path); + } return new Database(() -> DriverManager.getConnection(jdbc), Dialect.SQLITE, ""); } String jdbc; diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 095fca8c7..f810f4807 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Accept `sqlite:path` and `sqlite::memory:` in the CLI alongside the existing `sqlite://` forms, including offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.3.1] - 2026-11-05 ### Fixed diff --git a/js/cli/README.md b/js/cli/README.md index 1598de306..8d8e18e84 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -28,8 +28,10 @@ Run `npx riverqueue --help` for every command, and - `postgres://…` or `postgresql://…` for Postgres. Without `--database-url`, Postgres commands use the standard `PG*` environment variables when `PGDATABASE` is set. -- `sqlite://PATH` for SQLite, for example `sqlite:///var/lib/app/river.db` - for an absolute path or `sqlite://river.db` for a relative one. +- `sqlite://PATH` or `sqlite:PATH` for SQLite, for example + `sqlite:///var/lib/app/river.db` for an absolute path or `sqlite://river.db` + for a relative one. `sqlite::memory:` and `sqlite://:memory:` use an in-memory + database. See the shared [SQLite URL reference](../../docs/sqlite-urls.md). Other common flags: diff --git a/js/cli/src/database.ts b/js/cli/src/database.ts index 942ce05df..e4ce064d2 100644 --- a/js/cli/src/database.ts +++ b/js/cli/src/database.ts @@ -43,7 +43,8 @@ const SQLITE_BUSY_TIMEOUT_MS = 5_000; * * `postgres://` and `postgresql://` URLs select Postgres. `sqlite://PATH` * selects SQLite, where `PATH` is a file path (`sqlite:///abs/river.db` or - * `sqlite://relative/river.db`), `:memory:`, or a `file:` URL. Without a URL, + * `sqlite://relative/river.db`), `:memory:`, or a `file:` URL. The `//` is + * optional: `sqlite:relative/river.db` and `sqlite::memory:` also work. Without a URL, * Postgres is configured from `PG*` environment variables when * `PGDATABASE` is set, as node-postgres and River's Go CLI do. */ @@ -63,13 +64,15 @@ export function parseDatabaseUrl( ); } - const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase(); + const scheme = /^sqlite:/i.test(url) + ? "sqlite" + : /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase(); switch (scheme) { case "postgres": case "postgresql": return { backend: "postgres", connectionString: url }; case "sqlite": { - const path = url.slice("sqlite://".length); + const path = url.slice("sqlite:".length).replace(/^\/\//, ""); if (path.length === 0) { throw new UsageError( "a SQLite --database-url needs a path, such as sqlite:///var/lib/river.db", diff --git a/js/cli/src/migrate-commands.ts b/js/cli/src/migrate-commands.ts index 9d2513a42..9bd9358ad 100644 --- a/js/cli/src/migrate-commands.ts +++ b/js/cli/src/migrate-commands.ts @@ -457,9 +457,9 @@ function sqlDialect( url: string | undefined ): MigrationBackend { if (url === undefined) return "postgres"; + if (/^sqlite:/i.test(url)) return "sqlite"; const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase(); if (scheme === "postgres" || scheme === "postgresql") return "postgres"; - if (scheme === "sqlite") return "sqlite"; throw new UsageError( "--database-url must start with postgres://, postgresql://, or sqlite://", command diff --git a/js/cli/src/run.test.ts b/js/cli/src/run.test.ts index ffff46908..a901e2f8b 100644 --- a/js/cli/src/run.test.ts +++ b/js/cli/src/run.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; import type { Migration, MigrationBackend } from "@riverqueue/migrate"; @@ -170,10 +170,6 @@ describe("argument errors", () => { ["migrate-up", "--database-url", "sqlite://"], "a SQLite --database-url needs a path", ], - [ - ["migrate-up", "--database-url", "sqlite::memory:"], - "--database-url must start with", - ], [ [ "migrate-up", @@ -359,7 +355,13 @@ describe("migrate-get", () => { expect(headers).toEqual(["007", "006", "005", "004", "003", "002"]); }); - it("prints SQLite SQL when the URL selects SQLite", async () => { + it.each([ + "sqlite:", + "sqlite://", + "sqlite::memory:", + "sqlite://:memory:", + "sqlite:unused.db", + ])("prints SQLite SQL for %s", async (url) => { const postgres = await invoke(["migrate-get", "--version", "2", "--up"]); const sqlite = await invoke([ "migrate-get", @@ -367,7 +369,7 @@ describe("migrate-get", () => { "2", "--up", "--database-url", - "sqlite://", + url, ]); expect(sqlite.exitCode).toBe(0); @@ -428,6 +430,42 @@ describe("SQLite migrations", () => { rmSync(directory, { force: true, recursive: true }); }); + it.each(["sqlite:", "sqlite://"])( + "opens the same file with %s and its alternate form", + async (prefix) => { + const path = join(directory, "forms.db"); + const relativeUrl = `${prefix}${relative(process.cwd(), path)}`; + const exported = await invoke([ + "migrate-get", + "--version", + "1", + "--up", + "--database-url", + relativeUrl, + ]); + expect(exported.exitCode).toBe(0); + expect(existsSync(path)).toBe(false); + const up = await invoke(["migrate-up", "--database-url", relativeUrl]); + expect(up.exitCode, up.stderr).toBe(0); + expect(existsSync(path)).toBe(true); + const validated = await invoke([ + "validate", + "--database-url", + `sqlite://${path}`, + ]); + expect(validated.exitCode, validated.stderr).toBe(0); + } + ); + + it.each(["sqlite::memory:", "sqlite://:memory:"])( + "migrates an in-memory database with %s", + async (url) => { + const result = await invoke(["migrate-up", "--database-url", url]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("applied migration 008"); + } + ); + it("migrates up, lists, validates, and migrates down", async () => { const missing = await invoke(["validate", "--database-url", url]); expect(missing).toEqual({ diff --git a/ruby/CHANGELOG.md b/ruby/CHANGELOG.md index 02511619f..c6f16029c 100644 --- a/ruby/CHANGELOG.md +++ b/ruby/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Accept `sqlite:` and `sqlite://` URLs with both Active Record and Sequel, including `:memory:` databases, while preserving the `sqlite3:` aliases. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.16.0] - 2026-11-04 ### Added diff --git a/ruby/docs/migrations.md b/ruby/docs/migrations.md index fd9451e4b..cb3dc91e4 100644 --- a/ruby/docs/migrations.md +++ b/ruby/docs/migrations.md @@ -18,12 +18,14 @@ Create the database first, then run: bundle exec river migrate-status --database-url postgres://localhost/my_app bundle exec river migrate-up --database-url postgres://localhost/my_app -# SQLite, using Sequel: +# SQLite, using either driver: bundle exec river migrate-up --database-url sqlite://storage/river.sqlite3 ``` -`DATABASE_URL` supplies the URL when `--database-url` is omitted. ActiveRecord -uses `sqlite3:` URLs instead of Sequel's `sqlite:` URLs. To target an existing +`DATABASE_URL` supplies the URL when `--database-url` is omitted. Both drivers +accept `sqlite://path`, `sqlite:path`, and the `sqlite3:` aliases. For an in-memory +database, use `sqlite::memory:` or `sqlite://:memory:`. See the shared +[SQLite URL reference](../../docs/sqlite-urls.md) for path handling. To target an existing Postgres schema, pass `--schema jobs`; otherwise the connection's current schema is used. Schema names must be simple SQL identifiers. SQLite always uses its main schema. @@ -91,7 +93,7 @@ migration-tracking table and can usually be omitted with another framework. Preserve version boundaries and apply each migration in its own transaction. Export defaults to Postgres and ignores `DATABASE_URL`. Use -`--database-url sqlite://` (or `sqlite3://`) to select SQLite SQL; the URL is only +`--database-url sqlite:` or `sqlite://` (including the `sqlite3:` aliases) to select SQLite SQL; the URL is only a dialect hint. `--schema jobs` qualifies Postgres objects with that schema. Without it, objects are unqualified. Schema placeholders are always substituted. diff --git a/ruby/lib/migration_cli.rb b/ruby/lib/migration_cli.rb index 5cfd2f962..d0b1544a1 100644 --- a/ruby/lib/migration_cli.rb +++ b/ruby/lib/migration_cli.rb @@ -57,15 +57,30 @@ def self.run(argv, err: $stderr, out: $stdout) end adapter = detect_driver + sqlite_path = url.sub(/\Asqlite3?:/i, "").delete_prefix("//") if url.match?(/\Asqlite3?:/i) if adapter == "activerecord" require "riverqueue-activerecord" active_record_base = Object.const_get("ActiveRecord::Base") - active_record_base.establish_connection(url) + connection = if sqlite_path == ":memory:" + {adapter: "sqlite3", database: sqlite_path} + elsif sqlite_path + "sqlite3://#{sqlite_path}" + else + url + end + active_record_base.establish_connection(connection) driver = River::Driver.const_get(:ActiveRecord).new else require "riverqueue-sequel" - database = Object.const_get(:Sequel).connect(url) + connection = if sqlite_path == ":memory:" + {adapter: "sqlite", database: sqlite_path} + elsif sqlite_path + "sqlite://#{sqlite_path}" + else + url + end + database = Object.const_get(:Sequel).connect(connection) driver = River::Driver.const_get(:Sequel).new(database) end diff --git a/ruby/spec/migration_cli_shared_examples.rb b/ruby/spec/migration_cli_shared_examples.rb index 427a4f641..9cb4eb586 100644 --- a/ruby/spec/migration_cli_shared_examples.rb +++ b/ruby/spec/migration_cli_shared_examples.rb @@ -5,6 +5,25 @@ require_relative "../lib/migration_cli" RSpec.shared_examples "migration command" do |adapter| + %w[sqlite: sqlite:// sqlite3: sqlite3://].each do |prefix| + it "accepts #{prefix} file and memory URLs with #{adapter}", database: :sqlite do + Dir.mktmpdir("river-cli-urls-") do |directory| + path = File.join(directory, "river.sqlite3") + out = StringIO.new + err = StringIO.new + relative = Pathname.new(path).relative_path_from(Pathname.pwd) + url = "#{prefix}#{relative}" + + expect(River::MigrationCLI.run(["migrate-get", "--database-url", url, "--version", "1", "--up"], err: err, out: out)).to eq(0), err.string + expect(File.exist?(path)).to be(false) + expect(River::MigrationCLI.run(["migrate-up", "--database-url", url], err: err, out: out)).to eq(0), err.string + expect(File.exist?(path)).to be(true) + expect(River::MigrationCLI.run(["validate", "--database-url", "sqlite://#{path}"], err: err, out: out)).to eq(0), err.string + expect(River::MigrationCLI.run(["migrate-up", "--database-url", "#{prefix}:memory:"], err: err, out: out)).to eq(0), err.string + end + end + end + it "supports help and reports usage errors" do out = StringIO.new err = StringIO.new diff --git a/ruby/spec/migration_export_spec.rb b/ruby/spec/migration_export_spec.rb index a8fbbdfd5..b5bf7d489 100644 --- a/ruby/spec/migration_export_spec.rb +++ b/ruby/spec/migration_export_spec.rb @@ -39,6 +39,13 @@ def versions expect(out.string).to eq("") end + %w[sqlite: sqlite:// sqlite::memory: sqlite://:memory: sqlite3: sqlite3:// sqlite3::memory: sqlite3://:memory:].each do |url| + it "exports SQLite SQL for #{url}" do + expect(run("--version", "1", "--up", "--database-url", url)).to eq(0), err.string + expect(out.string).to include("id integer PRIMARY KEY") + end + end + it "exports selected versions in supplied order with schema substitution" do expect(run("--version", "3,1", "--version", "2", "--up", "--schema", "jobs")).to eq(0), err.string expect(versions).to eq([3, 1, 2]) diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index fa43bf6a7..219196a36 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -10,6 +10,10 @@ Changes to River for Go are recorded in the [repository changelog](../CHANGELOG. ## [Unreleased] +### Fixed + +- Accept `sqlite:` URL hints in offline migration SQL export, matching migration commands and preserving the existing `sqlite://` forms. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.6.0] - 2026-11-05 ### Added diff --git a/rust/riverqueue-cli/README.md b/rust/riverqueue-cli/README.md index 80b0e3dea..bfe4f4df6 100644 --- a/rust/riverqueue-cli/README.md +++ b/rust/riverqueue-cli/README.md @@ -18,6 +18,10 @@ riverqueue migrate-up --database-url postgres://localhost/app --schema river riverqueue migrate-up --database-url sqlite://app.sqlite3 ``` +SQLite URLs accept either `sqlite://` or `sqlite:` before the path, including +`sqlite://:memory:` and `sqlite::memory:`. Add `?mode=rwc` to create a missing +file. See the shared [SQLite URL reference](../../docs/sqlite-urls.md). + `migrate-down`, `migrate-list`, and `validate` take the same connection options. `--target-version N`, `--max-steps N`, and `--dry-run` limit or preview a migration run. Applications can instead migrate from Rust with the diff --git a/rust/riverqueue-cli/src/migrate_get.rs b/rust/riverqueue-cli/src/migrate_get.rs index a78fbc5a1..48a5310c3 100644 --- a/rust/riverqueue-cli/src/migrate_get.rs +++ b/rust/riverqueue-cli/src/migrate_get.rs @@ -30,7 +30,7 @@ Options: Pass exactly one of --all or --version, and exactly one of --up or --down. Explicit versions retain their supplied order, including for down migrations. No database connection is made. DATABASE_URL is ignored; use --database-url -to select Postgres (postgres:// or postgresql://) or SQLite (sqlite://). +to select Postgres (postgres:// or postgresql://) or SQLite (sqlite: or sqlite://). Only the main migration line is bundled. Examples: @@ -120,6 +120,12 @@ pub(crate) fn run( } fn all_versions(database_url: &str) -> Result<&'static [Migration], Box> { + if database_url.starts_with("sqlite:") { + #[cfg(feature = "sqlite")] + return Ok(SqliteMigrator::all_versions()); + #[cfg(not(feature = "sqlite"))] + return Err("SQLite support requires the `sqlite` feature".into()); + } match database_url.split_once("://").map(|(scheme, _)| scheme) { Some("postgres" | "postgresql") => { #[cfg(feature = "postgres")] @@ -127,12 +133,6 @@ fn all_versions(database_url: &str) -> Result<&'static [Migration], Box { - #[cfg(feature = "sqlite")] - return Ok(SqliteMigrator::all_versions()); - #[cfg(not(feature = "sqlite"))] - return Err("SQLite support requires the `sqlite` feature".into()); - } _ => { Err("--database-url must use a postgres://, postgresql://, or sqlite:// prefix".into()) } diff --git a/rust/riverqueue-cli/tests/migrate.rs b/rust/riverqueue-cli/tests/migrate.rs index 32d00c33a..e482fb7a0 100644 --- a/rust/riverqueue-cli/tests/migrate.rs +++ b/rust/riverqueue-cli/tests/migrate.rs @@ -56,6 +56,27 @@ async fn sqlite_migration_output() { std::fs::remove_file(path).unwrap(); } +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_url_forms() { + for url in ["sqlite::memory:", "sqlite://:memory:"] { + let output = success(run(url, None, &["migrate-up"])); + assert!(output.contains("applied migration 008")); + } + let path = std::path::PathBuf::from(format!( + "river-cli-forms-{}-{}.db", + std::process::id(), + nonce() + )); + let short = format!("sqlite:{}?mode=rwc", path.display()); + let absolute = std::env::current_dir().unwrap().join(&path); + let long = format!("sqlite://{}?mode=rwc", absolute.display()); + success(run(&short, None, &["migrate-up"])); + assert!(path.exists()); + success(run(&long, None, &["validate"])); + std::fs::remove_file(path).unwrap(); +} + fn assert_listing(command: &impl Fn(&[&str]) -> Output, current: Option) { let names = [ "create_river_migration", diff --git a/rust/riverqueue-cli/tests/migrate_get.rs b/rust/riverqueue-cli/tests/migrate_get.rs index ae87052ee..ae5b91f7d 100644 --- a/rust/riverqueue-cli/tests/migrate_get.rs +++ b/rust/riverqueue-cli/tests/migrate_get.rs @@ -79,28 +79,41 @@ fn sqlite_does_not_create_database() { .unwrap() .as_nanos() )); - let database_url = format!("sqlite://{}?mode=rwc", path.display()); - let output = success(run(&database_url, &["--version", "1", "--up"])); - assert!(output.contains("id integer PRIMARY KEY")); - assert!(!path.exists(), "export must not create a database file"); + for prefix in ["sqlite:", "sqlite://"] { + let database_url = format!("{prefix}{}?mode=rwc", path.display()); + let output = success(run(&database_url, &["--version", "1", "--up"])); + assert!(output.contains("id integer PRIMARY KEY")); + assert!(!path.exists(), "export must not create a database file"); + } } #[cfg(feature = "sqlite")] #[test] fn sqlite_export() { - exercise_export( + for url in [ + "sqlite:", "sqlite://", - riverqueue_migrate::SqliteMigrator::all_versions(), - ); + "sqlite::memory:", + "sqlite://:memory:", + ] { + exercise_export(url, riverqueue_migrate::SqliteMigrator::all_versions()); + } } #[cfg(not(feature = "sqlite"))] #[test] fn sqlite_requires_feature() { - failure( - run("sqlite://", &["--all", "--up"]), - "SQLite support requires the `sqlite` feature", - ); + for url in [ + "sqlite:", + "sqlite://", + "sqlite::memory:", + "sqlite://:memory:", + ] { + failure( + run(url, &["--all", "--up"]), + "SQLite support requires the `sqlite` feature", + ); + } } fn command(arguments: &[&str]) -> Command { From 9c435d993f84bafc67fbe0809eea56ce30cd9d1f Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 9 Nov 2026 12:47:17 -0600 Subject: [PATCH 2/2] Reject empty SQLite paths and fix CLI URL regressions Preserve relative paths with Active Record, migrate short-form memory databases before benchmarking, and reject empty paths in commands that connect to SQLite. Keep bare dialect hints working for offline export across all languages, and clarify upgrade and URL portability notes. --- CHANGELOG.md | 1 + cmd/river/rivercli/command.go | 4 +++ cmd/river/rivercli/river_cli.go | 2 +- cmd/river/rivercli/river_cli_test.go | 30 +++++++++++++++++++ docs/sqlite-urls.md | 12 ++++++-- java/CHANGELOG.md | 2 ++ .../java/com/riverqueue/cli/MigrationCli.java | 2 ++ .../com/riverqueue/cli/MigrationCliTest.java | 12 ++++++++ js/cli/src/run.test.ts | 15 ++++++++++ ruby/CHANGELOG.md | 1 + ruby/lib/migration_cli.rb | 5 ++-- ruby/spec/migration_cli_shared_examples.rb | 17 ++++++++++- rust/CHANGELOG.md | 1 + rust/riverqueue-cli/src/migrate.rs | 3 ++ rust/riverqueue-cli/tests/migrate.rs | 14 +++++++++ 15 files changed, 114 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d8b47ff..5d640ff9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Accept `sqlite:path` and `sqlite::memory:` in the CLI alongside the existing `sqlite://` forms, including offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). - Fixed `JobListParams.Where` writing into the first `NamedArgs` it's given when merging multiple sets of named arguments, which could leak arguments into a map the caller reused for another query. It also no longer panics when the first set is `nil`. [PR #1575](https://github.com/riverqueue/river/pull/1575). ## [0.51.1] - 2026-11-05 diff --git a/cmd/river/rivercli/command.go b/cmd/river/rivercli/command.go index 0da631189..fcdcdf7a1 100644 --- a/cmd/river/rivercli/command.go +++ b/cmd/river/rivercli/command.go @@ -99,6 +99,10 @@ func RunCommand[TOpts CommandOpts](ctx context.Context, bundle *RunCommandBundle } case "sqlite": + if _, isMigrateGet := any(command).(*migrateGet); urlWithoutProtocol == "" && !isMigrateGet { + return false, fmt.Errorf("SQLite database URL (`%s`) needs a path or `:memory:`", *databaseURL) + } + dbPool, err := openSQLitePool(protocol, urlWithoutProtocol) if err != nil { return false, err diff --git a/cmd/river/rivercli/river_cli.go b/cmd/river/rivercli/river_cli.go index 17b78e63a..6e18dd6f4 100644 --- a/cmd/river/rivercli/river_cli.go +++ b/cmd/river/rivercli/river_cli.go @@ -412,7 +412,7 @@ func (c *bench) Run(ctx context.Context, opts *benchOpts) (bool, error) { // In the special case of a purely ephemeral memory database, migrate // the schema up to make sure it's fully prepared for use. (Otherwise // there's no way to raise it.) - if parsedDatabaseURL.Host == ":memory:" { + if parsedDatabaseURL.Host == ":memory:" || parsedDatabaseURL.Opaque == ":memory:" { migrator, err := c.DriverProcurer.GetMigrator(&rivermigrate.Config{ Schema: opts.Schema, }) diff --git a/cmd/river/rivercli/river_cli_test.go b/cmd/river/rivercli/river_cli_test.go index 0037cf727..b1fe3929b 100644 --- a/cmd/river/rivercli/river_cli_test.go +++ b/cmd/river/rivercli/river_cli_test.go @@ -561,6 +561,18 @@ func TestBaseCommandSetSQLiteURLs(t *testing.T) { return out.String() } + t.Run("BenchMemory", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite::memory:", "sqlite://:memory:"} { + t.Run(databaseURL, func(t *testing.T) { + t.Parallel() + + runFunc(t, databaseURL, "bench", "--num-total-jobs", "10") + }) + } + }) + t.Run("DialectHint", func(t *testing.T) { t.Parallel() @@ -570,6 +582,24 @@ func TestBaseCommandSetSQLiteURLs(t *testing.T) { } }) + t.Run("EmptyPathRejected", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite:", "sqlite://"} { + for _, command := range []string{"bench", "migrate-down", "migrate-list", "migrate-up", "validate"} { + t.Run(databaseURL+"/"+command, func(t *testing.T) { + t.Parallel() + + cli := NewCLI(&Config{Name: "River"}) + cli.SetOut(&bytes.Buffer{}) + cmd := cli.BaseCommandSet() + cmd.SetArgs([]string{command, "--database-url", databaseURL}) + require.ErrorContains(t, cmd.Execute(), "needs a path or `:memory:`") + }) + } + } + }) + t.Run("File", func(t *testing.T) { t.Parallel() diff --git a/docs/sqlite-urls.md b/docs/sqlite-urls.md index efca9a4b0..faf9b6404 100644 --- a/docs/sqlite-urls.md +++ b/docs/sqlite-urls.md @@ -13,11 +13,17 @@ Relative paths are resolved from the command's working directory. An in-memory database lasts only for that invocation; use a file to share a database between migration commands and application processes. +Commands that connect to a database reject an empty SQLite path. Use an explicit +`:memory:` path if you want a temporary database. + For `migrate-get`, `--database-url` only selects the SQL dialect. Both `sqlite:` and `sqlite://` work without a path, and export never opens or creates a database. Ruby also accepts `sqlite3:` and `sqlite3://` with either Active Record or Sequel. Java also accepts native `jdbc:sqlite:` URLs, passed unchanged to the JDBC driver. -These aliases are specific to those languages. Driver-specific URI options and -query parameters are not portable between implementations; for example, Rust's -`?mode=rwc` creates a missing database file. +These aliases are specific to those languages. Query parameters and +percent-encoding are handled differently by each language and can select a +different file, or turn an in-memory database into an on-disk file. For portable +URLs, avoid `?` and `%` in paths, and don't add parameters to `:memory:`. +Driver-specific URI options are not portable; for example, Rust's `?mode=rwc` +creates a missing database file. diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 48f0b6143..f70e58122 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Normalize `sqlite://` URLs before opening JDBC connections so relative paths remain relative and `sqlite://:memory:` opens an in-memory database. Preserve `sqlite:` and native `jdbc:sqlite:` URLs. [PR #1588](https://github.com/riverqueue/river/pull/1588). + - **Upgrade note:** `sqlite://var/lib/x.db` now opens `./var/lib/x.db` instead of `/var/lib/x.db`. Use `sqlite:///var/lib/x.db` to keep targeting the absolute path. +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). ## [0.4.0] - 2026-11-06 diff --git a/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java b/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java index bdee00485..8fdfa07f5 100644 --- a/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java +++ b/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java @@ -46,6 +46,8 @@ private static Database database( && !url.startsWith("sqlite:") && !url.startsWith("jdbc:sqlite:")) throw new Usage("Expected a Postgres URL or a SQLite JDBC URL"); + if (!offline && Set.of("sqlite:", "sqlite://", "jdbc:sqlite:").contains(url)) + throw new Usage("SQLite database URL needs a path or `:memory:`"); Database database; try { database = diff --git a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java index 042e2f9bc..af4cc2757 100644 --- a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java +++ b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java @@ -117,6 +117,18 @@ void operationFailureHasNonzeroExit() { assertTrue(result.out().isEmpty()); } + @Test + void sqliteEmptyPathsAreRejected() { + for (String url : List.of("sqlite:", "sqlite://", "jdbc:sqlite:")) { + for (String command : List.of("migrate-down", "migrate-list", "migrate-up")) { + var result = run(Map.of(), command, "--database-url", url); + assertEquals(2, result.exit(), result.err()); + assertTrue(result.err().contains("needs a path or `:memory:`"), result.err()); + assertTrue(result.out().isEmpty()); + } + } + } + @Test void sqliteUrlForms() { for (String prefix : List.of("sqlite:", "sqlite://", "jdbc:sqlite:")) { diff --git a/js/cli/src/run.test.ts b/js/cli/src/run.test.ts index a901e2f8b..f2f1883fb 100644 --- a/js/cli/src/run.test.ts +++ b/js/cli/src/run.test.ts @@ -430,6 +430,21 @@ describe("SQLite migrations", () => { rmSync(directory, { force: true, recursive: true }); }); + it.each(["sqlite:", "sqlite://"])("rejects empty %s paths", async (url) => { + for (const command of [ + "migrate-down", + "migrate-list", + "migrate-up", + "validate", + ]) { + const result = await invoke([command, "--database-url", url]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("a SQLite --database-url needs a path"); + expect(result.stdout).toBe(""); + } + }); + it.each(["sqlite:", "sqlite://"])( "opens the same file with %s and its alternate form", async (prefix) => { diff --git a/ruby/CHANGELOG.md b/ruby/CHANGELOG.md index c6f16029c..90b62e6c2 100644 --- a/ruby/CHANGELOG.md +++ b/ruby/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Accept `sqlite:` and `sqlite://` URLs with both Active Record and Sequel, including `:memory:` databases, while preserving the `sqlite3:` aliases. [PR #1588](https://github.com/riverqueue/river/pull/1588). +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). ## [0.16.0] - 2026-11-04 diff --git a/ruby/lib/migration_cli.rb b/ruby/lib/migration_cli.rb index d0b1544a1..86ed8353c 100644 --- a/ruby/lib/migration_cli.rb +++ b/ruby/lib/migration_cli.rb @@ -56,16 +56,17 @@ def self.run(argv, err: $stderr, out: $stdout) raise ArgumentError, "down migrations may delete data; pass --yes to confirm" end - adapter = detect_driver sqlite_path = url.sub(/\Asqlite3?:/i, "").delete_prefix("//") if url.match?(/\Asqlite3?:/i) + raise ArgumentError, "SQLite database URL needs a path or `:memory:`" if sqlite_path == "" + adapter = detect_driver if adapter == "activerecord" require "riverqueue-activerecord" active_record_base = Object.const_get("ActiveRecord::Base") connection = if sqlite_path == ":memory:" {adapter: "sqlite3", database: sqlite_path} elsif sqlite_path - "sqlite3://#{sqlite_path}" + "sqlite3:#{sqlite_path}" else url end diff --git a/ruby/spec/migration_cli_shared_examples.rb b/ruby/spec/migration_cli_shared_examples.rb index 9cb4eb586..eae5da314 100644 --- a/ruby/spec/migration_cli_shared_examples.rb +++ b/ruby/spec/migration_cli_shared_examples.rb @@ -7,7 +7,9 @@ RSpec.shared_examples "migration command" do |adapter| %w[sqlite: sqlite:// sqlite3: sqlite3://].each do |prefix| it "accepts #{prefix} file and memory URLs with #{adapter}", database: :sqlite do - Dir.mktmpdir("river-cli-urls-") do |directory| + # Keep the relative path below the working directory so a lost first + # segment cannot be hidden by a run of ../ components. + Dir.mktmpdir("river-cli-urls-", Dir.pwd) do |directory| path = File.join(directory, "river.sqlite3") out = StringIO.new err = StringIO.new @@ -22,6 +24,19 @@ expect(River::MigrationCLI.run(["migrate-up", "--database-url", "#{prefix}:memory:"], err: err, out: out)).to eq(0), err.string end end + + %w[migrate-down migrate-status migrate-up validate].each do |command| + it "rejects an empty #{prefix} path for #{command} with #{adapter}", database: :sqlite do + out = StringIO.new + err = StringIO.new + args = [command, "--database-url", prefix] + args << "--yes" if command == "migrate-down" + + expect(River::MigrationCLI.run(args, err: err, out: out)).to eq(1) + expect(err.string).to include("needs a path or `:memory:`") + expect(out.string).to be_empty + end + end end it "supports help and reports usage errors" do diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 219196a36..67433a78a 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -13,6 +13,7 @@ Changes to River for Go are recorded in the [repository changelog](../CHANGELOG. ### Fixed - Accept `sqlite:` URL hints in offline migration SQL export, matching migration commands and preserving the existing `sqlite://` forms. [PR #1588](https://github.com/riverqueue/river/pull/1588). +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). ## [0.6.0] - 2026-11-05 diff --git a/rust/riverqueue-cli/src/migrate.rs b/rust/riverqueue-cli/src/migrate.rs index eb3d1b6ba..c194a382b 100644 --- a/rust/riverqueue-cli/src/migrate.rs +++ b/rust/riverqueue-cli/src/migrate.rs @@ -239,6 +239,9 @@ fn parse_args( if args.database_url.is_empty() { args.database_url = database_url_env.ok_or("--database-url or DATABASE_URL is required")?; } + if matches!(args.database_url.as_str(), "sqlite:" | "sqlite://") { + return Err("SQLite database URL needs a path or `:memory:`".into()); + } Ok(Some(args)) } diff --git a/rust/riverqueue-cli/tests/migrate.rs b/rust/riverqueue-cli/tests/migrate.rs index e482fb7a0..e2278e730 100644 --- a/rust/riverqueue-cli/tests/migrate.rs +++ b/rust/riverqueue-cli/tests/migrate.rs @@ -34,6 +34,20 @@ async fn postgres_migration_output() { pool.close().await; } +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_empty_paths_are_rejected() { + for url in ["sqlite:", "sqlite://"] { + for command in ["migrate-down", "migrate-list", "migrate-up", "validate"] { + let output = run(url, None, &[command]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(String::from_utf8(output.stdout).unwrap(), ""); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains("needs a path or `:memory:`"), "{error}"); + } + } +} + #[cfg(feature = "sqlite")] #[tokio::test] async fn sqlite_migration_output() {