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
15 changes: 15 additions & 0 deletions docs/PERSISTENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ loop closes the current rows; cancellation and query/iteration failures are
returned through the iterator. Ordinary `Read(ctx, from, 0)` still reads without
a result limit.

Use `sqlite.OpenReadOnly(path, options...)` to read an existing store without
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.

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
snapshot. SQLite may create or update WAL shared-memory sidecars when directory
permissions allow it. Schema validation happens at open; corruption encountered
in event pages is reported by reads, without an automatic integrity scan or
repair. Applications requiring an entirely immutable filesystem copy must
arrange that separately.

### Durable-Streams Store

Remote storage using the [Durable Streams](https://electric-sql.com/blog/2025/12/09/announcing-durable-streams) protocol - an HTTP-based persistent stream primitive for reliable, resumable, real-time data streaming developed by [Electric](https://electric-sql.com).
Expand Down
3 changes: 2 additions & 1 deletion stores/sqlite/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type config struct {
path string
busyTimeout time.Duration
autoMigrate bool
readOnly bool
logger Logger
metricsHook MetricsHook
streamBatchSize int
Expand All @@ -54,7 +55,7 @@ func WithBusyTimeout(timeout time.Duration) Option {
}

// WithAutoMigrate enables or disables automatic schema migration
// Default is true
// Default is true for New; OpenReadOnly defaults to false and rejects true.
func WithAutoMigrate(enabled bool) Option {
return func(c *config) {
c.autoMigrate = enabled
Expand Down
79 changes: 79 additions & 0 deletions stores/sqlite/readonly.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package sqlite

import (
"context"
"errors"
"fmt"
"net/url"
"path/filepath"
)

// ErrReadOnly is returned by mutations on a store opened with OpenReadOnly.
var ErrReadOnly = errors.New("sqlite: store is read-only")

// OpenReadOnly opens an existing file-backed store without changing its data,
// 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.
//
// 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
// when the directory permits it; this is not an immutable filesystem snapshot.
// Open validates the schema, while corruption encountered reading event pages
// is reported by the read operation. No integrity scan or repair is performed.
func OpenReadOnly(path string, opts ...Option) (*SQLiteStore, error) {
if path == "" || path == ":memory:" {
return nil, errors.New("sqlite: read-only opening requires an existing file path")
}
cfg := defaultConfig()
cfg.autoMigrate = false
cfg.readOnly = true
for _, opt := range opts {
opt(cfg)
}
if cfg.autoMigrate {
return nil, errors.New("sqlite: read-only opening cannot enable automatic migration")
}
abs, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("sqlite: resolve read-only path: %w", 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())
if err != nil {
return nil, fmt.Errorf("sqlite: open read-only database: %w", err)
}
var version int
if err := db.QueryRowContext(context.Background(), "SELECT MAX(version) FROM schema_version").Scan(&version); err != nil {
_ = db.Close()
return nil, fmt.Errorf("sqlite: inspect read-only schema: %w", err)
}
if version != currentSchemaVersion {
_ = db.Close()
return nil, fmt.Errorf("sqlite: unsupported read-only schema version %d (expected %d)", version, currentSchemaVersion)
}
// database/sql Prepare may defer SQL compilation until first use. Execute
// bounded read queries so a missing table or column fails opening rather
// than appearing to be a valid initialized store. No event pages are scanned.
for _, query := range []string{
"SELECT position, type, data, timestamp, event_id, origin, metadata FROM events LIMIT 0",
"SELECT subscription_id, position, updated_at FROM subscription_positions LIMIT 0",
"SELECT snapshot_id, position, data, updated_at FROM snapshots LIMIT 0",
} {
rows, err := db.QueryContext(context.Background(), query)
if err == nil {
err = rows.Close()
}
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("sqlite: inspect read-only schema: %w", err)
}
}
return newFromDB(db, cfg, nil)
}
Loading
Loading