Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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
Expand Down
14 changes: 11 additions & 3 deletions cmd/river/rivercli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Accepting the short form here exposes a gap in bench. Its in-memory migration check at river_cli.go:415 only looks at parsedDatabaseURL.Host, but url.Parse("sqlite::memory:") leaves Host empty and puts :memory: in Opaque. So river bench --database-url sqlite::memory: skips the migration and fails with SQL logic error: no such table: river_job (1), while sqlite://:memory: works.

One-line fix in cmd/river/rivercli/river_cli.go:

-		if parsedDatabaseURL.Host == ":memory:" {
+		if parsedDatabaseURL.Host == ":memory:" || parsedDatabaseURL.Opaque == ":memory:" {

Suggested regression test, as the first subtest in TestBaseCommandSetSQLiteURLs in river_cli_test.go. The sqlite::memory: case fails on this branch and passes with the fix:

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")
		})
	}
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 9c435d99 by checking Opaque as well as Host. Added the benchmark regression test for both memory URL forms; the short form reproduced the missing-table error before the fix, and both now pass. Full Go make test and make lint also passed.

} else {
protocol, urlWithoutProtocol, hasProtocol = strings.Cut(*databaseURL, "://")
}
if !hasProtocol {
return false, fmt.Errorf("expected database URL (`%s`) to be formatted like `postgres://...`", *bundle.DatabaseURL)
}
}
Expand All @@ -95,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
Expand Down
4 changes: 2 additions & 2 deletions cmd/river/rivercli/river_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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,
})
Expand Down
96 changes: 96 additions & 0 deletions cmd/river/rivercli/river_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
Expand Down Expand Up @@ -544,6 +546,100 @@ 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("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()

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("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()

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()

Expand Down
29 changes: 29 additions & 0 deletions docs/sqlite-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 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.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Bare forms are documented only for migrate-get, but migration commands accept them too, and SQLite treats an empty path as a temporary database. So migrate-up --database-url sqlite: reports every migration as applied and exits 0 on Go, Sequel, Java, and Rust, with nothing saved. An unset variable in sqlite:$DB_PATH would hit this and look like a successful deploy step. Active Record raises a raw exception instead, and JavaScript rejects it with a usage error.

For Go, the bare sqlite: is new in this PR, while sqlite:// already behaved this way. Could every CLI reject an empty path except for migrate-get? A tested Go version, in RunCommand in command.go, though there may be a cleaner way to mark commands that don't need a database:

 			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)

Suggested regression test, as a subtest between DialectHint and File in TestBaseCommandSetSQLiteURLs. It fails on this branch and passes with the change above:

t.Run("EmptyPathRejected", func(t *testing.T) {
	t.Parallel()

	for _, databaseURL := range []string{"sqlite:", "sqlite://"} {
		t.Run(databaseURL, func(t *testing.T) {
			t.Parallel()

			cli := NewCLI(&Config{Name: "River"})
			cli.SetOut(&bytes.Buffer{})
			cmd := cli.BaseCommandSet()
			cmd.SetArgs([]string{"migrate-up", "--database-url", databaseURL})
			require.ErrorContains(t, cmd.Execute(), "needs a path")
		})
	}
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 9c435d99. Go, Java, Ruby, and Rust now reject empty SQLite paths before connecting; JavaScript already rejected them and now has matching regression coverage. Tests cover each supported migration command, Go's benchmark, Ruby's sqlite3: aliases, and Java's bare jdbc:sqlite: form. Bare prefixes remain valid for migrate-get, with export tests passing across all five languages. The shared URL reference now documents this rule.


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. 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.
6 changes: 6 additions & 0 deletions java/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ 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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Non-blocking, and probably low impact given how few Java users there are yet: sqlite://var/lib/x.db used to open the absolute /var/lib/x.db and now opens ./var/lib/x.db. Worth a brief mention here if you think anyone is relying on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Added an upgrade note to the Java changelog in 9c435d99: sqlite://var/lib/x.db now resolves to ./var/lib/x.db, and callers intending the absolute path should use sqlite:///var/lib/x.db.

- **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

### Added
Expand Down
3 changes: 3 additions & 0 deletions java/DIFFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
36 changes: 36 additions & 0 deletions java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,42 @@ 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:")) {
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"));
Expand Down
7 changes: 6 additions & 1 deletion java/river/src/main/java/com/riverqueue/Database.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions js/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions js/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
9 changes: 6 additions & 3 deletions js/cli/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion js/cli/src/migrate-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading