diff --git a/.golangci.yaml b/.golangci.yaml index c2497ebd..be59ee23 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -19,7 +19,7 @@ linters: - errname - errorlint - exhaustive - - exhaustruct + - exhaustruct_v5 - exptostd - fatcontext - funlen @@ -122,7 +122,7 @@ linters: rules: - path: _test\.go linters: - - exhaustruct + - exhaustruct_v5 - funlen # NOTE: false positive to ignore in this file. - path: command/scenario_test\.go diff --git a/README.md b/README.md index 93663796..f12adfef 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,31 @@ In `eventually` there are a couple of implementation types: Pick this when you want snapshot-style reads but still need the event log for projections, auditing, or downstream consumers. +- **`postgres.TransactionAwareAggregateRepository`** uses a caller-owned + PostgreSQL transaction for both snapshot reads and writes. Supply a function + that retrieves a `pgx.Tx` from the operation's context: + + ```go + userRepository := postgres.NewTransactionAwareAggregateRepository( + unitofwork.TxFromContext, // func(context.Context) (pgx.Tx, bool) + UserType, + userSerde, + messageSerde, + ) + ``` + + This repository requires a transaction for every `Get` and `Save`; a missing + transaction returns `postgres.ErrTransactionRequired`. It needs no connection + pool and never begins, commits, or rolls back transactions. Configure table + names with the same `WithAggregateTableName`, `WithEventsTableName`, and + `WithStreamsTableName` options used by `AggregateRepository`. + + The unit of work must open a **Serializable** transaction, propagate it through + context, and roll back if any operation fails. `Save` success means the snapshot + and events are staged; only the unit-of-work commit makes them durable. After a + failed or rolled-back save, discard affected aggregate instances and reload + before retrying the command, because saving flushes their recorded events. + ### CQRS with Commands and Queries CQRS - or _Command/Query Responsibility Segregation_ - splits the write path from the read path. diff --git a/command/command.go b/command/command.go index 382fe0ac..a12a8a92 100644 --- a/command/command.go +++ b/command/command.go @@ -54,7 +54,7 @@ func FromGenericEnvelope[T Command](cmd GenericEnvelope) (Envelope[T], bool) { }, true } - return Envelope[T]{}, false //nolint:exhaustruct // This is a zero value anyway. + return Envelope[T]{}, false //nolint:exhaustruct_v5 // This is a zero value anyway. } // ToEnvelope is a convenience function that wraps the provided Command type diff --git a/examples/todolist/internal/todolist/add_item_command.go b/examples/todolist/internal/todolist/add_item_command.go index 8fa1dad9..b7254f1a 100644 --- a/examples/todolist/internal/todolist/add_item_command.go +++ b/examples/todolist/internal/todolist/add_item_command.go @@ -20,7 +20,7 @@ type AddItemCommand struct { // Name implements message.Message. func (AddItemCommand) Name() string { return "AddTodoListItem" } -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ command.Handler[AddItemCommand] = AddItemCommandHandler{} // AddItemCommandHandler is the command.Handler for AddItemCommand commands. diff --git a/examples/todolist/internal/todolist/create_command.go b/examples/todolist/internal/todolist/create_command.go index 3e8e6ac7..2081cc82 100644 --- a/examples/todolist/internal/todolist/create_command.go +++ b/examples/todolist/internal/todolist/create_command.go @@ -18,7 +18,7 @@ type CreateCommand struct { // Name implements message.Message. func (CreateCommand) Name() string { return "CreateTodoList" } -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ command.Handler[CreateCommand] = CreateCommandHandler{} // CreateCommandHandler is the Command Handler for CreateCommand commands. diff --git a/examples/todolist/internal/todolist/delete_item_command.go b/examples/todolist/internal/todolist/delete_item_command.go index 475e57a9..46109cb0 100644 --- a/examples/todolist/internal/todolist/delete_item_command.go +++ b/examples/todolist/internal/todolist/delete_item_command.go @@ -16,7 +16,7 @@ type DeleteItemCommand struct { // Name implements message.Message. func (DeleteItemCommand) Name() string { return "DeleteTodoListItem" } -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ command.Handler[DeleteItemCommand] = DeleteItemCommandHandler{} // DeleteItemCommandHandler is the command.Handler for DeleteItemCommand diff --git a/examples/todolist/internal/todolist/get_query.go b/examples/todolist/internal/todolist/get_query.go index 6ef99982..5cf4784c 100644 --- a/examples/todolist/internal/todolist/get_query.go +++ b/examples/todolist/internal/todolist/get_query.go @@ -17,7 +17,7 @@ type GetQuery struct { // Name implements message.Message. func (GetQuery) Name() string { return "GetTodoList" } -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ query.Handler[GetQuery, *TodoList] = GetQueryHandler{} // GetQueryHandler handles a GetQuery by returning the TodoList specified diff --git a/examples/todolist/internal/todolist/mark_item_as_done_command.go b/examples/todolist/internal/todolist/mark_item_as_done_command.go index 6409d640..6f0193d8 100644 --- a/examples/todolist/internal/todolist/mark_item_as_done_command.go +++ b/examples/todolist/internal/todolist/mark_item_as_done_command.go @@ -17,7 +17,7 @@ type MarkItemAsDoneCommand struct { // Name implements message.Message. func (MarkItemAsDoneCommand) Name() string { return "MarkTodoListItemAsDone" } -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ command.Handler[MarkItemAsDoneCommand] = MarkItemAsDoneCommandHandler{} // MarkItemAsDoneCommandHandler is the command.Handler for diff --git a/examples/todolist/internal/todolist/mark_item_as_pending_command.go b/examples/todolist/internal/todolist/mark_item_as_pending_command.go index 3a279a2d..03ae7fcf 100644 --- a/examples/todolist/internal/todolist/mark_item_as_pending_command.go +++ b/examples/todolist/internal/todolist/mark_item_as_pending_command.go @@ -17,7 +17,7 @@ type MarkItemAsPendingCommand struct { // Name implements message.Message. func (MarkItemAsPendingCommand) Name() string { return "MarkTodoListItemAsPending" } -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ command.Handler[MarkItemAsPendingCommand] = MarkItemAsPendingCommandHandler{} // MarkItemAsPendingCommandHandler is the command.Handler for diff --git a/examples/todolist/internal/todolist/todolist.go b/examples/todolist/internal/todolist/todolist.go index dce6d584..67d294d1 100644 --- a/examples/todolist/internal/todolist/todolist.go +++ b/examples/todolist/internal/todolist/todolist.go @@ -80,7 +80,7 @@ func (tl *TodoList) Apply(evt event.Event) error { tl.CreationTime = evt.CreationTime case ItemWasAdded: - item := &Item{} //nolint:exhaustruct // Applied below. + item := &Item{} //nolint:exhaustruct_v5 // Applied below. if err := item.Apply(evt); err != nil { return fmt.Errorf("todolist.TodoList.Apply: failed to apply item event, %w", err) } diff --git a/examples/todolist/main.go b/examples/todolist/main.go index 831c09f6..37f24161 100644 --- a/examples/todolist/main.go +++ b/examples/todolist/main.go @@ -100,7 +100,7 @@ func run() error { //nolint:funlen // Single linear wire-up of the service; spli connectgrpcreflect.NewStaticReflector(todolistv1connect.TodoListServiceName), )) - srv := &http.Server{ //nolint:exhaustruct // Stdlib struct with many optional fields; defaults are fine. + srv := &http.Server{ //nolint:exhaustruct_v5 // Stdlib struct with many optional fields; defaults are fine. Addr: cfg.Server.Address, Handler: mux, ReadTimeout: cfg.Server.ReadTimeout, diff --git a/flake.lock b/flake.lock index 225d4a91..1a36b130 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1781577229, - "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "lastModified": 1790822859, + "narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 495d157c..d28b8f40 100644 --- a/flake.nix +++ b/flake.nix @@ -13,8 +13,8 @@ config.allowUnfree = true; }; - go = pkgs.go_1_26; - withOurGoVersion = pkg: pkg.override { buildGoModule = pkgs.buildGo126Module; }; + go = pkgs.go_1_27; + withOurGoVersion = pkg: pkg.override { buildGoModule = pkgs.buildGo127Module; }; in { devShells.default = with pkgs; mkShell { diff --git a/go.work.sum b/go.work.sum index 976f999b..ba61884f 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,11 +1,14 @@ cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/accessapproval v1.11.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI= cloud.google.com/go/accessapproval v1.12.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI= cloud.google.com/go/accesscontextmanager v1.12.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k= cloud.google.com/go/accesscontextmanager v1.13.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k= cloud.google.com/go/aiplatform v1.124.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= +cloud.google.com/go/aiplatform v1.126.0/go.mod h1:iR3za3evdprLe1XL2pLu0cYVCuTbc87QG0pgvcgiJlE= cloud.google.com/go/analytics v0.33.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc= cloud.google.com/go/analytics v0.34.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc= cloud.google.com/go/apigateway v1.10.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk= @@ -16,6 +19,7 @@ cloud.google.com/go/apigeeregistry v0.13.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/Q cloud.google.com/go/apigeeregistry v0.14.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0= cloud.google.com/go/appengine v1.12.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM= cloud.google.com/go/appengine v1.13.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM= +cloud.google.com/go/appengine v1.15.0/go.mod h1:/8gGZsOX5GDjOo4mAWk8IV59p2991dxTbEtKIlhDjzU= cloud.google.com/go/area120 v0.13.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y= cloud.google.com/go/area120 v0.14.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y= cloud.google.com/go/artifactregistry v1.23.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8= @@ -25,6 +29,7 @@ cloud.google.com/go/asset v1.26.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtC cloud.google.com/go/assuredworkloads v1.16.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss= cloud.google.com/go/assuredworkloads v1.17.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss= cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/automl v1.18.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI= cloud.google.com/go/automl v1.19.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI= @@ -35,6 +40,7 @@ cloud.google.com/go/batch v1.18.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZc cloud.google.com/go/beyondcorp v1.5.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY= cloud.google.com/go/beyondcorp v1.6.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY= cloud.google.com/go/bigquery v1.76.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= +cloud.google.com/go/bigquery v1.83.0/go.mod h1:cc0XscySNQNuHBxuZSg5yyxFsg/ZHAfViAG49gJbWew= cloud.google.com/go/bigtable v1.46.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4= cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4= cloud.google.com/go/billing v1.24.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk= @@ -51,18 +57,24 @@ cloud.google.com/go/clouddms v1.11.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD cloud.google.com/go/clouddms v1.12.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk= cloud.google.com/go/cloudtasks v1.16.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= cloud.google.com/go/cloudtasks v1.17.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= +cloud.google.com/go/cloudtasks v1.19.0/go.mod h1:8q8wNubq0jFvXW5Pz8P3O7QWJBXOmfrY918FqTgIqHA= cloud.google.com/go/compute v1.60.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute v1.61.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/contactcenterinsights v1.20.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ= cloud.google.com/go/contactcenterinsights v1.21.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ= cloud.google.com/go/container v1.49.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs= +cloud.google.com/go/container v1.54.0/go.mod h1:/ZI9J3uuAQh0O3/n9qxkdTVCDB6kuwxEpC9MvEWnf/Q= cloud.google.com/go/containeranalysis v0.17.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ= cloud.google.com/go/containeranalysis v0.18.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ= +cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ= cloud.google.com/go/datacatalog v1.29.0/go.mod h1:MP8V3kNuESnwMk4mB6zdWmw/4KQ5xZ8dyUNVsggqN5I= cloud.google.com/go/datacatalog v1.30.0/go.mod h1:MP8V3kNuESnwMk4mB6zdWmw/4KQ5xZ8dyUNVsggqN5I= +cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= cloud.google.com/go/dataflow v0.14.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ= cloud.google.com/go/dataflow v0.15.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ= +cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ= cloud.google.com/go/dataform v0.17.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk= cloud.google.com/go/dataform v0.18.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk= cloud.google.com/go/datafusion v1.11.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc= @@ -73,6 +85,7 @@ cloud.google.com/go/dataplex v1.32.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGog cloud.google.com/go/dataplex v1.33.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGogx3+RAgfIt8= cloud.google.com/go/dataproc/v2 v2.19.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI= cloud.google.com/go/dataproc/v2 v2.20.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI= +cloud.google.com/go/dataproc/v2 v2.25.0/go.mod h1:hkiM6kzc8CwLGoquMN1oghyhuI1fE0girmChH4h9W7w= cloud.google.com/go/dataqna v0.12.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs= cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs= cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8= @@ -87,8 +100,10 @@ cloud.google.com/go/dlp v1.32.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6 cloud.google.com/go/dlp v1.33.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q= cloud.google.com/go/documentai v1.46.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c= cloud.google.com/go/documentai v1.47.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c= +cloud.google.com/go/documentai v1.49.0/go.mod h1:VyQA+SxPnCPlVLSJ5UcFx+LQm8JCzK7uUXdkOaAHvG8= cloud.google.com/go/domains v0.13.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk= cloud.google.com/go/domains v0.14.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk= +cloud.google.com/go/domains v0.16.0/go.mod h1:O5AhaEyUAgZC2X4M10nSu3dQt2cJLtbjhtrNrdeSPF8= cloud.google.com/go/edgecontainer v1.7.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE= cloud.google.com/go/edgecontainer v1.8.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE= cloud.google.com/go/errorreporting v0.7.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84= @@ -110,10 +125,12 @@ cloud.google.com/go/gkehub v0.19.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNf cloud.google.com/go/gkehub v0.20.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0= cloud.google.com/go/gkemulticloud v1.9.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM= cloud.google.com/go/gkemulticloud v1.10.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM= +cloud.google.com/go/grafeas v0.3.17/go.mod h1:MLYFFap01tm1Rbcd0AT6V2NW1ho45dbX+AxNEcprExY= cloud.google.com/go/gsuiteaddons v1.10.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg= cloud.google.com/go/gsuiteaddons v1.11.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg= cloud.google.com/go/iam v1.9.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/iam v1.10.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0= cloud.google.com/go/iap v1.15.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0= cloud.google.com/go/iap v1.16.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0= cloud.google.com/go/ids v1.8.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ= @@ -128,8 +145,10 @@ cloud.google.com/go/lifesciences v0.13.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTL cloud.google.com/go/lifesciences v0.14.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U= cloud.google.com/go/logging v1.16.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= cloud.google.com/go/logging v1.17.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg= cloud.google.com/go/longrunning v0.11.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/longrunning v0.12.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= cloud.google.com/go/managedidentities v1.10.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA= cloud.google.com/go/managedidentities v1.11.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA= cloud.google.com/go/maps v1.33.0/go.mod h1:HH1V8tduMn+b9oRMCdl3vok98uvHco/wElZXyJQ/9kU= @@ -142,6 +161,7 @@ cloud.google.com/go/metastore v1.17.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG4 cloud.google.com/go/metastore v1.18.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs= cloud.google.com/go/monitoring v1.27.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/monitoring v1.28.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/networkconnectivity v1.24.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg= cloud.google.com/go/networkconnectivity v1.25.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg= cloud.google.com/go/networkmanagement v1.26.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU= @@ -175,6 +195,7 @@ cloud.google.com/go/recommendationengine v0.12.0/go.mod h1:UP9cN46tDpZ/N57eDYIWe cloud.google.com/go/recommendationengine v0.13.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k= cloud.google.com/go/recommender v1.16.0/go.mod h1:INRBLfBQJCrgPqjBVFht4OjaFq/WhB/c5V1sqBOdX8g= cloud.google.com/go/recommender v1.17.0/go.mod h1:INRBLfBQJCrgPqjBVFht4OjaFq/WhB/c5V1sqBOdX8g= +cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU= cloud.google.com/go/redis v1.21.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= cloud.google.com/go/redis v1.22.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= cloud.google.com/go/resourcemanager v1.13.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE= @@ -192,15 +213,19 @@ cloud.google.com/go/security v1.22.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/Iu cloud.google.com/go/security v1.23.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/IujpXFJalFK0= cloud.google.com/go/securitycenter v1.42.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U= cloud.google.com/go/securitycenter v1.43.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U= +cloud.google.com/go/securitycenter v1.46.0/go.mod h1:7mAlzsCsKlEVmciAFORl431laDGpoKGFkSQndAzFs30= cloud.google.com/go/servicedirectory v1.15.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk= cloud.google.com/go/servicedirectory v1.16.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk= +cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk= cloud.google.com/go/shell v1.11.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY= cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY= cloud.google.com/go/spanner v1.90.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0= cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0= +cloud.google.com/go/spanner v1.95.0/go.mod h1:Z2+83J5oVDmd1n5ntVMmjEuiNoXOpAyNeG7y1tuEHk0= cloud.google.com/go/speech v1.33.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8= cloud.google.com/go/speech v1.34.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8= cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/storagetransfer v1.16.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= cloud.google.com/go/storagetransfer v1.17.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= cloud.google.com/go/talent v1.11.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y= @@ -215,8 +240,10 @@ cloud.google.com/go/translate v1.15.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD cloud.google.com/go/translate v1.16.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU= cloud.google.com/go/video v1.30.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk= cloud.google.com/go/video v1.31.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk= +cloud.google.com/go/video v1.33.0/go.mod h1:hEx8TNpQT6kdjMVsywePvT8BCb63Ee3F/R0GRa9wnzo= cloud.google.com/go/videointelligence v1.15.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4= cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4= +cloud.google.com/go/videointelligence v1.17.0/go.mod h1:Phxz7AQpvXoOvz+KrrOZEJRo4CDgYXMDVDqhCtdF1jc= cloud.google.com/go/vision/v2 v2.12.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ= cloud.google.com/go/vision/v2 v2.13.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ= cloud.google.com/go/vmmigration v1.13.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY= @@ -243,11 +270,16 @@ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/ClickHouse/clickhouse-go v1.4.3/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3/go.mod h1:dppbR7CwXD4pgtV9t3wD1812RaLDcBjtblcDF5f1vI0= +github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0/go.mod h1:I7kE2kM3qCr9QPT4cU4cCFYkEpVyVr16YOGUHzy+nR0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= +github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/aws/aws-sdk-go v1.49.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.16.16/go.mod h1:SwiyXi/1zTUZ6KIAmLK5V5ll8SiURNUYOqTerZPaF9k= @@ -265,38 +297,50 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11/go.mod h1:fmgDANqTUCxciViKl9hb/ github.com/aws/smithy-go v1.13.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cockroachdb/cockroach-go/v2 v2.1.1/go.mod h1:7NtUnP6eK+l6k483WSYNrq3Kb23bWV10IRV1TyeSpwM= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/form3tech-oss/jwt-go v3.2.5+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= -github.com/get-eventually/go-eventually v0.4.0/go.mod h1:sp7/2JOA7HCwVhhmoYOIq51Re0GLZ6tsxoSs8i8V01I= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= @@ -310,7 +354,7 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALr github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4= github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -320,6 +364,7 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcs github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= @@ -329,16 +374,17 @@ github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1: github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= github.com/pierrec/lz4/v4 v4.1.16/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rqlite/gorqlite v0.0.0-20230708021416-2acd02b70b79/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/snowflakedb/gosnowflake v1.6.19/go.mod h1:FM1+PWUdwB9udFDsXdfD58NONC0m+MlOSmQRvimobSM= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= @@ -350,38 +396,54 @@ gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2 go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/api v0.287.1/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4/go.mod h1:fJ2lYaWjqNknJyQBOCd0fA3HnEElJqGplH71a2txi+g= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg= diff --git a/internal/user/create_user.go b/internal/user/create_user.go index 01efd070..a6991802 100644 --- a/internal/user/create_user.go +++ b/internal/user/create_user.go @@ -11,7 +11,7 @@ import ( "github.com/get-eventually/go-eventually/command" ) -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var ( _ command.Command = CreateCommand{} _ command.Handler[CreateCommand] = CreateCommandHandler{} diff --git a/internal/user/serde.go b/internal/user/serde.go index 3104ef38..dd8e77fd 100644 --- a/internal/user/serde.go +++ b/internal/user/serde.go @@ -52,7 +52,7 @@ func protoDeserializer(src *userv1.User) (*User, error) { return nil, fmt.Errorf("user.protoDeserialize: failed to deserialize user id, %w", err) } - user := &User{ //nolint:exhaustruct // Other fields will be set by eventually. + user := &User{ //nolint:exhaustruct_v5 // Other fields will be set by eventually. id: id, firstName: src.FirstName, lastName: src.LastName, diff --git a/postgres/aggregate_repository.go b/postgres/aggregate_repository.go index a29b0a18..2e7194dc 100644 --- a/postgres/aggregate_repository.go +++ b/postgres/aggregate_repository.go @@ -16,20 +16,144 @@ import ( "github.com/get-eventually/go-eventually/version" ) -// AggregateRepository implements the aggregate.Repository interface -// for PostgreSQL databases. -// -// This implementation uses the "aggregates" table (by default) in the database -// as its main operational table. -// -// At the same time, it also writes -// to both "events" and "event_streams" to append the Domain events -// recorded by Aggregate Roots. These updates are performed within the same transaction. -// -// Note: the tables the Repository points to can be changed using the -// available functional options. +const ( + // DefaultAggregateTableName is the default aggregate snapshot table. + DefaultAggregateTableName = "aggregates" + // DefaultEventsTableName is the default domain events table. + DefaultEventsTableName = "events" + // DefaultStreamsTableName is the default event streams table. + DefaultStreamsTableName = "event_streams" +) + +// AggregateRepository implements aggregate.Repository for PostgreSQL. +// It reads aggregate snapshots from the pool and saves snapshots and recorded +// domain events together in a self-managed Serializable transaction. +// Table names can be configured using the available functional options. type AggregateRepository[ID aggregate.ID, T aggregate.Root[ID]] struct { - conn *pgxpool.Pool + conn *pgxpool.Pool + repository aggregateRepository[ID, T] +} + +// NewAggregateRepository returns a new AggregateRepository instance. +func NewAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]]( + conn *pgxpool.Pool, + aggregateType aggregate.Type[ID, T], + aggregateSerde serde.Bytes[T], + messageSerde serde.Bytes[message.Message], + options ...Option[ID, T], +) AggregateRepository[ID, T] { + return AggregateRepository[ID, T]{ + conn: conn, + repository: newAggregateRepository(aggregateType, aggregateSerde, messageSerde, options...), + } +} + +// Get returns the aggregate.Root instance specified by id. +// Returns aggregate.ErrRootNotFound if the aggregate does not exist. +func (repo AggregateRepository[ID, T]) Get(ctx context.Context, id ID) (T, error) { + return repo.repository.get(ctx, repo.conn, id) +} + +// Save saves the snapshot and recorded events of root in its own transaction. +func (repo AggregateRepository[ID, T]) Save(ctx context.Context, root T) error { + txOpts := pgx.TxOptions{ //nolint:exhaustruct_v5 // We don't need all fields. + IsoLevel: pgx.Serializable, + AccessMode: pgx.ReadWrite, + } + + return internal.RunTransaction(ctx, repo.conn, txOpts, func(ctx context.Context, tx pgx.Tx) error { + return repo.repository.save(ctx, tx, root) + }) +} + +// TxRetriever retrieves a caller-owned transaction from context. +type TxRetriever func(context.Context) (pgx.Tx, bool) + +// ErrTransactionRequired reports a missing transaction in context. +var ErrTransactionRequired = errors.New("postgres.TransactionAwareAggregateRepository: transaction required but not found in context") + +// TransactionAwareAggregateRepository implements aggregate.Repository using +// caller-owned transactions for both reads and writes. It never begins, commits, +// or rolls back a transaction. Transactions must use Serializable isolation. +// Save success means writes are staged; the caller must commit the transaction. +// After a failed or rolled-back save, discard the aggregate and reload before retrying. +type TransactionAwareAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]] struct { + retrieveTx TxRetriever + repository aggregateRepository[ID, T] +} + +// NewTransactionAwareAggregateRepository returns a repository requiring a +// transaction from retrieveTx for every operation. +func NewTransactionAwareAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]]( + retrieveTx TxRetriever, + aggregateType aggregate.Type[ID, T], + aggregateSerde serde.Bytes[T], + messageSerde serde.Bytes[message.Message], + options ...Option[ID, T], +) TransactionAwareAggregateRepository[ID, T] { + return TransactionAwareAggregateRepository[ID, T]{ + retrieveTx: retrieveTx, + repository: newAggregateRepository(aggregateType, aggregateSerde, messageSerde, options...), + } +} + +// Get reads an aggregate snapshot within the transaction retrieved from ctx. +// Returns ErrTransactionRequired if no transaction is available, or +// aggregate.ErrRootNotFound if the aggregate does not exist. +func (repo TransactionAwareAggregateRepository[ID, T]) Get(ctx context.Context, id ID) (T, error) { + tx, ok := repo.retrieveTx(ctx) + if !ok || tx == nil { + var zero T + + return zero, ErrTransactionRequired + } + + return repo.repository.get(ctx, tx, id) +} + +// Save writes the snapshot and recorded events within the transaction retrieved +// from ctx. Returns ErrTransactionRequired without flushing events if no +// transaction is available. The caller must roll back on error. +func (repo TransactionAwareAggregateRepository[ID, T]) Save(ctx context.Context, root T) error { + tx, ok := repo.retrieveTx(ctx) + if !ok || tx == nil { + return ErrTransactionRequired + } + + return repo.repository.save(ctx, tx, root) +} + +// Option configures the shared implementation of PostgreSQL aggregate repositories. +type Option[ID aggregate.ID, T aggregate.Root[ID]] interface { + apply(*aggregateRepository[ID, T]) +} + +type option[T any] func(T) + +func (apply option[T]) apply(val T) { apply(val) } + +// WithAggregateTableName configures the aggregate snapshot table. +func WithAggregateTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[ID, T] { + return option[*aggregateRepository[ID, T]](func(repo *aggregateRepository[ID, T]) { + repo.aggregateTableName = tableName + }) +} + +// WithEventsTableName configures the domain events table. +func WithEventsTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[ID, T] { + return option[*aggregateRepository[ID, T]](func(repo *aggregateRepository[ID, T]) { + repo.eventsTableName = tableName + }) +} + +// WithStreamsTableName configures the event streams table. +func WithStreamsTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[ID, T] { + return option[*aggregateRepository[ID, T]](func(repo *aggregateRepository[ID, T]) { + repo.streamsTableName = tableName + }) +} + +type aggregateRepository[ID aggregate.ID, T aggregate.Root[ID]] struct { aggregateType aggregate.Type[ID, T] aggregateSerde serde.Bytes[T] messageSerde serde.Bytes[message.Message] @@ -39,16 +163,13 @@ type AggregateRepository[ID aggregate.ID, T aggregate.Root[ID]] struct { streamsTableName string } -// NewAggregateRepository returns a new AggregateRepository instance. -func NewAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]]( - conn *pgxpool.Pool, +func newAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]]( aggregateType aggregate.Type[ID, T], aggregateSerde serde.Bytes[T], messageSerde serde.Bytes[message.Message], - options ...Option[*AggregateRepository[ID, T]], -) AggregateRepository[ID, T] { - repo := AggregateRepository[ID, T]{ - conn: conn, + options ...Option[ID, T], +) aggregateRepository[ID, T] { + repo := aggregateRepository[ID, T]{ aggregateType: aggregateType, aggregateSerde: aggregateSerde, messageSerde: messageSerde, @@ -64,12 +185,6 @@ func NewAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]]( return repo } -// Get returns the aggregate.Root instance specified by the provided id. -// Returns aggregate.ErrRootNotFound if the Aggregate Root doesn't exist. -func (repo AggregateRepository[ID, T]) Get(ctx context.Context, id ID) (T, error) { - return repo.get(ctx, repo.conn, id) -} - type queryRower interface { QueryRow(context.Context, string, ...any) pgx.Row } @@ -80,7 +195,7 @@ const getAggregateQueryTemplate = ` WHERE aggregate_id = $1 AND "type" = $2 ` -func (repo AggregateRepository[ID, T]) get(ctx context.Context, tx queryRower, id ID) (T, error) { +func (repo aggregateRepository[ID, T]) get(ctx context.Context, tx queryRower, id ID) (T, error) { var zeroValue T row := tx.QueryRow( @@ -114,39 +229,31 @@ func (repo AggregateRepository[ID, T]) get(ctx context.Context, tx queryRower, i return root, nil } -// Save saves the new state of the provided aggregate.Root instance. -func (repo AggregateRepository[ID, T]) Save(ctx context.Context, root T) (err error) { - txOpts := pgx.TxOptions{ //nolint:exhaustruct // We don't need all fields. - IsoLevel: pgx.Serializable, - AccessMode: pgx.ReadWrite, +func (repo aggregateRepository[ID, T]) save(ctx context.Context, tx pgx.Tx, root T) error { + eventsToCommit := root.FlushRecordedEvents() + expectedRootVersion := root.Version() - version.Version(len(eventsToCommit)) //nolint:gosec // This should not overflow. + eventStreamID := event.StreamID(root.AggregateID().String()) + + newEventStreamVersion, err := appendDomainEvents( + ctx, tx, + repo.eventsTableName, repo.streamsTableName, + repo.messageSerde, + eventStreamID, + version.CheckExact(expectedRootVersion), + eventsToCommit..., + ) + if err != nil { + return err } - return internal.RunTransaction(ctx, repo.conn, txOpts, func(ctx context.Context, tx pgx.Tx) error { - eventsToCommit := root.FlushRecordedEvents() - expectedRootVersion := root.Version() - version.Version(len(eventsToCommit)) //nolint:gosec // This should not overflow. - eventStreamID := event.StreamID(root.AggregateID().String()) - - newEventStreamVersion, err := appendDomainEvents( - ctx, tx, - repo.eventsTableName, repo.streamsTableName, - repo.messageSerde, - eventStreamID, - version.CheckExact(expectedRootVersion), - eventsToCommit..., - ) - if err != nil { - return err - } - - if newEventStreamVersion != root.Version() { - return repo.saveErr("version mismatch between event stream and aggregate", version.ConflictError{ - Expected: newEventStreamVersion, - Actual: root.Version(), - }) - } - - return repo.saveAggregateState(ctx, tx, eventStreamID, root) - }) + if newEventStreamVersion != root.Version() { + return repo.saveErr("version mismatch between event stream and aggregate", version.ConflictError{ + Expected: newEventStreamVersion, + Actual: root.Version(), + }) + } + + return repo.saveAggregateState(ctx, tx, eventStreamID, root) } const saveAggregateQueryTemplate = ` @@ -156,7 +263,7 @@ const saveAggregateQueryTemplate = ` UPDATE SET "version" = $3, "state" = $4 ` -func (repo AggregateRepository[ID, T]) saveAggregateState( +func (repo aggregateRepository[ID, T]) saveAggregateState( ctx context.Context, tx pgx.Tx, id event.StreamID, @@ -178,6 +285,6 @@ func (repo AggregateRepository[ID, T]) saveAggregateState( return nil } -func (repo AggregateRepository[ID, T]) saveErr(msg string, args ...any) error { +func (repo aggregateRepository[ID, T]) saveErr(msg string, args ...any) error { return fmt.Errorf("postgres.AggregateRepository: "+msg, args...) } diff --git a/postgres/aggregate_repository_test.go b/postgres/aggregate_repository_test.go index 0ca9d7fe..74f6e4de 100644 --- a/postgres/aggregate_repository_test.go +++ b/postgres/aggregate_repository_test.go @@ -4,19 +4,27 @@ import ( "context" "database/sql" "testing" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" _ "github.com/jackc/pgx/v5/stdlib" // Used to bring in the driver for sql.Open. + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/get-eventually/go-eventually/aggregate" "github.com/get-eventually/go-eventually/internal/user" userv1 "github.com/get-eventually/go-eventually/internal/user/gen/user/v1" + "github.com/get-eventually/go-eventually/message" "github.com/get-eventually/go-eventually/postgres" "github.com/get-eventually/go-eventually/postgres/internal" "github.com/get-eventually/go-eventually/serde" + "github.com/get-eventually/go-eventually/version" ) +var _ aggregate.Repository[uuid.UUID, *user.User] = postgres.TransactionAwareAggregateRepository[uuid.UUID, *user.User]{} + func TestAggregateRepository(t *testing.T) { if testing.Short() { t.SkipNow() @@ -54,3 +62,225 @@ func TestAggregateRepository(t *testing.T) { postgres.WithStreamsTableName[uuid.UUID, *user.User](postgres.DefaultStreamsTableName), ))(t) } + +type transactionKey struct{} + +func retrieveTransaction(ctx context.Context) (pgx.Tx, bool) { + tx, ok := ctx.Value(transactionKey{}).(pgx.Tx) + return tx, ok +} + +func newTransactionAwareRepository( + retrieve postgres.TxRetriever, + options ...postgres.Option[uuid.UUID, *user.User], +) postgres.TransactionAwareAggregateRepository[uuid.UUID, *user.User] { + return postgres.NewTransactionAwareAggregateRepository( + retrieve, user.Type, + serde.Chain(user.ProtoSerde, serde.NewProtoJSON(func() *userv1.User { return new(userv1.User) })), + serde.Chain(user.EventProtoSerde, serde.NewProtoJSON(func() *userv1.Event { return new(userv1.Event) })), + options..., + ) +} + +func newTransactionTestUser(t *testing.T) *user.User { + t.Helper() + + now := time.Now().UTC() + birthDate := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + root, err := user.Create(uuid.New(), "John", "Doe", "john@doe.com", birthDate, now) + require.NoError(t, err) + + return root +} + +func TestTransactionAwareAggregateRepositoryRequiresTransaction(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + tx pgx.Tx + ok bool + }{ + {name: "missing"}, + {name: "nil transaction", ok: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + repo := newTransactionAwareRepository(func(context.Context) (pgx.Tx, bool) { + return test.tx, test.ok + }) + root := newTransactionTestUser(t) + ctx := t.Context() + + got, err := repo.Get(ctx, root.AggregateID()) + require.ErrorIs(t, err, postgres.ErrTransactionRequired) + assert.Nil(t, got) + require.ErrorIs(t, repo.Save(ctx, root), postgres.ErrTransactionRequired) + assert.Len(t, root.FlushRecordedEvents(), 1) + }) + } +} + +type transactionSpy struct { + pgx.Tx + begins int + commits int + rollbacks int +} + +func (tx *transactionSpy) Begin(ctx context.Context) (pgx.Tx, error) { + tx.begins++ + return tx.Tx.Begin(ctx) +} + +func (tx *transactionSpy) Commit(ctx context.Context) error { + tx.commits++ + return tx.Tx.Commit(ctx) +} + +func (tx *transactionSpy) Rollback(ctx context.Context) error { + tx.rollbacks++ + return tx.Tx.Rollback(ctx) +} + +func TestTransactionAwareAggregateRepository(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + ctx := t.Context() + container, err := internal.NewPostgresContainer(ctx) + require.NoError(t, err) + + defer func() { require.NoError(t, container.Terminate(context.Background())) }() + + db, err := sql.Open("pgx", container.ConnectionDSN) + require.NoError(t, err) + require.NoError(t, postgres.RunMigrations(db)) + require.NoError(t, db.Close()) + + pool, err := pgxpool.New(ctx, container.ConnectionDSN) + require.NoError(t, err) + + defer pool.Close() + + _, err = pool.Exec(ctx, ` + CREATE SCHEMA custom; + ALTER TABLE aggregates SET SCHEMA custom; + ALTER TABLE event_streams SET SCHEMA custom; + ALTER TABLE events SET SCHEMA custom; + ALTER TABLE custom.aggregates ADD CONSTRAINT max_version CHECK (version < 2); + `) + require.NoError(t, err) + + repo := newTransactionAwareRepository(retrieveTransaction, + postgres.WithAggregateTableName[uuid.UUID, *user.User]("custom.aggregates"), + postgres.WithStreamsTableName[uuid.UUID, *user.User]("custom.event_streams"), + postgres.WithEventsTableName[uuid.UUID, *user.User]("custom.events"), + ) + + for _, commit := range []bool{false, true} { + name := "rollback" + if commit { + name = "commit" + } + + t.Run(name, func(t *testing.T) { + root := newTransactionTestUser(t) + tx := beginRepositoryTransaction(ctx, t, pool) + txCtx := context.WithValue(ctx, transactionKey{}, tx) + _, err := repo.Get(txCtx, root.AggregateID()) + require.ErrorIs(t, err, aggregate.ErrRootNotFound) + require.NoError(t, repo.Save(txCtx, root)) + got, err := repo.Get(txCtx, root.AggregateID()) + require.NoError(t, err) + assert.Equal(t, root, got) + assertRepositoryRows(txCtx, t, tx, root.AggregateID(), 1) + assertRepositoryRows(ctx, t, pool, root.AggregateID(), 0) + + stale, err := user.Create(root.AggregateID(), "John", "Doe", "john@doe.com", time.Now(), time.Now()) + require.NoError(t, err) + + var conflict version.ConflictError + require.ErrorAs(t, repo.Save(txCtx, stale), &conflict) + assert.Zero(t, tx.begins) + assert.Zero(t, tx.commits) + assert.Zero(t, tx.rollbacks) + + if commit { + require.NoError(t, tx.Commit(ctx)) + assertRepositoryRows(ctx, t, pool, root.AggregateID(), 1) + } else { + require.NoError(t, tx.Rollback(ctx)) + assertRepositoryRows(ctx, t, pool, root.AggregateID(), 0) + } + + _, err = repo.Get(txCtx, root.AggregateID()) + require.ErrorIs(t, err, pgx.ErrTxClosed) + require.ErrorIs(t, repo.Save(txCtx, newTransactionTestUser(t)), pgx.ErrTxClosed) + }) + } + + t.Run("snapshot failure rolls back staged events", func(t *testing.T) { + root := newTransactionTestUser(t) + tx := beginRepositoryTransaction(ctx, t, pool) + txCtx := context.WithValue(ctx, transactionKey{}, tx) + require.NoError(t, repo.Save(txCtx, root)) + require.NoError(t, tx.Commit(ctx)) + + tx = beginRepositoryTransaction(ctx, t, pool) + txCtx = context.WithValue(ctx, transactionKey{}, tx) + got, err := repo.Get(txCtx, root.AggregateID()) + require.NoError(t, err) + require.NoError(t, got.UpdateEmail("updated@doe.com", time.Now(), message.Metadata{})) + require.Error(t, repo.Save(txCtx, got)) + assert.Zero(t, tx.begins) + assert.Zero(t, tx.commits) + assert.Zero(t, tx.rollbacks) + require.NoError(t, tx.Rollback(ctx)) + assertRepositoryRows(ctx, t, pool, root.AggregateID(), 1) + + tx = beginRepositoryTransaction(ctx, t, pool) + got, err = repo.Get(context.WithValue(ctx, transactionKey{}, tx), root.AggregateID()) + require.NoError(t, err) + assert.Equal(t, root, got) + require.NoError(t, tx.Rollback(ctx)) + }) +} + +func beginRepositoryTransaction(ctx context.Context, t *testing.T, pool *pgxpool.Pool) *transactionSpy { + t.Helper() + + tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable, AccessMode: pgx.ReadWrite}) + require.NoError(t, err) + t.Cleanup(func() { + err := tx.Rollback(context.WithoutCancel(ctx)) + if err != nil { + require.ErrorIs(t, err, pgx.ErrTxClosed) + } + }) + + return &transactionSpy{Tx: tx} +} + +type repositoryQueryRower interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func assertRepositoryRows(ctx context.Context, t *testing.T, db repositoryQueryRower, id uuid.UUID, expected int) { + t.Helper() + + var snapshots, streams, events int + + err := db.QueryRow(ctx, ` + SELECT + (SELECT count(*) FROM custom.aggregates WHERE aggregate_id = $1), + (SELECT count(*) FROM custom.event_streams WHERE event_stream_id = $1), + (SELECT count(*) FROM custom.events WHERE event_stream_id = $1) + `, id.String()).Scan(&snapshots, &streams, &events) + require.NoError(t, err) + assert.Equal(t, expected, snapshots) + assert.Equal(t, expected, streams) + assert.Equal(t, expected, events) +} diff --git a/postgres/doc.go b/postgres/doc.go deleted file mode 100644 index 2e23bf31..00000000 --- a/postgres/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package postgres contains implementations of go-eventually interfaces -// specific to PostgreSQL, such as Aggregate Repository, Event Store, etc. -package postgres diff --git a/postgres/event_store.go b/postgres/event_store.go index 56337d6b..0dcb3872 100644 --- a/postgres/event_store.go +++ b/postgres/event_store.go @@ -15,7 +15,7 @@ import ( "github.com/get-eventually/go-eventually/version" ) -//nolint:exhaustruct // Interface implementation assertion. +//nolint:exhaustruct_v5 // Interface implementation assertion. var _ event.Store = EventStore{} // EventStore is an event.Store implementation targeted to PostgreSQL databases. @@ -104,7 +104,7 @@ func (es EventStore) Append( ) (version.Version, error) { var newVersion version.Version - txOpts := pgx.TxOptions{ //nolint:exhaustruct // We don't need all fields. + txOpts := pgx.TxOptions{ //nolint:exhaustruct_v5 // We don't need all fields. IsoLevel: pgx.Serializable, AccessMode: pgx.ReadWrite, } diff --git a/postgres/migration.go b/postgres/migration.go index e3ab2916..0ca929a0 100644 --- a/postgres/migration.go +++ b/postgres/migration.go @@ -28,7 +28,7 @@ func RunMigrations(db *sql.DB) error { return wrapErr(err, "failed to create new iofs driver for reading migrations") } - driver, err := pgx.WithInstance(db, &pgx.Config{ //nolint:exhaustruct // We don't need all fields. + driver, err := pgx.WithInstance(db, &pgx.Config{ //nolint:exhaustruct_v5 // We don't need all fields. MigrationsTable: "eventually_schema_migrations", DatabaseName: "", SchemaName: "", diff --git a/postgres/option.go b/postgres/option.go deleted file mode 100644 index 89dcc685..00000000 --- a/postgres/option.go +++ /dev/null @@ -1,49 +0,0 @@ -package postgres - -import "github.com/get-eventually/go-eventually/aggregate" - -// Option can be used to change the configuration of an object. -type Option[T any] interface { - apply(T) -} - -type option[T any] func(T) - -func newOption[T any](f func(T)) option[T] { return option[T](f) } - -func (apply option[T]) apply(val T) { apply(val) } - -const ( - // DefaultAggregateTableName is the default Aggregate table name an AggregateRepository points to. - DefaultAggregateTableName = "aggregates" - // DefaultEventsTableName is the default Domain Events table name an AggregateRepository points to. - DefaultEventsTableName = "events" - // DefaultStreamsTableName is the default Event Streams table name an AggregateRepository points to. - DefaultStreamsTableName = "event_streams" -) - -// WithAggregateTableName allows you to specify a different Aggregate table name -// that an AggregateRepository should manage. -func WithAggregateTableName[ID aggregate.ID, T aggregate.Root[ID]]( - tableName string, -) Option[*AggregateRepository[ID, T]] { - return newOption(func(repository *AggregateRepository[ID, T]) { - repository.aggregateTableName = tableName - }) -} - -// WithEventsTableName allows you to specify a different Events table name -// that an AggregateRepository should manage. -func WithEventsTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[*AggregateRepository[ID, T]] { - return newOption(func(repository *AggregateRepository[ID, T]) { - repository.eventsTableName = tableName - }) -} - -// WithStreamsTableName allows you to specify a different Event Streams table name -// that an AggregateRepository should manage. -func WithStreamsTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[*AggregateRepository[ID, T]] { - return newOption(func(repository *AggregateRepository[ID, T]) { - repository.streamsTableName = tableName - }) -} diff --git a/postgres/append_domain_events.go b/postgres/postgres.go similarity index 95% rename from postgres/append_domain_events.go rename to postgres/postgres.go index a5bd5ade..4dbcc660 100644 --- a/postgres/append_domain_events.go +++ b/postgres/postgres.go @@ -1,3 +1,5 @@ +// Package postgres contains implementations of go-eventually interfaces +// specific to PostgreSQL, such as Aggregate Repository, Event Store, etc. package postgres import ( diff --git a/query/scenario.go b/query/scenario.go index 3401c8ce..2bacf4b6 100644 --- a/query/scenario.go +++ b/query/scenario.go @@ -53,7 +53,7 @@ func (sc ScenarioInit[Q, R, T]) Given(events ...event.Persisted) ScenarioGiven[Q // When provides the Domain Query to evaluate. func (sc ScenarioInit[Q, R, T]) When(q Envelope[Q]) ScenarioWhen[Q, R, T] { - //nolint:exhaustruct // Zero values are fine here. + //nolint:exhaustruct_v5 // Zero values are fine here. return ScenarioWhen[Q, R, T]{ when: q, } @@ -84,7 +84,7 @@ type ScenarioWhen[Q Query, R any, T ProcessorHandler[Q, R]] struct { // Then sets a positive expectation on the scenario outcome, to produce // the Query Result provided in input. func (sc ScenarioWhen[Q, R, T]) Then(result R) ScenarioThen[Q, R, T] { - //nolint:exhaustruct // Zero values are fine here. + //nolint:exhaustruct_v5 // Zero values are fine here. return ScenarioThen[Q, R, T]{ ScenarioWhen: sc, then: result, @@ -98,7 +98,7 @@ func (sc ScenarioWhen[Q, R, T]) Then(result R) ScenarioThen[Q, R, T] { // by the Query Handler is unwrapped until the cause error to match // the provided expectation. func (sc ScenarioWhen[Q, R, T]) ThenError(err error) ScenarioThen[Q, R, T] { - //nolint:exhaustruct // Zero values are fine here. + //nolint:exhaustruct_v5 // Zero values are fine here. return ScenarioThen[Q, R, T]{ ScenarioWhen: sc, wantError: true, @@ -112,7 +112,7 @@ func (sc ScenarioWhen[Q, R, T]) ThenError(err error) ScenarioThen[Q, R, T] { // This is useful when the error returned is not important for the Domain Query // you're trying to test. func (sc ScenarioWhen[Q, R, T]) ThenFails() ScenarioThen[Q, R, T] { - //nolint:exhaustruct // Zero values are fine here. + //nolint:exhaustruct_v5 // Zero values are fine here. return ScenarioThen[Q, R, T]{ ScenarioWhen: sc, wantError: true,