Documentation
¶
Overview ¶
Package sqlite is the thread's second durable backend: every session in one SQLite file on the CGO-free modernc.org/sqlite driver — the store's choice (store/sqlite, Crush's before it), reused so one dependency serves both modules — with WAL and embedded migrations. An Append that returned survives the process dying; whether it also survives a power cut is the fsync policy's choice (see Open: the default fsyncs every commit). It is its own module because the driver would otherwise leak into thread's go.mod (ADR 0011 §1: "own module only if its driver would leak into thread" — it would; thread stays root-and-stdlib only).
A session's bytes are the same lines jsonl writes — the header line, then one entry line per row, the thread wire verbatim (ADR 0011 §2, §6) — so the backends differ in where the lines live, never in what they say: Load here decodes exactly what Load there would, the threadtest table runs the same on both, and the loud rules (the unknown kind and the newer version are ErrNewerFormat, never a skip; anything else undecodable is ErrCorrupt naming the line; salvage or not) are thread's own decode path, not a reimplementation.
The one-writer rule (ADR 0011 §5) is a lock row per session, taken on a session's first write and held until the session is released (the thread.Releaser capability), deleted, or the holder's process exits: a second writer — another Storage in this process, or a process on this machine — fails with thread.ErrLocked. A holder that has died is taken over, so a crashed writer never strands its session: flock's death-release semantics, rebuilt on the database the backend already needs. "Died" is judged on the holder's own host by more than its pid, because pids are reused: the row carries the holder's process token and start time, so a restarted process that wears its predecessor's pid (a container's PID 1) takes its own sessions back, and an unrelated process wearing a dead holder's pid does not keep them locked. A holder on another host is never judged from here: its row stands until it releases or an operator removes it (BreakLock). Readers never lock — Load and List always work. Between Sessions sharing one Storage the same rule is the lease (the thread.Leaser capability): the instance remembers which writer has the row it took.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNewerSchema = errors.New("sqlite: database schema is newer than this weft")
ErrNewerSchema is returned by Open when the file's thread_migrations is ahead of this binary's highest migration: a database written by a newer weft fails loudly instead of running nothing and saying nothing — the store's rule (its ErrNewerSchema), verbatim, because the failure is the same failure.
Functions ¶
func BreakLock ¶
BreakLock removes the writer lock on a session, whoever holds it. It is an operator's action, for the one case the lock cannot decide on its own: a row left by a holder on another host. The lock never judges such a holder dead (acquire's rule), so when that host is gone for good — a container replaced under a new hostname, a database restored from a backup that carried its lock rows — every write to the session fails with thread.ErrLocked until the row is removed. The caller vouches that the holder is gone: nothing here can check it.
st is the Storage this package's Open returned. The next writer takes the lock as on an unheld session. A holder that was alive after all is not left writing beside it: its next write finds the row is no longer its own and fails with thread.ErrLocked, and a Session it serves then answers thread.ErrStale if the session was written in between. The removal is logged at Warn on the storage's logger (thread.OpenLogger), naming the holder it removed.
A session with no lock row is left as it is and BreakLock returns nil; one the database does not hold fails with thread.ErrNotFound. To clear a whole database after a host change, List the sessions and break each.
func Open ¶
Open opens (creating if needed) the sessions database at path and brings its schema up to date, returning a thread.Storage. ":memory:" works — one private in-process database per Open, for tests and examples, alive for as long as the returned Storage is. Connections carry busy_timeout=30000, foreign_keys=ON, and immediate write transactions (preventing deferred-to-writer upgrade deadlocks; read-only transactions stay deferred and take no write lock), and the handle uses a single working connection so every write is serialized. WAL is not a connection pragma: it is a persistent property of the file, switched once by the first Open (setWAL), so a reader in another process — a watcher, the Inspector — reads concurrently through its own Open.
Durability ¶
Every Append is one committed transaction, so a process that dies — a crash, a SIGKILL — never loses an Append that returned, under either fsync policy. What the policy decides is power loss and kernel crashes:
- thread.FsyncEveryAppend, the default: synchronous=FULL. Each commit fsyncs the write-ahead log before Append returns — an accepted entry survives losing power, the same promise jsonl makes for its default.
- thread.FsyncOnFlush: synchronous=NORMAL. A commit is written but not fsynced; the entries since the last sync can be lost to a power cut (the database stays consistent — WAL's guarantee — it only ends earlier). Flush is the sync point: it checkpoints the log, which fsyncs it. SQLite also checkpoints on its own as the log grows.
":memory:" has nothing to sync and ignores both.
path is a file name, taken literally: characters that mean something in a SQLite URI ('?', '#', '%') are part of the name, never options.
The shared open vocabulary (thread/backend.Resolve) applies: Salvage downgrades a malformed line from a load failure to a skip reported in the LoadReport; OpenLogger names where a lock takeover and a removed torn row are reported; the fsync-policy options choose the sync cadence described above; NoLock is accepted and is a no-op: the lock is a row, it needs no platform support, and it stays on. The database file is created 0600 and its directory 0700, matching jsonl's rule (ADR 0011 §5); the -wal and -shm side files are SQLite's own and share the main file's directory.
Example ¶
Open a sessions database, write one turn's worth of entries, read the session back. ":memory:" keeps the example self-contained; a path gives the same answers on disk.
package main
import (
"context"
"fmt"
"time"
"github.com/weftgo/weft"
"github.com/weftgo/weft/thread"
"github.com/weftgo/weft/thread/sqlite"
)
func main() {
st, err := sqlite.Open(":memory:")
if err != nil {
fmt.Println(err)
return
}
ctx := context.Background()
created := time.Date(2026, 9, 29, 9, 0, 0, 0, time.UTC)
if err := st.Create(ctx, thread.Header{ID: "s_demo", Created: created}); err != nil {
fmt.Println(err)
return
}
if err := st.Append(ctx, "s_demo",
thread.MessageEntry{
ID: "e_1", Created: created.Add(time.Second), Message: weft.User("Where is order 1234?"),
},
thread.TurnEntry{
ID: "e_2", Created: created.Add(2 * time.Second),
RunID: "s_demo-t1", StopReason: weft.StopEndTurn, Steps: 1,
},
); err != nil {
fmt.Println(err)
return
}
h, entries, report, err := st.Load(ctx, "s_demo")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(h.ID, len(entries), entries[0].(thread.MessageEntry).Message.Text(), report == nil)
}
Output: s_demo 2 Where is order 1234? true
Example (SecondWriter) ¶
The one-writer rule is per session and crosses processes: a second Storage over the same file — here in the same process, the cheapest way to show it — is refused with thread.ErrLocked until the holder lets go, while Load keeps answering: readers never lock.
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/weftgo/weft"
"github.com/weftgo/weft/thread"
"github.com/weftgo/weft/thread/sqlite"
)
func main() {
dir, err := os.MkdirTemp("", "weft-sqlite-example")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = os.RemoveAll(dir) }()
ctx := context.Background()
path := filepath.Join(dir, "sessions.db")
holder, err := sqlite.Open(path)
if err != nil {
fmt.Println(err)
return
}
if err := holder.Create(ctx, thread.Header{ID: "s_demo", Created: time.Now().UTC()}); err != nil {
fmt.Println(err)
return
}
second, err := sqlite.Open(path)
if err != nil {
fmt.Println(err)
return
}
err = second.Append(ctx, "s_demo", thread.MessageEntry{
ID: "e_1", Created: time.Now().UTC(), Message: weft.User("second writer"),
})
fmt.Println(errors.Is(err, thread.ErrLocked))
}
Output: true
Types ¶
This section is empty.