~f4814n/frost

be824a5219b2f43701dd091c2f8a0a12013b5b34 — Fabian Geiselhart 11 months ago 9d4efdf
Create sqlite backend
A appdirs/appdirs.go => appdirs/appdirs.go +15 -0
@@ 0,0 1,15 @@
// +build !linux,!windows,!darwin

package appdirs

func UserDataDir(appname, appauthor string) string {
	panic("Unsupported platform")
}

func UserCacheDir(appname, appauthor string) string {
	panic("Unsupported platform")
}

func UserConfigDir(appname, appauthor string) string {
	panic("Unsupported platform")
}

A appdirs/appdirs_darwin.go => appdirs/appdirs_darwin.go +0 -0
A appdirs/appdirs_linux.go => appdirs/appdirs_linux.go +32 -0
@@ 0,0 1,32 @@
package appdirs

import (
	"os"
	"fmt"
)

func getenv(key, def string) (ret string) {
	if val, ok := os.LookupEnv(key); ok {
		ret = val
	} else {
		ret = def
	}

	if ret[0] == '~' {
		return os.Getenv("HOME") + ret[1:]
	}

	return ret
}

func UserDataDir(appname, appauthor string) string {
	return fmt.Sprintf("%s/%s", getenv("XDG_DATA_HOME", "~/.local/share"), appname)
}

func UserCacheDir(appname, appauthor string) string {
	return fmt.Sprintf("%s/%s", getenv("XDG_CACHE_HOME", "~/.cache"), appname)
}

func UserConfigDir(appname, appauthor string) string {
	return fmt.Sprintf("%s/%s", getenv("XDG_CONFIG_HOME", "~/.config"), appname)
}

A appdirs/appdirs_windows.go => appdirs/appdirs_windows.go +13 -0
@@ 0,0 1,13 @@
package appdirs

func UserDataDir(appname, appauthor string) string {
	panic("Unsupported platform")
}

func UserCacheDir(appname, appauthor string) string {
	panic("Unsupported platform")
}

func UserConfigDir(appname, appauthor string) string {
	panic("Unsupported platform")
}

M go.mod => go.mod +3 -0
@@ 4,6 4,9 @@ go 1.15

require (
	gioui.org v0.0.0-20200917085049-ef7b3e75f4dc
	github.com/jmoiron/sqlx v1.2.0
	github.com/mattn/go-sqlite3 v1.14.3
	github.com/sirupsen/logrus v1.6.0
	golang.org/x/exp v0.0.0-20200917184745-18d7dbdd5567
	google.golang.org/appengine v1.6.6 // indirect
)

M go.sum => go.sum +16 -0
@@ 6,8 6,18 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.3 h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
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/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=


@@ 30,6 40,7 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=


@@ 41,8 52,13 @@ golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSf
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

M main.go => main.go +70 -12
@@ 1,6 1,8 @@
package main

import (
	"os"
	"encoding/json"
	"net/http"

	"gioui.org/app"


@@ 11,10 13,15 @@ import (
	"gioui.org/unit"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/frost/matrix"
	memorybackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	backend "git.sr.ht/~f4814n/frost/matrix/backend/sqlite"
	"git.sr.ht/~f4814n/frost/appdirs"
	log "github.com/sirupsen/logrus"
)

var (
	configFilePath = appdirs.UserDataDir("frost", "f4814n") + "/config.json"
)

var theme *material.Theme

type Gtx = layout.Context


@@ 27,20 34,30 @@ type Page interface {
	Stop()
}

type AppConfig struct {
	Session *SessionConfig `json:"session"`
}

type SessionConfig struct {
	MxID string `json:"mxid"`
	DeviceID string `json:"device_id"`
}

type App struct {
	window *app.Window
	client *matrix.Client
	cli *matrix.Client
	rx, tx chan Event
	page   Page
	config AppConfig
}

func newApp() *App {
	return &App{
		rx: make(chan Event, 10),
		tx: make(chan Event, 10),
		client: matrix.NewClient(matrix.ClientOpts{
		cli: matrix.NewClient(matrix.ClientOpts{
			HTTPClient: http.DefaultClient,
			Backend:    memorybackend.New(),
			Backend:    backend.New(appdirs.UserDataDir("frost", "f4814n")),
		}),
		window: app.NewWindow(
			app.Size(unit.Dp(400), unit.Dp(800)),


@@ 54,16 71,17 @@ func (a *App) run() error {
		select {
		case e := <-a.window.Events():
			switch e := e.(type) {
			case system.StageEvent:
				theme = material.NewTheme(gofont.Collection())
				a.loadConfig()
				page := a.initialPage()
				a.page = page
				page.Start(a.tx, a.rx)
			case system.FrameEvent:
				var ops op.Ops
				gtx := layout.NewContext(&ops, e)
				a.Layout(gtx)
				e.Frame(gtx.Ops)
			case system.StageEvent:
				theme = material.NewTheme(gofont.Collection())
				page := NewLoginPage()
				a.page = page
				page.Start(a.tx, a.rx)
			case system.DestroyEvent:
				return e.Err
			default:


@@ 75,16 93,18 @@ func (a *App) run() error {
			case LoginStartEvent:
				go func() {
					log.Info("Logging in")
					err := a.client.Login(e.Username, e.Password)
					err := a.cli.Login(e.Username, e.Password)
					if err != nil {
						log.Info("Failed to log in")
						a.tx <- LoginErrorEvent{Err: err}
						return
					}
					log.Info("Successfully authenticated")
					a.page.Stop()
					a.config.Session = &SessionConfig{MxID: a.cli.MxID(), DeviceID: a.cli.DeviceID()}
					a.flushConfig()

					a.page = NewOverviewPage(a.client)
					a.page.Stop()
					a.page = NewOverviewPage(a.cli)
					a.page.Start(a.tx, a.rx)
				}()
			case InvalidationEvent:


@@ 96,6 116,44 @@ func (a *App) run() error {
	}
}

func (a *App) loadConfig() {
	f, err := os.Open(configFilePath)
	if err != nil {
		log.WithField("error", err).Error("Failed to open config file")
		return
	}

	err = json.NewDecoder(f).Decode(&a.config)
	if err != nil {
		// XXX Better error communication
		log.WithField("error", err).Error("Failed to parse config file")
	}
}

func (a *App) flushConfig() {
	f, err := os.Create(configFilePath)
	if err != nil {
		log.WithField("error", err).Error("Failed to flush config file")
	}

	err = json.NewEncoder(f).Encode(a.config)
	if err != nil {
		log.WithField("error", err).Error("Failed to encode configuration")
	}
}

func (a *App) initialPage() Page {
	if a.config.Session != nil {
		err := a.cli.LoadToken(a.config.Session.MxID, a.config.Session.DeviceID)
		if err != nil {
			log.WithField("error", err).Error("Failed to reuse session")
		}
		return NewOverviewPage(a.cli)
	}

	return NewLoginPage()
}

func (a *App) Layout(gtx Gtx) {
	a.page.Layout(gtx)
}

M matrix/api/endpoints.go => matrix/api/endpoints.go +1 -1
@@ 52,7 52,7 @@ type LoginBody struct {
	Address                  string     `json:"address,omitempty"`
	Password                 string     `json:"password,omitempty"`
	Token                    string     `json:"token,omitempty"`
	DeviceID                 string     `json:"device_id"`
	DeviceID                 string     `json:"device_id,omitempty"`
	InitialDeviceDisplayName string     `json:"initial_device_display_name,omitempty"`
}


M matrix/backend.go => matrix/backend.go +5 -2
@@ 14,7 14,10 @@ type Backend interface {
	// available (i.e if Open(mxid, deviceID) would return
	// nil.
	// Calling Initialize multiple times panics.
	Initialize(mxid, deviceID, accessToken string)
	// All structs obtained by calling methods of this Backend
	// will have their private fields set up in such a way, that
	// calling their methods will utilize the passed client.
	Initialize(cli *Client, mxid, deviceID, accessToken string)

	// Open configures the backend to load already an already
	// persisted session. This should be called if the


@@ 22,7 25,7 @@ type Backend interface {
	// between restarts. If the provided mxid-deviceID
	// combination is not known this returns an error.
	// Calling Open multiple times panics.
	Open(mxid, deviceID string) error
	Open(cli *Client, mxid, deviceID string) error

	// MxID returns the MxID of the user owing the device.
	MxID() string

M matrix/backend/memory/backend.go => matrix/backend/memory/backend.go +2 -2
@@ 33,7 33,7 @@ func New() *Backend {
	}
}

func (b *Backend) Initialize(mxid, deviceID, accessToken string) {
func (b *Backend) Initialize(cli *matrix.Client, mxid, deviceID, accessToken string) {
	b.mut.Lock()
	defer b.mut.Unlock()



@@ 43,7 43,7 @@ func (b *Backend) Initialize(mxid, deviceID, accessToken string) {
	b.mxid, b.deviceID, b.accessToken = mxid, deviceID, accessToken
}

func (b *Backend) Open(string, string) error {
func (b *Backend) Open(*matrix.Client, string, string) error {
	b.mut.RLock()
	defer b.mut.RUnlock()


M matrix/backend/sqlite/backend.go => matrix/backend/sqlite/backend.go +389 -0
@@ 1,1 1,390 @@
package sqlite

import (
	"errors"
	"context"
	"github.com/jmoiron/sqlx"
	"database/sql"
	_ "github.com/mattn/go-sqlite3"
	"git.sr.ht/~f4814n/frost/matrix"
	"encoding/json"
	"os"
	"fmt"
)

const ErrNoRowsStr = "sql: no rows in result set";

const (
	// XXX DOC
	stateEvent = "stateEvent"
	roomEvent = "roomEvent"
)

// Backend implements a matrix storage backend using sqlite
type Backend struct {
	path string
	db *sqlx.DB
	cli *matrix.Client
}

func New(path string) *Backend {
	return &Backend{path: path}
}

// Close closes the underlying database. After calling Close() the Backend will become unusable
func (b *Backend) Close() {
	b.db.Close()
}

// Initialize implements the backend interface
func (b *Backend) Initialize(cli *matrix.Client, mxid, deviceID, accessToken string) {
	// Backend already initialized
	if b.db != nil {
		panic("backend/sqlite: Backend already initialized")
	}

	// Create b.Path directory
	if _, err := os.Stat(b.path); os.IsNotExist(err) {
		os.Mkdir(b.path, os.ModePerm) // XXX Correct permissions
	}

	var dbFile string
	if b.path == ":memory:" {
		dbFile = ":memory:"
	} else {
		dbFile = fmt.Sprintf("file:%s/%s-%s.sqlite", b.path, mxid, deviceID)

		// Open would overwrite already initialized backend
		if _, err := os.Stat(dbFile); !os.IsNotExist(err) {
			panic("backend/sqlite: Combination of mixd and deviceID is already initialized")
		}
	}

	db, err := sqlx.Connect("sqlite3", dbFile)
	if err != nil {
		panic(err)
	}

	if b.path == ":memory:" {
		db.SetMaxOpenConns(1)
	}

	b.db = db
	b.cli = cli

	db.MustExec(`
		CREATE TABLE kvstore (
			key TEXT NOT NULL,
			value TEXT NOT NULL,
			UNIQUE(key)
		);

		CREATE TABLE accountData (
			roomID TEXT,
			type TEXT NOT NULL,
			eventJSON BLOB NOT NULL,
			UNIQUE(roomID,type)
		);

		CREATE TABLE roomState (
			roomID TEXT NOT NULL,
			type TEXT NOT NULL,
			key TEXT NOT NULL,
			eventJSON BLOB NOT NULL,
			UNIQUE(roomID,type,key)
		);

		CREATE TABLE historyBatches (
			roomID TEXT NOT NULL,
			prevBatch TEXT NOT NULL,
			UNIQUE(roomID)
		);

		CREATE TABLE latestEvents (
			roomID TEXT NOT NULL,
			ID TEXT NOT NULL,
			eventType TEXT NOT NULL,
			eventJSON BLOB NOT NULL,
			UNIQUE(roomID,ID)
		);
	`);

	db.MustExec(
		"INSERT INTO kvstore(key, value) VALUES ('mxid', ?), ('deviceID', ?), ('accessToken', ?)",
		mxid, deviceID, accessToken,
	)
}

func (b *Backend) Open(cli *matrix.Client, mxid, deviceID string) error {
	if b.path == ":memory:" {
		return errors.New(`Unable to open backend. This backend is configured as in-memory
			Backend. Is this really what you want?`)
	}
	dbFile := fmt.Sprintf("file:%s/%s-%s.sqlite", b.path, mxid, deviceID)
	db, err := sqlx.Connect("sqlite3", dbFile)

	if err != nil {
		return err
	}

	b.db = db
	b.cli = cli
	return nil
}

// queryKV gets the value of a key in the kvstore table. If it does not exist, it panics
func (b *Backend) queryKV(key string) (val string) {
	row := b.db.QueryRowx("SELECT value FROM kvstore WHERE key = ?", key)
	if row.Err() != nil {
		panic(row.Err())
	}

	if err := row.Scan(&val); err != nil {
		panic(err)
	}

	return
}

// MxID implements the Backend interface
func (b *Backend) MxID() string {
	return b.queryKV("mxid")
}

// DeviceID implements the Backend interface
func (b *Backend) DeviceID() string {
	return b.queryKV("deviceID")
}

// AccessToken implements the Backend interface
func (b *Backend) AccessToken() string {
	return b.queryKV("accessToken")
}

// AccountData implements the Backend interface
func (b *Backend) AccountData(room *matrix.Room, type_ string) *matrix.AccountDataEvent {
	var roomID string

	if room != nil {
		roomID = room.ID
	}

	row := b.db.QueryRowx(
		"SELECT eventJSON FROM accountData WHERE roomID = ? AND type = ?",
		&roomID, type_,
	)

	if row.Err() != nil { // XXX: Detect no result
		panic(row.Err())
	}

	var eventJSON []byte
	err := row.Scan(&eventJSON)
	if err != nil {
		if err.Error() == ErrNoRowsStr {
			return nil
		}
		panic(err)
	}

	var event matrix.AccountDataEvent
	err = json.Unmarshal(eventJSON, &event)
	if err != nil {
		panic(err)
	}

	if room != nil {
		event = event.WithRoom(*room)
	}

	return &event
}

// UpdateAccountData implements the AccountDataInterface
func (b *Backend) UpdateAccountData(event matrix.AccountDataEvent) {
	eventJSON, err := json.Marshal(event)
	if err != nil {
		panic(err)
	}

	var roomID *string
	if event.Room() != nil {
		roomID = &event.Room().ID
	}

	b.db.MustExec(`
		INSERT INTO accountData(roomID, type, eventJSON)
		VALUES (?, ?, ?)
		ON CONFLICT(roomID,type) DO UPDATE SET eventJSON = excluded.eventJSON`,
		roomID, event.Type, eventJSON,
	)
}

func (b *Backend) RoomState(room matrix.Room, type_ string, key string) *matrix.StateEvent {
	row := b.db.QueryRowx(
		"SELECT eventJSON FROM roomState WHERE roomID = ? AND type = ? AND key = ?",
		room.ID, type_, key,
	)

	if row.Err() != nil {
		panic(row.Err())
	}

	var eventJSON []byte
	err := row.Scan(&eventJSON)
	if err != nil {
		if err.Error() == ErrNoRowsStr {
			return nil
		}
		panic(fmt.Sprintf("%#v", err))
	}

	var event matrix.StateEvent
	err = json.Unmarshal(eventJSON, &event)
	if err != nil {
		panic(err)
	}

	event = event.WithClient(b.cli)
	return &event
}

func (b *Backend) RoomStateList(room matrix.Room, type_ string) []matrix.StateEvent {
	rows, err := b.db.Queryx(
		"SELECT eventJSON FROM roomState WHERE roomID = ? AND type = ?",
		room.ID, type_,
	)
	if err != nil {
		panic(err)
	}

	var events []matrix.StateEvent

	for rows.Next() {
		var (
			eventJSON []byte
			event matrix.StateEvent
		)

		err := rows.Scan(&eventJSON)
		if err != nil {
			panic(err)
		}

		err = json.Unmarshal(eventJSON, &event)
		if err != nil {
			panic(err)
		}

		events = append(events, event.WithClient(b.cli))
	}

	return events
}

func (b *Backend) UpdateRoomState(event matrix.StateEvent) {
	eventJSON, err := json.Marshal(event)
	if err != nil {
		panic(err)
	}

	b.db.MustExec(`
		INSERT INTO roomState(roomID,type,key,eventJSON)
		VALUES (?, ?, ?, ?)
		ON CONFLICT(roomID,type,key) DO UPDATE SET eventJSON=excluded.eventJSON`,
		event.Room.ID, event.Type, event.StateKey, eventJSON,
	)
}

func (b *Backend) AddLatestEvents(room matrix.Room, events []matrix.Event, prevBatch string) {
	tx := b.db.MustBegin()

	tx.MustExec(`
		INSERT INTO historyBatches(roomID, prevBatch)
		VALUES (?, ?)
		ON CONFLICT(roomID) DO UPDATE SET prevBatch=excluded.prevBatch`,
		room.ID, prevBatch)

	tx.MustExec("DELETE FROM latestEvents WHERE roomID = ?", room.ID)

	for id, event := range events {
		eventJSON, err := json.Marshal(event)
		if err != nil {
			panic(err)
		}

		var eventType string
		switch event.(type) {
		case matrix.StateEvent:
			eventType = stateEvent
		case matrix.RoomEvent:
			eventType = roomEvent
		default:
			// XXX Handle this?
			println("unexpected event type. skipping")
			continue
		}

		tx.MustExec(
			"INSERT INTO latestEvents(roomID, ID, eventType, eventJSON) VALUES (?, ?, ?, ?)",
			room.ID, id, eventType, eventJSON,
		)
	}

	if err := tx.Commit(); err != nil {
		panic(err)
	}
}

func (b *Backend) LatestEvents(room matrix.Room) (events []matrix.Event, prevBatch string) {
	tx := b.db.MustBeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
	defer tx.Commit()

	row := tx.QueryRowx("SELECT prevBatch FROM historyBatches WHERE roomID = ?", room.ID)
	if err := row.Err(); err != nil {
		if err.Error() == ErrNoRowsStr {
			return // XXX How to handle this
		}
		panic(err)
	}

	err := row.Scan(&prevBatch)
	if err != nil {
		panic(err)
	}

	rows, err := tx.Queryx(
		"SELECT eventJSON, eventType FROM latestEVENTS WHERE roomID = ? ORDER BY ID",
		room.ID,
	)

	if err != nil {
		panic(err)
	}

	for rows.Next() {
		res := make(map[string]interface{})
		rows.MapScan(res)

		var event matrix.Event
		if res["eventType"].(string) == stateEvent {
			var ev matrix.StateEvent
			err := json.Unmarshal(res["eventJSON"].([]byte), &ev)
			if err != nil {
				panic(err)
			}
			event = ev.WithClient(b.cli)
		} else if res["eventType"].(string) == roomEvent {
			var ev matrix.RoomEvent
			err := json.Unmarshal(res["eventJSON"].([]byte), &ev)
			if err != nil {
				panic(err)
			}
			event = ev.WithClient(b.cli)
		}

		events = append(events, event)
	}

	return
}

A matrix/backend/sqlite/backend_test.go => matrix/backend/sqlite/backend_test.go +12 -0
@@ 0,0 1,12 @@
package sqlite

import (
	"testing"

	"git.sr.ht/~f4814n/frost/matrix/backend"
)

func Test(t *testing.T) {
	b := &Backend{path: ":memory:"}
	backend.Test(t, b)
}

M matrix/backend/test.go => matrix/backend/test.go +6 -4
@@ 10,6 10,8 @@ import (
func Test(t *testing.T, backend matrix.Backend) {
	t.Parallel()

	backend.Initialize(nil, "@test:test.org", "TESTID", "token")

	t.Run("initialization", func(t *testing.T) { testInit(t, backend) })

	t.Run("state", func(t *testing.T) { testState(t, backend) })


@@ 20,15 22,13 @@ func Test(t *testing.T, backend matrix.Backend) {
func testInit(t *testing.T, backend matrix.Backend) {
	t.Parallel()

	backend.Initialize("@test:test.org", "TESTID", "token")

	func() {
		defer func() {
			if r := recover(); r == nil {
				t.Fatal("multiple initialization did not panic")
			}
		}()
		backend.Initialize("@test:test.org", "TESTID", "token")
		backend.Initialize(nil, "@test:test.org", "TESTID", "token")
	}()

	if backend.MxID() != "@test:test.org" {


@@ 96,7 96,7 @@ func testLatestEvents(t *testing.T, backend matrix.Backend) {
	events := []matrix.Event{
		matrix.RoomEvent{
			Content: map[string]interface{}{
				"a": []string{"1", "2", "3"},
				"a": []interface{}{"1", "2", "3"},
			},
			Type: "de.f4814n.testing",
		},


@@ 120,6 120,8 @@ func testLatestEvents(t *testing.T, backend matrix.Backend) {
	}

	if !reflect.DeepEqual(ev[0], events[0]) && reflect.DeepEqual(ev[1], events[1]) {
		t.Logf("%#v", ev)
		t.Logf("%#v", events)
		t.Fatal("got wrong events")
	}
}

M matrix/client.go => matrix/client.go +7 -2
@@ 59,6 59,11 @@ func (cli *Client) DeviceID() string {
	return cli.backend.DeviceID()
}

// MxID returns the matrix ID of the Client.
func (cli *Client) MxID() string {
	return cli.backend.MxID()
}

// Login using a matrix user id and a password (m.login.password)
func (cli *Client) Login(mxid string, password string) error {
	cli.mut.Lock() // Before Login is done the client is useless anyway.


@@ 94,7 99,7 @@ func (cli *Client) Login(mxid string, password string) error {
	cli.auth = func(r *http.Request) {
		r.Header.Add("Authorization", "Bearer "+resp.AccessToken)
	}
	cli.backend.Initialize(mxid, resp.DeviceID, resp.AccessToken)
	cli.backend.Initialize(cli, mxid, resp.DeviceID, resp.AccessToken)

	return nil
}


@@ 104,7 109,7 @@ func (cli *Client) Login(mxid string, password string) error {
// and finally calls the whoami API endpoint to check, whether the access token
// matches the mxID.
func (cli *Client) LoadToken(mxid, deviceID string) error {
	if err := cli.backend.Open(mxid, deviceID); err != nil {
	if err := cli.backend.Open(cli, mxid, deviceID); err != nil {
		return LogicError{Err: err}
	}


M matrix/event.go => matrix/event.go +24 -0
@@ 51,6 51,15 @@ func (e RoomEvent) GetContent() map[string]interface{} {
	return e.Content
}

// XXX HELPERDOC
func (e RoomEvent) WithClient(cli *Client) RoomEvent {
	e.Sender.User.cli = cli
	e.Sender.Room.cli = cli
	e.Room.cli = cli

	return e
}

// StateEvent is a matrix state event.
type StateEvent struct {
	Content        map[string]interface{}


@@ 74,6 83,15 @@ func (e StateEvent) GetContent() map[string]interface{} {
	return e.Content
}

// XXX HELPERDOC
func (e StateEvent) WithClient(cli *Client) StateEvent {
	e.Room.cli = cli
	e.Sender.User.cli = cli
	e.Sender.Room.cli = cli

	return e
}

// AccountDataEvent is a matrix account data event. If this event is attached to
// a Room, this Room can be obtained using the Room method.
type AccountDataEvent struct {


@@ 97,3 115,9 @@ func (e AccountDataEvent) GetContent() map[string]interface{} {
func (e AccountDataEvent) Room() *Room {
	return e.room
}

// XXX HELPERDOC
func(e AccountDataEvent) WithRoom(room Room) AccountDataEvent {
	e.room = &room
	return e
}