diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3dcfa9a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Working conventions + +These instructions apply to every contributor and coding harness. + +## Releases + +- Use one shared version for all four public modules: `github.com/jilio/ebu`, + `github.com/jilio/ebu/stores/sqlite`, `github.com/jilio/ebu/stores/durablestream`, + and `github.com/jilio/ebu/otel`. Never release a module independently. +- Publish the root tag `vX.Y.Z` and the corresponding `stores/sqlite/vX.Y.Z`, + `stores/durablestream/vX.Y.Z`, and `otel/vX.Y.Z` tags as one project-wide + release. Publish the root dependency before validating and publishing modules + that depend on its new version. +- Keep direct dependencies between these modules and the example modules' + dependency pins aligned with the shared release version. +- Compatible changes during `0.x` use a patch release. A minor release requires + a breaking change or an explicit owner decision; an additive API alone does + not justify a minor bump. +- Never rewrite or remove a published tag. +- Record changes in `CHANGELOG.md` and complete the formatting, tests, and + coverage checks required by [CLAUDE.md](CLAUDE.md) before opening a pull + request. Test each affected module; a root `go test ./...` does not cross + module boundaries. diff --git a/CHANGELOG.md b/CHANGELOG.md index c51301e..88718b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.20.1] - Unreleased + +### Fixed + +- Apply the same SQLite read-side page-cache, temporary-storage, memory-mapping, + and busy-timeout settings to every connection opened by `New` and + `OpenReadOnly`. Read-only connections retain their write protection and do + not request writer transaction locks or durability settings. +- Treat SQLite filenames containing `%`, `?`, or `#` literally in `New`, using + the same URI escaping as `OpenReadOnly`. The special `:memory:` behavior is + unchanged. Both constructors reject NUL bytes instead of opening a truncated + filename. + +### Changed + +- Document the shared release version for all four public modules and use + patch releases for compatible changes during `0.x`. +- Align nested-module and example dependency pins with the shared project + release. + +## [0.20.0] - 2026-09-12 + +### Added + +- SQLite `OpenReadOnly` opens existing stores without creating or migrating a + database or changing its journal mode. Unsupported schemas fail without + repair; mutations return `ErrReadOnly`. SQLite enforces read-only access on + every pooled connection while allowing later WAL commits to remain visible. + WAL shared-memory sidecars may still be created or updated. + +### Changed + +- Publish `0.20.0` as one shared release across the core, SQLite, Durable + Streams, and OpenTelemetry modules. + ## [0.19.0] - 2026-09-05 ### Added @@ -920,6 +955,8 @@ Initial release of ebu (Event BUs) - a lightweight, type-safe event bus for Go. - `ClearAll`: Remove all handlers - `WaitAsync`: Wait for async handlers to complete +[0.20.1]: https://github.com/jilio/ebu/compare/v0.20.0...HEAD +[0.20.0]: https://github.com/jilio/ebu/compare/v0.19.0...v0.20.0 [0.19.0]: https://github.com/jilio/ebu/compare/v0.18.1...v0.19.0 [0.18.1]: https://github.com/jilio/ebu/compare/v0.18.0...v0.18.1 [0.18.0]: https://github.com/jilio/ebu/compare/v0.17.0...v0.18.0 diff --git a/docs/PERSISTENCE.md b/docs/PERSISTENCE.md index fddd654..1f663a2 100644 --- a/docs/PERSISTENCE.md +++ b/docs/PERSISTENCE.md @@ -199,7 +199,20 @@ creating a database, migrating its schema, or changing its journal mode. The stored schema version must be supported; an uninitialized or incompatible database is rejected. `WithAutoMigrate(true)` is an error with this constructor. `Append`, `SaveOffset`, `SaveSnapshot`, and `TruncateBefore` return -`sqlite.ErrReadOnly`. Ordinary `New` behavior is unchanged. +`sqlite.ErrReadOnly`. `New` continues to enable WAL and automatic migrations +by default. + +Both constructors accept literal filenames, not SQLite URIs: characters such +as `%`, `?`, and `#` are escaped rather than interpreted as options. NUL bytes +are rejected. `New` also accepts the special path `:memory:` for an isolated, +pooled in-memory store; `OpenReadOnly` requires an existing file. + +For file-backed stores, every pooled connection uses the same read-side +settings: a 64 MB page cache, memory-backed temporary storage, and up to +256 MB of memory-mapped reads. Both constructors honor `WithBusyTimeout` +(five seconds by default). Read-only connections additionally enforce +`query_only` and do not use the writer's synchronous or immediate-transaction +settings. Read-only stores use SQLite `mode=ro`, so later commits from a concurrent WAL writer remain visible. They do not use `immutable=1` and do not provide a fixed diff --git a/examples/observability/generator/go.mod b/examples/observability/generator/go.mod index 280c683..d1ba157 100644 --- a/examples/observability/generator/go.mod +++ b/examples/observability/generator/go.mod @@ -3,8 +3,8 @@ module github.com/jilio/ebu/examples/observability/generator go 1.25.12 require ( - github.com/jilio/ebu v0.19.0 - github.com/jilio/ebu/otel v0.19.0 + github.com/jilio/ebu v0.20.0 + github.com/jilio/ebu/otel v0.20.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 diff --git a/examples/observability/generator/go.sum b/examples/observability/generator/go.sum index 10b8b65..0d60b26 100644 --- a/examples/observability/generator/go.sum +++ b/examples/observability/generator/go.sum @@ -17,10 +17,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= -github.com/jilio/ebu v0.19.0 h1:W2Bf5OyCsXH+a9qoWndN9OCGbgBJkunWj8TjKGkly78= -github.com/jilio/ebu v0.19.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= -github.com/jilio/ebu/otel v0.19.0 h1:vh09+rlW8tkKgEpBPvIl/v+e4lxzfCy0pRALbK+8rPs= -github.com/jilio/ebu/otel v0.19.0/go.mod h1:1jHZ12PzAyACNljhX5tEYma1S2P70LVAGqGKenubgvI= +github.com/jilio/ebu v0.20.0 h1:zqhm01cWZQVsV+pVcUVO+xZBdf2OZ1rBxy3UvoSTnlE= +github.com/jilio/ebu v0.20.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= +github.com/jilio/ebu/otel v0.20.0 h1:4WLujzd5yqJV1xinZxheQFxcnDm55PBqoFa5rPa0b7k= +github.com/jilio/ebu/otel v0.20.0/go.mod h1:1jHZ12PzAyACNljhX5tEYma1S2P70LVAGqGKenubgvI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/examples/observability/go.mod b/examples/observability/go.mod index bab68ed..f4ef478 100644 --- a/examples/observability/go.mod +++ b/examples/observability/go.mod @@ -3,8 +3,8 @@ module github.com/jilio/ebu/examples/observability go 1.25.12 require ( - github.com/jilio/ebu v0.19.0 - github.com/jilio/ebu/otel v0.19.0 + github.com/jilio/ebu v0.20.0 + github.com/jilio/ebu/otel v0.20.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 diff --git a/examples/observability/go.sum b/examples/observability/go.sum index 38b6f85..eab80e9 100644 --- a/examples/observability/go.sum +++ b/examples/observability/go.sum @@ -11,10 +11,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jilio/ebu v0.19.0 h1:W2Bf5OyCsXH+a9qoWndN9OCGbgBJkunWj8TjKGkly78= -github.com/jilio/ebu v0.19.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= -github.com/jilio/ebu/otel v0.19.0 h1:vh09+rlW8tkKgEpBPvIl/v+e4lxzfCy0pRALbK+8rPs= -github.com/jilio/ebu/otel v0.19.0/go.mod h1:1jHZ12PzAyACNljhX5tEYma1S2P70LVAGqGKenubgvI= +github.com/jilio/ebu v0.20.0 h1:zqhm01cWZQVsV+pVcUVO+xZBdf2OZ1rBxy3UvoSTnlE= +github.com/jilio/ebu v0.20.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= +github.com/jilio/ebu/otel v0.20.0 h1:4WLujzd5yqJV1xinZxheQFxcnDm55PBqoFa5rPa0b7k= +github.com/jilio/ebu/otel v0.20.0/go.mod h1:1jHZ12PzAyACNljhX5tEYma1S2P70LVAGqGKenubgvI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/otel/go.mod b/otel/go.mod index e33edcf..17a8aac 100644 --- a/otel/go.mod +++ b/otel/go.mod @@ -3,7 +3,7 @@ module github.com/jilio/ebu/otel go 1.25.12 require ( - github.com/jilio/ebu v0.19.0 + github.com/jilio/ebu v0.20.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 diff --git a/otel/go.sum b/otel/go.sum index 60c7f7c..2ce841d 100644 --- a/otel/go.sum +++ b/otel/go.sum @@ -11,8 +11,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jilio/ebu v0.19.0 h1:W2Bf5OyCsXH+a9qoWndN9OCGbgBJkunWj8TjKGkly78= -github.com/jilio/ebu v0.19.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= +github.com/jilio/ebu v0.20.0 h1:zqhm01cWZQVsV+pVcUVO+xZBdf2OZ1rBxy3UvoSTnlE= +github.com/jilio/ebu v0.20.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/stores/durablestream/go.mod b/stores/durablestream/go.mod index 498f9dd..e29311e 100644 --- a/stores/durablestream/go.mod +++ b/stores/durablestream/go.mod @@ -4,5 +4,5 @@ go 1.25.1 require ( github.com/durable-streams/durable-streams/packages/client-go v0.2.0 - github.com/jilio/ebu v0.19.0 + github.com/jilio/ebu v0.20.0 ) diff --git a/stores/durablestream/go.sum b/stores/durablestream/go.sum index fcbee7b..be90a53 100644 --- a/stores/durablestream/go.sum +++ b/stores/durablestream/go.sum @@ -1,4 +1,4 @@ github.com/durable-streams/durable-streams/packages/client-go v0.2.0 h1:s5QL0QEu06m954GVbV76Hl21sXCq2M9883PaoMI0oh4= github.com/durable-streams/durable-streams/packages/client-go v0.2.0/go.mod h1:G9l/FuZz1LWA1IhuCg6epPrMKU7Yd9805L4yC+0iuHc= -github.com/jilio/ebu v0.19.0 h1:W2Bf5OyCsXH+a9qoWndN9OCGbgBJkunWj8TjKGkly78= -github.com/jilio/ebu v0.19.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= +github.com/jilio/ebu v0.20.0 h1:zqhm01cWZQVsV+pVcUVO+xZBdf2OZ1rBxy3UvoSTnlE= +github.com/jilio/ebu v0.20.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= diff --git a/stores/sqlite/dsn.go b/stores/sqlite/dsn.go new file mode 100644 index 0000000..e60b49a --- /dev/null +++ b/stores/sqlite/dsn.go @@ -0,0 +1,52 @@ +package sqlite + +import ( + "fmt" + "net/url" + "path/filepath" + "strings" +) + +// buildDSN configures each pooled connection, including connections opened +// after construction. Running connection-scoped pragmas through db.Exec would +// configure only the connection handed out by the pool at that moment. +func buildDSN(cfg *config) (string, error) { + if cfg.path == ":memory:" { + // Shared cache mode lets pooled connections see the same database. + // Each store gets a unique name so independent stores stay isolated. + return fmt.Sprintf("file:ebu_memdb_%d?mode=memory&cache=shared", memDBCounter.Add(1)), nil + } + // SQLite truncates decoded URI paths at NUL. Reject it before encoding so + // an invalid filename cannot open or create a different, shorter path. + if strings.ContainsRune(cfg.path, '\x00') { + return "", fmt.Errorf("sqlite: path cannot contain NUL") + } + abs, err := filepath.Abs(cfg.path) + if err != nil { + return "", fmt.Errorf("sqlite: resolve database path: %w", err) + } + // Both constructors share read-side settings. Writer durability and + // transaction locking belong only to New; OpenReadOnly never requests a + // journal-mode change or an immediate (write-locking) transaction. + query := url.Values{"_pragma": { + fmt.Sprintf("busy_timeout(%d)", cfg.busyTimeout.Milliseconds()), + "cache_size(-64000)", // 64MB cache + "temp_store(MEMORY)", + "mmap_size(268435456)", // 256MB mmap + }} + if cfg.readOnly { + query.Set("mode", "ro") + query.Add("_pragma", "query_only(1)") + } else { + query.Add("_pragma", "synchronous(NORMAL)") + // Immediate transactions take the write lock at BEGIN (honoring + // busy_timeout) instead of upgrading mid-transaction, which can + // return SQLITE_BUSY immediately. Only migrations use them here. + query.Set("_txlock", "immediate") + } + // Escape the filename and parameters separately. Literal %, ? and # must + // never redirect the file or inject SQLite options into either constructor. + // OmitHost also keeps Windows drive letters in the path, not an authority. + uri := url.URL{Scheme: "file", OmitHost: true, Path: filepath.ToSlash(abs), RawQuery: query.Encode()} + return uri.String(), nil +} diff --git a/stores/sqlite/dsn_test.go b/stores/sqlite/dsn_test.go new file mode 100644 index 0000000..4c73891 --- /dev/null +++ b/stores/sqlite/dsn_test.go @@ -0,0 +1,235 @@ +package sqlite + +import ( + "bytes" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + eventbus "github.com/jilio/ebu" +) + +func TestFileConnectionPragmasAcrossPool(t *testing.T) { + for _, constructor := range []struct { + name string + open func(string, ...Option) (*SQLiteStore, error) + readOnly bool + }{ + {"writer", New, false}, + {"reader", OpenReadOnly, true}, + } { + for _, timeout := range []struct { + name string + opts []Option + want int + }{ + {"default", nil, 5000}, + {"custom", []Option{WithBusyTimeout(1250 * time.Millisecond)}, 1250}, + } { + t.Run(constructor.name+"/"+timeout.name, func(t *testing.T) { + path, _ := readOnlyFixture(t) + rawReadOnlyFixtureSQL(t, path, "PRAGMA journal_mode=DELETE") + before := readOnlyFileBytes(t, path) + store, err := constructor.open(path, timeout.opts...) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + store.db.SetMaxOpenConns(3) + var connections []*sql.Conn + // Hold all connections at once to force three separate SQLite + // connections, including ones opened after schema validation. + for range 3 { + connection, err := store.db.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = connection.Close() }) + connections = append(connections, connection) + } + for i, connection := range connections { + for pragma, want := range map[string]int{ + "busy_timeout": timeout.want, + "cache_size": -64000, + "temp_store": 2, // MEMORY + "mmap_size": 268435456, + } { + var got int + if err := connection.QueryRowContext(t.Context(), "PRAGMA "+pragma).Scan(&got); err != nil || got != want { + t.Errorf("connection %d: %s=%d, want %d (error %v)", i, pragma, got, want, err) + } + } + if constructor.readOnly { + var synchronous int + if err := connection.QueryRowContext(t.Context(), "PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 2 { + t.Fatalf("connection %d changed SQLite's default synchronous setting: %d (error %v)", i, synchronous, err) + } + var queryOnly int + if err := connection.QueryRowContext(t.Context(), "PRAGMA query_only").Scan(&queryOnly); err != nil || queryOnly != 1 { + t.Fatalf("connection %d: query_only=%d (error %v)", i, queryOnly, err) + } + if _, err := connection.ExecContext(t.Context(), "CREATE TEMP TABLE forbidden (value TEXT)"); err == nil { + t.Fatal("read-only connection accepted temporary table creation") + } + // Even without query_only, URI mode=ro must still protect + // every connection from data and schema changes. + if _, err := connection.ExecContext(t.Context(), "PRAGMA query_only=OFF"); err != nil { + t.Fatal(err) + } + for _, statement := range []string{"DELETE FROM events", "CREATE TABLE forbidden (value TEXT)"} { + if _, err := connection.ExecContext(t.Context(), statement); err == nil { + t.Fatalf("connection %d accepted %s", i, statement) + } + } + } else { + var synchronous int + if err := connection.QueryRowContext(t.Context(), "PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 1 { + t.Fatalf("connection %d: synchronous=%d (error %v)", i, synchronous, err) + } + } + if err := connection.Close(); err != nil { + t.Fatal(err) + } + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + if constructor.readOnly { + if !bytes.Equal(before, readOnlyFileBytes(t, path)) { + t.Fatal("pooled read-only pragmas or rejected writes changed database bytes") + } + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + if _, err := os.Stat(path + suffix); !os.IsNotExist(err) { + t.Fatalf("read-only opening created sidecar %s: %v", suffix, err) + } + } + } + }) + } + } +} + +func TestOpenReadOnlyTransactionDoesNotAcquireWriterLock(t *testing.T) { + path, _ := readOnlyFixture(t) + writer, err := New(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = writer.Close() }) + reader, err := OpenReadOnly(path, WithBusyTimeout(5*time.Millisecond)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reader.Close() }) + writeTx, err := writer.db.BeginTx(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = writeTx.Rollback() }) + if _, err := writeTx.ExecContext(t.Context(), "UPDATE events SET type='uncommitted'"); err != nil { + t.Fatal(err) + } + // Copying the writer's _txlock=immediate into the reader DSN would make + // this begin fail while the writer's transaction is still open. + readTx, err := reader.db.BeginTx(t.Context(), nil) + if err != nil { + t.Fatalf("read transaction tried to acquire a writer lock: %v", err) + } + t.Cleanup(func() { _ = readTx.Rollback() }) + var eventType string + if err := readTx.QueryRowContext(t.Context(), "SELECT type FROM events LIMIT 1").Scan(&eventType); err != nil || eventType != "example" { + t.Fatalf("read transaction: type=%q error=%v", eventType, err) + } +} + +func TestNewAndOpenReadOnlyUseLiteralFilePaths(t *testing.T) { + for _, name := range []string{ + "escaped%41.db", + "literal%00.db", + "query?mode=ro&_pragma=query_only(1).db", + "fragment#events.db", + "literal%3fmode=rw?mode=memory&cache=shared#fragment.db", + "space + café.db", + } { + for _, relative := range []bool{false, true} { + prefix := "absolute/" + if relative { + prefix = "relative/" + } + t.Run(prefix+name, func(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, name) + if relative { + t.Chdir(directory) + path = name + } + writer, err := New(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = writer.Close() }) + offset, err := writer.Append(t.Context(), &eventbus.Event{Type: "literal-path", Data: []byte("payload"), Timestamp: time.Now()}) + if err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(directory) + if err != nil || len(entries) != 1 || entries[0].Name() != name { + t.Fatalf("literal filename changed: entries=%v error=%v", entries, err) + } + before := readOnlyFileBytes(t, path) + reader, err := OpenReadOnly(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reader.Close() }) + events, tail, err := reader.Read(t.Context(), eventbus.OffsetOldest, 0) + if err != nil || len(events) != 1 || tail != offset || events[0].Type != "literal-path" { + t.Fatalf("literal path round trip: events=%v tail=%q error=%v", events, tail, err) + } + reader.db.SetMaxOpenConns(1) + if _, err := reader.db.Exec("PRAGMA query_only=OFF"); err != nil { + t.Fatal(err) + } + if _, err := reader.db.Exec("DELETE FROM events"); err == nil { + t.Fatal("literal filename overrode read-only mode") + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, readOnlyFileBytes(t, path)) { + t.Fatal("read-only opening changed literal database") + } + }) + } + } +} + +func TestConstructorsRejectNULWithoutOpeningPrefix(t *testing.T) { + path, _ := readOnlyFixture(t) + before := readOnlyFileBytes(t, path) + for _, constructor := range []struct { + name string + open func(string, ...Option) (*SQLiteStore, error) + }{ + {"writer", New}, + {"reader", OpenReadOnly}, + } { + t.Run(constructor.name, func(t *testing.T) { + store, err := constructor.open(path + "\x00suffix") + if store != nil { + _ = store.Close() + } + if err == nil || store != nil { + t.Fatal("invalid NUL filename opened its existing prefix") + } + if !bytes.Equal(before, readOnlyFileBytes(t, path)) { + t.Fatal("invalid filename modified its existing prefix") + } + }) + } +} diff --git a/stores/sqlite/go.mod b/stores/sqlite/go.mod index 033495e..ca54919 100644 --- a/stores/sqlite/go.mod +++ b/stores/sqlite/go.mod @@ -3,7 +3,7 @@ module github.com/jilio/ebu/stores/sqlite go 1.24.2 require ( - github.com/jilio/ebu v0.19.0 + github.com/jilio/ebu v0.20.0 modernc.org/sqlite v1.40.1 ) diff --git a/stores/sqlite/go.sum b/stores/sqlite/go.sum index 7ea82cd..5f57fc3 100644 --- a/stores/sqlite/go.sum +++ b/stores/sqlite/go.sum @@ -4,8 +4,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jilio/ebu v0.19.0 h1:W2Bf5OyCsXH+a9qoWndN9OCGbgBJkunWj8TjKGkly78= -github.com/jilio/ebu v0.19.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= +github.com/jilio/ebu v0.20.0 h1:zqhm01cWZQVsV+pVcUVO+xZBdf2OZ1rBxy3UvoSTnlE= +github.com/jilio/ebu v0.20.0/go.mod h1:HudFk9G56WhAmSpucnJFC7nf6/uSpCcEZYS2sItng74= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= diff --git a/stores/sqlite/readonly.go b/stores/sqlite/readonly.go index 65ef361..0031a29 100644 --- a/stores/sqlite/readonly.go +++ b/stores/sqlite/readonly.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "net/url" - "path/filepath" ) // ErrReadOnly is returned by mutations on a store opened with OpenReadOnly. @@ -15,6 +13,7 @@ var ErrReadOnly = errors.New("sqlite: store is read-only") // schema or journal mode. It never creates or migrates a database. The stored // schema must match the version this package reads; WithAutoMigrate(true) is // rejected. Other options have the same meaning as for New. +// The path is a literal filename, not a SQLite URI. // // SQLite mode=ro is used, not immutable=1: reads can observe later commits by // a concurrent writer. SQLite may create or update WAL shared-memory sidecars @@ -26,6 +25,7 @@ func OpenReadOnly(path string, opts ...Option) (*SQLiteStore, error) { return nil, errors.New("sqlite: read-only opening requires an existing file path") } cfg := defaultConfig() + cfg.path = path cfg.autoMigrate = false cfg.readOnly = true for _, opt := range opts { @@ -34,18 +34,11 @@ func OpenReadOnly(path string, opts ...Option) (*SQLiteStore, error) { if cfg.autoMigrate { return nil, errors.New("sqlite: read-only opening cannot enable automatic migration") } - abs, err := filepath.Abs(path) + dsn, err := buildDSN(cfg) if err != nil { - return nil, fmt.Errorf("sqlite: resolve read-only path: %w", err) + return nil, err } - cfg.path = abs - uri := url.URL{Scheme: "file", Path: abs} - query := url.Values{"mode": {"ro"}, "_pragma": { - fmt.Sprintf("busy_timeout(%d)", cfg.busyTimeout.Milliseconds()), - "query_only(1)", - }} - uri.RawQuery = query.Encode() - db, err := dbOpener("sqlite", uri.String()) + db, err := dbOpener("sqlite", dsn) if err != nil { return nil, fmt.Errorf("sqlite: open read-only database: %w", err) } diff --git a/stores/sqlite/store.go b/stores/sqlite/store.go index 635ec8e..4f8ca3b 100644 --- a/stores/sqlite/store.go +++ b/stores/sqlite/store.go @@ -8,7 +8,6 @@ import ( "fmt" "iter" "strconv" - "strings" "sync/atomic" "time" @@ -57,6 +56,8 @@ var dbOpener = sql.Open var memDBCounter atomic.Int64 // New creates a new SQLiteStore with the given path and options. +// The path is a literal filename, not a SQLite URI. The special path +// :memory: creates an isolated, pooled in-memory store. // // Note: When WithAutoMigrate is enabled (the default), migrations run with // context.Background() and are not cancellable. This ensures migrations @@ -66,18 +67,17 @@ func New(path string, opts ...Option) (*SQLiteStore, error) { return nil, errors.New("sqlite: path is required") } - // Validate path to prevent URI parameter injection - if path != ":memory:" && (strings.Contains(path, "?") || strings.Contains(path, "#")) { - return nil, errors.New("sqlite: path cannot contain '?' or '#' characters") - } - cfg := defaultConfig() cfg.path = path for _, opt := range opts { opt(cfg) } - db, err := dbOpener("sqlite", buildDSN(cfg)) + dsn, err := buildDSN(cfg) + if err != nil { + return nil, err + } + db, err := dbOpener("sqlite", dsn) if err != nil { return nil, fmt.Errorf("sqlite: open database: %w", err) } @@ -133,42 +133,6 @@ func newFromDB(db *sql.DB, cfg *config, memConn *sql.Conn) (*SQLiteStore, error) return store, nil } -// buildDSN constructs the modernc.org/sqlite connection string. -// -// Pragmas are connection-scoped in SQLite, and database/sql maintains a pool -// of connections, so they must travel as modernc `_pragma=` DSN parameters — -// that way the driver applies them to EVERY pooled connection. Applying them -// via db.Exec would configure only whichever single connection the pool -// happened to hand out (leaving the rest without e.g. busy_timeout, which -// made concurrent appends fail with SQLITE_BUSY). -func buildDSN(cfg *config) string { - if cfg.path == ":memory:" { - // Shared cache mode lets database/sql's pooled connections see the - // same in-memory database. Each store gets a unique name so two - // independent :memory: stores in the same process don't share data. - return fmt.Sprintf("file:ebu_memdb_%d?mode=memory&cache=shared", memDBCounter.Add(1)) - } - - pragmas := []string{ - fmt.Sprintf("busy_timeout(%d)", cfg.busyTimeout.Milliseconds()), - "synchronous(NORMAL)", - "cache_size(-64000)", // 64MB cache - "temp_store(MEMORY)", - "mmap_size(268435456)", // 256MB mmap - } - - params := make([]string, 0, len(pragmas)+1) - // Immediate transactions take the write lock at BEGIN (honoring - // busy_timeout) instead of upgrading mid-transaction, which can return - // SQLITE_BUSY immediately. Only migrations use transactions here. - params = append(params, "_txlock=immediate") - for _, p := range pragmas { - params = append(params, "_pragma="+p) - } - - return fmt.Sprintf("file:%s?%s", cfg.path, strings.Join(params, "&")) -} - // enableWAL switches a file-backed database to WAL journaling. Unlike the // connection-scoped pragmas in buildDSN, journal_mode is database-scoped and // persists in the file, so a one-time db.Exec is the right scope. The initial diff --git a/stores/sqlite/store_test.go b/stores/sqlite/store_test.go index af1daee..5420700 100644 --- a/stores/sqlite/store_test.go +++ b/stores/sqlite/store_test.go @@ -90,20 +90,6 @@ func TestNew(t *testing.T) { } }) - t.Run("rejects path with query params", func(t *testing.T) { - _, err := New("/tmp/test.db?mode=ro") - if err == nil { - t.Fatal("expected error for path with '?' character") - } - }) - - t.Run("rejects path with fragment", func(t *testing.T) { - _, err := New("/tmp/test.db#fragment") - if err == nil { - t.Fatal("expected error for path with '#' character") - } - }) - t.Run("creates in-memory store", func(t *testing.T) { store, err := New(":memory:") if err != nil {