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
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1868-windows-sqlite-file-uris.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: Windows database paths open without a misleading SQLite out-of-memory error
pr: 1868
surface: [chat, engine]
invalidates: []
---

Graph memory, automation storage, collections, and read-only plan previews now
construct rooted SQLite file URIs. Windows drive paths use `file:///C:/...`
instead of `file://C:%5C...`, which SQLite rejects before opening the database.
The shared constructor preserves URL escaping and each caller's query settings.
4 changes: 2 additions & 2 deletions internal/automation/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/Agent-Field/codeaf/internal/sqliteuri"
_ "modernc.org/sqlite"
)

Expand Down Expand Up @@ -79,7 +79,7 @@ func Open(root string) (*Store, error) {
} else if err := f.Close(); err != nil {
return nil, fmt.Errorf("automations: %w", err)
}
u := url.URL{Scheme: "file", Path: path}
u := sqliteuri.File(path)
q := u.Query()
q.Set("mode", "rw")
q.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", busyTimeout.Milliseconds()))
Expand Down
11 changes: 9 additions & 2 deletions internal/plandb/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package plandb

import (
"database/sql"
"net/url"
"path/filepath"
"time"

"github.com/Agent-Field/codeaf/internal/sqliteuri"
)

// RootPreview is the original request behind a saved run. Listing a conversation
Expand All @@ -18,7 +20,12 @@ type RootPreview struct {
// creates a database, migrates its schema or settles a task left running by a
// process that exited, so a conversation picker can safely read live work too.
func ReadRootPreview(path string) (RootPreview, error) {
uri := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"}
absolute, err := filepath.Abs(path)
if err != nil {
return RootPreview{}, err
}
uri := sqliteuri.File(absolute)
uri.RawQuery = "mode=ro"
db, err := sql.Open("sqlite", uri.String())
if err != nil {
return RootPreview{}, err
Expand Down
16 changes: 16 additions & 0 deletions internal/sqliteuri/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package sqliteuri

import (
"net/url"
"path/filepath"
"strings"
)

// File builds a SQLite file URI from an absolute filesystem path.
func File(path string) url.URL {
path = filepath.ToSlash(path)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return url.URL{Scheme: "file", Path: path}
}
125 changes: 125 additions & 0 deletions internal/sqliteuri/path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package sqliteuri

import (
"database/sql"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"

_ "modernc.org/sqlite"
)

func TestFilePreservesPOSIXURIs(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX path spelling is a contract on POSIX hosts")
}
for _, path := range []string{
"/tmp/memory.db", "/tmp/with spaces.db", "/tmp/hash#1.db",
"/tmp/a&b.db", "/tmp/100%.db", "/tmp/literal%20.db",
"/tmp/query?mode=ro.db", "/tmp/記憶-é.db", "/tmp/back\\slash.db",
"//server/share/db", "/tmp/../tmp/db",
} {
t.Run(path, func(t *testing.T) {
before := url.URL{Scheme: "file", Path: path}
after := File(path)
q := url.Values{"mode": {"rw"}, "_pragma": {"busy_timeout(5000)", "foreign_keys(1)"}, "_txlock": {"immediate"}}
before.RawQuery, after.RawQuery = q.Encode(), q.Encode()
if after.String() != before.String() {
t.Fatalf("URI changed: got %q, dev produced %q", after.String(), before.String())
}
})
}
}

func TestFileWindowsPaths(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("filepath.ToSlash follows the host filesystem")
}
for _, test := range []struct{ path, want string }{
{`C:\x\y.db`, "file:///C:/x/y.db"},
{`C:/x/y.db`, "file:///C:/x/y.db"},
{`C:\x\with space #&%.db`, "file:///C:/x/with%20space%20%23&%25.db"},
{`C:\記憶\é.db`, "file:///C:/%E8%A8%98%E6%86%B6/%C3%A9.db"},
{`\\server\share\db`, "file:////server/share/db"},
{`\\server\share\with space #&%.db`, "file:////server/share/with%20space%20%23&%25.db"},
} {
t.Run(test.path, func(t *testing.T) {
u := File(test.path)
if u.String() != test.want || u.Host != "" {
t.Fatalf("URI = %q, host = %q; want %q with no authority", u.String(), u.Host, test.want)
}
q := url.Values{"mode": {"ro"}, "_pragma": {"busy_timeout(5000)", "foreign_keys(1)"}, "_txlock": {"immediate"}}
u.RawQuery = q.Encode()
if u.String() != test.want+"?"+q.Encode() {
t.Fatalf("caller query changed: %q", u.String())
}
})
}
}

func TestFileOpensTheNamedDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "memory #1 & 100%.db")
u := File(path)
q := u.Query()
q.Add("_pragma", "foreign_keys(1)")
q.Add("_pragma", "busy_timeout(5000)")
q.Set("_txlock", "immediate")
u.RawQuery = q.Encode()
db, err := sql.Open("sqlite", u.String())
if err != nil {
t.Fatal(err)
}
var foreignKeys int
if err := db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil || foreignKeys != 1 {
db.Close()
t.Fatalf("connection pragma was lost: foreign_keys=%d, err=%v", foreignKeys, err)
}
var busyTimeout int
if err := db.QueryRow("PRAGMA busy_timeout").Scan(&busyTimeout); err != nil || busyTimeout != 5000 {
db.Close()
t.Fatalf("connection pragma was lost: busy_timeout=%d, err=%v", busyTimeout, err)
}
if _, err := db.Exec("CREATE TABLE probe(value TEXT); INSERT INTO probe VALUES ('persisted')"); err != nil {
db.Close()
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
q.Set("mode", "ro")
u.RawQuery = q.Encode()
db, err = sql.Open("sqlite", u.String())
if err != nil {
t.Fatal(err)
}
defer db.Close()
var value string
if err := db.QueryRow("SELECT value FROM probe").Scan(&value); err != nil || value != "persisted" {
t.Fatalf("wrong database: value=%q, err=%v", value, err)
}
if _, err := db.Exec("INSERT INTO probe VALUES ('unexpected')"); err == nil {
t.Fatal("mode=ro opened a writable database")
}
}

func TestFileReadOnlyDoesNotCreateAMissingDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing #&%.db")
u := File(path)
u.RawQuery = "mode=ro"
db, err := sql.Open("sqlite", u.String())
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err == nil {
t.Fatal("read-only open accepted a missing database")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("read-only open created the database: %v", err)
}
}
4 changes: 2 additions & 2 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import (
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/Agent-Field/codeaf/internal/cas"
"github.com/Agent-Field/codeaf/internal/sqliteuri"
_ "modernc.org/sqlite"
)

Expand Down Expand Up @@ -697,7 +697,7 @@ func Open(path string) (*Store, error) {
return nil, fmt.Errorf("could not open %s: %w", storePathForPerson(absolute), pathReason(err))
}

u := url.URL{Scheme: "file", Path: absolute}
u := sqliteuri.File(absolute)
query := u.Query()
// busy_timeout is SQLite's own patience and nothing else's: no caller waits
// it out any more, because every write goes through [Store.beginWrite] and
Expand Down
4 changes: 2 additions & 2 deletions internal/workspace/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import (
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"

"github.com/Agent-Field/codeaf/internal/sqliteuri"
sqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
Expand Down Expand Up @@ -78,7 +78,7 @@ func Open(path string) (*Store, error) {
return nil, collectionsOpenFailure(absolute, err)
}
}
u := url.URL{Scheme: "file", Path: absolute}
u := sqliteuri.File(absolute)
q := u.Query()
// The private file was created above. SQLite must not create another file
// with its default permissions if the path is a dangling link or disappears.
Expand Down
Loading