15 // errLocked tells that another process owns the chat.
16 var errLocked = errors.New("another process owns the chat")
18 // kinds holds the kinds that a message can have.
19 var kinds = map[string]bool{
28 // Message is one entry of the chat log. ID is the permanent position of the
29 // message in the chat, from 0. Size is the UTF-8 byte length of Text. Date is
30 // the time of the write in RFC 3339 format.
33 Kind string `json:"kind"`
34 Text string `json:"text"`
35 Size int `json:"size"`
36 Date string `json:"date"`
39 // Log is the append-only message log of one chat. It writes one JSON line for
40 // each message to main/YYYY-MM-DD.jsonl, named for the local day of the write.
41 // It never edits or deletes a line. Each write goes to the file and to the disk
42 // before Append returns. An exclusive lock on the lock file makes sure that
43 // only one process writes the log. After a failed write, the end of the file is
44 // not known, so the log refuses all writes until it is opened again. The caller
45 // serializes all calls.
47 clock func() time.Time
54 // openLog locks the chat in root and loads all of its messages. The clock gives
55 // the date of each new message.
56 func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, error) {
57 err := os.MkdirAll(filepath.Join(root, "main"), 0o700)
59 return nil, fmt.Errorf("create message directory: %w", err)
62 lock, err := lockFile(filepath.Join(root, "lock"))
71 dir: filepath.Join(root, "main"),
77 return nil, errors.Join(fmt.Errorf("load message log: %w", err), l.Close())
83 func (l *Log) load(ctx context.Context) error {
84 return readLines(ctx, l.journal.dir, func(message Message) error {
85 if message.ID != len(l.messages) {
86 return fmt.Errorf("expected message %d, found %d", len(l.messages), message.ID)
89 err := validateMessage(message)
91 return fmt.Errorf("message %d: %w", message.ID, err)
94 l.messages = append(l.messages, message)
100 // Close releases the lock. The log refuses all calls after Close.
101 func (l *Log) Close() error {
102 err := l.journal.Close()
104 err = errors.Join(err, l.lock.Close())
111 // Append writes a new message to the log and returns it with its ID, size, and
113 func (l *Log) Append(kind, text string) (Message, error) {
115 return Message{}, os.ErrClosed
118 if l.failure != nil {
119 return Message{}, fmt.Errorf("log needs a reopen after a failed write: %w", l.failure)
128 Date: now.Format(time.RFC3339Nano),
131 err := validateMessage(message)
133 return Message{}, err
136 line, err := json.Marshal(message)
138 return Message{}, fmt.Errorf("encode message: %w", err)
141 err = l.journal.Write(now.Format(time.DateOnly), append(line, '\n'))
144 return Message{}, fmt.Errorf("write message %d: %w", message.ID, err)
147 l.messages = append(l.messages, message)
152 // Len returns the number of messages in the log.
153 func (l *Log) Len() int {
154 return len(l.messages)
157 // Message returns the message with the given ID.
158 func (l *Log) Message(id int) Message {
159 return l.messages[id]
162 // validateMessage makes sure that a message has a known kind, valid UTF-8 text,
163 // the size of its text, and a valid date.
164 func validateMessage(message Message) error {
165 if !kinds[message.Kind] {
166 return fmt.Errorf("unknown message kind %q", message.Kind)
169 if !utf8.ValidString(message.Text) {
170 return errors.New("message text is not valid UTF-8")
173 if message.Size != len(message.Text) {
174 return fmt.Errorf("message size %d is not the text size %d", message.Size, len(message.Text))
177 _, err := time.Parse(time.RFC3339Nano, message.Date)
179 return fmt.Errorf("parse message date: %w", err)
185 // lockFile opens the file at path and takes an exclusive lock on it. The lock
186 // stays until the file closes or the process ends.
187 func lockFile(path string) (*os.File, error) {
188 file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
190 return nil, fmt.Errorf("open lock file: %w", err)
193 err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
194 if errors.Is(err, syscall.EWOULDBLOCK) {
199 return nil, errors.Join(fmt.Errorf("lock chat: %w", err), file.Close())