Skip to content

Commit 2f35781

Browse files
committed
Merge remote-tracking branch 'benma/notes-migration'
2 parents d649a36 + 6b19012 commit 2f35781

File tree

4 files changed

+137
-56
lines changed

4 files changed

+137
-56
lines changed

backend/accounts/baseaccount.go

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,7 @@ type BaseAccount struct {
7171
offline error
7272

7373
// notes handles transaction notes.
74-
//
75-
// It is a slice for migration purposes: from v4.27.0 to v4.28.0, the account identifiers
76-
// changed. The slice contains all possible instances of where notes are stored. The first
77-
// element is the newest, and other elements are notes stored under legacy names. After
78-
// `Initialize()`, this will always have at least one element.
79-
notes []*notes.Notes
74+
notes *notes.Notes
8075

8176
proposedTxNote string
8277
proposedTxNoteMu sync.Mutex
@@ -151,10 +146,28 @@ func (account *BaseAccount) Initialize(accountIdentifier string) error {
151146
if err != nil {
152147
return err
153148
}
154-
account.notes = []*notes.Notes{txNotes}
149+
account.notes = txNotes
155150

156-
// Append legacy notes (notes stored in files based on obsolete account identifiers). Account
157-
// identifiers changed from v4.27.0 to v4.28.0.
151+
if err := account.migrateLegacyNotes(); err != nil {
152+
return err
153+
}
154+
155+
// An account syncdone event is generated when new rates are available. This allows the frontend
156+
// to reload the relevant data.
157+
if account.config.RateUpdater != nil {
158+
account.config.RateUpdater.Observe(func(e observable.Event) {
159+
if e.Subject == rates.RatesEventSubject {
160+
account.config.OnEvent(types.EventSyncDone)
161+
}
162+
})
163+
}
164+
165+
return nil
166+
}
167+
168+
// Migrate legacy notes (notes stored in files based on obsolete account identifiers). Account
169+
// identifiers changed from v4.27.0 to v4.28.0.
170+
func (account *BaseAccount) migrateLegacyNotes() error {
158171
if len(account.Config().Config.SigningConfigurations) == 0 {
159172
return nil
160173
}
@@ -199,18 +212,11 @@ func (account *BaseAccount) Initialize(accountIdentifier string) error {
199212
if err != nil {
200213
return err
201214
}
202-
account.notes = append(account.notes, legacyNotes)
203-
}
204-
205-
// An account syncdone event is generated when new rates are available. This allows the frontend to reload the relevant data.
206-
if account.config.RateUpdater != nil {
207-
account.config.RateUpdater.Observe(func(e observable.Event) {
208-
if e.Subject == rates.RatesEventSubject {
209-
account.config.OnEvent(types.EventSyncDone)
210-
}
211-
})
215+
if err := account.notes.MergeLegacy(legacyNotes); err != nil {
216+
return err
217+
}
218+
_ = os.Remove(notesFile)
212219
}
213-
214220
return nil
215221
}
216222

@@ -235,31 +241,17 @@ func (account *BaseAccount) GetAndClearProposedTxNote() string {
235241

236242
// SetTxNote implements accounts.Account.
237243
func (account *BaseAccount) SetTxNote(txID string, note string) error {
238-
// The notes slice is guaranteed to have at least one element by BaseAccount.Initialize.
239-
if err := account.notes[0].SetTxNote(txID, note); err != nil {
244+
if err := account.notes.SetTxNote(txID, note); err != nil {
240245
return err
241246
}
242-
// Delete the notes in legacy files. Don't really care if it fails.
243-
for i, notes := range account.notes[1:] {
244-
if err := notes.SetTxNote(txID, ""); err != nil {
245-
account.log.WithError(err).Errorf("Can't delete a note from a legacy file idx=%d", i)
246-
}
247-
}
248247
// Prompt refresh.
249248
account.config.OnEvent(types.EventStatusChanged)
250249
return nil
251250
}
252251

253252
// TxNote fetches a note for a transcation. Returns the empty string if no note was found.
254253
func (account *BaseAccount) TxNote(txID string) string {
255-
// Take the first note we can find. The first slice element is the regular location of notes,
256-
// the other elements lookup notes in legacy locations, so they are not lost when upgrading.
257-
for _, notes := range account.notes {
258-
if note := notes.TxNote(txID); note != "" {
259-
return note
260-
}
261-
}
262-
return ""
254+
return account.notes.TxNote(txID)
263255
}
264256

265257
// ExportCSV implements accounts.Account.

backend/accounts/baseaccount_test.go

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,28 +74,40 @@ func TestBaseAccount(t *testing.T) {
7474
// This tests notes migration from v4.27.0 to v4.28.0.
7575
require.NoError(t,
7676
os.WriteFile(
77-
path.Join(cfg.NotesFolder, "account-54b4597c3a5c48177ef2b12c97e0cb30b6fef0b431e7821675b17704c330ce5d-tbtc.json"),
78-
[]byte(`{"transactions": { "legacy-1": "legacy note in unified account" }}`),
77+
path.Join(cfg.NotesFolder, "test-account-identifier.json"),
78+
[]byte(`{"transactions": { "conflict": "conflict note new" }}`),
79+
0666,
80+
),
81+
)
82+
83+
legacyNotesFilename1 := path.Join(cfg.NotesFolder, "account-54b4597c3a5c48177ef2b12c97e0cb30b6fef0b431e7821675b17704c330ce5d-tbtc.json")
84+
legacyNotesFilename2 := path.Join(cfg.NotesFolder, "account-989b84ec36f0b84e7926f9f5715e4b59a0592993b1fa4b70836addbcb0cb6e09-tbtc-p2pkh.json")
85+
legacyNotesFilename3 := path.Join(cfg.NotesFolder, "account-e60b99507ba983d522f15932dbe1214e99de13c56e2bea75ed9c285f7c013117-tbtc-p2wpkh.json")
86+
legacyNotesFilename4 := path.Join(cfg.NotesFolder, "account-9e779e0d49e77236f0769e0bab2fd656958d3fd023dce2388525ee66fead88bb-tbtc-p2wpkh-p2sh.json")
87+
require.NoError(t,
88+
os.WriteFile(
89+
legacyNotesFilename1,
90+
[]byte(`{"transactions": { "legacy-1": "legacy note in unified account", "conflict": "conflict note old" }}`),
7991
0666,
8092
),
8193
)
8294
require.NoError(t,
8395
os.WriteFile(
84-
path.Join(cfg.NotesFolder, "account-989b84ec36f0b84e7926f9f5715e4b59a0592993b1fa4b70836addbcb0cb6e09-tbtc-p2pkh.json"),
96+
legacyNotesFilename2,
8597
[]byte(`{"transactions": { "legacy-2": "legacy note in split account, p2pkh" }}`),
8698
0666,
8799
),
88100
)
89101
require.NoError(t,
90102
os.WriteFile(
91-
path.Join(cfg.NotesFolder, "account-e60b99507ba983d522f15932dbe1214e99de13c56e2bea75ed9c285f7c013117-tbtc-p2wpkh.json"),
103+
legacyNotesFilename3,
92104
[]byte(`{"transactions": { "legacy-3": "legacy note in split account, p2wpkh" }}`),
93105
0666,
94106
),
95107
)
96108
require.NoError(t,
97109
os.WriteFile(
98-
path.Join(cfg.NotesFolder, "account-9e779e0d49e77236f0769e0bab2fd656958d3fd023dce2388525ee66fead88bb-tbtc-p2wpkh-p2sh.json"),
110+
legacyNotesFilename4,
99111
[]byte(`{"transactions": { "legacy-4": "legacy note in split account, p2wpkh-p2sh" }}`),
100112
0666,
101113
),
@@ -150,6 +162,17 @@ func TestBaseAccount(t *testing.T) {
150162
})
151163

152164
t.Run("notes", func(t *testing.T) {
165+
// Legacy note files have been deleted after migration.
166+
for _, filename := range []string{
167+
legacyNotesFilename1,
168+
legacyNotesFilename2,
169+
legacyNotesFilename3,
170+
legacyNotesFilename4,
171+
} {
172+
_, err := os.Stat(filename)
173+
require.True(t, os.IsNotExist(err))
174+
}
175+
153176
require.Equal(t, "", account.GetAndClearProposedTxNote())
154177
account.ProposeTxNote("test note")
155178
require.Equal(t, "test note", account.GetAndClearProposedTxNote())
@@ -161,21 +184,14 @@ func TestBaseAccount(t *testing.T) {
161184
require.Equal(t, "another test note", account.TxNote("test-tx-id"))
162185

163186
// Test notes migration from v4.27.0 to v4.28.0
187+
require.Equal(t, "conflict note new", account.TxNote("conflict"))
164188
require.Equal(t, "legacy note in unified account", account.TxNote("legacy-1"))
165189
require.Equal(t, "legacy note in split account, p2pkh", account.TxNote("legacy-2"))
166190
require.Equal(t, "legacy note in split account, p2wpkh", account.TxNote("legacy-3"))
167191
require.Equal(t, "legacy note in split account, p2wpkh-p2sh", account.TxNote("legacy-4"))
168192
// Setting a note sets it in the main notes file, and wipes it out in legacy note files.
169193
require.NoError(t, account.SetTxNote("legacy-1", "updated legacy note"))
170194
require.Equal(t, "updated legacy note", account.TxNote("legacy-1"))
171-
contents, err := os.ReadFile(path.Join(cfg.NotesFolder, "account-54b4597c3a5c48177ef2b12c97e0cb30b6fef0b431e7821675b17704c330ce5d-tbtc.json"))
172-
require.NoError(t, err)
173-
require.JSONEq(t, `{"transactions":{}}`, string(contents))
174-
175-
// Test that the notes were persisted under the right file name with the right contents.
176-
contents, err = os.ReadFile(path.Join(cfg.NotesFolder, "test-account-identifier.json"))
177-
require.NoError(t, err)
178-
require.JSONEq(t, `{"transactions":{"legacy-1": "updated legacy note", "test-tx-id": "another test note"}}`, string(contents))
179195
})
180196

181197
t.Run("exportCSV", func(t *testing.T) {

backend/accounts/notes/notes.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import (
2626
// maxNoteLen is the maximum length per note.
2727
const maxNoteLen = 1024
2828

29-
// NotesData is the notes JSON data serialized to disk.
30-
type notesData struct {
29+
// Data is the notes JSON data serialized to disk.
30+
type Data struct {
3131
// More fields to be added when we can label more stuff, e.g. receive addresses, utxos, etc.
3232

3333
// a map of transaction ID to transaction note.
@@ -36,27 +36,28 @@ type notesData struct {
3636

3737
// read deserializes the json files into notes. If the file does not exist yet, no error is
3838
// returned, and the struct is retruned with default values.
39-
func read(filename string) (*notesData, error) {
39+
func read(filename string) (*Data, error) {
4040
file, err := os.Open(filename)
4141
if err != nil {
4242
if os.IsNotExist(err) {
43-
return &notesData{}, nil
43+
return &Data{}, nil
4444
}
4545
return nil, errp.WithStack(err)
4646
}
4747
defer file.Close() //nolint:errcheck
48-
var notes notesData
48+
var notes Data
4949
if err := json.NewDecoder(file).Decode(&notes); err != nil {
5050
return nil, errp.WithStack(err)
5151
}
5252
return &notes, nil
5353
}
5454

55-
func write(data *notesData, filename string) error {
55+
func write(data *Data, filename string) error {
5656
file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
5757
if err != nil {
5858
return err
5959
}
60+
defer func() { _ = file.Close() }()
6061
encoder := json.NewEncoder(file)
6162
encoder.SetIndent("", " ")
6263
if err := encoder.Encode(data); err != nil {
@@ -68,7 +69,7 @@ func write(data *notesData, filename string) error {
6869
// Notes is a high level helper for notes, allowing you to read and set notes for transactions.
6970
type Notes struct {
7071
filename string
71-
data *notesData
72+
data *Data
7273
dataMu sync.RWMutex
7374
}
7475

@@ -116,3 +117,29 @@ func (notes *Notes) TxNote(txID string) string {
116117

117118
return notes.data.TransactionNotes[txID]
118119
}
120+
121+
// Data retrieves all stored notes. You must not modify the returned object.
122+
func (notes *Notes) Data() *Data {
123+
notes.dataMu.RLock()
124+
defer notes.dataMu.RUnlock()
125+
return notes.data
126+
}
127+
128+
// MergeLegacy merges the notes from an older/legacy notes file. Current/new notes take priority in
129+
// case of conflict.
130+
func (notes *Notes) MergeLegacy(legacy *Notes) error {
131+
notes.dataMu.Lock()
132+
defer notes.dataMu.Unlock()
133+
134+
if notes.data.TransactionNotes == nil {
135+
notes.data.TransactionNotes = map[string]string{}
136+
}
137+
138+
legacyData := legacy.Data()
139+
for txID, note := range legacyData.TransactionNotes {
140+
if _, ok := notes.data.TransactionNotes[txID]; !ok {
141+
notes.data.TransactionNotes[txID] = note
142+
}
143+
}
144+
return write(notes.data, notes.filename)
145+
}

backend/accounts/notes/notes_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ func TestNotes(t *testing.T) {
3939
require.NoError(t, notes.SetTxNote("tx-id-2", "note for tx-id-2"))
4040
require.Equal(t, "note for tx-id-1", notes.TxNote("tx-id-1"))
4141
require.Equal(t, "note for tx-id-2", notes.TxNote("tx-id-2"))
42+
43+
require.Equal(t,
44+
&Data{
45+
TransactionNotes: map[string]string{
46+
"tx-id-1": "note for tx-id-1",
47+
"tx-id-2": "note for tx-id-2",
48+
},
49+
},
50+
notes.Data())
4251
}
4352

4453
// TestNotesPersisted checks that notes are persisted.
@@ -68,3 +77,40 @@ func TestMaxLen(t *testing.T) {
6877
require.NoError(t, notes.SetTxNote("tx-id", strings.Repeat("x", 1024)))
6978
require.Error(t, notes.SetTxNote("tx-id", strings.Repeat("x", 1025)))
7079
}
80+
81+
func TestMergeLegacy(t *testing.T) {
82+
filename := test.TstTempFile("account-notes")
83+
notes, err := LoadNotes(filename)
84+
require.NoError(t, err)
85+
require.NoError(t, notes.SetTxNote("tx-id-1", "note for tx-id-1"))
86+
require.NoError(t, notes.SetTxNote("tx-id-2", "note for tx-id-2"))
87+
88+
legacyNotes, err := LoadNotes(test.TstTempFile("legacy-notes"))
89+
require.NoError(t, err)
90+
require.NoError(t, legacyNotes.SetTxNote("tx-id-1", "legacy note for tx-id-1"))
91+
require.NoError(t, legacyNotes.SetTxNote("tx-id-3", "legacy note for tx-id-3"))
92+
93+
require.NoError(t, notes.MergeLegacy(legacyNotes))
94+
require.Equal(t,
95+
&Data{
96+
TransactionNotes: map[string]string{
97+
"tx-id-1": "note for tx-id-1",
98+
"tx-id-2": "note for tx-id-2",
99+
"tx-id-3": "legacy note for tx-id-3",
100+
},
101+
},
102+
notes.Data())
103+
104+
// Check that the merged notes were persisted.
105+
notes, err = LoadNotes(filename)
106+
require.NoError(t, err)
107+
require.Equal(t,
108+
&Data{
109+
TransactionNotes: map[string]string{
110+
"tx-id-1": "note for tx-id-1",
111+
"tx-id-2": "note for tx-id-2",
112+
"tx-id-3": "legacy note for tx-id-3",
113+
},
114+
},
115+
notes.Data())
116+
}

0 commit comments

Comments
 (0)