14 // logKinds holds the logKinds that a message can have.
15 var logKinds = map[string]bool{
24 // openLog locks the chat in root and indexes its new lines. The clock gives the
25 // date of each new message.
26 func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, error) {
27 for _, dir := range []string{"main", "index"} {
28 err := os.MkdirAll(filepath.Join(root, dir), 0o700)
30 return nil, fmt.Errorf("create %s directory: %w", dir, err)
34 lock, err := lockFile(filepath.Join(root, "lock"))
43 dir: filepath.Join(root, "main"),
47 l.index, err = openRecords(filepath.Join(root, "index", "main.idx"), positionBytes)
49 return nil, errors.Join(err, l.Close())
54 return nil, errors.Join(fmt.Errorf("index message log: %w", err), l.Close())
60 // Log is the append-only message log of one chat. It writes one JSON line for
61 // each message to main/YYYY-MM-DD.jsonl, named for the local day of the write.
62 // It never edits or deletes a line. Each line goes to the operating system
63 // before Append returns, so a crash of the process loses no message. An
64 // exclusive lock on the lock file makes sure that only one process writes the
65 // log. After a failed write, the end of the file is not known, so the log
66 // refuses all writes until it is opened again. The caller serializes all calls.
68 // The log keeps no message in memory. The index index/main.idx holds the
69 // position of message i in record i, so a read of a message is one read of the
70 // index and one of its day file. At open the log indexes the lines after the
71 // last indexed one, so a missing index is built again from the day files.
73 clock func() time.Time
80 // Close releases the lock. The log refuses all calls after Close.
81 func (l *Log) Close() error {
82 err := l.journal.Close()
84 err = errors.Join(err, l.index.Close())
89 err = errors.Join(err, l.lock.Close())
96 // Append writes a new message to the log and returns it with its ID, size, and
98 func (l *Log) Append(kind, text string) (Message, error) {
100 return Message{}, os.ErrClosed
103 if l.failure != nil {
104 return Message{}, fmt.Errorf("log needs a reopen after a failed write: %w", l.failure)
113 Date: now.Format(time.RFC3339Nano),
116 err := logValidateMessage(message)
118 return Message{}, err
121 line, err := json.Marshal(message)
123 return Message{}, fmt.Errorf("encode message: %w", err)
126 place, err := l.journal.Write(now.Format(time.DateOnly), append(line, '\n'))
128 err = l.addIndex(place)
133 return Message{}, fmt.Errorf("write message %d: %w", message.ID, err)
139 // Len returns the number of messages in the log.
140 func (l *Log) Len() int {
144 // Message reads the message with the given ID from its day file.
145 func (l *Log) Message(id int) (Message, error) {
146 record, found, err := l.index.Read(id)
148 return Message{}, err
152 return Message{}, fmt.Errorf("no message %d", id)
155 line, err := l.journal.Read(decodePosition(record))
157 return Message{}, fmt.Errorf("read message %d: %w", id, err)
161 err = json.Unmarshal(line, &message)
163 return Message{}, fmt.Errorf("decode message %d: %w", id, err)
166 if message.ID != id {
167 return Message{}, fmt.Errorf("index of message %d points to message %d", id, message.ID)
173 // catchUp indexes the lines after the last indexed line.
174 func (l *Log) catchUp(ctx context.Context) error {
176 if l.index.count > 0 {
177 record, _, err := l.index.Read(l.index.count - 1)
182 last := decodePosition(record)
185 offset: last.offset + uint64(last.length),
189 return l.journal.Scan(ctx, start, func(line []byte, place Position) error {
191 err := json.Unmarshal(line, &message)
196 if message.ID != l.index.count {
197 return fmt.Errorf("expected message %d, found %d", l.index.count, message.ID)
200 err = logValidateMessage(message)
202 return fmt.Errorf("message %d: %w", message.ID, err)
205 return l.addIndex(place)
209 func (l *Log) addIndex(place Position) error {
210 record := make([]byte, positionBytes)
211 encodePosition(record, place)
213 return l.index.Append(record)
216 // Message is one entry of the chat log. ID is the permanent position of the
217 // message in the chat, from 0. Size is the UTF-8 byte length of Text. Date is
218 // the time of the write in RFC 3339 format.
219 type Message struct {
221 Kind string `json:"kind"`
222 Text string `json:"text"`
223 Size int `json:"size"`
224 Date string `json:"date"`
227 // logValidateMessage makes sure that a message has a known kind, valid UTF-8
228 // text, the size of its text, and a valid date.
229 func logValidateMessage(message Message) error {
230 if !logKinds[message.Kind] {
231 return fmt.Errorf("unknown message kind %q", message.Kind)
234 if !utf8.ValidString(message.Text) {
235 return errors.New("message text is not valid UTF-8")
238 if message.Size != len(message.Text) {
239 return fmt.Errorf("message size %d is not the text size %d", message.Size, len(message.Text))
242 _, err := time.Parse(time.RFC3339Nano, message.Date)
244 return fmt.Errorf("parse message date: %w", err)