1package persist
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11
12 "github.com/bnema/vev/internal/domain"
13)
14
15const legacyCatalogueBackupSuffix = ".pre-v6.bak"
16
17// Migration describes an automatic, lossless catalogue format migration.
18type Migration struct {
19 Performed bool
20 SourceFormats []uint16
21 TargetFormat uint16
22 RecordCount int
23 BackupPath string
24}
25
26func (p *Persister) migrateLegacyRecords(path string, records []domain.CatalogueRecord) (Migration, error) {
27 if p == nil || p.store == nil {
28 return Migration{}, errPersistenceUnavailable
29 }
30 formats, err := p.recordFormats()
31 if err != nil {
32 return Migration{}, err
33 }
34 legacy := false
35 for _, format := range formats {
36 if format != catalogueRecordVersion {
37 legacy = true
38 break
39 }
40 }
41 if !legacy {
42 return Migration{}, nil
43 }
44
45 raw, err := os.ReadFile(path)
46 if err != nil {
47 return Migration{}, fmt.Errorf("read catalogue backup source: %w", err)
48 }
49 backupPath := path + legacyCatalogueBackupSuffix
50 if err := createMatchingBackup(backupPath, raw); err != nil {
51 return Migration{}, err
52 }
53
54 updates := make(map[string]*domain.CatalogueRecord, len(records))
55 for i := range records {
56 updates[records[i].Name] = &records[i]
57 }
58 if err := p.Apply(updates); err != nil {
59 return Migration{}, fmt.Errorf("write migrated catalogue: %w", err)
60 }
61 return Migration{
62 Performed: true,
63 SourceFormats: formats,
64 TargetFormat: catalogueRecordVersion,
65 RecordCount: len(records),
66 BackupPath: backupPath,
67 }, nil
68}
69
70func (p *Persister) recordFormats() ([]uint16, error) {
71 formats := make(map[uint16]struct{})
72 var decodeErr error
73 p.store.Range(func(key, value []byte) bool {
74 stored, err := decodeStoredRecordValue(string(key), value)
75 if err != nil {
76 decodeErr = err
77 return false
78 }
79 formats[stored.formatVersion] = struct{}{}
80 return true
81 })
82 if decodeErr != nil {
83 return nil, decodeErr
84 }
85 out := make([]uint16, 0, len(formats))
86 for format := range formats {
87 out = append(out, format)
88 }
89 sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
90 return out, nil
91}
92
93func createMatchingBackup(path string, data []byte) error {
94 matched, err := existingBackupMatches(path, data)
95 if err != nil || matched {
96 return err
97 }
98
99 file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
100 if err != nil {
101 return fmt.Errorf("create catalogue backup temporary file: %w", err)
102 }
103 tmp := file.Name()
104 cleanup := func(err error) error {
105 return errors.Join(err, file.Close(), os.Remove(tmp))
106 }
107 if err := file.Chmod(0o600); err != nil {
108 return cleanup(fmt.Errorf("set catalogue backup permissions: %w", err))
109 }
110 if err := writeBackup(file, data); err != nil {
111 return cleanup(fmt.Errorf("write catalogue backup: %w", err))
112 }
113 if err := file.Sync(); err != nil {
114 return cleanup(fmt.Errorf("sync catalogue backup: %w", err))
115 }
116 if err := file.Close(); err != nil {
117 _ = os.Remove(tmp)
118 return fmt.Errorf("close catalogue backup: %w", err)
119 }
120 if err := os.Link(tmp, path); err != nil {
121 _ = os.Remove(tmp)
122 if errors.Is(err, os.ErrExist) {
123 matched, matchErr := existingBackupMatches(path, data)
124 if matchErr != nil {
125 return matchErr
126 }
127 if !matched {
128 return fmt.Errorf("persist: catalogue backup %q disappeared during migration", path)
129 }
130 } else {
131 return fmt.Errorf("publish catalogue backup: %w", err)
132 }
133 } else if err := os.Remove(tmp); err != nil {
134 return fmt.Errorf("remove catalogue backup temporary file: %w", err)
135 }
136 dir, err := os.Open(filepath.Dir(path))
137 if err != nil {
138 return fmt.Errorf("open catalogue backup directory: %w", err)
139 }
140 defer dir.Close()
141 if err := dir.Sync(); err != nil {
142 return fmt.Errorf("sync catalogue backup directory: %w", err)
143 }
144 return nil
145}
146
147func existingBackupMatches(path string, data []byte) (bool, error) {
148 info, err := os.Lstat(path)
149 if errors.Is(err, os.ErrNotExist) {
150 return false, nil
151 }
152 if err != nil {
153 return false, fmt.Errorf("stat existing catalogue backup: %w", err)
154 }
155 if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
156 return false, fmt.Errorf("persist: catalogue backup %q is not a private regular file", path)
157 }
158 existing, err := os.ReadFile(path)
159 if err != nil {
160 return false, fmt.Errorf("read existing catalogue backup: %w", err)
161 }
162 if !bytes.Equal(existing, data) {
163 return false, fmt.Errorf("persist: catalogue backup %q does not match migration source", path)
164 }
165 return true, nil
166}
167
168func writeBackup(file *os.File, data []byte) error {
169 for len(data) > 0 {
170 n, err := file.Write(data)
171 if err != nil {
172 return err
173 }
174 if n == 0 {
175 return io.ErrShortWrite
176 }
177 data = data[n:]
178 }
179 return nil
180}