Documentation
¶
Overview ¶
Package sqlite is the durable ledger event store (ADR-0034, ADR-0035).
An adapter, so the constructs the domain forbids are legitimate here: I/O, `context`, mutable state, a driver. That asymmetry is the architecture working — a purity rule that fired in this package would be a rule nobody kept.
A separate module rather than a package inside `libs/ledger`, because Go resolves dependencies per module: the driver would otherwise land in the `go.sum` of every consumer that imports `libs/ledger/domain`, including consumers that never touch storage (ADR-0013).
Index ¶
- Variables
- type Store
- func (s *Store) Append(ctx context.Context, name string, expect app.Expectation, ...) (ref domain.Ref, err error)
- func (s *Store) Close() error
- func (s *Store) Load(ctx context.Context, name string) (domain.Stream, error)
- func (s *Store) Serialise(ctx context.Context, name string, fn func(context.Context, app.Store) error) (err error)
Constants ¶
This section is empty.
Variables ¶
var ErrEncodingVersion = errors.New("sqlite: database uses an older temporal encoding")
ErrEncodingVersion is returned when a database was written under an encoding this build cannot order correctly.
Refusing rather than migrating is the decision, not a limitation (ADR-0040): a store whose facts this build cannot order is one it must not answer an as-of query from, and an as-of answer is the only thing the ledger is for.
var ErrGap = errors.New("sqlite: stream has a sequence gap")
ErrGap is returned when a stream's stored sequences are not 1..N contiguous.
An append-only stream whose sequence is assigned by the store (ADR-0034) has no legitimate source of gaps: Append never reserves a number it might not use, which is what makes gaplessness free here and expensive in a database sequence. So a gap is not a condition to tolerate — it is evidence that rows were deleted or the file was altered out of band, and the only safe response is to refuse the stream and say which sequence is missing.
Tolerating one is worse than it sounds. Replay assigns refs by position, so a single missing row silently re-points every later ref at different content: a FactCorrected naming s#3 would correct whatever landed at position 3 instead.
Functions ¶
This section is empty.
Types ¶
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is a durable app.Store backed by SQLite.
func Open ¶
Open opens or creates the database at dsn and applies the schema.
`dsn` is a file path. There is deliberately no in-memory convenience constructor: `libs/ledger/adapters/memory` is the in-memory store, and a second one here would be a second implementation of the same thing whose divergence nobody would notice.
func (*Store) Append ¶
func (s *Store) Append( ctx context.Context, name string, expect app.Expectation, envelope domain.Envelope, kind domain.Kind, payload domain.Payload, ) (ref domain.Ref, err error)
Append records one fact and returns the reference the store assigned.
Everything happens inside one immediate transaction: reading the current length, checking the caller's expectation, checking knowledge-time monotonicity, and the insert. That is the whole point of moving the append here — the sequence is assigned where writes serialise, so two writers cannot compute the same Ref (ADR-0034).
The transaction is IMMEDIATE, set on the connection in Open. A deferred transaction — the default — takes its write lock at the first write, which would leave the read that decides the sequence outside the lock: the same time-of-check gap this design exists to close, reintroduced one layer down.
func (*Store) Load ¶
Load rebuilds the stream from its facts, or returns app.ErrStreamNotFound.
The stream is replayed through `domain.Stream.Append` rather than reconstructed field by field, so a decoded fact goes through the same constructor a new one does. A store that assembled a Stream directly could produce one the domain would refuse to build.
Replay assigns each ref from the stream's current length, so it reproduces the stored sequences exactly when they are 1..N contiguous — and silently renumbers them when they are not. The stored sequence is therefore read and compared rather than discarded, which is what makes the replay's assumption checked instead of assumed. The sequence is stored twice, in the column and inside the encoded fact's own ref, and both are compared: they cannot disagree unless the row was written by something other than Append.
func (*Store) Serialise ¶ added in v0.3.0
func (s *Store) Serialise( ctx context.Context, name string, fn func(context.Context, app.Store) error, ) (err error)
Serialise runs fn holding this database's write lock (ADR-0041).
The lock is the transaction ¶
`BEGIN IMMEDIATE` takes SQLite's write lock at the statement rather than at the first write, and holds it until commit or rollback. So a region is a transaction held open across fn, and the caller's clock read happens inside it — which is what closes the window ADR-0036 closed for one process and could not close for two.
`name` is ignored, and that is the honest answer rather than a shortcut ¶
SQLite has one writer per *database*, not per stream. There is no per-name lock to take, so a region over `acct-1` excludes a writer to `acct-2` as well. ADR-0041 records this as the cost that ADR-0042's per-stream advisory locks exist to pay down. The parameter stays in the signature because the port has it and a second engine uses it; pretending it were honoured here would be worse than ignoring it visibly.
Why this serialises processes, which a mutex could not ¶
The lock is the file's, so it is held against every process that has the database open, not only every goroutine in this one. Measured before this existed: 128 concurrent admissions to one stream admitted 128 from a single process and 106 from sixteen, and what kept the number as high as it was is this same lock being taken by Append — just too late to cover the clock read.
SetMaxOpenConns(1) makes misuse a deadlock rather than a stale read ¶
fn must use the Store it is given. Reaching past it to the outer Store would ask for a second connection, and there is only one — so the mistake hangs instead of silently reading outside the region. Hence [regional], whose methods run on this transaction and whose Serialise refuses.