diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 6caae5b..3a46285 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -113,7 +113,7 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { // buildStore returns the Store implementation that BuildReport should use. // When --state-file is empty an EphemeralStore is used so behaviour is -// identical to pre-persistence runs. Otherwise a JSON-backed FileStore is +// identical to pre-persistence runs. Otherwise a JSON-backed JSONStore is // returned, wired to a platform-specific RecordSerializer. func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, error) { if stateFile == "" { @@ -128,7 +128,7 @@ func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform) } - store, err := internal.NewFileStore(stateFile, platform, serializer) + store, err := internal.NewJSONStore(stateFile, platform, serializer) if err != nil { return nil, fmt.Errorf("creating file store: %w", err) } diff --git a/internal/file_store.go b/internal/json_store.go similarity index 72% rename from internal/file_store.go rename to internal/json_store.go index 4a099ff..ad43c91 100644 --- a/internal/file_store.go +++ b/internal/json_store.go @@ -9,33 +9,32 @@ import ( "io" "os" "path/filepath" - "strconv" "strings" "time" ) -// FileStore is a Store backed by a single JSON file on disk. The on-disk +// JSONStore is a Store backed by a single JSON file on disk. The on-disk // schema is the platform-agnostic State struct; per-broker Record data is // encoded and decoded through the supplied RecordSerializer. -type FileStore struct { +type JSONStore struct { filename string platform string serializer RecordSerializer } -// NewFileStore constructs a FileStore that reads from and writes to the given -// filename. The FileStore does not keep an *os.File open — Load and Save each +// NewJSONStore constructs a JSONStore that reads from and writes to the given +// filename. The JSONStore does not keep an *os.File open — Load and Save each // open the file themselves — so callers do not need to close it. The platform // string is recorded into the saved state and validated on Load so a state // file from a different broker cannot be loaded by mistake. -func NewFileStore(filename string, platform string, serializer RecordSerializer) (*FileStore, error) { +func NewJSONStore(filename string, platform string, serializer RecordSerializer) (*JSONStore, error) { if filename == "" { return nil, fmt.Errorf("filename cannot be empty") } if serializer == nil { return nil, fmt.Errorf("serializer cannot be nil") } - return &FileStore{ + return &JSONStore{ filename: filename, platform: platform, serializer: serializer, @@ -45,7 +44,7 @@ func NewFileStore(filename string, platform string, serializer RecordSerializer) // Load reads the state file and reconstructs the per-symbol FillerQueue map. // A missing file is not an error — it returns an empty map so the first run // against a new state file Just Works. -func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error) { +func (fs *JSONStore) Load(ctx context.Context) (map[string]*FillerQueue, error) { data, err := os.ReadFile(fs.filename) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -96,7 +95,7 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error) // Save serialises the queue map to disk. The write is done via a temp file in // the same directory followed by an atomic rename so a crash mid-write cannot // leave a half-written state file. -func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) error { +func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error { state := State{ Version: StateVersion, Platform: fs.platform, @@ -124,16 +123,6 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er state.Queues[symbol] = persisted } - ext := filepath.Ext(fs.filename) - name, _ := strings.CutSuffix(fs.filename, ext) - backupFilename := name + "." + strconv.FormatInt(time.Now().UnixMilli(), 10) + ext - err := os.Rename(fs.filename, backupFilename) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("rename old state file: %w", err) - } - } - buf := &bytes.Buffer{} enc := json.NewEncoder(buf) enc.SetIndent("", " ") @@ -141,13 +130,37 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er return fmt.Errorf("encoding state: %w", err) } - dst, err := os.Create(fs.filename) + tmp, err := os.CreateTemp(filepath.Dir(fs.filename), filepath.Base(fs.filename)+".*.tmp") if err != nil { - return fmt.Errorf("creating new state file: %w", err) + return fmt.Errorf("creating temp state file: %w", err) + } + tmpFilename := tmp.Name() + defer os.Remove(tmpFilename) + defer tmp.Close() + + if _, err := io.Copy(tmp, buf); err != nil { + return fmt.Errorf("writing temp state file: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("syncing temp state file: %w", err) } - if _, err := io.Copy(dst, buf); err != nil { - return fmt.Errorf("writing to new state file: %w", err) + // Closing the temp file before renaming/moving for cross-platform compatibility + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp state file: %w", err) } + + ext := filepath.Ext(fs.filename) + name, _ := strings.CutSuffix(fs.filename, ext) + backupFilename := name + "." + time.Now().Format("2006-01-02_150405") + ext + if err := os.Rename(fs.filename, backupFilename); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("rename old state file: %w", err) + } + } + if err := os.Rename(tmpFilename, fs.filename); err != nil { + return fmt.Errorf("atomic rename of state file: %w", err) + } + return nil } diff --git a/internal/file_store_test.go b/internal/json_store_test.go similarity index 78% rename from internal/file_store_test.go rename to internal/json_store_test.go index b3c12c6..6ec0539 100644 --- a/internal/file_store_test.go +++ b/internal/json_store_test.go @@ -16,7 +16,7 @@ import ( "go.uber.org/mock/gomock" ) -func TestFileStore_RoundTrip(t *testing.T) { +func TestJSONStore_RoundTrip(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, path := newStore(t, "fake", ser) @@ -58,15 +58,52 @@ func TestFileStore_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("read state file: %v", err) } - if strings.Contains(string(data), `"reader_data"`) { + body := string(data) + if strings.Contains(body, `"reader_data"`) { t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"") } - if !strings.Contains(string(data), `"record_data"`) { + if !strings.Contains(body, `"record_data"`) { t.Errorf("saved state file does not contain expected key \"record_data\"") } + + // Regression: the "record_data" value must be inline JSON, not a base64 + // blob. Version-1 state files encoded the per-record JSON as a base64 + // string (which always starts with "ey" — base64 of '{' followed by '"'). + // After Chunk 1 of the refactor the value is json.RawMessage, which the + // JSON encoder emits inline. roundTripSerializer emits a JSON string per + // symbol, so the file should contain `"record_data": "AAA"` etc. + if strings.Contains(body, `"record_data": "ey`) { + t.Errorf("saved state file still has base64-encoded record_data (version-1 format); want inline JSON") + } + wantInlineAAA := `"record_data": "AAA"` + if !strings.Contains(body, wantInlineAAA) { + t.Errorf("saved state file does not contain inline JSON %q under record_data; got:\n%s", wantInlineAAA, body) + } + + // Sanity: the file must be valid top-level JSON (it always was, but + // confirming the inline form is parseable guards against accidentally + // double-encoding it again in the future). + var parsed struct { + Queues map[string][]struct { + RecordData json.RawMessage `json:"record_data"` + } `json:"queues"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("saved state file is not valid JSON: %v", err) + } + for symbol, lots := range parsed.Queues { + for i, lot := range lots { + if len(lot.RecordData) == 0 { + t.Errorf("symbol %q lot %d: record_data is empty", symbol, i) + } + if !json.Valid(lot.RecordData) { + t.Errorf("symbol %q lot %d: record_data is not valid JSON: %s", symbol, i, lot.RecordData) + } + } + } } -func TestFileStore_SaveSkipsEmptyQueue(t *testing.T) { +func TestJSONStore_SaveSkipsEmptyQueue(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, path := newStore(t, "fake", ser) @@ -96,7 +133,7 @@ func TestFileStore_SaveSkipsEmptyQueue(t *testing.T) { } } -func TestFileStore_LoadEmptyFileReturnsEmpty(t *testing.T) { +func TestJSONStore_LoadEmptyFileReturnsEmpty(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, path := newStore(t, "fake", ser) @@ -119,7 +156,7 @@ func TestFileStore_LoadEmptyFileReturnsEmpty(t *testing.T) { } } -func TestFileStore_LoadMissingFileReturnsEmpty(t *testing.T) { +func TestJSONStore_LoadMissingFileReturnsEmpty(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, path := newStore(t, "fake", ser) @@ -141,7 +178,7 @@ func TestFileStore_LoadMissingFileReturnsEmpty(t *testing.T) { } } -func TestFileStore_LoadVersionMismatch(t *testing.T) { +func TestJSONStore_LoadVersionMismatch(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, path := newStore(t, "fake", ser) @@ -173,7 +210,7 @@ func TestFileStore_LoadVersionMismatch(t *testing.T) { } } -func TestFileStore_LoadPlatformMismatch(t *testing.T) { +func TestJSONStore_LoadPlatformMismatch(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, path := newStore(t, "fake", ser) @@ -205,7 +242,7 @@ func TestFileStore_LoadPlatformMismatch(t *testing.T) { } } -func TestFileStore_SplitAdjustedLotSurvivesRoundTrip(t *testing.T) { +func TestJSONStore_SplitAdjustedLotSurvivesRoundTrip(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, _ := newStore(t, "fake", ser) @@ -251,7 +288,7 @@ func TestFileStore_SplitAdjustedLotSurvivesRoundTrip(t *testing.T) { } } -func TestFileStore_PartiallyFilledLotSurvivesRoundTrip(t *testing.T) { +func TestJSONStore_PartiallyFilledLotSurvivesRoundTrip(t *testing.T) { ctrl := gomock.NewController(t) ser := roundTripSerializer(ctrl) store, _ := newStore(t, "fake", ser) @@ -292,20 +329,20 @@ func TestFileStore_PartiallyFilledLotSurvivesRoundTrip(t *testing.T) { } } -func TestNewFileStore_ValidatesArguments(t *testing.T) { +func TestNewJSONStore_ValidatesArguments(t *testing.T) { ctrl := gomock.NewController(t) ser := mocks.NewMockRecordSerializer(ctrl) - if _, err := internal.NewFileStore("", "fake", ser); err == nil { - t.Errorf("NewFileStore with empty filename should fail") + if _, err := internal.NewJSONStore("", "fake", ser); err == nil { + t.Errorf("NewJSONStore with empty filename should fail") } - if _, err := internal.NewFileStore("/tmp/x", "fake", nil); err == nil { - t.Errorf("NewFileStore with nil serializer should fail") + if _, err := internal.NewJSONStore("/tmp/x", "fake", nil); err == nil { + t.Errorf("NewJSONStore with nil serializer should fail") } } // newRecord builds a MockRecord whose Symbol() returns the given symbol. -// The FileStore only reads Symbol() off the loaded Record during tests +// The JSONStore only reads Symbol() off the loaded Record during tests // (quantity/price/filled come from the persisted struct fields), so all // other Record methods can be left as default-mocked values. func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord { @@ -335,35 +372,39 @@ func newQueue(fillers ...*internal.Filler) *internal.FillerQueue { return q } -// newStore creates a FileStore backed by a temp file and returns the store +// newStore creates a JSONStore backed by a temp file and returns the store // plus the path to the underlying state file. -func newStore(t *testing.T, platform string, ser internal.RecordSerializer) (*internal.FileStore, string) { +func newStore(t *testing.T, platform string, ser internal.RecordSerializer) (*internal.JSONStore, string) { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "state.json") - store, err := internal.NewFileStore(path, platform, ser) + store, err := internal.NewJSONStore(path, platform, ser) if err != nil { - t.Fatalf("NewFileStore returned unexpected error: %v", err) + t.Fatalf("NewJSONStore returned unexpected error: %v", err) } return store, path } // roundTripSerializer returns a serializer mock whose MarshalRecord encodes -// the Symbol into bytes and whose UnmarshalRecord decodes those bytes back -// into a fresh MockRecord returning the encoded Symbol. Tests use this when -// they need Save + Load to round-trip equivalent Records. +// the Symbol as a JSON string (e.g. "AAA") and whose UnmarshalRecord decodes +// those JSON bytes back into a fresh MockRecord returning the encoded Symbol. +// Tests use this when they need Save + Load to round-trip equivalent Records. func roundTripSerializer(ctrl *gomock.Controller) *mocks.MockRecordSerializer { ser := mocks.NewMockRecordSerializer(ctrl) ser.EXPECT(). MarshalRecord(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, r internal.Record) ([]byte, error) { - return []byte(r.Symbol()), nil + DoAndReturn(func(_ context.Context, r internal.Record) (json.RawMessage, error) { + return json.Marshal(r.Symbol()) }). AnyTimes() ser.EXPECT(). UnmarshalRecord(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, b []byte) (internal.Record, error) { - return newRecord(ctrl, string(b)), nil + DoAndReturn(func(_ context.Context, b json.RawMessage) (internal.Record, error) { + var symbol string + if err := json.Unmarshal(b, &symbol); err != nil { + return nil, err + } + return newRecord(ctrl, symbol), nil }). AnyTimes() return ser diff --git a/internal/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go index 059b76d..de1ba5b 100644 --- a/internal/mocks/mocks_gen.go +++ b/internal/mocks/mocks_gen.go @@ -11,6 +11,7 @@ package mocks import ( context "context" + json "encoding/json" reflect "reflect" time "time" @@ -573,10 +574,10 @@ func (m *MockRecordEncoder) EXPECT() *MockRecordEncoderMockRecorder { } // MarshalRecord mocks base method. -func (m *MockRecordEncoder) MarshalRecord(arg0 context.Context, arg1 internal.Record) ([]byte, error) { +func (m *MockRecordEncoder) MarshalRecord(arg0 context.Context, arg1 internal.Record) (json.RawMessage, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1) - ret0, _ := ret[0].([]byte) + ret0, _ := ret[0].(json.RawMessage) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -594,19 +595,19 @@ type MockRecordEncoderMarshalRecordCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockRecordEncoderMarshalRecordCall) Return(arg0 []byte, arg1 error) *MockRecordEncoderMarshalRecordCall { +func (c *MockRecordEncoderMarshalRecordCall) Return(arg0 json.RawMessage, arg1 error) *MockRecordEncoderMarshalRecordCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockRecordEncoderMarshalRecordCall) Do(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordEncoderMarshalRecordCall { +func (c *MockRecordEncoderMarshalRecordCall) Do(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordEncoderMarshalRecordCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRecordEncoderMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordEncoderMarshalRecordCall { +func (c *MockRecordEncoderMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordEncoderMarshalRecordCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -636,7 +637,7 @@ func (m *MockRecordDecoder) EXPECT() *MockRecordDecoderMockRecorder { } // UnmarshalRecord mocks base method. -func (m *MockRecordDecoder) UnmarshalRecord(arg0 context.Context, arg1 []byte) (internal.Record, error) { +func (m *MockRecordDecoder) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (internal.Record, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1) ret0, _ := ret[0].(internal.Record) @@ -663,13 +664,13 @@ func (c *MockRecordDecoderUnmarshalRecordCall) Return(arg0 internal.Record, arg1 } // Do rewrite *gomock.Call.Do -func (c *MockRecordDecoderUnmarshalRecordCall) Do(f func(context.Context, []byte) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall { +func (c *MockRecordDecoderUnmarshalRecordCall) Do(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRecordDecoderUnmarshalRecordCall) DoAndReturn(f func(context.Context, []byte) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall { +func (c *MockRecordDecoderUnmarshalRecordCall) DoAndReturn(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -699,10 +700,10 @@ func (m *MockRecordSerializer) EXPECT() *MockRecordSerializerMockRecorder { } // MarshalRecord mocks base method. -func (m *MockRecordSerializer) MarshalRecord(arg0 context.Context, arg1 internal.Record) ([]byte, error) { +func (m *MockRecordSerializer) MarshalRecord(arg0 context.Context, arg1 internal.Record) (json.RawMessage, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1) - ret0, _ := ret[0].([]byte) + ret0, _ := ret[0].(json.RawMessage) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -720,25 +721,25 @@ type MockRecordSerializerMarshalRecordCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockRecordSerializerMarshalRecordCall) Return(arg0 []byte, arg1 error) *MockRecordSerializerMarshalRecordCall { +func (c *MockRecordSerializerMarshalRecordCall) Return(arg0 json.RawMessage, arg1 error) *MockRecordSerializerMarshalRecordCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockRecordSerializerMarshalRecordCall) Do(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordSerializerMarshalRecordCall { +func (c *MockRecordSerializerMarshalRecordCall) Do(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRecordSerializerMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordSerializerMarshalRecordCall { +func (c *MockRecordSerializerMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall { c.Call = c.Call.DoAndReturn(f) return c } // UnmarshalRecord mocks base method. -func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 []byte) (internal.Record, error) { +func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (internal.Record, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1) ret0, _ := ret[0].(internal.Record) @@ -765,13 +766,13 @@ func (c *MockRecordSerializerUnmarshalRecordCall) Return(arg0 internal.Record, a } // Do rewrite *gomock.Call.Do -func (c *MockRecordSerializerUnmarshalRecordCall) Do(f func(context.Context, []byte) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall { +func (c *MockRecordSerializerUnmarshalRecordCall) Do(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRecordSerializerUnmarshalRecordCall) DoAndReturn(f func(context.Context, []byte) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall { +func (c *MockRecordSerializerUnmarshalRecordCall) DoAndReturn(f func(context.Context, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/state.go b/internal/state.go index ad70c5f..10cb8c2 100644 --- a/internal/state.go +++ b/internal/state.go @@ -2,12 +2,13 @@ package internal import ( "context" + "encoding/json" "github.com/shopspring/decimal" ) // StateVersion is the schema version of the persisted State struct. -const StateVersion = "1" +const StateVersion = "2" // State is the platform-agnostic representation of the buy-queue state that is // written to disk after a successful run and reloaded on the next run. @@ -21,20 +22,20 @@ type State struct { // Record-specific data is kept as opaque bytes so the internal package does // not need to know about any broker package's concrete Record type. type persistedFiller struct { - RecordData []byte `json:"record_data"` + RecordData json.RawMessage `json:"record_data"` Quantity decimal.Decimal `json:"quantity"` Price decimal.Decimal `json:"price"` Filled decimal.Decimal `json:"filled"` } -// RecordEncoder encodes a Record into its broker-specific byte representation. +// RecordEncoder encodes a Record into its broker-specific JSON representation. type RecordEncoder interface { - MarshalRecord(context.Context, Record) ([]byte, error) + MarshalRecord(context.Context, Record) (json.RawMessage, error) } -// RecordDecoder decodes broker-specific bytes back into a Record. +// RecordDecoder decodes broker-specific JSON back into a Record. type RecordDecoder interface { - UnmarshalRecord(context.Context, []byte) (Record, error) + UnmarshalRecord(context.Context, json.RawMessage) (Record, error) } // RecordSerializer composes encoding and decoding of Records. Broker packages diff --git a/internal/trading212/serializer.go b/internal/trading212/serializer.go index 68d4899..dd6b3c0 100644 --- a/internal/trading212/serializer.go +++ b/internal/trading212/serializer.go @@ -40,7 +40,7 @@ func NewRecordSerializer(figi *ofigi.Client) *RecordSerializer { // MarshalRecord encodes the given internal.Record as JSON. It returns an // error if the record is not a trading212.Record (or a *trading212.Record). -func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) ([]byte, error) { +func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) (json.RawMessage, error) { var rec Record switch v := r.(type) { case Record: @@ -69,7 +69,7 @@ func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) ( // UnmarshalRecord decodes a JSON-encoded trading212.Record and re-wires its // natureGetter to the serializer's OpenFIGI client. -func (s *RecordSerializer) UnmarshalRecord(ctx context.Context, data []byte) (internal.Record, error) { +func (s *RecordSerializer) UnmarshalRecord(ctx context.Context, data json.RawMessage) (internal.Record, error) { var state recordState if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("unmarshal trading212 record: %w", err)