// chatfile.go is the files a chat message carries: an image pasted
// into the box, a file attached to the line. The bytes are in the
// repository's bucket under their hash, as every upload's are; this is
// which channel holds them, under what name, and from which message.
//
// A chat line is not in the repository's history and does not outlive
// the channel's retention, so neither does its file: a row goes when
// its message is deleted or ages out of the channel.
package oltp
import (
"database/sql"
"errors"
)
const chatFileSchema = `
CREATE TABLE IF NOT EXISTS chat_file (
repo TEXT NOT NULL,
seq INTEGER NOT NULL, -- the message that carries it
name TEXT NOT NULL, -- what its attach: names
size INTEGER NOT NULL,
type TEXT NOT NULL,
sha256 TEXT NOT NULL, -- the object's key in the bucket
who TEXT NOT NULL,
created TEXT NOT NULL,
PRIMARY KEY (repo, seq, name)
);
CREATE INDEX IF NOT EXISTS chat_file_sha ON chat_file(sha256);`
// ChatFile is one file a chat message carries.
type ChatFile struct {
Repo string `json:"repo"`
Name string `json:"name"`
Size int64 `json:"size"`
Type string `json:"type"`
SHA256 string `json:"sha256"`
Who string `json:"who"`
Seq uint64 `json:"seq"`
Created string `json:"created"`
}
// AddChatFile records a file against the message seq in repo's channel.
// A message names each of its files once; the same name again is the
// file replaced.
func (d *DB) AddChatFile(f ChatFile) error {
if f.Repo == "" || f.Name == "" || f.SHA256 == "" || f.Seq == 0 {
return errors.New("oltp: a chat file needs a repository, a name, a hash and its message")
}
_, err := d.w.Exec(`INSERT OR REPLACE INTO chat_file (repo, seq, name, size, type, sha256, who, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, f.Repo, f.Seq, f.Name, f.Size, f.Type, f.SHA256, f.Who, now())
return err
}
const chatFileCols = `repo, seq, name, size, type, sha256, who, created`
func scanChatFile(row interface{ Scan(...any) error }) (ChatFile, error) {
var f ChatFile
err := row.Scan(&f.Repo, &f.Seq, &f.Name, &f.Size, &f.Type, &f.SHA256, &f.Who, &f.Created)
return f, err
}
// ChatFile is the file named name that message seq in repo's channel
// carries; ok is false when it carries none.
func (d *DB) ChatFile(repo string, seq uint64, name string) (ChatFile, bool, error) {
f, err := scanChatFile(d.db.QueryRow(`SELECT `+chatFileCols+` FROM chat_file WHERE repo = ? AND seq = ? AND name = ?`, repo, seq, name))
if errors.Is(err, sql.ErrNoRows) {
return ChatFile{}, false, nil
}
return f, err == nil, err
}
// DropChatFiles forgets the files the message seq carried, answering
// them so their bytes can go too.
func (d *DB) DropChatFiles(repo string, seq uint64) ([]ChatFile, error) {
return d.dropChatFiles(`WHERE repo = ? AND seq = ?`, repo, seq)
}
// PruneChatFiles forgets the files of messages before first, the
// oldest sequence the channel still holds, answering them: the stream's
// retention took the messages, and their files go with them.
func (d *DB) PruneChatFiles(repo string, first uint64) ([]ChatFile, error) {
return d.dropChatFiles(`WHERE repo = ? AND seq < ?`, repo, first)
}
func (d *DB) dropChatFiles(where string, args ...any) ([]ChatFile, error) {
rows, err := d.db.Query(`SELECT `+chatFileCols+` FROM chat_file `+where, args...)
if err != nil {
return nil, err
}
var out []ChatFile
for rows.Next() {
f, err := scanChatFile(rows)
if err != nil {
rows.Close()
return nil, err
}
out = append(out, f)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
_, err = d.w.Exec(`DELETE FROM chat_file `+where, args...)
return out, err
}
// ChatFileShared says another chat file still names the bytes sha.
func (d *DB) ChatFileShared(sha string) bool {
var n int
_ = d.db.QueryRow(`SELECT COUNT(*) FROM chat_file WHERE sha256 = ?`, sha).Scan(&n)
return n > 0
}