From 69ecda76d349b7b5c5a210524c14576bec350bc9 Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 08:02:55 -0300 Subject: [PATCH 1/5] feat(contracts): harden local web adapter path --- CHANGELOG.md | 11 ++ cmd/gowdk/go_bindings_report.go | 9 +- docs/reference/contracts.md | 109 ++++++++++++- docs/reference/diagnostic-codes.md | 3 +- docs/reference/go-interop.md | 8 + examples/README.md | 22 ++- examples/contracts/gowdk.config.go | 12 ++ examples/contracts/patients.page.gwdk | 20 +++ examples/contracts/patients/contracts.go | 49 ++++++ internal/buildgen/build.go | 3 + internal/buildgen/build_data_routes_test.go | 21 +++ internal/buildgen/build_data_runner.go | 27 +++- internal/buildgen/build_report_test.go | 4 + internal/buildgen/render.go | 6 +- internal/buildgen/scoped_scripts.go | 15 +- .../compiler/backend_binding_diagnostics.go | 9 +- .../backend_binding_diagnostics_test.go | 103 +++++++++++- internal/compiler/backend_bindings.go | 152 +++++++++++++----- internal/contractscan/packages.go | 20 ++- internal/contractscan/scan.go | 10 -- internal/contractscan/scan_test.go | 56 +++++++ internal/diagnostics/registry.go | 1 + internal/project/config.go | 3 + internal/project/config_test.go | 9 +- internal/source/source.go | 4 + runtime/contracts/natsbroker/natsbroker.go | 45 ++++-- runtime/contracts/redisstream/redisstream.go | 18 ++- .../websocketfanout/websocketfanout.go | 5 +- .../websocketfanout/websocketfanout_test.go | 31 ++++ 29 files changed, 681 insertions(+), 104 deletions(-) create mode 100644 examples/contracts/gowdk.config.go create mode 100644 examples/contracts/patients.page.gwdk create mode 100644 examples/contracts/patients/contracts.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 224ba699..1d69e726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,17 @@ packages, and tooling contracts may change before a stable release. candidate function stays silent so the 501-stub workflow is unaffected; strict production builds still fail closed via `backend_binding_required`. This is the first slice of #328. +- Backend handler binding no longer hides failures behind silent fallbacks: a + handler declared in both same-package Go and an inline `go {}` block is + reported as `ambiguous_backend_handler` instead of silently preferring one + source; a sibling Go package that fails to compile keeps a "could not be + inspected" binding instead of falling back to an inline block and reporting a + misleading bound handler (the compile error is reported by `go_package_error`); + and a failing `go list` for a same-package build function now surfaces its real + cause (for example a missing `go.mod`) rather than a generic "requires a + buildable Go package" message. A component-script resolution error during + build now fails the build instead of silently omitting the page's component + scripts. ### Known Gaps diff --git a/cmd/gowdk/go_bindings_report.go b/cmd/gowdk/go_bindings_report.go index a3e57339..2db4ebef 100644 --- a/cmd/gowdk/go_bindings_report.go +++ b/cmd/gowdk/go_bindings_report.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "go/ast" "go/parser" @@ -309,7 +310,13 @@ func inspectSamePackageImportPath(sourcePath string) (string, error) { command.Dir = dir output, err := command.Output() if err != nil { - return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s", dir) + var exit *exec.ExitError + if errors.As(err, &exit) { + if stderr := strings.TrimSpace(string(exit.Stderr)); stderr != "" { + return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s: %w\n%s", dir, err, stderr) + } + } + return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s: %w", dir, err) } var info struct { ImportPath string diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index 3278c8f5..763669f8 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -83,6 +83,98 @@ Generated web adapters always execute command/query references with worker/cron/admin/API-only contract is a compiler diagnostic, not a generated route that fails later. +## Local Single-Binary App Path + +The supported M6 path is local-first: one generated binary can serve the page, +execute `g:command` and `g:query` web adapters through the web role, and replay +captured backend events through local runtime helpers. Split worker binaries +and cron generation remain planned, not production-ready behavior. + +Minimal page: + +```gwdk +package contracts + +import patients "github.com/acme/clinic/patients" + +page patients +route "/patients" +guard public + +view { +
+
+ + +
+
+
+} +``` + +Normal Go owns the contracts and handlers: + +```go +package patients + +import ( + "context" + + "github.com/cssbruno/gowdk/runtime/contracts" +) + +type GetPatientPage struct{ Filter string } +type PatientPageData struct{ Source string `json:"source"` } +type CreatePatient struct{ Name string } +type CreatePatientResult struct{ ID string `json:"id"` } +type PatientCreated struct{ ID string } + +func Register(registry *contracts.Registry) { + contracts.RegisterQuery[GetPatientPage, PatientPageData](registry, LoadPatientPage, contracts.RoleWeb) + contracts.RegisterCommand[CreatePatient, CreatePatientResult](registry, HandleCreatePatient, contracts.RoleWeb) + contracts.RegisterDomainEvent[PatientCreated](registry, SendWelcomeEmail, contracts.RoleWorker) +} + +func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData, error) { + return PatientPageData{Source: "db"}, nil +} + +func HandleCreatePatient(ctx context.Context, command CreatePatient) (CreatePatientResult, error) { + if err := contracts.EmitDomain(ctx, PatientCreated{ID: "patient-1"}); err != nil { + return CreatePatientResult{}, err + } + return CreatePatientResult{ID: "patient-1"}, nil +} + +func SendWelcomeEmail(ctx context.Context, event PatientCreated) error { return nil } +``` + +The generated command adapter captures events emitted by `HandleCreatePatient` +only after the command succeeds. Browser events remain untrusted UI input; +backend facts must be emitted from Go handlers with `EmitDomain`, +`EmitIntegration`, or `EmitPresentation`. + +The repository example is `examples/contracts/`: + +```sh +go run ./cmd/gowdk build --config examples/contracts/gowdk.config.go \ + --out /tmp/gowdk-contracts-build \ + --app /tmp/gowdk-contracts-app \ + --bin /tmp/gowdk-contracts-site \ + examples/contracts/patients.page.gwdk +``` + +Verify adapter metadata through the build report: + +```sh +grep -F '"name": "patients.CreatePatient"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"kind": "command"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"path": "/contracts/patients"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"guards": "public"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"name": "patients.GetPatientPage"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"kind": "query"' /tmp/gowdk-contracts-build/gowdk-build-report.json +``` + ## Observability `runtime/contracts` exposes stable operation names and labels for logs, @@ -300,7 +392,10 @@ err := contracts.RunEventWorker(ctx, r, PatientEventSource{}) successful subscriber replay, calls `Nack` when subscriber replay fails, stops cleanly when the source returns `ErrEventSourceClosed`, and returns the context error when `ctx` is canceled. `RunEventWorkerForRole` can be used for another -runtime role. +runtime role. `ErrEventSourceClosed` means a finite source drained cleanly, as +with the file outbox or in-memory broker; long-lived brokers such as Redis +Streams and NATS should keep blocking until the worker context is canceled or +the adapter is genuinely closed. Generated command routes use the same event-plumbing boundary through one configurable sink: @@ -358,6 +453,11 @@ err := gowdkapp.RunContractEventWorker(ctx, source) functions. `RunContractEventWorker` replays an `EventSource` through the same registrations with the worker role. +These helpers are deliberately local process APIs. Generated apps do not yet +emit separate worker or cron binaries, supervisor configs, queue topology, or +managed deployment recipes. Use them from the generated binary, a user-owned +command, or a test fixture until split worker generation is designed. + Dependency-free adapters: - `runtime/contracts/fileoutbox` stores JSON Lines records on disk and @@ -741,4 +841,9 @@ Use `g:on:*` for local UI/component events and `g:command` for backend intent. do not replace normal static, SPA, or SSR page responses. - Cross-package contract input field discovery remains planned. - Retry backoff policy, split web/worker/cron binaries, and managed deployment - recipes remain planned. + recipes remain planned. Split worker generation is blocked on stable local + command/query adapters, generated registry/replay helper usage, durable + outbox/broker policy, retry/backoff semantics, and deployment supervision + docs. Cron generation is blocked on the same runtime role policy plus + schedule ownership, overlap prevention, failure reporting, and restart + behavior. M6 does not make a production-readiness claim for either path. diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index 80dd7a73..db9aea52 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -123,7 +123,8 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `invalid_go_endpoint_handler`, `malformed_go_endpoint_comment`, `go_endpoint_parse_error`, `duplicate_go_endpoint_comment`, `unsupported_action_method`, `backend_binding_required`, - `unsupported_backend_signature`, `unexported_backend_handler`. + `unsupported_backend_signature`, `unexported_backend_handler`, + `ambiguous_backend_handler`. - Layouts, CSS, and cache: `duplicate_layout_id`, `unknown_layout_id`, `invalid_css_selection`, `duplicate_css_selection`, `revalidate_requires_cache`, `duplicate_revalidate_policy`. diff --git a/docs/reference/go-interop.md b/docs/reference/go-interop.md index bdd6792d..65f03e02 100644 --- a/docs/reference/go-interop.md +++ b/docs/reference/go-interop.md @@ -80,11 +80,19 @@ the JSON report or running a strict production build: - `unexported_backend_handler` — a same-named Go function exists but is not exported, so binding cannot see it (for example `func submit` when the block expects `Submit`). +- `ambiguous_backend_handler` — the same handler is declared in both + same-package Go and an inline `go {}` block. (When both live in the same + compiled package, Go's own redeclaration error surfaces first.) A handler with no candidate function stays silent because the default workflow generates 501 stubs for not-yet-implemented handlers; strict production builds still fail closed through `backend_binding_required`. +When the sibling Go package fails to compile, binding does not fall back to an +inline `go {}` block and report a misleading bound handler: the load/action/API +binding stays "could not be inspected" and the package error itself is reported +by `go_package_error`. + ## Load Functions Request-time pages with `load {}` bind same-package functions named diff --git a/examples/README.md b/examples/README.md index d2a535cf..ce5b1c51 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,6 +27,7 @@ even when explicit `.gwdk` files are passed. | `ssr/dynamic-ssr.page.gwdk` | Validation example for dynamic SSR route metadata. | `go run ./cmd/gowdk check --ssr examples/ssr/dynamic-ssr.page.gwdk` | | `ssr/dashboard.page.gwdk` | SSR page with `load` and guard metadata. | `go run ./cmd/gowdk check --ssr examples/ssr/dashboard.page.gwdk` | | `go-interop/imported-build.page.gwdk` | Buildable `.gwdk` import example that calls Go code from `build {}`. | `go run ./cmd/gowdk build --out /tmp/gowdk-go-interop examples/go-interop/imported-build.page.gwdk` | +| `contracts/patients.page.gwdk` | Local command/query contract web adapter example backed by normal Go registrations. | `go run ./cmd/gowdk build --config examples/contracts/gowdk.config.go --out /tmp/gowdk-contracts-build --app /tmp/gowdk-contracts-app --bin /tmp/gowdk-contracts-site examples/contracts/patients.page.gwdk` | | `embed/site.page.gwdk` | Standalone one-binary generated app example. | `go run ./cmd/gowdk build --out /tmp/gowdk-embed-build --app /tmp/gowdk-embed-app --bin /tmp/gowdk-embed-site examples/embed/site.page.gwdk` | | `css/styled.page.gwdk` | Configured stylesheet-link example. | `go run ./cmd/gowdk build --config examples/css/gowdk.config.go --out /tmp/gowdk-css-build examples/css/styled.page.gwdk` | | `tailwind/site.page.gwdk` | Tailwind v4 addon example using the standalone CLI. | `go run ./cmd/gowdk build --config examples/tailwind/gowdk.config.go --out /tmp/gowdk-tailwind-build examples/tailwind/site.page.gwdk` | @@ -37,10 +38,10 @@ even when explicit `.gwdk` files are passed. Check all current examples with SSR validation enabled: ```sh -go run ./cmd/gowdk check --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk -go run ./cmd/gowdk manifest --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk -go run ./cmd/gowdk sitemap --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk -go run ./cmd/gowdk routes --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk +go run ./cmd/gowdk check --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk +go run ./cmd/gowdk manifest --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk +go run ./cmd/gowdk sitemap --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk +go run ./cmd/gowdk routes --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk ``` Build the current simple page: @@ -101,6 +102,19 @@ test -f /tmp/gowdk-go-interop/go-imported/index.html go test ./examples/go-interop ``` +Build the local command/query contract web adapter example: + +```sh +go run ./cmd/gowdk build --config examples/contracts/gowdk.config.go --out /tmp/gowdk-contracts-build --app /tmp/gowdk-contracts-app --bin /tmp/gowdk-contracts-site examples/contracts/patients.page.gwdk +test -x /tmp/gowdk-contracts-site +grep -F '"name": "patients.CreatePatient"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"kind": "command"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"path": "/contracts/patients"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"guards": "public"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"name": "patients.GetPatientPage"' /tmp/gowdk-contracts-build/gowdk-build-report.json +grep -F '"kind": "query"' /tmp/gowdk-contracts-build/gowdk-build-report.json +``` + Build the one-binary generated app example: ```sh diff --git a/examples/contracts/gowdk.config.go b/examples/contracts/gowdk.config.go new file mode 100644 index 00000000..f2536129 --- /dev/null +++ b/examples/contracts/gowdk.config.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/cssbruno/gowdk" + contractsaddon "github.com/cssbruno/gowdk/addons/contracts" +) + +var Config = gowdk.Config{ + Addons: []gowdk.Addon{ + contractsaddon.Addon(), + }, +} diff --git a/examples/contracts/patients.page.gwdk b/examples/contracts/patients.page.gwdk new file mode 100644 index 00000000..36f90bf0 --- /dev/null +++ b/examples/contracts/patients.page.gwdk @@ -0,0 +1,20 @@ +package contracts + +import patients "github.com/cssbruno/gowdk/examples/contracts/patients" + +page contractPatients +route "/contracts/patients" +guard public + +view { +
+

Contracts

+
+ + +
+
+

Patient query data is available through the generated JSON adapter.

+
+
+} diff --git a/examples/contracts/patients/contracts.go b/examples/contracts/patients/contracts.go new file mode 100644 index 00000000..bd4819d0 --- /dev/null +++ b/examples/contracts/patients/contracts.go @@ -0,0 +1,49 @@ +package patients + +import ( + "context" + + "github.com/cssbruno/gowdk/runtime/contracts" +) + +type GetPatientPage struct { + Filter string +} + +type PatientPageData struct { + Filter string `json:"filter"` + Source string `json:"source"` +} + +type CreatePatient struct { + Name string +} + +type CreatePatientResult struct { + ID string `json:"id"` +} + +type PatientCreated struct { + ID string +} + +func Register(registry *contracts.Registry) { + contracts.RegisterQuery[GetPatientPage, PatientPageData](registry, LoadPatientPage, contracts.RoleWeb) + contracts.RegisterCommand[CreatePatient, CreatePatientResult](registry, HandleCreatePatient, contracts.RoleWeb) + contracts.RegisterDomainEvent[PatientCreated](registry, SendWelcomeEmail, contracts.RoleWorker) +} + +func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData, error) { + return PatientPageData{Filter: query.Filter, Source: "contracts-example"}, nil +} + +func HandleCreatePatient(ctx context.Context, command CreatePatient) (CreatePatientResult, error) { + if err := contracts.EmitDomain(ctx, PatientCreated{ID: "patient-1"}); err != nil { + return CreatePatientResult{}, err + } + return CreatePatientResult{ID: "patient-1"}, nil +} + +func SendWelcomeEmail(ctx context.Context, event PatientCreated) error { + return nil +} diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 3d04de80..83d6a7be 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -356,6 +356,9 @@ func reportContractReferences(reporter *buildReporter, refs []gwdkir.ContractRef if len(ref.Roles) > 0 { data["roles"] = strings.Join(ref.Roles, ",") } + if len(ref.Guards) > 0 { + data["guards"] = strings.Join(ref.Guards, ",") + } if len(ref.InputFields) > 0 { var fields []string for _, field := range ref.InputFields { diff --git a/internal/buildgen/build_data_routes_test.go b/internal/buildgen/build_data_routes_test.go index 3c5c08ea..965df032 100644 --- a/internal/buildgen/build_data_routes_test.go +++ b/internal/buildgen/build_data_routes_test.go @@ -12,6 +12,27 @@ import ( "github.com/cssbruno/gowdk/internal/gwdkir" ) +func TestSamePackageImportPathSurfacesGoListError(t *testing.T) { + // A temp dir outside any Go module makes `go list` fail with a clear reason + // (no go.mod), which must be surfaced rather than collapsed into a generic + // "requires a buildable Go package" message. + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "page.go"), []byte("package app\n"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := samePackageImportPath(filepath.Join(root, "home.page.gwdk")) + if err == nil { + t.Fatal("expected a same-package build data error outside a Go module") + } + if !strings.Contains(err.Error(), "buildable Go package") { + t.Fatalf("expected the user-facing message, got: %v", err) + } + if !strings.Contains(err.Error(), "go.mod") { + t.Fatalf("expected the underlying go list error (go.mod not found) to be surfaced, got: %v", err) + } +} + func TestBuildRendersLiteralBuildData(t *testing.T) { outputDir := t.TempDir() app := gwdkanalysis.Sources{ diff --git a/internal/buildgen/build_data_runner.go b/internal/buildgen/build_data_runner.go index 0289cb83..7b2df052 100644 --- a/internal/buildgen/build_data_runner.go +++ b/internal/buildgen/build_data_runner.go @@ -3,6 +3,7 @@ package buildgen import ( "bytes" "encoding/json" + "errors" "fmt" "go/ast" "go/format" @@ -222,7 +223,10 @@ func inlineBuildDataMainDecl(function string, returnsError bool) ast.Decl { func samePackageImportPath(source string) (string, error) { dir := sourceDir(source) - info := goListDir(dir) + info, err := goListDir(dir) + if err != nil { + return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s: %w", dir, err) + } if strings.TrimSpace(info.ImportPath) == "" { return "", fmt.Errorf("same-package build data function requires a buildable Go package for %s", dir) } @@ -233,18 +237,31 @@ type goListDirInfo struct { ImportPath string } -func goListDir(dir string) goListDirInfo { +func goListDir(dir string) (goListDirInfo, error) { command := exec.Command("go", "list", "-json", ".") command.Dir = dir output, err := command.Output() if err != nil { - return goListDirInfo{} + return goListDirInfo{}, goListError(err) } var info goListDirInfo if err := json.Unmarshal(output, &info); err != nil { - return goListDirInfo{} + return goListDirInfo{}, fmt.Errorf("parse go list output: %w", err) + } + return info, nil +} + +// goListError surfaces the underlying go list failure, including its stderr, +// instead of collapsing it into a generic message that hides the cause (for +// example a sibling package compile error or a missing go.mod). +func goListError(err error) error { + var exit *exec.ExitError + if errors.As(err, &exit) { + if stderr := strings.TrimSpace(string(exit.Stderr)); stderr != "" { + return fmt.Errorf("%w\n%s", err, stderr) + } } - return info + return err } func sourceDir(source string) string { diff --git a/internal/buildgen/build_report_test.go b/internal/buildgen/build_report_test.go index a7f16e9f..7be4bdfd 100644 --- a/internal/buildgen/build_report_test.go +++ b/internal/buildgen/build_report_test.go @@ -286,6 +286,7 @@ func TestBuildReportIncludesContractReferences(t *testing.T) { Package: "pages", ID: "patients", Route: "/patients", + Guards: []string{"public", "auth.required"}, Blocks: gwdkir.Blocks{ View: true, ViewBody: `
`, @@ -312,6 +313,9 @@ func TestBuildReportIncludesContractReferences(t *testing.T) { if event.Data["method"] != "POST" || event.Data["path"] != "/patients" { t.Fatalf("unexpected command method/path: %#v", event.Data) } + if event.Data["guards"] != "public,auth.required" { + t.Fatalf("unexpected command guards: %#v", event.Data) + } } func TestBuildReportDerivesCommandReferencePathFromPageRoute(t *testing.T) { diff --git a/internal/buildgen/render.go b/internal/buildgen/render.go index 3321e38d..39c6d440 100644 --- a/internal/buildgen/render.go +++ b/internal/buildgen/render.go @@ -184,7 +184,11 @@ func actionRoutes(page gwdkir.Page, data map[string]string) map[string]string { func pageScripts(config gowdk.Config, page gwdkir.Page, viewSource string, components map[string]view.Component, policy renderModePolicy) ([]gowdk.Script, error) { scripts := append([]gowdk.Script{}, nonEmptyScripts(config.Build.Scripts)...) - for _, href := range scopedScriptHrefs(page, viewSource, components) { + hrefs, err := scopedScriptHrefs(page, viewSource, components) + if err != nil { + return nil, err + } + for _, href := range hrefs { scripts = append(scripts, gowdk.Script{Src: href, Type: "module"}) } if policy != renderModeSPA { diff --git a/internal/buildgen/scoped_scripts.go b/internal/buildgen/scoped_scripts.go index 8ae9dc10..6a551b31 100644 --- a/internal/buildgen/scoped_scripts.go +++ b/internal/buildgen/scoped_scripts.go @@ -188,7 +188,7 @@ func safeScriptAssetName(scriptPath string) string { return name } -func scopedScriptHrefs(page gwdkir.Page, viewSource string, components map[string]view.Component) []string { +func scopedScriptHrefs(page gwdkir.Page, viewSource string, components map[string]view.Component) ([]string, error) { seen := map[string]bool{} var scripts []string add := func(href string) { @@ -208,17 +208,20 @@ func scopedScriptHrefs(page gwdkir.Page, viewSource string, components map[strin } add("/" + pageScopedJSLogicalPath(page.ID, name)) } - componentHrefs := scopedComponentScriptHrefs(page, viewSource, components) + componentHrefs, err := scopedComponentScriptHrefs(page, viewSource, components) + if err != nil { + return nil, err + } for _, href := range componentHrefs { add(href) } - return scripts + return scripts, nil } -func scopedComponentScriptHrefs(page gwdkir.Page, viewSource string, components map[string]view.Component) []string { +func scopedComponentScriptHrefs(page gwdkir.Page, viewSource string, components map[string]view.Component) ([]string, error) { usages, err := recursiveViewComponentCallUsages(viewSource, components, page.Package, componentUses(page.Uses)) if err != nil { - return nil + return nil, err } seen := map[string]bool{} var scripts []string @@ -246,5 +249,5 @@ func scopedComponentScriptHrefs(page gwdkir.Page, viewSource string, components } } sort.Strings(scripts) - return scripts + return scripts, nil } diff --git a/internal/compiler/backend_binding_diagnostics.go b/internal/compiler/backend_binding_diagnostics.go index 4cfecb5e..460f8ddc 100644 --- a/internal/compiler/backend_binding_diagnostics.go +++ b/internal/compiler/backend_binding_diagnostics.go @@ -9,9 +9,12 @@ import ( // visible during gowdk check and build instead of only in inspect go-bindings // or a strict production build. // -// It intentionally warns only when there is positive evidence of a near-miss -// the author almost certainly meant to bind: +// It intentionally warns only when there is positive evidence of a problem the +// author almost certainly cares about: // +// - ambiguous_backend_handler: the same handler is declared in both +// same-package Go and an inline go {} block, so the chosen source is +// ambiguous. // - unsupported_backend_signature: a same-named Go function exists but has a // signature GOWDK does not support. // - unexported_backend_handler: a same-named Go function exists but is not @@ -25,6 +28,8 @@ func BackendBindingDiagnostics(bindings []source.BackendBinding) []ValidationErr var diagnostics []ValidationError for _, binding := range bindings { switch { + case binding.Ambiguous: + diagnostics = append(diagnostics, backendBindingDiagnostic("ambiguous_backend_handler", binding)) case binding.Status == source.BackendBindingUnsupportedSignature: diagnostics = append(diagnostics, backendBindingDiagnostic("unsupported_backend_signature", binding)) case binding.Status == source.BackendBindingMissing && binding.UnexportedCandidate: diff --git a/internal/compiler/backend_binding_diagnostics_test.go b/internal/compiler/backend_binding_diagnostics_test.go index e398937e..118c2217 100644 --- a/internal/compiler/backend_binding_diagnostics_test.go +++ b/internal/compiler/backend_binding_diagnostics_test.go @@ -1,6 +1,7 @@ package compiler import ( + "path/filepath" "strings" "testing" @@ -9,13 +10,21 @@ import ( "github.com/cssbruno/gowdk/internal/source" ) +// emptyPackageSource returns a page source path inside a fresh empty directory. +// inspectFeaturePackage reports the directory as having no Go files (no +// LoadError, no functions), so binding falls through to the inline go {} block. +func emptyPackageSource(t *testing.T, name string) string { + t.Helper() + return filepath.Join(t.TempDir(), name) +} + func fragmentPolicyProgram(t *testing.T, goBlockBody string) gwdkir.Program { t.Helper() return gwdkir.Program{ Pages: []gwdkir.Page{{ ID: "patients", Package: "pages", - Source: "no-such-dir/patients.page.gwdk", + Source: emptyPackageSource(t, "patients.page.gwdk"), Route: "/patients", Blocks: gwdkir.Blocks{ Fragments: []gwdkir.FragmentEndpoint{{Name: "Summary", Method: "GET", Route: "/patients/summary", Target: "#summary"}}, @@ -60,12 +69,12 @@ func findBindingByName(bindings []source.BackendBinding, kind, name string) (sou } func TestComputeBackendBindingsFlagsUnexportedInlineActionHandler(t *testing.T) { - // Same-package directory does not exist, so the only candidate is the + // The same-package directory has no Go files, so the only candidate is the // inline default go {} block, which declares an unexported near-miss. page := gwdkir.Page{ ID: "signup", Package: "pages", - Source: "no-such-dir/signup.page.gwdk", + Source: emptyPackageSource(t, "signup.page.gwdk"), Route: "/signup", Blocks: gwdkir.Blocks{ Actions: []gwdkir.Action{{Name: "Submit", Method: "POST", Route: "/signup"}}, @@ -90,7 +99,7 @@ func TestComputeBackendBindingsFlagsUnexportedInlineFragmentHandler(t *testing.T page := gwdkir.Page{ ID: "patients", Package: "pages", - Source: "no-such-dir/patients.page.gwdk", + Source: emptyPackageSource(t, "patients.page.gwdk"), Route: "/patients", Blocks: gwdkir.Blocks{ Fragments: []gwdkir.FragmentEndpoint{{Name: "Summary", Method: "GET", Route: "/patients/summary", Target: "#summary"}}, @@ -111,11 +120,95 @@ func TestComputeBackendBindingsFlagsUnexportedInlineFragmentHandler(t *testing.T } } +const inlineSubmitGoBlock = `import ( + "context" + + "github.com/cssbruno/gowdk/runtime/response" +) + +func Submit(context.Context) (response.Response, error) { + return response.Response{}, nil +}` + +func TestComputeBackendBindingsFlagsAmbiguousHandler(t *testing.T) { + root := t.TempDir() + writeCompilerTestModule(t, root) + writeCompilerTestFile(t, filepath.Join(root, "handlers.go"), `package pages + +import ( + "context" + + "github.com/cssbruno/gowdk/runtime/response" +) + +func Submit(context.Context) (response.Response, error) { + return response.Response{}, nil +} +`) + page := gwdkir.Page{ + ID: "signup", + Package: "pages", + Source: filepath.Join(root, "signup.page.gwdk"), + Route: "/signup", + Blocks: gwdkir.Blocks{ + Actions: []gwdkir.Action{{Name: "Submit", Method: "POST", Route: "/signup"}}, + GoBlocks: []gwdkir.GoBlock{{Body: inlineSubmitGoBlock}}, + }, + } + bindings := computeBackendBindings(gwdkir.Program{Pages: []gwdkir.Page{page}}) + binding, ok := findBindingByName(bindings, "action", "Submit") + if !ok || !binding.Ambiguous { + t.Fatalf("expected an ambiguous Submit binding, got %#v", binding) + } + if !strings.Contains(binding.Message, "declared in both") { + t.Fatalf("expected ambiguity message, got %q", binding.Message) + } + diagnostics := BackendBindingDiagnostics(bindings) + if len(diagnostics) != 1 || diagnostics[0].Code != "ambiguous_backend_handler" { + t.Fatalf("expected one ambiguous_backend_handler diagnostic, got %#v", diagnostics) + } +} + +func TestComputeBackendBindingsDoesNotMaskSamePackageCompileError(t *testing.T) { + root := t.TempDir() + writeCompilerTestModule(t, root) + // A sibling Go file that fails to type-check, so the same-package package + // cannot be inspected. + writeCompilerTestFile(t, filepath.Join(root, "broken.go"), `package pages + +func Broken() int { return "not an int" } +`) + page := gwdkir.Page{ + ID: "signup", + Package: "pages", + Source: filepath.Join(root, "signup.page.gwdk"), + Route: "/signup", + Blocks: gwdkir.Blocks{ + Actions: []gwdkir.Action{{Name: "Submit", Method: "POST", Route: "/signup"}}, + GoBlocks: []gwdkir.GoBlock{{Body: inlineSubmitGoBlock}}, + }, + } + bindings := computeBackendBindings(gwdkir.Program{Pages: []gwdkir.Page{page}}) + binding, ok := findBindingByName(bindings, "action", "Submit") + if !ok { + t.Fatalf("expected a Submit binding, got %#v", bindings) + } + // Even though the inline go {} block declares a valid Submit, a broken + // same-package package must not be masked by reporting the inline handler as + // bound. The compile error itself is reported separately by go_package_error. + if binding.Status == source.BackendBindingBound { + t.Fatalf("expected the broken same-package package not to report Submit as bound via inline fallback, got %#v", binding) + } + if !strings.Contains(binding.Message, "could not be inspected") { + t.Fatalf("expected a could-not-inspect message surfacing the compile error, got %q", binding.Message) + } +} + func TestComputeBackendBindingsStaysSilentForFragmentWithoutCandidate(t *testing.T) { page := gwdkir.Page{ ID: "patients", Package: "pages", - Source: "no-such-dir/patients.page.gwdk", + Source: emptyPackageSource(t, "patients.page.gwdk"), Route: "/patients", Blocks: gwdkir.Blocks{ Fragments: []gwdkir.FragmentEndpoint{{Name: "Summary", Method: "GET", Route: "/patients/summary", Target: "#summary"}}, diff --git a/internal/compiler/backend_bindings.go b/internal/compiler/backend_bindings.go index 8ac51026..72f6fc7b 100644 --- a/internal/compiler/backend_bindings.go +++ b/internal/compiler/backend_bindings.go @@ -53,25 +53,23 @@ func computeBackendBindings(ir gwdkir.Program) []source.BackendBinding { bindings = append(bindings, bindLoad(page, pkg)) } for _, action := range page.Blocks.Actions { - binding := bindAction(page, action, pkg) - if binding.Status == source.BackendBindingMissing { - binding = preferInlineBinding(binding, bindAction(page, action, defaultInlinePkg())) - } - bindings = append(bindings, binding) + inlinePkg := defaultInlinePkg() + bindings = append(bindings, resolveBackendBinding( + bindAction(page, action, pkg), + bindAction(page, action, inlinePkg), + pkg, inlinePkg, action.Name, + )) } for _, api := range page.Blocks.APIs { - binding := bindAPI(page, api, pkg) - if binding.Status == source.BackendBindingMissing { - binding = preferInlineBinding(binding, bindAPI(page, api, defaultInlinePkg())) - } - bindings = append(bindings, binding) + inlinePkg := defaultInlinePkg() + bindings = append(bindings, resolveBackendBinding( + bindAPI(page, api, pkg), + bindAPI(page, api, inlinePkg), + pkg, inlinePkg, api.Name, + )) } for _, fragment := range page.Blocks.Fragments { - if binding, ok := bindFragment(page, fragment, pkg); ok { - bindings = append(bindings, binding) - } else if binding, ok := bindFragment(page, fragment, defaultInlinePkg()); ok { - bindings = append(bindings, binding) - } else if binding, ok := bindMissingFragmentCandidate(page, fragment, pkg, defaultInlinePkg()); ok { + if binding, ok := resolveFragmentBinding(page, fragment, pkg, defaultInlinePkg()); ok { bindings = append(bindings, binding) } } @@ -104,37 +102,51 @@ func computeBackendBindings(ir gwdkir.Program) []source.BackendBinding { func bindLoad(page gwdkir.Page, pkg featurePackage) source.BackendBinding { functionName := loadFunctionName(page.ID) - if function, ok := pkg.Functions[functionName]; ok { + // A broken same-package Go package cannot be inspected: surface that instead + // of falling back to an inline go ssr {} block and reporting a misleading + // bound status. The broken package itself is reported by go_package_error. + if pkg.LoadError != "" { binding := baseBackendBinding(page, loadHandlerKind, functionName, "GET", page.Route, pkg) - if !function.Load() { - binding.Status = source.BackendBindingUnsupportedSignature - binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s must have signature func(ssr.LoadContext) map[string]any or func(ssr.LoadContext) (map[string]any, error)", bindingPackageLabel(binding, pkg), functionName) - return binding - } - binding.Signature = function.Signature - binding.Status = source.BackendBindingBound + binding.Status = source.BackendBindingMissing + binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s could not be inspected: %s", bindingPackageLabel(binding, pkg), functionName, pkg.LoadError) return binding } inlinePkg := inspectInlineScriptFeaturePackage(page, "ssr") - if function, ok := inlinePkg.Functions[functionName]; ok { - binding := baseBackendBinding(page, loadHandlerKind, functionName, "GET", page.Route, inlinePkg) - if !function.Load() { - binding.Status = source.BackendBindingUnsupportedSignature - binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s must have signature func(ssr.LoadContext) map[string]any or func(ssr.LoadContext) (map[string]any, error)", bindingPackageLabel(binding, inlinePkg), functionName) - return binding + _, inSame := pkg.Functions[functionName] + _, inInline := inlinePkg.Functions[functionName] + switch { + case inSame && inInline: + binding := bindLoadFromPackage(page, functionName, pkg) + binding.Ambiguous = true + binding.Message = ambiguousHandlerMessage(loadHandlerKind, functionName) + return binding + case inSame: + return bindLoadFromPackage(page, functionName, pkg) + case inInline: + return bindLoadFromPackage(page, functionName, inlinePkg) + default: + binding := baseBackendBinding(page, loadHandlerKind, functionName, "GET", page.Route, pkg) + binding.Status = source.BackendBindingMissing + binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s is not implemented", bindingPackageLabel(binding, pkg), functionName) + binding = markUnexportedCandidate(binding, pkg) + if !binding.UnexportedCandidate { + binding = markUnexportedCandidate(binding, inlinePkg) } - binding.Signature = function.Signature - binding.Status = source.BackendBindingBound return binding } +} + +func bindLoadFromPackage(page gwdkir.Page, functionName string, pkg featurePackage) source.BackendBinding { binding := baseBackendBinding(page, loadHandlerKind, functionName, "GET", page.Route, pkg) - binding.Status = source.BackendBindingMissing - if pkg.LoadError != "" { - binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s could not be inspected: %s", bindingPackageLabel(binding, pkg), functionName, pkg.LoadError) + function := pkg.Functions[functionName] + if !function.Load() { + binding.Status = source.BackendBindingUnsupportedSignature + binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s must have signature func(ssr.LoadContext) map[string]any or func(ssr.LoadContext) (map[string]any, error)", bindingPackageLabel(binding, pkg), functionName) return binding } - binding.Message = fmt.Sprintf("GOWDK SSR load handler %s.%s is not implemented", bindingPackageLabel(binding, pkg), functionName) - return markUnexportedCandidate(binding, pkg) + binding.Signature = function.Signature + binding.Status = source.BackendBindingBound + return binding } func bindStandaloneAction(endpoint gwdkir.GoEndpoint, pkg featurePackage) source.BackendBinding { @@ -300,6 +312,74 @@ func baseStandaloneBackendBinding(endpoint gwdkir.GoEndpoint, kind, method strin } } +// resolveBackendBinding chooses the binding for an action/api block across its +// two possible Go sources — sibling same-package code and the inline default +// go {} block — without masking failures: +// +// - When the same-package package failed to compile (LoadError), the +// could-not-inspect binding is kept instead of falling back to the inline +// result, so a broken package never looks bound. The compile error itself is +// reported by go_package_error. +// - When the handler is declared in BOTH sources it is ambiguous: the binding +// is flagged so tooling reports the conflict instead of silently preferring +// one source. +// - Otherwise the source that actually declares the handler wins, and when +// neither does, an unexported near-miss in either source is surfaced. +func resolveBackendBinding(samePackage, inline source.BackendBinding, samePkg, inlinePkg featurePackage, name string) source.BackendBinding { + if samePkg.LoadError != "" { + return samePackage + } + inSame := packageHasFunction(samePkg, name) + inInline := packageHasFunction(inlinePkg, name) + switch { + case inSame && inInline: + out := samePackage + out.Ambiguous = true + out.Message = ambiguousHandlerMessage(out.Kind, name) + return out + case inSame: + return samePackage + case inInline: + return inline + default: + return preferInlineBinding(samePackage, inline) + } +} + +// resolveFragmentBinding mirrors resolveBackendBinding for fragments, which +// never require a Go handler: a fragment with no bound handler renders static +// .gwdk output. It returns ok=false (no binding, static) unless a handler is +// declared, ambiguous, or only present as an unexported near-miss. +func resolveFragmentBinding(page gwdkir.Page, fragment gwdkir.FragmentEndpoint, samePkg, inlinePkg featurePackage) (source.BackendBinding, bool) { + if samePkg.LoadError != "" { + return source.BackendBinding{}, false + } + inSame := packageHasFunction(samePkg, fragment.Name) + inInline := packageHasFunction(inlinePkg, fragment.Name) + switch { + case inSame && inInline: + binding, _ := bindFragment(page, fragment, samePkg) + binding.Ambiguous = true + binding.Message = ambiguousHandlerMessage(fragmentHandlerKind, fragment.Name) + return binding, true + case inSame: + return bindFragment(page, fragment, samePkg) + case inInline: + return bindFragment(page, fragment, inlinePkg) + default: + return bindMissingFragmentCandidate(page, fragment, samePkg, inlinePkg) + } +} + +func packageHasFunction(pkg featurePackage, name string) bool { + _, ok := pkg.Functions[name] + return ok +} + +func ambiguousHandlerMessage(kind, name string) string { + return fmt.Sprintf("GOWDK %s handler %s is declared in both same-package Go and an inline go {} block; declare it in exactly one source", kind, name) +} + // preferInlineBinding resolves an action/api block that did not bind in its // same-package Go code by consulting the inline default go {} block. It prefers // the inline result when the inline block actually binds (bound or unsupported diff --git a/internal/contractscan/packages.go b/internal/contractscan/packages.go index 06b974c1..70dd0814 100644 --- a/internal/contractscan/packages.go +++ b/internal/contractscan/packages.go @@ -16,9 +16,8 @@ import ( ) type fileScan struct { - Contracts []Contract - Diagnostics []Diagnostic - EmitsByHandler map[string][]EventRef + Contracts []Contract + Diagnostics []Diagnostic } type inputStruct struct { @@ -121,10 +120,19 @@ func scanPackage(fset *token.FileSet, files []parsedGoFile, inspectionCache *pac diagnostics = append(diagnostics, validateEventNames(contracts)...) diagnostics = append(diagnostics, validateContracts(contracts, typedPackage.Functions)...) diagnostics = append(diagnostics, validateContractInputStructs(contracts, inputStructs)...) + attachCommandEmits(contracts, emitsByHandler) return fileScan{ - Contracts: contracts, - Diagnostics: diagnostics, - EmitsByHandler: emitsByHandler, + Contracts: contracts, + Diagnostics: diagnostics, + } +} + +func attachCommandEmits(contracts []Contract, emitsByHandler map[string][]EventRef) { + for index := range contracts { + if contracts[index].Kind != runtimecontracts.Command { + continue + } + contracts[index].Emits = copyEventRefs(emitsByHandler[contracts[index].Handler]) } } diff --git a/internal/contractscan/scan.go b/internal/contractscan/scan.go index 186c0ddf..445fa821 100644 --- a/internal/contractscan/scan.go +++ b/internal/contractscan/scan.go @@ -95,7 +95,6 @@ func Scan(root string) (Report, error) { fset := token.NewFileSet() var contracts []Contract var diagnostics []Diagnostic - emitsByHandler := map[string][]EventRef{} packages, err := parseScanPackages(fset, absRoot, files) if err != nil { return Report{}, err @@ -105,17 +104,8 @@ func Scan(root string) (Report, error) { discovered := scanPackage(fset, pkg, inspectionCache) contracts = append(contracts, discovered.Contracts...) diagnostics = append(diagnostics, discovered.Diagnostics...) - for handler, emits := range discovered.EmitsByHandler { - emitsByHandler[handler] = append(emitsByHandler[handler], emits...) - } } diagnostics = append(diagnostics, duplicateCommandDiagnostics(contracts)...) - for index := range contracts { - if contracts[index].Kind != runtimecontracts.Command { - continue - } - contracts[index].Emits = copyEventRefs(emitsByHandler[contracts[index].Handler]) - } diagnostics = append(diagnostics, emittedEventCategoryDiagnostics(contracts)...) sort.Slice(contracts, func(i, j int) bool { if contracts[i].Kind != contracts[j].Kind { diff --git a/internal/contractscan/scan_test.go b/internal/contractscan/scan_test.go index e2fe71ba..305cd8ea 100644 --- a/internal/contractscan/scan_test.go +++ b/internal/contractscan/scan_test.go @@ -176,6 +176,62 @@ func HandleCreatePatient(ctx context.Context, command CreatePatient) (CreatePati } } +func TestScanKeepsEmittedEventsScopedToPackageHandlers(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "patients", "patients.go"), `package patients + +import ( + "context" + contracts "github.com/cssbruno/gowdk/runtime/contracts" +) + +type CreatePatient struct{} +type CreatePatientResult struct{} +type PatientCreated struct{} + +func Register(r *contracts.Registry) { + contracts.RegisterCommand[CreatePatient, CreatePatientResult](r, HandleCreate) + contracts.RegisterDomainEvent[PatientCreated](r, SendWelcomeEmail) +} + +func HandleCreate(ctx context.Context, command CreatePatient) (CreatePatientResult, error) { + if err := contracts.EmitDomain(ctx, PatientCreated{}); err != nil { + return CreatePatientResult{}, err + } + return CreatePatientResult{}, nil +} + +func SendWelcomeEmail(ctx context.Context, event PatientCreated) error { + return nil +} +`) + writeFile(t, filepath.Join(root, "billing", "billing.go"), `package billing + +import ( + "context" + contracts "github.com/cssbruno/gowdk/runtime/contracts" +) + +type PatientCreated struct{} + +func HandleCreate(ctx context.Context) error { + return contracts.EmitPresentation(ctx, PatientCreated{}) +} +`) + + report, err := Scan(root) + if err != nil { + t.Fatal(err) + } + if len(report.Diagnostics) != 0 { + t.Fatalf("unexpected diagnostics from unrelated package handler emits: %#v", report.Diagnostics) + } + command := findContract(t, report.Contracts, runtimecontracts.Command, "CreatePatient") + if len(command.Emits) != 1 || command.Emits[0] != (EventRef{Category: runtimecontracts.DomainEvent, Type: "PatientCreated"}) { + t.Fatalf("unexpected command emits: %#v", command.Emits) + } +} + func TestScanReportsInvalidHandlerSignatures(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "patients.go"), `package patients diff --git a/internal/diagnostics/registry.go b/internal/diagnostics/registry.go index 80a0af53..ed551822 100644 --- a/internal/diagnostics/registry.go +++ b/internal/diagnostics/registry.go @@ -58,6 +58,7 @@ var missingUseFix = &Fix{ // compiler, build generator, contract scanner, and language tooling. var Registry = []Code{ {Code: "addon_go_block_diagnostic", Area: "go-block", Stability: StabilityAddon, Severity: SeverityError, Summary: "addon-provided go block diagnostic without a custom code"}, + {Code: "ambiguous_backend_handler", Area: "backend", Stability: StabilityStable, Severity: SeverityWarning, Summary: "a backend handler is declared in both same-package Go and an inline go block"}, {Code: "ambiguous_dynamic_route", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "dynamic page route overlaps another dynamic page route"}, {Code: "backend_binding_required", Area: "backend", Stability: StabilityStable, Severity: SeverityError, Summary: "strict builds require a supported backend handler binding"}, {Code: "client_go_block_wasm_build_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM build failed"}, diff --git a/internal/project/config.go b/internal/project/config.go index b41bdde2..d433a6be 100644 --- a/internal/project/config.go +++ b/internal/project/config.go @@ -14,6 +14,7 @@ import ( "github.com/cssbruno/gowdk/addons/actions" "github.com/cssbruno/gowdk/addons/api" "github.com/cssbruno/gowdk/addons/auth" + contractsaddon "github.com/cssbruno/gowdk/addons/contracts" "github.com/cssbruno/gowdk/addons/css" "github.com/cssbruno/gowdk/addons/db" "github.com/cssbruno/gowdk/addons/embed" @@ -847,6 +848,8 @@ func parseBuiltInAddon(expression ast.Expr, imports map[string]string) (gowdk.Ad return api.Addon(), true case auth.ImportPath: return auth.Addon(), true + case contractsaddon.ImportPath: + return contractsaddon.Addon(), true case css.ImportPath: return css.Addon(), true case db.ImportPath: diff --git a/internal/project/config_test.go b/internal/project/config_test.go index 094f13ef..dcef90ca 100644 --- a/internal/project/config_test.go +++ b/internal/project/config_test.go @@ -391,6 +391,7 @@ import ( "github.com/cssbruno/gowdk" act "github.com/cssbruno/gowdk/addons/actions" apiaddon "github.com/cssbruno/gowdk/addons/api" + contractsaddon "github.com/cssbruno/gowdk/addons/contracts" cssaddon "github.com/cssbruno/gowdk/addons/css" embedaddon "github.com/cssbruno/gowdk/addons/embed" partialaddon "github.com/cssbruno/gowdk/addons/partial" @@ -404,6 +405,7 @@ var Config = gowdk.Config{ Addons: []gowdk.Addon{ act.Addon(), apiaddon.Addon(), + contractsaddon.Addon(), cssaddon.Addon(), embedaddon.Addon(), partialaddon.Addon(), @@ -421,15 +423,16 @@ var Config = gowdk.Config{ if err != nil { t.Fatal(err) } - if len(config.Addons) != 9 { + if len(config.Addons) != 10 { t.Fatalf("unexpected addons: %#v", config.Addons) } - if config.Addons[8].Name() != "static" { - t.Fatalf("expected static addon, got %#v", config.Addons[8]) + if config.Addons[9].Name() != "static" { + t.Fatalf("expected static addon, got %#v", config.Addons[9]) } for _, feature := range []gowdk.Feature{ gowdk.FeatureActions, gowdk.FeatureAPI, + gowdk.FeatureContracts, gowdk.FeatureCSS, gowdk.FeatureEmbed, gowdk.FeaturePartial, diff --git a/internal/source/source.go b/internal/source/source.go index f5b77287..ae6afca6 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -231,6 +231,10 @@ type BackendBinding struct { // unexported Go function exists in the inspected package, so tooling can // explain that the function is present but not exported. UnexportedCandidate bool + // Ambiguous is set when the same handler name is declared in more than one + // Go source (sibling same-package code and an inline go {} block), so tooling + // reports the conflict instead of silently preferring one. + Ambiguous bool } // ErrorPagePath returns a clean generated-output-relative error page path. diff --git a/runtime/contracts/natsbroker/natsbroker.go b/runtime/contracts/natsbroker/natsbroker.go index 36e45f6d..8d767d53 100644 --- a/runtime/contracts/natsbroker/natsbroker.go +++ b/runtime/contracts/natsbroker/natsbroker.go @@ -130,9 +130,12 @@ func (broker *Broker) ReceiveEventBatch(ctx context.Context) (contracts.EventBat events := []contracts.EventEnvelope{first} for len(events) < broker.batchSize { pollCtx, cancel := context.WithTimeout(ctx, time.Millisecond) - event, err := broker.nextMessage(pollCtx) + event, ok, err := broker.tryNextMessage(pollCtx) cancel() if err != nil { + return contracts.EventBatch{}, err + } + if !ok { break } events = append(events, event) @@ -175,23 +178,39 @@ func (broker *Broker) ensureSubscription() error { } func (broker *Broker) nextMessage(ctx context.Context) (contracts.EventEnvelope, error) { - waitCtx := ctx - cancel := func() {} - if broker.timeout > 0 { - waitCtx, cancel = context.WithTimeout(ctx, broker.timeout) + for { + waitCtx := ctx + cancel := func() {} + if broker.timeout > 0 { + waitCtx, cancel = context.WithTimeout(ctx, broker.timeout) + } + event, ok, err := broker.tryNextMessage(waitCtx) + cancel() + if err != nil { + return contracts.EventEnvelope{}, err + } + if ok { + return event, nil + } + if err := ctx.Err(); err != nil { + return contracts.EventEnvelope{}, err + } } - defer cancel() - message, err := broker.sub.NextMsgWithContext(waitCtx) +} + +func (broker *Broker) tryNextMessage(ctx context.Context) (contracts.EventEnvelope, bool, error) { + message, err := broker.sub.NextMsgWithContext(ctx) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - if ctx.Err() != nil { - return contracts.EventEnvelope{}, ctx.Err() - } - return contracts.EventEnvelope{}, contracts.ErrEventSourceClosed + return contracts.EventEnvelope{}, false, nil } - return contracts.EventEnvelope{}, err + return contracts.EventEnvelope{}, false, err + } + event, err := broker.decodeMessage(message) + if err != nil { + return contracts.EventEnvelope{}, false, err } - return broker.decodeMessage(message) + return event, true, nil } func (broker *Broker) decodeMessage(message *nats.Msg) (contracts.EventEnvelope, error) { diff --git a/runtime/contracts/redisstream/redisstream.go b/runtime/contracts/redisstream/redisstream.go index dd5635ea..4ac30137 100644 --- a/runtime/contracts/redisstream/redisstream.go +++ b/runtime/contracts/redisstream/redisstream.go @@ -156,14 +156,18 @@ func (store *Store) ReceiveEventBatch(ctx context.Context) (contracts.EventBatch } store.setPendingFirst(false) } - batch, ok, err := store.readBatch(ctx, ">") - if err != nil { - return contracts.EventBatch{}, err - } - if !ok { - return contracts.EventBatch{}, contracts.ErrEventSourceClosed + for { + batch, ok, err := store.readBatch(ctx, ">") + if err != nil { + return contracts.EventBatch{}, err + } + if ok { + return batch, nil + } + if err := ctx.Err(); err != nil { + return contracts.EventBatch{}, err + } } - return batch, nil } func (store *Store) pendingFirst() bool { diff --git a/runtime/contracts/websocketfanout/websocketfanout.go b/runtime/contracts/websocketfanout/websocketfanout.go index d7df83de..b2d82a87 100644 --- a/runtime/contracts/websocketfanout/websocketfanout.go +++ b/runtime/contracts/websocketfanout/websocketfanout.go @@ -64,16 +64,17 @@ func (hub *Hub) ServeHTTP(response http.ResponseWriter, request *http.Request) { } defer conn.Close(websocket.StatusNormalClosure, "") + ctx := conn.CloseRead(request.Context()) client := make(chan []byte, hub.bufferSize) hub.add(conn, client) defer hub.remove(conn) for { select { - case <-request.Context().Done(): + case <-ctx.Done(): return case payload := <-client: - if err := conn.Write(request.Context(), websocket.MessageText, payload); err != nil { + if err := conn.Write(ctx, websocket.MessageText, payload); err != nil { return } } diff --git a/runtime/contracts/websocketfanout/websocketfanout_test.go b/runtime/contracts/websocketfanout/websocketfanout_test.go index 8ae79ebc..f97118f5 100644 --- a/runtime/contracts/websocketfanout/websocketfanout_test.go +++ b/runtime/contracts/websocketfanout/websocketfanout_test.go @@ -58,3 +58,34 @@ func TestHubStreamsPresentationEvents(t *testing.T) { t.Fatalf("websocket payload included non-presentation event: %s", source) } } + +func TestHubRemovesDisconnectedClientsWithoutBroadcast(t *testing.T) { + hub := New() + server := httptest.NewServer(hub) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + + waitForClientCount(t, hub, 1) + if err := conn.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Fatal(err) + } + waitForClientCount(t, hub, 0) +} + +func waitForClientCount(t *testing.T, hub *Hub, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if got := hub.ClientCount(); got == want { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("hub.ClientCount() = %d, want %d", hub.ClientCount(), want) +} From 1fb5eda69a8d106db66ed3735256e410b40922bf Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 08:30:40 -0300 Subject: [PATCH 2/5] feat(contracts): stabilize adapters and event dedup --- CHANGELOG.md | 9 ++ docs/engineering/architecture.md | 10 +- docs/product/requirements.md | 2 +- docs/reference/contracts.md | 62 +++++-- examples/contracts/gowdk.config.go | 6 + internal/appgen/appgen_test.go | 152 +++++++++++++++++- internal/appgen/source_actions.go | 8 + internal/appgen/source_contracts.go | 23 ++- .../generated_go_golden/app.go.golden | 13 +- runtime/contracts/contracts.go | 16 +- runtime/contracts/contracts_test.go | 28 ++++ runtime/contracts/event_id.go | 43 +++++ runtime/contracts/fileoutbox/fileoutbox.go | 10 +- .../contracts/fileoutbox/fileoutbox_test.go | 50 ++++++ runtime/contracts/fileoutbox/seenstore.go | 140 ++++++++++++++++ runtime/contracts/membroker/membroker.go | 2 +- runtime/contracts/membroker/membroker_test.go | 3 + runtime/contracts/natsbroker/natsbroker.go | 1 + .../contracts/natsbroker/natsbroker_test.go | 7 +- runtime/contracts/recorder.go | 4 +- runtime/contracts/redisstream/redisstream.go | 1 + .../contracts/redisstream/redisstream_test.go | 46 +++++- runtime/contracts/redisstream/seenstore.go | 57 +++++++ runtime/contracts/seenstore.go | 61 +++++++ runtime/contracts/seenstore_test.go | 47 ++++++ runtime/contracts/worker.go | 63 +++++++- runtime/contracts/worker_test.go | 77 +++++++++ runtime/response/response.go | 20 +++ runtime/response/response_test.go | 33 ++++ 29 files changed, 949 insertions(+), 45 deletions(-) create mode 100644 runtime/contracts/event_id.go create mode 100644 runtime/contracts/fileoutbox/seenstore.go create mode 100644 runtime/contracts/redisstream/seenstore.go create mode 100644 runtime/contracts/seenstore.go create mode 100644 runtime/contracts/seenstore_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d69e726..27c42a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ packages, and tooling contracts may change before a stable release. buildable Go package" message. A component-script resolution error during build now fails the build instead of silently omitting the page's component scripts. +- Generated `g:command` and `g:query` contract web adapters now use one JSON + response contract: success writes the command/query result as no-store JSON, + and failures write `{"error":"..."}` as no-store JSON with ordinary 5xx + details redacted unless the handler returns an explicit `response.HandlerError`. +- Contract event envelopes now carry stable IDs for durable delivery. Workers + can opt into deduplication with `RunEventWorkerWithSeenStore` or + `RunEventWorkerForRoleWithSeenStore`; duplicate IDs are acked without + subscriber dispatch inside the configured window. Runtime includes bounded + in-memory, file-backed, and Redis SETNX-with-TTL seen-store adapters. ### Known Gaps diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 36102c67..71d02645 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -42,9 +42,11 @@ retry metadata and opt-in dead-letter storage. Runtime also includes in-memory broker/EventSource and SSE presentation fanout adapters in the root module. Concrete Redis Streams, NATS, and WebSocket adapters live as nested optional Go modules under `runtime/contracts` so those third-party clients do not enter the -root module graph. Generated apps can expose contract event sink registration, -fresh registry construction, and a worker replay helper for executable contract -registrations. Split runtime binaries, retry backoff policy, and managed +root module graph. Durable event envelopes carry stable IDs, workers can use +in-memory, file-backed, or Redis seen stores to skip duplicates inside a +deduplication window, and generated apps can expose contract event sink +registration, fresh registry construction, and worker replay helpers for +executable contract registrations. Split runtime binaries, retry backoff policy, and managed deployment recipes remain planned. Still partial: broad local client-side reactivity, richer hybrid streaming and @@ -170,7 +172,7 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `runtime/asset` | Asset manifest resolution. | Runtime | Initial manifest helper implemented. | | `runtime/route` | Runtime route matching for generated request-time routes. | Runtime | Dynamic route matcher for first-slice generated SSR routes implemented. | | `runtime/app` | Shared generated app HTTP server. | Runtime | Serves embedded spa files, identity headers, health checks, asset manifest counts, optional generated 404/500 pages, no-JS cookie acknowledgement, server-side cookie notice hiding, generated CSRF token injection for POST forms, request-time panic boundaries, and generated action/API/fragment/SSR callback hooks. | -| `runtime/contracts` | Typed contract registry and in-process dispatch. | Runtime | First runtime slice implemented for queries, commands, backend-owned domain and integration events, presentation events, jobs, metadata, stable observation names and labels for logs/metrics/traces, local command-buffered event dispatch, event-envelope capture/replay, dependency-free outbox/broker/presentation-fanout/event-source interfaces, command event sinks, an event worker loop with ack/nack plus context cancellation, a dependency-free file outbox adapter, dependency-free in-memory broker/EventSource adapter, and dependency-free SSE presentation fanout adapter. Concrete Redis Streams, NATS, and WebSocket adapters are nested optional modules. Split worker/cron generation, retry backoff policy, and managed deployment recipes remain planned. | +| `runtime/contracts` | Typed contract registry and in-process dispatch. | Runtime | First runtime slice implemented for queries, commands, backend-owned domain and integration events, presentation events, jobs, metadata, stable observation names and labels for logs/metrics/traces, local command-buffered event dispatch, event-envelope capture/replay with stable IDs, dependency-free outbox/broker/presentation-fanout/event-source/seen-store interfaces, command event sinks, an event worker loop with ack/nack plus context cancellation and optional deduplication windows, a dependency-free file outbox adapter, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, and dependency-free SSE presentation fanout adapter. Concrete Redis Streams, Redis SETNX seen-store, NATS, and WebSocket adapters are nested optional modules. Split worker/cron generation, retry backoff policy, and managed deployment recipes remain planned. | | `addons/static` | Build-time static page output. | Addon | Capability boundary implemented; build-time output uses `runtime/render` through the compiler view renderer. | | `addons/spa` | Static-first SPA navigation compatibility surface. | Addon | Keeps the existing SPA feature package and aliases build-time route output types from `addons/static`. | | `addons/actions` | Typed backend actions, form decoding, CSRF. | Addon | Capability boundary, generated request-shape validation for direct literal required/minlength/maxlength/pattern controls, escaped live-region validation fragments for partial requests, signed CSRF validator, and generated action CSRF wiring implemented; user-defined domain validation helpers remain planned. | diff --git a/docs/product/requirements.md b/docs/product/requirements.md index cfcc8287..ef3b0600 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -74,7 +74,7 @@ implemented. | Inline Go authoring | Allow optional Go code blocks inside `.gwdk` only when they extract to normal importable, formatted, testable package Go. Separate `.go` files remain supported and generated adapters remain glue. Saved default `go {}` blocks are type-checked with sibling Go files during validation, default `go {}` blocks can provide build-time no-argument functions for `build { => LocalFunc() }` and same-page action/API/fragment handlers, page-level `go client {}` blocks can opt into client-side Go by exporting `GOWDKMount` for generated WASM page mounts, generated app source materializes default `go {}` and `go ssr {}` blocks under `gowdk_go/`, `go ssr {}` can provide generated SSR load handlers, and configured addons implementing `gowdk.GoBlockConsumer` can validate `go addon. {}` blocks and emit generated app Go files. Source-adjacent extraction and addon adapter contracts remain planned. | Partial | | Forms | Keep progressive-enhancement-first form behavior; full POST and enhanced POST share action result semantics; domain validation stays in user Go. | Partial | Generated enhanced forms preserve no-JavaScript POST behavior, send partial request headers, swap server fragments, expose failed enhanced response status/body/detail events, and use escaped live-region validation fragments. Domain validation stays in user Go. | | APIs | Broaden APIs through public request/response helpers and typed body/query helpers, not framework-specific adapters. | Partial — `addons/api` provides strict JSON body decoding, typed query helpers, and JSON/error/no-content response helpers for current `func(context.Context, *http.Request) (response.Response, error)` handlers. Generated typed handler signatures, per-route result contracts, CORS policy, and richer examples remain planned. | -| Contract runtime | Add typed Go queries, commands, backend-owned domain/integration events, presentation events, and jobs after endpoint/adapter IR is stable. Frontend UI events trigger commands or queries, commands have one owner, domain events are emitted after backend state changes succeed, local in-process dispatch is default, and broker/outbox/worker roles are optional. First runtime registry, runtime role filtering helpers, event-envelope capture/replay, stable observation names and labels for logs/metrics/traces, dependency-free outbox, broker, presentation-fanout, and event-source interfaces, event worker loop with ack/nack and context cancellation, dependency-free file outbox adapter with retry metadata and opt-in dead-letter storage, dependency-free in-memory broker/EventSource adapter, Redis Streams broker/EventSource adapter, NATS broker/EventSource adapter, dependency-free SSE presentation fanout adapter, WebSocket presentation fanout adapter, generated command event sink registration, generated contract registry construction, generated worker replay helper, Go AST scanner, scan-local package inspection cache, local-package and imported-handler `go/types` diagnostics, local and imported contract/result type diagnostics, local exported struct/function contract diagnostics, duplicate command-owner scan diagnostics, generated-app import-cycle diagnostics, emitted-event category diagnostics, first browser-UI and vague event-name diagnostics, contract/list/graph/trace CLI, form-local `g:command` metadata with literal form method/action, element-local `g:query` metadata with page-route source metadata, import-path-aware command/query reference linking, `g:event` rejection, IR command/query references with exact source locations, command/query reference binding status, appgen adapter IR exposure metadata, command method/path adapter metadata, query page-route adapter metadata, generated web command/query adapters, page-route query JSON negotiation, AST/printer/format generated adapter source, page-guard propagation for generated command/query routes, rate-limit and guard preflight before contract execution, CSRF-before-command-decoding ordering, routes-report contract endpoint metadata, missing/invalid/non-web-role contract-reference diagnostics, enforced Go contract scan diagnostics in check/build, and build-report contract-reference events with role metadata are implemented; all diagnostic spans, fragment/API-specific query execution, split-binary worker/cron wiring, retry backoff policies, and managed deployment recipes remain planned. | Partial | +| Contract runtime | Add typed Go queries, commands, backend-owned domain/integration events, presentation events, and jobs after endpoint/adapter IR is stable. Frontend UI events trigger commands or queries, commands have one owner, domain events are emitted after backend state changes succeed, local in-process dispatch is default, and broker/outbox/worker roles are optional. First runtime registry, runtime role filtering helpers, event-envelope capture/replay with stable event IDs, stable observation names and labels for logs/metrics/traces, dependency-free outbox, broker, presentation-fanout, event-source, and seen-store interfaces, event worker loop with ack/nack, context cancellation, and optional deduplication windows, dependency-free file outbox adapter with retry metadata and opt-in dead-letter storage, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, Redis Streams broker/EventSource adapter, Redis SETNX-with-TTL seen store, NATS broker/EventSource adapter, dependency-free SSE presentation fanout adapter, WebSocket presentation fanout adapter, generated command event sink registration, generated contract registry construction, generated worker replay helper with optional seen-store dedup, Go AST scanner, scan-local package inspection cache, local-package and imported-handler `go/types` diagnostics, local and imported contract/result type diagnostics, local exported struct/function contract diagnostics, duplicate command-owner scan diagnostics, generated-app import-cycle diagnostics, emitted-event category diagnostics, first browser-UI and vague event-name diagnostics, contract/list/graph/trace CLI, form-local `g:command` metadata with literal form method/action, element-local `g:query` metadata with page-route source metadata, import-path-aware command/query reference linking, `g:event` rejection, IR command/query references with exact source locations, command/query reference binding status, appgen adapter IR exposure metadata, command method/path adapter metadata, query page-route adapter metadata, generated web command/query adapters, page-route query JSON negotiation, stable JSON success/error response shape, AST/printer/format generated adapter source, page-guard propagation for generated command/query routes, rate-limit and guard preflight before contract execution, CSRF-before-command-decoding ordering, routes-report contract endpoint metadata, missing/invalid/non-web-role contract-reference diagnostics, enforced Go contract scan diagnostics in check/build, and build-report contract-reference events with role metadata are implemented; all diagnostic spans, fragment/API-specific query execution, split-binary worker/cron wiring, retry backoff policies, and managed deployment recipes remain planned. | Partial | | Cache | Keep `cache` and `revalidate` as HTTP cache policy; keep action-driven data refresh explicit through redirects, fragments, JSON, or reload responses. | Partial | | Guards | Extend guards with safe local redirects and response helpers before richer request-local state. | Planned | | Component CSS | Make component CSS explicit, compiler-scoped, and documented; Tailwind and processors remain optional. | Partial | diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index 763669f8..712af7a9 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -207,12 +207,14 @@ The stable operation names include: | Worker receive batch | `gowdk.contract.worker.receive` | | Worker ack batch | `gowdk.contract.worker.ack` | | Worker nack batch | `gowdk.contract.worker.nack` | +| Worker dedup skip | `gowdk.contract.worker.dedup_skip` | `Metadata.ObservationLabels()` returns the stable contract labels: kind, event category, contract type name, result type name, role, roles, and handler count when known. `EventEnvelope.ObservationLabels()` returns the event kind, -category, and captured event contract type. `ContractName[T]()` returns the same -Go contract type name used by metadata and event envelopes. +category, stable event ID, and captured event contract type. +`ContractName[T]()` returns the same Go contract type name used by metadata and +event envelopes. `ObservationForRole` records the runtime role that performed the operation. Inside a command handler, emit backend-owned events through the command context: @@ -241,8 +243,8 @@ result, events, err := contracts.CaptureCommandEvents[CreatePatient, CreatePatie ) ``` -Each captured `EventEnvelope` contains the event category, Go type name, and -typed value. Capturing does not run event subscribers. +Each captured `EventEnvelope` contains a stable event ID, event category, Go +type name, and typed value. Capturing does not run event subscribers. For dependency-free outbox integration, implement the small `Outbox` interface: @@ -315,11 +317,31 @@ Applications that need database transactions, cross-process locking, retry backoff, broker delivery, or operational dead-letter processing should use a database-backed or broker-backed adapter. -Subscriber handlers must be idempotent for any durable delivery adapter. A -worker can crash after a subscriber side effect but before `Ack`, or an adapter -can retry after `Nack`. Use a stable domain key, event id, outbox record id, or -application-level dedupe table to make repeated deliveries safe. GOWDK Runtime -does not hide retries behind generated JavaScript or browser state. +Delivery guarantees: + +- Local in-process dispatch is process-local exactly once for that command + execution because subscribers run before the command response is written. +- Outbox and broker delivery is at-least-once. Use `RunEventWorkerWithSeenStore` + or `RunEventWorkerForRoleWithSeenStore` with a `contracts.SeenStore` to skip + duplicate event IDs inside a configured deduplication window. Duplicate + batches are acknowledged without invoking subscribers. +- A deduplication window is not an exactly-once guarantee. Subscribers must + still tolerate redelivery outside the window, after store loss, or when a + subscriber fails after the worker has marked the event ID as seen. + +GOWDK Runtime provides three seen-store adapters: + +- `contracts.NewMemorySeenStore(limit)` keeps a bounded process-local LRU + window for local single-binary apps and tests. +- `fileoutbox.NewSeenStore(path, fileoutbox.WithSeenLimit(limit))` keeps a + dependency-free JSON Lines window next to the file outbox. +- `redisstream.NewSeenStore(client, prefix, ttl)` records IDs with Redis + `SETNX` and an expiration TTL for Redis Streams worker deployments. + +Subscriber handlers must still be idempotent for any durable delivery adapter. +Use a stable domain key, event ID, outbox record ID, or application-level +dedupe table to make repeated deliveries safe. GOWDK Runtime does not hide +retries behind generated JavaScript or browser state. External broker adapters can implement the dependency-free `Broker` interface: @@ -447,11 +469,14 @@ Generated packages with executable contract registrations also expose: ```go registry := gowdkapp.NewContractRegistry() err := gowdkapp.RunContractEventWorker(ctx, source) +err = gowdkapp.RunContractEventWorkerWithSeenStore(ctx, source, seen) ``` `NewContractRegistry` creates a fresh registry using the scanned registration functions. `RunContractEventWorker` replays an `EventSource` through the same -registrations with the worker role. +registrations with the worker role. `RunContractEventWorkerWithSeenStore` uses +the same worker role and skips duplicate event IDs through the provided +`contracts.SeenStore`. These helpers are deliberately local process APIs. Generated apps do not yet emit separate worker or cron binaries, supervisor configs, queue topology, or @@ -675,6 +700,11 @@ Current behavior: `CaptureCommandEventsForRole(..., contracts.RoleWeb, input)`, send captured events to the configured command event sink, and return the command result as no-store JSON. +- Success responses are `200 application/json` with the command result encoded + directly as JSON. Error responses are `application/json` with + `{"error":"..."}` and `Cache-Control: no-store`; ordinary 5xx errors use the + generic HTTP status text, while `response.NewHandlerError(status, message, + cause)` can opt into an explicit client-safe status and message. - When the scanner can see the exported command input struct fields, generated adapters parse submitted form values, allow only the scanned fields, decode supported scalar fields, and pass the typed command input to the registry. @@ -721,6 +751,11 @@ Current behavior: local `runtime/contracts.Registry`, route page-owned query references through the backend router, execute the query with `ExecuteQueryForRole(..., contracts.RoleWeb, input)`, and return the query result as no-store JSON. +- Success responses are `200 application/json` with the query result encoded + directly as JSON. Error responses are `application/json` with + `{"error":"..."}` and `Cache-Control: no-store`; ordinary 5xx errors use the + generic HTTP status text, while `response.NewHandlerError(status, message, + cause)` can opt into an explicit client-safe status and message. - Page-owned query routes share the page path, so generated apps dispatch them only for explicit query requests: `Accept: application/json`, another `+json` media type, or `X-GOWDK-Query: true`. Normal document requests keep @@ -799,11 +834,15 @@ Use `g:on:*` for local UI/component events and `g:command` for backend intent. - `runtime/contracts` can capture command-emitted events as `EventEnvelope` values and pass them to a dependency-free `Outbox` interface without dispatching subscribers. +- `EventEnvelope` carries a stable event ID for outbox/broker replay and worker + deduplication. - Captured event envelopes can be replayed later with `PublishEnvelope`, `PublishEnvelopes`, and role-filtered variants. - `runtime/contracts/fileoutbox` provides a dependency-free JSON Lines adapter that implements `contracts.Outbox` and `contracts.EventSource`, including nack retry metadata and an opt-in dead-letter file. +- `contracts.NewMemorySeenStore`, `fileoutbox.NewSeenStore`, and + `redisstream.NewSeenStore` provide deduplication windows for event workers. - External broker adapters can implement the dependency-free `Broker` interface and receive captured envelopes through `ExecuteCommandToBroker` or `PublishEventsToBroker`. @@ -814,7 +853,8 @@ Use `g:on:*` for local UI/component events and `g:command` for backend intent. `CommandEventSink` receives captured command events before the generated adapter writes the JSON command result. - Generated contract packages expose `NewContractRegistry` and - `RunContractEventWorker` when executable contract registrations are present. + `RunContractEventWorker` / `RunContractEventWorkerWithSeenStore` when + executable contract registrations are present. - Queue/outbox adapters can implement the dependency-free `EventSource` interface and drive worker-role subscribers through `RunEventWorker`. - `internal/appgen` records command/query contract exposure metadata in backend diff --git a/examples/contracts/gowdk.config.go b/examples/contracts/gowdk.config.go index f2536129..eb06b74f 100644 --- a/examples/contracts/gowdk.config.go +++ b/examples/contracts/gowdk.config.go @@ -6,6 +6,12 @@ import ( ) var Config = gowdk.Config{ + Source: gowdk.SourceConfig{ + Include: []string{"examples/contracts/*.gwdk"}, + }, + CSS: gowdk.CSSConfig{ + Include: []string{"examples/contracts/**/*.css"}, + }, Addons: []gowdk.Addon{ contractsaddon.Addon(), }, diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index 5c4b45a8..ad36b16b 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -375,6 +375,7 @@ func TestGenerateWritesBoundContractBackendRoutes(t *testing.T) { `contractEventSinkMu.RLock()`, `func NewContractRegistry() *gowdkcontracts.Registry`, `func RunContractEventWorker(ctx context.Context, source gowdkcontracts.EventSource) error`, + `func RunContractEventWorkerWithSeenStore(ctx context.Context, source gowdkcontracts.EventSource, seen gowdkcontracts.SeenStore) error`, `func commandPatientsCreatePatientPOSTPatients(contractRegistry *gowdkcontracts.Registry) gowdkruntime.BackendHandler`, `request.Body = http.MaxBytesReader(response, request.Body, maxActionBodyBytes)`, `values := gowdkform.FromURLValues(request.PostForm)`, @@ -394,6 +395,7 @@ func TestGenerateWritesBoundContractBackendRoutes(t *testing.T) { `func decodeContractPatientsGetPatientPageInput(values gowdkform.Values) (patients.GetPatientPage, error)`, `input.Filter = field0`, `gowdkresponse.JSONValue(http.StatusOK, result)`, + `gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError)`, } { if !strings.Contains(source, expected) { t.Fatalf("expected generated contract app source to contain %q:\n%s", expected, source) @@ -3634,8 +3636,11 @@ func TestGeneratedBinaryContractFallbacksAreExplicitNoStore(t *testing.T) { if response.StatusCode != http.StatusNotImplemented { t.Fatalf("expected missing contract response status 501, got %d: %s", response.StatusCode, payload) } - if !strings.Contains(string(payload), "command patients.CreatePatient is not registered") { - t.Fatalf("expected explicit missing contract response, got %s", payload) + if contentType := response.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" { + t.Fatalf("expected JSON missing contract response, got content type %q: %s", contentType, payload) + } + if strings.TrimSpace(string(payload)) != `{"error":"command patients.CreatePatient is not registered"}` { + t.Fatalf("expected explicit JSON missing contract response, got %s", payload) } if cacheControl := response.Header.Get("Cache-Control"); cacheControl != "no-store" { t.Fatalf("expected no-store on missing contract response, got %q", cacheControl) @@ -3866,6 +3871,149 @@ func init() { } } +func TestGeneratedBinaryContractAdaptersReturnJSONErrors(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + binaryPath := filepath.Join(root, "site") + writeTestFile(t, filepath.Join(outputDir, "patients", "index.html"), "
Patients page
") + + program := &gwdkir.Program{ContractRefs: []gwdkir.ContractReference{ + { + Kind: gwdkir.ContractCommand, + Name: "patients.CreatePatient", + ImportAlias: "patients", + ImportPath: "gowdk-generated-app/patients", + Type: "CreatePatient", + Result: "CreatePatientResult", + InputFields: []source.BackendInputField{{FieldName: "Name", FormName: "name", Type: "string"}}, + Method: http.MethodPost, + Path: "/patients", + Status: gwdkir.ContractBindingBound, + Handler: "HandleCreatePatient", + Register: "Register", + OwnerKind: gwdkir.SourcePage, + OwnerID: "patients", + }, + { + Kind: gwdkir.ContractQuery, + Name: "patients.GetPatientPage", + ImportAlias: "patients", + ImportPath: "gowdk-generated-app/patients", + Type: "GetPatientPage", + Result: "PatientPageData", + InputFields: []source.BackendInputField{{FieldName: "Filter", FormName: "filter", Type: "string"}}, + Method: http.MethodGet, + Path: "/patients", + Status: gwdkir.ContractBindingBound, + Handler: "LoadPatientPage", + Register: "Register", + OwnerKind: gwdkir.SourcePage, + OwnerID: "patients", + }, + }} + if _, err := GenerateWithOptions(outputDir, appDir, Options{IR: program}); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(appDir, "patients", "patients.go"), `package patients + +import ( + "context" + "errors" + "net/http" + + "github.com/cssbruno/gowdk/runtime/contracts" + gowdkresponse "github.com/cssbruno/gowdk/runtime/response" +) + +type CreatePatient struct { + Name string +} + +type CreatePatientResult struct { + ID string `+"`json:\"id\"`"+` +} + +type GetPatientPage struct { + Filter string +} + +type PatientPageData struct { + Filter string `+"`json:\"filter\"`"+` +} + +func Register(registry *contracts.Registry) { + contracts.RegisterCommand[CreatePatient, CreatePatientResult](registry, HandleCreatePatient) + contracts.RegisterQuery[GetPatientPage, PatientPageData](registry, LoadPatientPage) +} + +func HandleCreatePatient(ctx context.Context, command CreatePatient) (CreatePatientResult, error) { + return CreatePatientResult{}, errors.New("secret command failure") +} + +func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData, error) { + return PatientPageData{}, gowdkresponse.NewHandlerError(http.StatusBadRequest, "invalid filter", nil) +} +`) + if _, err := BuildBinary(appDir, binaryPath); err != nil { + t.Fatal(err) + } + + addr := freeAddr(t) + command := exec.Command(binaryPath) + command.Env = append(os.Environ(), "GOWDK_ADDR="+addr) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _, _ = command.Process.Wait() + }() + + commandResponse, err := waitForHTTPStatus("http://"+addr+"/patients", http.MethodPost, "name=Ada") + if err != nil { + t.Fatal(err) + } + commandPayload, err := io.ReadAll(commandResponse.Body) + _ = commandResponse.Body.Close() + if err != nil { + t.Fatal(err) + } + if commandResponse.StatusCode != http.StatusInternalServerError { + t.Fatalf("expected command error status 500, got %d: %s", commandResponse.StatusCode, commandPayload) + } + if commandResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("expected command JSON error content type, got %q", commandResponse.Header.Get("Content-Type")) + } + if strings.TrimSpace(string(commandPayload)) != `{"error":"Internal Server Error"}` { + t.Fatalf("unexpected command JSON error payload: %s", commandPayload) + } + if strings.Contains(string(commandPayload), "secret") { + t.Fatalf("command JSON error leaked handler detail: %s", commandPayload) + } + + queryResponse, err := waitForHTTPStatusWithHeaders("http://"+addr+"/patients?filter=bad", http.MethodGet, "", map[string]string{ + "Accept": "application/json", + }) + if err != nil { + t.Fatal(err) + } + queryPayload, err := io.ReadAll(queryResponse.Body) + _ = queryResponse.Body.Close() + if err != nil { + t.Fatal(err) + } + if queryResponse.StatusCode != http.StatusBadRequest { + t.Fatalf("expected query error status 400, got %d: %s", queryResponse.StatusCode, queryPayload) + } + if queryResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("expected query JSON error content type, got %q", queryResponse.Header.Get("Content-Type")) + } + if strings.TrimSpace(string(queryPayload)) != `{"error":"invalid filter"}` { + t.Fatalf("unexpected query JSON error payload: %s", queryPayload) + } +} + func TestGeneratedBinaryRegisteredGuardsAllowRequestTimeRoutes(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") diff --git a/internal/appgen/source_actions.go b/internal/appgen/source_actions.go index 9fa1baf0..290087e6 100644 --- a/internal/appgen/source_actions.go +++ b/internal/appgen/source_actions.go @@ -722,6 +722,14 @@ func writeNoStoreHandlerErrorExprStmt(err ast.Expr, fallbackStatus ast.Expr) ast return exprStmt(call(sel("gowdkresponse", "WriteNoStoreHandlerError"), id("response"), err, fallbackStatus)) } +func writeNoStoreJSONErrorStmt(status ast.Expr, message string) ast.Stmt { + return exprStmt(call(sel("gowdkresponse", "WriteNoStoreJSONError"), id("response"), status, stringLit(message))) +} + +func writeNoStoreHandlerJSONErrorExprStmt(err ast.Expr, fallbackStatus ast.Expr) ast.Stmt { + return exprStmt(call(sel("gowdkresponse", "WriteNoStoreHandlerJSONError"), id("response"), err, fallbackStatus)) +} + func handlerErrorMessageExpr(err ast.Expr, fallbackStatus ast.Expr) ast.Expr { return call(sel("gowdkresponse", "HandlerErrorMessage"), err, fallbackStatus) } diff --git a/internal/appgen/source_contracts.go b/internal/appgen/source_contracts.go index 7cf8619d..069cb961 100644 --- a/internal/appgen/source_contracts.go +++ b/internal/appgen/source_contracts.go @@ -59,7 +59,7 @@ func executableContractHandlerStmts(exposure BackendContractExposure, csrf bool, &ast.IfStmt{ Cond: notNil("err"), Body: block( - writeNoStoreHandlerErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), + writeNoStoreHandlerJSONErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), returnBool(true), ), }, @@ -81,7 +81,7 @@ func executableCommandContractStmts(exposure BackendContractExposure) []ast.Stmt &ast.IfStmt{ Cond: notNil("err"), Body: block( - writeNoStoreHandlerErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), + writeNoStoreHandlerJSONErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), returnBool(true), ), }, @@ -89,7 +89,7 @@ func executableCommandContractStmts(exposure BackendContractExposure) []ast.Stmt Init: define([]ast.Expr{id("dispatchErr")}, call(sel("gowdkcontracts", "DispatchCommandEvents"), id("ctx"), call(id("currentContractEventSink")), id("contractRegistry"), sel("gowdkcontracts", "RoleWeb"), id("events"))), Cond: notNil("dispatchErr"), Body: block( - writeNoStoreHandlerErrorExprStmt(id("dispatchErr"), sel("http", "StatusInternalServerError")), + writeNoStoreHandlerJSONErrorExprStmt(id("dispatchErr"), sel("http", "StatusInternalServerError")), returnBool(true), ), }, @@ -108,7 +108,7 @@ func executableQueryContractStmts(exposure BackendContractExposure) []ast.Stmt { &ast.IfStmt{ Cond: notNil("err"), Body: block( - writeNoStoreHandlerErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), + writeNoStoreHandlerJSONErrorExprStmt(id("err"), sel("http", "StatusInternalServerError")), returnBool(true), ), }, @@ -197,7 +197,7 @@ func contractFormSchemaExpr(fields []source.BackendInputField) ast.Expr { func fallbackContractHandlerDecl(exposure BackendContractExposure) *ast.FuncDecl { return funcDecl(contractHandlerName(exposure), actionParams(), boolResults(), []ast.Stmt{ - writeNoStoreErrorStmt(sel("http", "StatusNotImplemented"), contractFallbackMessage(exposure)), + writeNoStoreJSONErrorStmt(sel("http", "StatusNotImplemented"), contractFallbackMessage(exposure)), returnBool(true), }) } @@ -298,6 +298,7 @@ func contractRegistryDecls(exposures []BackendContractExposure) []ast.Decl { return []ast.Decl{ newContractRegistryDecl(exposures), runContractEventWorkerDecl(), + runContractEventWorkerWithSeenStoreDecl(), } } @@ -325,6 +326,18 @@ func runContractEventWorkerDecl() ast.Decl { }) } +func runContractEventWorkerWithSeenStoreDecl() ast.Decl { + return funcDecl("RunContractEventWorkerWithSeenStore", []*ast.Field{ + {Names: []*ast.Ident{id("ctx")}, Type: sel("context", "Context")}, + {Names: []*ast.Ident{id("source")}, Type: sel("gowdkcontracts", "EventSource")}, + {Names: []*ast.Ident{id("seen")}, Type: sel("gowdkcontracts", "SeenStore")}, + }, []*ast.Field{{Type: id("error")}}, []ast.Stmt{ + &ast.ReturnStmt{Results: []ast.Expr{ + call(sel("gowdkcontracts", "RunEventWorkerWithSeenStore"), id("ctx"), call(id("NewContractRegistry")), id("source"), id("seen")), + }}, + }) +} + func contractEventSinkMutexVarDecl() ast.Decl { return &ast.GenDecl{Tok: token.VAR, Specs: []ast.Spec{&ast.ValueSpec{ Names: []*ast.Ident{id("contractEventSinkMu")}, diff --git a/internal/appgen/testdata/generated_go_golden/app.go.golden b/internal/appgen/testdata/generated_go_golden/app.go.golden index 12feb3b5..227c0d08 100644 --- a/internal/appgen/testdata/generated_go_golden/app.go.golden +++ b/internal/appgen/testdata/generated_go_golden/app.go.golden @@ -135,16 +135,16 @@ func commandPatientsCreatePatientPOSTPatients(contractRegistry *gowdkcontracts.R } result, events, err := gowdkcontracts.CaptureCommandEventsForRole[patients.CreatePatient, patients.CreatePatientResult](ctx, contractRegistry, gowdkcontracts.RoleWeb, input) if err != nil { - gowdkresponse.WriteNoStoreHandlerError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) return true } if dispatchErr := gowdkcontracts.DispatchCommandEvents(ctx, currentContractEventSink(), contractRegistry, gowdkcontracts.RoleWeb, events); dispatchErr != nil { - gowdkresponse.WriteNoStoreHandlerError(response, dispatchErr, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreHandlerJSONError(response, dispatchErr, http.StatusInternalServerError) return true } httpResult, err := gowdkresponse.JSONValue(http.StatusOK, result) if err != nil { - gowdkresponse.WriteNoStoreHandlerError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) return true } _ = gowdkresponse.WriteNoStoreHTTP(response, httpResult) @@ -163,12 +163,12 @@ func queryPatientsGetPatientPageGETPatients(contractRegistry *gowdkcontracts.Reg } result, err := gowdkcontracts.ExecuteQueryForRole[patients.GetPatientPage, patients.PatientPageData](ctx, contractRegistry, gowdkcontracts.RoleWeb, input) if err != nil { - gowdkresponse.WriteNoStoreHandlerError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) return true } httpResult, err := gowdkresponse.JSONValue(http.StatusOK, result) if err != nil { - gowdkresponse.WriteNoStoreHandlerError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) return true } _ = gowdkresponse.WriteNoStoreHTTP(response, httpResult) @@ -247,6 +247,9 @@ func NewContractRegistry() *gowdkcontracts.Registry { func RunContractEventWorker(ctx context.Context, source gowdkcontracts.EventSource) error { return gowdkcontracts.RunEventWorker(ctx, NewContractRegistry(), source) } +func RunContractEventWorkerWithSeenStore(ctx context.Context, source gowdkcontracts.EventSource, seen gowdkcontracts.SeenStore) error { + return gowdkcontracts.RunEventWorkerWithSeenStore(ctx, NewContractRegistry(), source, seen) +} func newBackendRouter() (*gowdkruntime.BackendRouter, error) { contractRegistry := NewContractRegistry() return gowdkruntime.NewBackendRouter(gowdkruntime.BackendRoute{Method: http.MethodPost, Path: "/newsletter", Kind: "action", Handler: action}, gowdkruntime.BackendRoute{Method: http.MethodPost, Path: "/patients", Kind: "command", Handler: commandPatientsCreatePatientPOSTPatients(contractRegistry)}, gowdkruntime.BackendRoute{Method: http.MethodGet, Path: "/patients", Kind: "query", Handler: queryPatientsGetPatientPageGETPatients(contractRegistry)}) diff --git a/runtime/contracts/contracts.go b/runtime/contracts/contracts.go index dbadfa6e..48b6696b 100644 --- a/runtime/contracts/contracts.go +++ b/runtime/contracts/contracts.go @@ -52,6 +52,7 @@ type ( // EventEnvelope is a backend-owned event captured from a successful command. type EventEnvelope struct { + ID string Category EventCategory Type string Value any @@ -64,6 +65,7 @@ type EventDecoder func(json.RawMessage) (any, error) // StoredEventEnvelope is the JSON transport shape shared by contract outbox // and broker adapters. type StoredEventEnvelope struct { + ID string `json:"id,omitempty"` Category EventCategory `json:"category"` Type string `json:"type"` Value json.RawMessage `json:"value"` @@ -83,11 +85,12 @@ func JSONEventDecoder[T any]() EventDecoder { // MarshalEventEnvelopeJSON encodes an event envelope into the shared JSON // transport shape. func MarshalEventEnvelopeJSON(event EventEnvelope) ([]byte, error) { + event = EnsureEventID(event) value, err := json.Marshal(event.Value) if err != nil { return nil, err } - return json.Marshal(StoredEventEnvelope{Category: event.Category, Type: event.Type, Value: value}) + return json.Marshal(StoredEventEnvelope{ID: event.ID, Category: event.Category, Type: event.Type, Value: value}) } // DecodeEventEnvelopeJSON decodes the shared JSON transport shape and uses a @@ -106,7 +109,7 @@ func DecodeEventEnvelopeJSON(payload []byte, decoders map[string]EventDecoder) ( } value = decoded } - return EventEnvelope{Category: stored.Category, Type: stored.Type, Value: value}, nil + return EventEnvelope{ID: stored.ID, Category: stored.Category, Type: stored.Type, Value: value}, nil } // Outbox stores command-emitted events for durable delivery. Implementations @@ -127,6 +130,12 @@ type PresentationFanout interface { SendPresentationEvents(context.Context, []EventEnvelope) error } +// SeenStore records durable event IDs that have already been accepted for +// worker dispatch within an adapter-defined deduplication window. +type SeenStore interface { + MarkIfNew(context.Context, string) (bool, error) +} + // CommandEventSink receives events captured from a successful command. The // registry and role let sinks choose between in-process subscriber dispatch, // durable storage, broker publication, or browser-facing presentation delivery. @@ -211,6 +220,7 @@ const ( ObservationWorkerReceiveEventBatch ObservationName = "gowdk.contract.worker.receive" ObservationWorkerAckEventBatch ObservationName = "gowdk.contract.worker.ack" ObservationWorkerNackEventBatch ObservationName = "gowdk.contract.worker.nack" + ObservationWorkerDedupSkip ObservationName = "gowdk.contract.worker.dedup_skip" ) // ObservationLabels are stable contract attributes for logs, metrics, and @@ -219,6 +229,7 @@ const ( type ObservationLabels struct { Kind Kind EventCategory EventCategory + EventID string Contract string Result string Role Role @@ -269,6 +280,7 @@ func (event EventEnvelope) ObservationLabels() ObservationLabels { return ObservationLabels{ Kind: Event, EventCategory: event.Category, + EventID: event.ID, Contract: event.Type, } } diff --git a/runtime/contracts/contracts_test.go b/runtime/contracts/contracts_test.go index d132932a..33b03837 100644 --- a/runtime/contracts/contracts_test.go +++ b/runtime/contracts/contracts_test.go @@ -870,6 +870,7 @@ func TestNewObservationCopiesRoleLabels(t *testing.T) { func TestEventEnvelopeObservationUsesStableLabels(t *testing.T) { envelope := EventEnvelope{ + ID: "event-1", Category: DomainEvent, Type: ContractName[patientCreated](), Value: patientCreated{ID: "patient-1"}, @@ -888,6 +889,9 @@ func TestEventEnvelopeObservationUsesStableLabels(t *testing.T) { if observation.Labels.EventCategory != DomainEvent { t.Fatalf("event category = %q, want domain", observation.Labels.EventCategory) } + if observation.Labels.EventID != "event-1" { + t.Fatalf("event id = %q, want event-1", observation.Labels.EventID) + } if observation.Labels.Contract != ContractName[patientCreated]() { t.Fatalf("contract = %q, want patientCreated", observation.Labels.Contract) } @@ -896,6 +900,30 @@ func TestEventEnvelopeObservationUsesStableLabels(t *testing.T) { } } +func TestEventEnvelopeJSONPreservesID(t *testing.T) { + payload, err := MarshalEventEnvelopeJSON(EventEnvelope{ + ID: "event-1", + Category: DomainEvent, + Type: ContractName[patientCreated](), + Value: patientCreated{ID: "patient-1"}, + }) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + decoded, err := DecodeEventEnvelopeJSON(payload, map[string]EventDecoder{ + ContractName[patientCreated](): JSONEventDecoder[patientCreated](), + }) + if err != nil { + t.Fatalf("decode envelope: %v", err) + } + if decoded.ID != "event-1" { + t.Fatalf("decoded ID = %q, want event-1", decoded.ID) + } + if value, ok := decoded.Value.(patientCreated); !ok || value.ID != "patient-1" { + t.Fatalf("decoded value = %#v, want patientCreated patient-1", decoded.Value) + } +} + func TestMetadataIsDeterministic(t *testing.T) { registry := NewRegistry() must(t, RegisterCommand[createPatient, createPatientResult](registry, func(ctx context.Context, command createPatient) (createPatientResult, error) { diff --git a/runtime/contracts/event_id.go b/runtime/contracts/event_id.go new file mode 100644 index 00000000..697619b8 --- /dev/null +++ b/runtime/contracts/event_id.go @@ -0,0 +1,43 @@ +package contracts + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync/atomic" + "time" +) + +var fallbackEventIDSeq uint64 + +// NewEventID returns a process-local unique event ID suitable for durable +// envelope storage and worker deduplication. +func NewEventID() string { + var random [16]byte + if _, err := rand.Read(random[:]); err == nil { + return hex.EncodeToString(random[:]) + } + seq := atomic.AddUint64(&fallbackEventIDSeq, 1) + return fmt.Sprintf("%d-%d", time.Now().UTC().UnixNano(), seq) +} + +// EnsureEventID returns event with a durable ID assigned when it is missing. +func EnsureEventID(event EventEnvelope) EventEnvelope { + if event.ID == "" { + event.ID = NewEventID() + } + return event +} + +// EnsureEventIDs returns a copy of events where every envelope has a durable +// ID. Existing IDs are preserved. +func EnsureEventIDs(events []EventEnvelope) []EventEnvelope { + if len(events) == 0 { + return nil + } + out := make([]EventEnvelope, len(events)) + for index, event := range events { + out[index] = EnsureEventID(event) + } + return out +} diff --git a/runtime/contracts/fileoutbox/fileoutbox.go b/runtime/contracts/fileoutbox/fileoutbox.go index 80b45bc2..34c1d21a 100644 --- a/runtime/contracts/fileoutbox/fileoutbox.go +++ b/runtime/contracts/fileoutbox/fileoutbox.go @@ -49,7 +49,6 @@ type Store struct { deadLetterPath string maxAttempts int decoders map[string]Decoder - seq uint64 now func() time.Time } @@ -134,13 +133,14 @@ func (store *Store) StoreEvents(ctx context.Context, events []contracts.EventEnv encoder := json.NewEncoder(file) for _, event := range events { + event = contracts.EnsureEventID(event) value, err := json.Marshal(event.Value) if err != nil { file.Close() return err } record := Record{ - ID: store.nextID(), + ID: event.ID, StoredAt: store.now().UTC(), Category: event.Category, Type: event.Type, @@ -232,11 +232,6 @@ func (store *Store) ReceiveEventBatch(ctx context.Context) (contracts.EventBatch }, nil } -func (store *Store) nextID() string { - store.seq++ - return fmt.Sprintf("%d-%d", store.now().UTC().UnixNano(), store.seq) -} - // decodeRecordsLocked decodes records into typed envelopes. Records that have // no decoder or fail to decode are reported as failed instead of failing the // whole batch so one poison record cannot wedge delivery of the rest; failed @@ -261,6 +256,7 @@ func (store *Store) decodeRecordsLocked(records []Record) ([]contracts.EventEnve } decoded[record.ID] = true events = append(events, contracts.EventEnvelope{ + ID: record.ID, Category: record.Category, Type: record.Type, Value: value, diff --git a/runtime/contracts/fileoutbox/fileoutbox_test.go b/runtime/contracts/fileoutbox/fileoutbox_test.go index dda6f876..83a47f35 100644 --- a/runtime/contracts/fileoutbox/fileoutbox_test.go +++ b/runtime/contracts/fileoutbox/fileoutbox_test.go @@ -43,6 +43,9 @@ func TestStoreEventsAppendsDurableRecords(t *testing.T) { if records[0].Category != contracts.DomainEvent || records[0].Type != patientCreatedType { t.Fatalf("unexpected record metadata: %#v", records[0]) } + if records[0].ID == "" { + t.Fatalf("expected durable record ID to be assigned: %#v", records[0]) + } var value patientCreated if err := json.Unmarshal(records[0].Value, &value); err != nil { t.Fatalf("unmarshal record value: %v", err) @@ -72,6 +75,9 @@ func TestReceiveEventBatchDecodesAndAcksRecords(t *testing.T) { if len(batch.Events) != 2 { t.Fatalf("len(batch.Events) = %d, want 2", len(batch.Events)) } + if batch.Events[0].ID == "" || batch.Events[1].ID == "" || batch.Events[0].ID == batch.Events[1].ID { + t.Fatalf("expected replayed events to carry unique IDs: %#v", batch.Events) + } first, ok := batch.Events[0].Value.(patientCreated) if !ok || first.ID != "patient-1" { t.Fatalf("first event value = %#v, want patientCreated patient-1", batch.Events[0].Value) @@ -350,3 +356,47 @@ func TestReceiveEventBatchRequiresDecoder(t *testing.T) { t.Fatalf("expected decoder error, got %v", err) } } + +func TestSeenStoreMarksNewOnceAndPersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "seen.jsonl") + store := NewSeenStore(path) + store.now = func() time.Time { return time.Unix(123, 0).UTC() } + + fresh, err := store.MarkIfNew(context.Background(), "event-1") + if err != nil || !fresh { + t.Fatalf("first mark fresh=%v err=%v, want true nil", fresh, err) + } + fresh, err = store.MarkIfNew(context.Background(), "event-1") + if err != nil || fresh { + t.Fatalf("second mark fresh=%v err=%v, want false nil", fresh, err) + } + + reopened := NewSeenStore(path) + fresh, err = reopened.MarkIfNew(context.Background(), "event-1") + if err != nil || fresh { + t.Fatalf("reopened mark fresh=%v err=%v, want false nil", fresh, err) + } + records, err := reopened.readRecordsLocked() + if err != nil { + t.Fatalf("read seen records: %v", err) + } + if len(records) != 1 || records[0].ID != "event-1" { + t.Fatalf("unexpected seen records: %#v", records) + } +} + +func TestSeenStoreEvictsOldestRecord(t *testing.T) { + path := filepath.Join(t.TempDir(), "seen.jsonl") + store := NewSeenStore(path, WithSeenLimit(1)) + + if fresh, err := store.MarkIfNew(context.Background(), "event-1"); err != nil || !fresh { + t.Fatalf("mark event-1 fresh=%v err=%v, want true nil", fresh, err) + } + if fresh, err := store.MarkIfNew(context.Background(), "event-2"); err != nil || !fresh { + t.Fatalf("mark event-2 fresh=%v err=%v, want true nil", fresh, err) + } + fresh, err := store.MarkIfNew(context.Background(), "event-1") + if err != nil || !fresh { + t.Fatalf("event-1 should be new after eviction, fresh=%v err=%v", fresh, err) + } +} diff --git a/runtime/contracts/fileoutbox/seenstore.go b/runtime/contracts/fileoutbox/seenstore.go new file mode 100644 index 00000000..4cb98986 --- /dev/null +++ b/runtime/contracts/fileoutbox/seenstore.go @@ -0,0 +1,140 @@ +package fileoutbox + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const defaultSeenLimit = 10000 + +// SeenRecord is one file-backed deduplication entry. +type SeenRecord struct { + ID string `json:"id"` + SeenAt time.Time `json:"seenAt"` +} + +// SeenStore records delivered event IDs in a JSON Lines file. It is intended +// for local single-binary apps that also use fileoutbox. +type SeenStore struct { + mu sync.Mutex + path string + limit int + now func() time.Time +} + +// SeenOption configures a SeenStore. +type SeenOption func(*SeenStore) + +// WithSeenLimit sets the maximum retained IDs. Non-positive values keep the +// default window. +func WithSeenLimit(limit int) SeenOption { + return func(store *SeenStore) { + if limit > 0 { + store.limit = limit + } + } +} + +// NewSeenStore creates a file-backed seen store at path. +func NewSeenStore(path string, options ...SeenOption) *SeenStore { + store := &SeenStore{ + path: path, + limit: defaultSeenLimit, + now: time.Now, + } + for _, option := range options { + if option != nil { + option(store) + } + } + return store +} + +// MarkIfNew records id and reports whether it was not already present in the +// retained file window. +func (store *SeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if id == "" { + return false, errors.New("event id is required") + } + store.mu.Lock() + defer store.mu.Unlock() + + records, err := store.readRecordsLocked() + if err != nil { + return false, err + } + for _, record := range records { + if record.ID == id { + return false, nil + } + } + records = append(records, SeenRecord{ID: id, SeenAt: store.now().UTC()}) + if len(records) > store.limit { + records = append([]SeenRecord(nil), records[len(records)-store.limit:]...) + } + if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil { + return false, err + } + return true, store.writeRecordsLocked(records) +} + +func (store *SeenStore) readRecordsLocked() ([]SeenRecord, error) { + file, err := os.Open(store.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer file.Close() + + var records []SeenRecord + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + line := 0 + for scanner.Scan() { + line++ + text := bytes.TrimSpace(scanner.Bytes()) + if len(text) == 0 { + continue + } + var record SeenRecord + if err := json.Unmarshal(text, &record); err != nil { + return nil, fmt.Errorf("file seen store %s line %d is invalid: %w", store.path, line, err) + } + records = append(records, record) + } + return records, scanner.Err() +} + +func (store *SeenStore) writeRecordsLocked(records []SeenRecord) error { + if len(records) == 0 { + if err := os.Remove(store.path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + file, err := os.OpenFile(store.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + encoder := json.NewEncoder(file) + for _, record := range records { + if err := encoder.Encode(record); err != nil { + file.Close() + return err + } + } + return file.Close() +} diff --git a/runtime/contracts/membroker/membroker.go b/runtime/contracts/membroker/membroker.go index f487dc34..4cc8d233 100644 --- a/runtime/contracts/membroker/membroker.go +++ b/runtime/contracts/membroker/membroker.go @@ -59,7 +59,7 @@ func (broker *Broker) PublishEvents(ctx context.Context, events []contracts.Even defer broker.mu.Unlock() for _, event := range events { broker.nextID++ - broker.records = append(broker.records, record{id: broker.nextID, event: event}) + broker.records = append(broker.records, record{id: broker.nextID, event: contracts.EnsureEventID(event)}) } return nil } diff --git a/runtime/contracts/membroker/membroker_test.go b/runtime/contracts/membroker/membroker_test.go index 91970129..a0f0b8c6 100644 --- a/runtime/contracts/membroker/membroker_test.go +++ b/runtime/contracts/membroker/membroker_test.go @@ -32,6 +32,9 @@ func TestBrokerPublishesReceivesAndAcksEvents(t *testing.T) { if len(batch.Events) != 1 { t.Fatalf("len(batch.Events) = %d, want 1", len(batch.Events)) } + if batch.Events[0].ID == "" { + t.Fatalf("expected broker to assign event ID: %#v", batch.Events[0]) + } if err := batch.Ack(context.Background()); err != nil { t.Fatalf("ack batch: %v", err) } diff --git a/runtime/contracts/natsbroker/natsbroker.go b/runtime/contracts/natsbroker/natsbroker.go index 8d767d53..3f9ad403 100644 --- a/runtime/contracts/natsbroker/natsbroker.go +++ b/runtime/contracts/natsbroker/natsbroker.go @@ -103,6 +103,7 @@ func (broker *Broker) PublishEvents(ctx context.Context, events []contracts.Even return err } for _, event := range events { + event = contracts.EnsureEventID(event) payload, err := marshalEnvelope(event) if err != nil { return err diff --git a/runtime/contracts/natsbroker/natsbroker_test.go b/runtime/contracts/natsbroker/natsbroker_test.go index 677cdbfa..72b4c190 100644 --- a/runtime/contracts/natsbroker/natsbroker_test.go +++ b/runtime/contracts/natsbroker/natsbroker_test.go @@ -22,7 +22,8 @@ func TestMarshalEnvelope(t *testing.T) { t.Fatalf("marshal envelope: %v", err) } source := string(payload) - if !strings.Contains(source, `"category":"integration"`) || + if !strings.Contains(source, `"id":"`) || + !strings.Contains(source, `"category":"integration"`) || !strings.Contains(source, `"type":"PatientCreated"`) || !strings.Contains(source, `"id":"patient-1"`) { t.Fatalf("unexpected payload: %s", source) @@ -35,6 +36,7 @@ func TestDecodePayloadWithRegisteredDecoder(t *testing.T) { t.Fatal(err) } payload, err := json.Marshal(contracts.StoredEventEnvelope{ + ID: "event-1", Category: contracts.IntegrationEvent, Type: "PatientCreated", Value: value, @@ -51,6 +53,9 @@ func TestDecodePayloadWithRegisteredDecoder(t *testing.T) { if event.Category != contracts.IntegrationEvent || event.Type != "PatientCreated" { t.Fatalf("unexpected event metadata: %#v", event) } + if event.ID != "event-1" { + t.Fatalf("event.ID = %q, want event-1", event.ID) + } if decoded, ok := event.Value.(patientCreated); !ok || decoded.ID != "patient-1" { t.Fatalf("event.Value = %#v, want patientCreated patient-1", event.Value) } diff --git a/runtime/contracts/recorder.go b/runtime/contracts/recorder.go index 5f1e37fe..d93d799b 100644 --- a/runtime/contracts/recorder.go +++ b/runtime/contracts/recorder.go @@ -59,11 +59,11 @@ func (recorder *eventRecorder) envelopes() []EventEnvelope { } envelopes := make([]EventEnvelope, 0, len(recorder.events)) for _, event := range recorder.events { - envelopes = append(envelopes, EventEnvelope{ + envelopes = append(envelopes, EnsureEventID(EventEnvelope{ Category: event.category, Type: event.eventType, Value: event.value, - }) + })) } return envelopes } diff --git a/runtime/contracts/redisstream/redisstream.go b/runtime/contracts/redisstream/redisstream.go index 4ac30137..76d8f145 100644 --- a/runtime/contracts/redisstream/redisstream.go +++ b/runtime/contracts/redisstream/redisstream.go @@ -122,6 +122,7 @@ func (store *Store) PublishEvents(ctx context.Context, events []contracts.EventE return err } for _, event := range events { + event = contracts.EnsureEventID(event) payload, err := marshalEnvelope(event) if err != nil { return err diff --git a/runtime/contracts/redisstream/redisstream_test.go b/runtime/contracts/redisstream/redisstream_test.go index 4ff48ddc..dbcdac2d 100644 --- a/runtime/contracts/redisstream/redisstream_test.go +++ b/runtime/contracts/redisstream/redisstream_test.go @@ -1,11 +1,14 @@ package redisstream import ( + "context" "encoding/json" "strings" "testing" + "time" "github.com/cssbruno/gowdk/runtime/contracts" + redis "github.com/redis/go-redis/v9" ) type patientCreated struct { @@ -21,7 +24,8 @@ func TestMarshalEnvelope(t *testing.T) { if err != nil { t.Fatalf("marshal envelope: %v", err) } - if !strings.Contains(payload, `"category":"domain"`) || + if !strings.Contains(payload, `"id":"`) || + !strings.Contains(payload, `"category":"domain"`) || !strings.Contains(payload, `"type":"PatientCreated"`) || !strings.Contains(payload, `"id":"patient-1"`) { t.Fatalf("unexpected payload: %s", payload) @@ -34,6 +38,7 @@ func TestDecodeMessageWithRegisteredDecoder(t *testing.T) { t.Fatal(err) } payload, err := json.Marshal(contracts.StoredEventEnvelope{ + ID: "event-1", Category: contracts.DomainEvent, Type: "PatientCreated", Value: value, @@ -50,6 +55,9 @@ func TestDecodeMessageWithRegisteredDecoder(t *testing.T) { if event.Category != contracts.DomainEvent || event.Type != "PatientCreated" { t.Fatalf("unexpected event metadata: %#v", event) } + if event.ID != "event-1" { + t.Fatalf("event.ID = %q, want event-1", event.ID) + } if decoded, ok := event.Value.(patientCreated); !ok || decoded.ID != "patient-1" { t.Fatalf("event.Value = %#v, want patientCreated patient-1", event.Value) } @@ -75,3 +83,39 @@ func TestNewStartsByDrainingPendingEntries(t *testing.T) { t.Fatal("expected Nack rewind to re-enable the pending drain") } } + +type fakeSeenClient struct { + values map[string]bool + key string + ttl time.Duration +} + +func (client *fakeSeenClient) SetNX(ctx context.Context, key string, value any, expiration time.Duration) *redis.BoolCmd { + client.key = key + client.ttl = expiration + if client.values == nil { + client.values = map[string]bool{} + } + if client.values[key] { + return redis.NewBoolResult(false, nil) + } + client.values[key] = true + return redis.NewBoolResult(true, nil) +} + +func TestSeenStoreUsesSetNXWithTTL(t *testing.T) { + client := &fakeSeenClient{} + store := newSeenStore(client, "seen:", time.Minute) + + fresh, err := store.MarkIfNew(context.Background(), "event-1") + if err != nil || !fresh { + t.Fatalf("first mark fresh=%v err=%v, want true nil", fresh, err) + } + if client.key != "seen:event-1" || client.ttl != time.Minute { + t.Fatalf("unexpected SETNX key/ttl: key=%q ttl=%s", client.key, client.ttl) + } + fresh, err = store.MarkIfNew(context.Background(), "event-1") + if err != nil || fresh { + t.Fatalf("second mark fresh=%v err=%v, want false nil", fresh, err) + } +} diff --git a/runtime/contracts/redisstream/seenstore.go b/runtime/contracts/redisstream/seenstore.go new file mode 100644 index 00000000..da74e37d --- /dev/null +++ b/runtime/contracts/redisstream/seenstore.go @@ -0,0 +1,57 @@ +package redisstream + +import ( + "context" + "errors" + "time" + + redis "github.com/redis/go-redis/v9" +) + +const ( + defaultSeenKeyPrefix = "gowdk:contracts:seen:" + defaultSeenTTL = 24 * time.Hour +) + +type seenClient interface { + SetNX(ctx context.Context, key string, value any, expiration time.Duration) *redis.BoolCmd +} + +// SeenStore records delivered event IDs in Redis with SETNX and a TTL-backed +// deduplication window. +type SeenStore struct { + client seenClient + prefix string + ttl time.Duration +} + +// NewSeenStore creates a Redis-backed SeenStore. Empty prefix and non-positive +// TTL values use local defaults. +func NewSeenStore(client *redis.Client, prefix string, ttl time.Duration) *SeenStore { + return newSeenStore(client, prefix, ttl) +} + +func newSeenStore(client seenClient, prefix string, ttl time.Duration) *SeenStore { + if prefix == "" { + prefix = defaultSeenKeyPrefix + } + if ttl <= 0 { + ttl = defaultSeenTTL + } + return &SeenStore{client: client, prefix: prefix, ttl: ttl} +} + +// MarkIfNew records id with SETNX and returns false when the ID already exists +// inside the TTL window. +func (store *SeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if store.client == nil { + return false, errors.New("redis seen store client is required") + } + if id == "" { + return false, errors.New("event id is required") + } + return store.client.SetNX(ctx, store.prefix+id, "1", store.ttl).Result() +} diff --git a/runtime/contracts/seenstore.go b/runtime/contracts/seenstore.go new file mode 100644 index 00000000..846a16e0 --- /dev/null +++ b/runtime/contracts/seenstore.go @@ -0,0 +1,61 @@ +package contracts + +import ( + "container/list" + "context" + "errors" + "sync" +) + +const defaultSeenStoreLimit = 10000 + +// MemorySeenStore is a bounded in-memory SeenStore for single-process apps, +// local development, and tests. It is process-local and intentionally not a +// durable delivery guarantee. +type MemorySeenStore struct { + mu sync.Mutex + limit int + order *list.List + items map[string]*list.Element +} + +// NewMemorySeenStore creates a bounded in-memory SeenStore. Non-positive +// limits use the default window. +func NewMemorySeenStore(limit int) *MemorySeenStore { + if limit <= 0 { + limit = defaultSeenStoreLimit + } + return &MemorySeenStore{ + limit: limit, + order: list.New(), + items: map[string]*list.Element{}, + } +} + +// MarkIfNew records id and reports whether it had not been seen inside the +// current memory window. +func (store *MemorySeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if id == "" { + return false, errors.New("event id is required") + } + store.mu.Lock() + defer store.mu.Unlock() + if element := store.items[id]; element != nil { + store.order.MoveToBack(element) + return false, nil + } + element := store.order.PushBack(id) + store.items[id] = element + for len(store.items) > store.limit { + oldest := store.order.Front() + if oldest == nil { + break + } + delete(store.items, oldest.Value.(string)) + store.order.Remove(oldest) + } + return true, nil +} diff --git a/runtime/contracts/seenstore_test.go b/runtime/contracts/seenstore_test.go new file mode 100644 index 00000000..79bec007 --- /dev/null +++ b/runtime/contracts/seenstore_test.go @@ -0,0 +1,47 @@ +package contracts + +import ( + "context" + "testing" +) + +func TestEnsureEventIDsAssignsAndPreservesIDs(t *testing.T) { + events := EnsureEventIDs([]EventEnvelope{ + {Category: DomainEvent, Type: "PatientCreated", Value: patientCreated{ID: "patient-1"}}, + {ID: "custom-id", Category: DomainEvent, Type: "PatientCreated", Value: patientCreated{ID: "patient-2"}}, + }) + + if len(events) != 2 { + t.Fatalf("len(events) = %d, want 2", len(events)) + } + if events[0].ID == "" { + t.Fatalf("expected first event ID to be assigned: %#v", events[0]) + } + if events[1].ID != "custom-id" { + t.Fatalf("expected existing ID to be preserved, got %q", events[1].ID) + } + if events[0].ID == events[1].ID { + t.Fatalf("expected unique event IDs, got %#v", events) + } +} + +func TestMemorySeenStoreMarksNewOnceAndEvictsOldest(t *testing.T) { + store := NewMemorySeenStore(1) + + fresh, err := store.MarkIfNew(context.Background(), "event-1") + if err != nil || !fresh { + t.Fatalf("first mark event-1 fresh=%v err=%v, want true nil", fresh, err) + } + fresh, err = store.MarkIfNew(context.Background(), "event-1") + if err != nil || fresh { + t.Fatalf("second mark event-1 fresh=%v err=%v, want false nil", fresh, err) + } + fresh, err = store.MarkIfNew(context.Background(), "event-2") + if err != nil || !fresh { + t.Fatalf("mark event-2 fresh=%v err=%v, want true nil", fresh, err) + } + fresh, err = store.MarkIfNew(context.Background(), "event-1") + if err != nil || !fresh { + t.Fatalf("event-1 should be new again after eviction, fresh=%v err=%v", fresh, err) + } +} diff --git a/runtime/contracts/worker.go b/runtime/contracts/worker.go index 6202a75d..ab678e54 100644 --- a/runtime/contracts/worker.go +++ b/runtime/contracts/worker.go @@ -48,6 +48,24 @@ func RunEventWorker(ctx context.Context, registry *Registry, source EventSource) // through Nack are logged via WorkerLogger and the worker keeps consuming; // it only stops when ctx ends, the source closes, or Ack/Nack fail. func RunEventWorkerForRole(ctx context.Context, registry *Registry, role Role, source EventSource) error { + return runEventWorkerForRole(ctx, registry, role, source, nil) +} + +// RunEventWorkerWithSeenStore reads worker-role batches and skips duplicate +// event IDs already present in seen. Duplicate-only batches are acknowledged +// without invoking subscribers. +func RunEventWorkerWithSeenStore(ctx context.Context, registry *Registry, source EventSource, seen SeenStore) error { + return RunEventWorkerForRoleWithSeenStore(ctx, registry, RoleWorker, source, seen) +} + +// RunEventWorkerForRoleWithSeenStore reads batches for role and skips duplicate +// event IDs already present in seen. A nil seen store preserves the default +// at-least-once worker behavior. +func RunEventWorkerForRoleWithSeenStore(ctx context.Context, registry *Registry, role Role, source EventSource, seen SeenStore) error { + return runEventWorkerForRole(ctx, registry, role, source, seen) +} + +func runEventWorkerForRole(ctx context.Context, registry *Registry, role Role, source EventSource, seen SeenStore) error { if source == nil { return Error{Kind: ErrNilHandler, Message: "event source cannot be nil"} } @@ -65,17 +83,24 @@ func RunEventWorkerForRole(ctx context.Context, registry *Registry, role Role, s } return err } - if err := dispatchEventBatch(ctx, registry, role, batch); err != nil { + if err := dispatchEventBatch(ctx, registry, role, batch, seen); err != nil { return err } } } -func dispatchEventBatch(ctx context.Context, registry *Registry, role Role, batch EventBatch) error { +func dispatchEventBatch(ctx context.Context, registry *Registry, role Role, batch EventBatch, seen SeenStore) error { if len(batch.Events) == 0 { return ackEventBatch(ctx, batch) } - if err := PublishEnvelopesForRole(ctx, registry, role, batch.Events); err != nil { + events, err := unseenEvents(ctx, role, batch.Events, seen) + if err != nil { + return err + } + if len(events) == 0 { + return ackEventBatch(ctx, batch) + } + if err := PublishEnvelopesForRole(ctx, registry, role, events); err != nil { if batch.Nack == nil { return err } @@ -90,6 +115,38 @@ func dispatchEventBatch(ctx context.Context, registry *Registry, role Role, batc return ackEventBatch(ctx, batch) } +func unseenEvents(ctx context.Context, role Role, events []EventEnvelope, seen SeenStore) ([]EventEnvelope, error) { + if seen == nil { + return events, nil + } + out := make([]EventEnvelope, 0, len(events)) + for _, event := range events { + if event.ID == "" { + out = append(out, event) + continue + } + fresh, err := seen.MarkIfNew(ctx, event.ID) + if err != nil { + return nil, err + } + if !fresh { + logWorkerDedupSkip(event, role) + continue + } + out = append(out, event) + } + return out, nil +} + +func logWorkerDedupSkip(event EventEnvelope, role Role) { + logger := WorkerLogger + if logger == nil { + return + } + observation := event.ObservationForRole(ObservationWorkerDedupSkip, role) + logger("gowdk: event worker skipped duplicate event " + event.ID + " (" + string(observation.Labels.EventCategory) + " " + observation.Labels.Contract + ")") +} + func ackEventBatch(ctx context.Context, batch EventBatch) error { if batch.Ack == nil { return nil diff --git a/runtime/contracts/worker_test.go b/runtime/contracts/worker_test.go index 5b81ab79..57083d0f 100644 --- a/runtime/contracts/worker_test.go +++ b/runtime/contracts/worker_test.go @@ -73,6 +73,83 @@ func TestRunEventWorkerDispatchesAndAcks(t *testing.T) { } } +func TestRunEventWorkerWithSeenStoreAcksDuplicateWithoutDispatch(t *testing.T) { + registry := NewRegistry() + var handled int + must(t, RegisterDomainEvent[patientCreated](registry, func(ctx context.Context, event patientCreated) error { + handled++ + return nil + }, RoleWorker)) + seen := NewMemorySeenStore(10) + if fresh, err := seen.MarkIfNew(context.Background(), "event-1"); err != nil || !fresh { + t.Fatalf("prime seen store fresh=%v err=%v, want true nil", fresh, err) + } + var acked, nacked int + source := &scriptedEventSource{ + batches: []EventBatch{{ + Events: []EventEnvelope{{ + ID: "event-1", + Category: DomainEvent, + Type: typeName[patientCreated](), + Value: patientCreated{ID: "patient-1"}, + }}, + Ack: func(ctx context.Context) error { + acked++ + return nil + }, + Nack: func(ctx context.Context, err error) error { + nacked++ + return nil + }, + }}, + err: ErrEventSourceClosed, + } + + if err := RunEventWorkerWithSeenStore(context.Background(), registry, source, seen); err != nil { + t.Fatalf("run event worker: %v", err) + } + if handled != 0 { + t.Fatalf("handled duplicate count = %d, want 0", handled) + } + if acked != 1 || nacked != 0 { + t.Fatalf("acked=%d nacked=%d, want acked=1 nacked=0", acked, nacked) + } +} + +func TestRunEventWorkerWithSeenStoreDispatchesOnlyNewEvents(t *testing.T) { + registry := NewRegistry() + var handled []string + must(t, RegisterDomainEvent[patientCreated](registry, func(ctx context.Context, event patientCreated) error { + handled = append(handled, event.ID) + return nil + }, RoleWorker)) + seen := NewMemorySeenStore(10) + if fresh, err := seen.MarkIfNew(context.Background(), "event-1"); err != nil || !fresh { + t.Fatalf("prime seen store fresh=%v err=%v, want true nil", fresh, err) + } + source := &scriptedEventSource{ + batches: []EventBatch{{ + Events: []EventEnvelope{ + {ID: "event-1", Category: DomainEvent, Type: typeName[patientCreated](), Value: patientCreated{ID: "duplicate"}}, + {ID: "event-2", Category: DomainEvent, Type: typeName[patientCreated](), Value: patientCreated{ID: "new"}}, + }, + Ack: func(ctx context.Context) error { return nil }, + }}, + err: ErrEventSourceClosed, + } + + if err := RunEventWorkerWithSeenStore(context.Background(), registry, source, seen); err != nil { + t.Fatalf("run event worker: %v", err) + } + if strings.Join(handled, ",") != "new" { + t.Fatalf("handled = %#v, want only new event", handled) + } + fresh, err := seen.MarkIfNew(context.Background(), "event-2") + if err != nil || fresh { + t.Fatalf("event-2 should be recorded as seen, fresh=%v err=%v", fresh, err) + } +} + func TestRunEventWorkerNacksSubscriberFailureAndContinues(t *testing.T) { registry := NewRegistry() subscriberErr := errors.New("subscriber unavailable") diff --git a/runtime/response/response.go b/runtime/response/response.go index 62d7759c..42d9d09c 100644 --- a/runtime/response/response.go +++ b/runtime/response/response.go @@ -111,6 +111,13 @@ func WriteNoStoreHandlerError(writer http.ResponseWriter, err error, fallbackSta WriteNoStoreError(writer, status, HandlerErrorMessage(err, status)) } +// WriteNoStoreHandlerJSONError writes a generated handler error as the stable +// JSON shape used by contract web adapters. +func WriteNoStoreHandlerJSONError(writer http.ResponseWriter, err error, fallbackStatus int) { + status := HandlerStatus(err, fallbackStatus) + WriteNoStoreJSONError(writer, status, HandlerErrorMessage(err, status)) +} + // HTMLBody creates a full HTML response. func HTMLBody(status int, body string) Response { return Response{Kind: HTML, Status: status, Body: body} @@ -268,6 +275,19 @@ func WriteNoStoreError(writer http.ResponseWriter, status int, message string) { http.Error(writer, message, status) } +// WriteNoStoreJSONError writes the stable generated JSON error shape. +func WriteNoStoreJSONError(writer http.ResponseWriter, status int, message string) { + writer.Header().Set("Cache-Control", "no-store") + result, err := JSONValue(status, struct { + Error string `json:"error"` + }{Error: message}) + if err != nil { + WriteNoStoreError(writer, http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + return + } + _ = WriteHTTP(writer, result) +} + func statusOrDefault(result Response) int { if result.Status != 0 { return result.Status diff --git a/runtime/response/response_test.go b/runtime/response/response_test.go index 1bf3e9bc..1a22684a 100644 --- a/runtime/response/response_test.go +++ b/runtime/response/response_test.go @@ -406,3 +406,36 @@ func TestWriteNoStoreHandlerError(t *testing.T) { t.Fatalf("unexpected safe handler error body: %q", body) } } + +func TestWriteNoStoreHandlerJSONError(t *testing.T) { + recorder := httptest.NewRecorder() + + WriteNoStoreHandlerJSONError(recorder, errors.New("internal database password=secret"), http.StatusInternalServerError) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if cache := recorder.Header().Get("Cache-Control"); cache != "no-store" { + t.Fatalf("expected no-store, got %q", cache) + } + if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" { + t.Fatalf("expected JSON content type, got %q", contentType) + } + if body := strings.TrimSpace(recorder.Body.String()); body != `{"error":"Internal Server Error"}` { + t.Fatalf("unexpected JSON handler error body: %q", body) + } +} + +func TestWriteNoStoreHandlerJSONErrorUsesExplicitHandlerErrorMessage(t *testing.T) { + recorder := httptest.NewRecorder() + err := NewHandlerError(http.StatusConflict, "duplicate patient", errors.New("unique constraint detail")) + + WriteNoStoreHandlerJSONError(recorder, err, http.StatusInternalServerError) + + if recorder.Code != http.StatusConflict { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if body := strings.TrimSpace(recorder.Body.String()); body != `{"error":"duplicate patient"}` { + t.Fatalf("unexpected JSON handler error body: %q", body) + } +} From 1a50729320a185e22121624ea7a5aa6ece92b50c Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 09:05:53 -0300 Subject: [PATCH 3/5] fix(contracts): address adapter review feedback --- CHANGELOG.md | 5 +- docs/engineering/architecture.md | 4 +- docs/product/requirements.md | 2 +- docs/reference/contracts.md | 10 +- internal/appgen/appgen_test.go | 60 ++++++++-- internal/appgen/source_actions.go | 10 ++ internal/appgen/source_contracts.go | 2 +- .../generated_go_golden/app.go.golden | 4 +- internal/project/config.go | 4 +- internal/project/config_test.go | 43 +++++++ runtime/contracts/contracts.go | 7 +- .../contracts/fileoutbox/fileoutbox_test.go | 51 +++++--- runtime/contracts/fileoutbox/seenstore.go | 65 +++++++++- .../contracts/redisstream/redisstream_test.go | 45 ++++++- runtime/contracts/redisstream/seenstore.go | 33 +++++- runtime/contracts/seenstore.go | 41 ++++++- runtime/contracts/seenstore_test.go | 33 ++++-- runtime/contracts/worker.go | 39 ++++-- runtime/contracts/worker_test.go | 112 ++++++++++++++++-- 19 files changed, 495 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c42a17..d943037e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,8 +53,9 @@ packages, and tooling contracts may change before a stable release. - Contract event envelopes now carry stable IDs for durable delivery. Workers can opt into deduplication with `RunEventWorkerWithSeenStore` or `RunEventWorkerForRoleWithSeenStore`; duplicate IDs are acked without - subscriber dispatch inside the configured window. Runtime includes bounded - in-memory, file-backed, and Redis SETNX-with-TTL seen-store adapters. + subscriber dispatch inside the configured window, and fresh IDs are marked + seen only after dispatch and source ack succeed. Runtime includes bounded + in-memory, file-backed, and Redis TTL seen-store adapters. ### Known Gaps diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 71d02645..46f3b2e9 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -44,7 +44,7 @@ Concrete Redis Streams, NATS, and WebSocket adapters live as nested optional Go modules under `runtime/contracts` so those third-party clients do not enter the root module graph. Durable event envelopes carry stable IDs, workers can use in-memory, file-backed, or Redis seen stores to skip duplicates inside a -deduplication window, and generated apps can expose contract event sink +post-ack deduplication window, and generated apps can expose contract event sink registration, fresh registry construction, and worker replay helpers for executable contract registrations. Split runtime binaries, retry backoff policy, and managed deployment recipes remain planned. @@ -172,7 +172,7 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `runtime/asset` | Asset manifest resolution. | Runtime | Initial manifest helper implemented. | | `runtime/route` | Runtime route matching for generated request-time routes. | Runtime | Dynamic route matcher for first-slice generated SSR routes implemented. | | `runtime/app` | Shared generated app HTTP server. | Runtime | Serves embedded spa files, identity headers, health checks, asset manifest counts, optional generated 404/500 pages, no-JS cookie acknowledgement, server-side cookie notice hiding, generated CSRF token injection for POST forms, request-time panic boundaries, and generated action/API/fragment/SSR callback hooks. | -| `runtime/contracts` | Typed contract registry and in-process dispatch. | Runtime | First runtime slice implemented for queries, commands, backend-owned domain and integration events, presentation events, jobs, metadata, stable observation names and labels for logs/metrics/traces, local command-buffered event dispatch, event-envelope capture/replay with stable IDs, dependency-free outbox/broker/presentation-fanout/event-source/seen-store interfaces, command event sinks, an event worker loop with ack/nack plus context cancellation and optional deduplication windows, a dependency-free file outbox adapter, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, and dependency-free SSE presentation fanout adapter. Concrete Redis Streams, Redis SETNX seen-store, NATS, and WebSocket adapters are nested optional modules. Split worker/cron generation, retry backoff policy, and managed deployment recipes remain planned. | +| `runtime/contracts` | Typed contract registry and in-process dispatch. | Runtime | First runtime slice implemented for queries, commands, backend-owned domain and integration events, presentation events, jobs, metadata, stable observation names and labels for logs/metrics/traces, local command-buffered event dispatch, event-envelope capture/replay with stable IDs, dependency-free outbox/broker/presentation-fanout/event-source/seen-store interfaces, command event sinks, an event worker loop with ack/nack plus context cancellation and optional post-ack deduplication windows, a dependency-free file outbox adapter, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, and dependency-free SSE presentation fanout adapter. Concrete Redis Streams, Redis TTL seen-store, NATS, and WebSocket adapters are nested optional modules. Split worker/cron generation, retry backoff policy, and managed deployment recipes remain planned. | | `addons/static` | Build-time static page output. | Addon | Capability boundary implemented; build-time output uses `runtime/render` through the compiler view renderer. | | `addons/spa` | Static-first SPA navigation compatibility surface. | Addon | Keeps the existing SPA feature package and aliases build-time route output types from `addons/static`. | | `addons/actions` | Typed backend actions, form decoding, CSRF. | Addon | Capability boundary, generated request-shape validation for direct literal required/minlength/maxlength/pattern controls, escaped live-region validation fragments for partial requests, signed CSRF validator, and generated action CSRF wiring implemented; user-defined domain validation helpers remain planned. | diff --git a/docs/product/requirements.md b/docs/product/requirements.md index ef3b0600..4acfa532 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -74,7 +74,7 @@ implemented. | Inline Go authoring | Allow optional Go code blocks inside `.gwdk` only when they extract to normal importable, formatted, testable package Go. Separate `.go` files remain supported and generated adapters remain glue. Saved default `go {}` blocks are type-checked with sibling Go files during validation, default `go {}` blocks can provide build-time no-argument functions for `build { => LocalFunc() }` and same-page action/API/fragment handlers, page-level `go client {}` blocks can opt into client-side Go by exporting `GOWDKMount` for generated WASM page mounts, generated app source materializes default `go {}` and `go ssr {}` blocks under `gowdk_go/`, `go ssr {}` can provide generated SSR load handlers, and configured addons implementing `gowdk.GoBlockConsumer` can validate `go addon. {}` blocks and emit generated app Go files. Source-adjacent extraction and addon adapter contracts remain planned. | Partial | | Forms | Keep progressive-enhancement-first form behavior; full POST and enhanced POST share action result semantics; domain validation stays in user Go. | Partial | Generated enhanced forms preserve no-JavaScript POST behavior, send partial request headers, swap server fragments, expose failed enhanced response status/body/detail events, and use escaped live-region validation fragments. Domain validation stays in user Go. | | APIs | Broaden APIs through public request/response helpers and typed body/query helpers, not framework-specific adapters. | Partial — `addons/api` provides strict JSON body decoding, typed query helpers, and JSON/error/no-content response helpers for current `func(context.Context, *http.Request) (response.Response, error)` handlers. Generated typed handler signatures, per-route result contracts, CORS policy, and richer examples remain planned. | -| Contract runtime | Add typed Go queries, commands, backend-owned domain/integration events, presentation events, and jobs after endpoint/adapter IR is stable. Frontend UI events trigger commands or queries, commands have one owner, domain events are emitted after backend state changes succeed, local in-process dispatch is default, and broker/outbox/worker roles are optional. First runtime registry, runtime role filtering helpers, event-envelope capture/replay with stable event IDs, stable observation names and labels for logs/metrics/traces, dependency-free outbox, broker, presentation-fanout, event-source, and seen-store interfaces, event worker loop with ack/nack, context cancellation, and optional deduplication windows, dependency-free file outbox adapter with retry metadata and opt-in dead-letter storage, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, Redis Streams broker/EventSource adapter, Redis SETNX-with-TTL seen store, NATS broker/EventSource adapter, dependency-free SSE presentation fanout adapter, WebSocket presentation fanout adapter, generated command event sink registration, generated contract registry construction, generated worker replay helper with optional seen-store dedup, Go AST scanner, scan-local package inspection cache, local-package and imported-handler `go/types` diagnostics, local and imported contract/result type diagnostics, local exported struct/function contract diagnostics, duplicate command-owner scan diagnostics, generated-app import-cycle diagnostics, emitted-event category diagnostics, first browser-UI and vague event-name diagnostics, contract/list/graph/trace CLI, form-local `g:command` metadata with literal form method/action, element-local `g:query` metadata with page-route source metadata, import-path-aware command/query reference linking, `g:event` rejection, IR command/query references with exact source locations, command/query reference binding status, appgen adapter IR exposure metadata, command method/path adapter metadata, query page-route adapter metadata, generated web command/query adapters, page-route query JSON negotiation, stable JSON success/error response shape, AST/printer/format generated adapter source, page-guard propagation for generated command/query routes, rate-limit and guard preflight before contract execution, CSRF-before-command-decoding ordering, routes-report contract endpoint metadata, missing/invalid/non-web-role contract-reference diagnostics, enforced Go contract scan diagnostics in check/build, and build-report contract-reference events with role metadata are implemented; all diagnostic spans, fragment/API-specific query execution, split-binary worker/cron wiring, retry backoff policies, and managed deployment recipes remain planned. | Partial | +| Contract runtime | Add typed Go queries, commands, backend-owned domain/integration events, presentation events, and jobs after endpoint/adapter IR is stable. Frontend UI events trigger commands or queries, commands have one owner, domain events are emitted after backend state changes succeed, local in-process dispatch is default, and broker/outbox/worker roles are optional. First runtime registry, runtime role filtering helpers, event-envelope capture/replay with stable event IDs, stable observation names and labels for logs/metrics/traces, dependency-free outbox, broker, presentation-fanout, event-source, and seen-store interfaces, event worker loop with ack/nack, context cancellation, and optional post-ack deduplication windows, dependency-free file outbox adapter with retry metadata and opt-in dead-letter storage, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, Redis Streams broker/EventSource adapter, Redis TTL seen store, NATS broker/EventSource adapter, dependency-free SSE presentation fanout adapter, WebSocket presentation fanout adapter, generated command event sink registration, generated contract registry construction, generated worker replay helper with optional seen-store dedup, Go AST scanner, scan-local package inspection cache, local-package and imported-handler `go/types` diagnostics, local and imported contract/result type diagnostics, local exported struct/function contract diagnostics, duplicate command-owner scan diagnostics, generated-app import-cycle diagnostics, emitted-event category diagnostics, first browser-UI and vague event-name diagnostics, contract/list/graph/trace CLI, form-local `g:command` metadata with literal form method/action, element-local `g:query` metadata with page-route source metadata, import-path-aware command/query reference linking, `g:event` rejection, IR command/query references with exact source locations, command/query reference binding status, appgen adapter IR exposure metadata, command method/path adapter metadata, query page-route adapter metadata, generated web command/query adapters, page-route query JSON negotiation, stable JSON success/error response shape, AST/printer/format generated adapter source, page-guard propagation for generated command/query routes, rate-limit and guard preflight before contract execution, CSRF-before-command-decoding ordering, routes-report contract endpoint metadata, missing/invalid/non-web-role contract-reference diagnostics, enforced Go contract scan diagnostics in check/build, and build-report contract-reference events with role metadata are implemented; all diagnostic spans, fragment/API-specific query execution, split-binary worker/cron wiring, retry backoff policies, and managed deployment recipes remain planned. | Partial | | Cache | Keep `cache` and `revalidate` as HTTP cache policy; keep action-driven data refresh explicit through redirects, fragments, JSON, or reload responses. | Partial | | Guards | Extend guards with safe local redirects and response helpers before richer request-local state. | Planned | | Component CSS | Make component CSS explicit, compiler-scoped, and documented; Tailwind and processors remain optional. | Partial | diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index 712af7a9..e59126f5 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -326,8 +326,9 @@ Delivery guarantees: duplicate event IDs inside a configured deduplication window. Duplicate batches are acknowledged without invoking subscribers. - A deduplication window is not an exactly-once guarantee. Subscribers must - still tolerate redelivery outside the window, after store loss, or when a - subscriber fails after the worker has marked the event ID as seen. + still tolerate redelivery outside the window, after store loss, after seen + store write failures, or across concurrent workers. Event IDs are marked seen + only after worker dispatch and source `Ack` both succeed. GOWDK Runtime provides three seen-store adapters: @@ -335,8 +336,9 @@ GOWDK Runtime provides three seen-store adapters: window for local single-binary apps and tests. - `fileoutbox.NewSeenStore(path, fileoutbox.WithSeenLimit(limit))` keeps a dependency-free JSON Lines window next to the file outbox. -- `redisstream.NewSeenStore(client, prefix, ttl)` records IDs with Redis - `SETNX` and an expiration TTL for Redis Streams worker deployments. +- `redisstream.NewSeenStore(client, prefix, ttl)` checks IDs with Redis + `EXISTS`, records IDs with `SET`, and applies an expiration TTL for Redis + Streams worker deployments. Subscriber handlers must still be idempotent for any durable delivery adapter. Use a stable domain key, event ID, outbox record ID, or application-level diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index ad36b16b..fc8b2f50 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -3886,14 +3886,17 @@ func TestGeneratedBinaryContractAdaptersReturnJSONErrors(t *testing.T) { ImportPath: "gowdk-generated-app/patients", Type: "CreatePatient", Result: "CreatePatientResult", - InputFields: []source.BackendInputField{{FieldName: "Name", FormName: "name", Type: "string"}}, - Method: http.MethodPost, - Path: "/patients", - Status: gwdkir.ContractBindingBound, - Handler: "HandleCreatePatient", - Register: "Register", - OwnerKind: gwdkir.SourcePage, - OwnerID: "patients", + InputFields: []source.BackendInputField{ + {FieldName: "Name", FormName: "name", Type: "string"}, + {FieldName: "Age", FormName: "age", Type: "int"}, + }, + Method: http.MethodPost, + Path: "/patients", + Status: gwdkir.ContractBindingBound, + Handler: "HandleCreatePatient", + Register: "Register", + OwnerKind: gwdkir.SourcePage, + OwnerID: "patients", }, { Kind: gwdkir.ContractQuery, @@ -3928,6 +3931,7 @@ import ( type CreatePatient struct { Name string + Age int } type CreatePatientResult struct { @@ -3992,6 +3996,25 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData t.Fatalf("command JSON error leaked handler detail: %s", commandPayload) } + commandDecodeResponse, err := waitForHTTPStatus("http://"+addr+"/patients", http.MethodPost, "name=Ada&age=not-int") + if err != nil { + t.Fatal(err) + } + commandDecodePayload, err := io.ReadAll(commandDecodeResponse.Body) + _ = commandDecodeResponse.Body.Close() + if err != nil { + t.Fatal(err) + } + if commandDecodeResponse.StatusCode != http.StatusBadRequest { + t.Fatalf("expected command decode error status 400, got %d: %s", commandDecodeResponse.StatusCode, commandDecodePayload) + } + if commandDecodeResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("expected command decode JSON error content type, got %q", commandDecodeResponse.Header.Get("Content-Type")) + } + if strings.TrimSpace(string(commandDecodePayload)) != `{"error":"invalid form"}` { + t.Fatalf("unexpected command decode JSON error payload: %s", commandDecodePayload) + } + queryResponse, err := waitForHTTPStatusWithHeaders("http://"+addr+"/patients?filter=bad", http.MethodGet, "", map[string]string{ "Accept": "application/json", }) @@ -4012,6 +4035,27 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData if strings.TrimSpace(string(queryPayload)) != `{"error":"invalid filter"}` { t.Fatalf("unexpected query JSON error payload: %s", queryPayload) } + + queryDecodeResponse, err := waitForHTTPStatusWithHeaders("http://"+addr+"/patients?filter=bad&role=admin", http.MethodGet, "", map[string]string{ + "Accept": "application/json", + }) + if err != nil { + t.Fatal(err) + } + queryDecodePayload, err := io.ReadAll(queryDecodeResponse.Body) + _ = queryDecodeResponse.Body.Close() + if err != nil { + t.Fatal(err) + } + if queryDecodeResponse.StatusCode != http.StatusBadRequest { + t.Fatalf("expected query decode error status 400, got %d: %s", queryDecodeResponse.StatusCode, queryDecodePayload) + } + if queryDecodeResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("expected query decode JSON error content type, got %q", queryDecodeResponse.Header.Get("Content-Type")) + } + if strings.TrimSpace(string(queryDecodePayload)) != `{"error":"invalid form"}` { + t.Fatalf("unexpected query decode JSON error payload: %s", queryDecodePayload) + } } func TestGeneratedBinaryRegisteredGuardsAllowRequestTimeRoutes(t *testing.T) { diff --git a/internal/appgen/source_actions.go b/internal/appgen/source_actions.go index 290087e6..bbb968e5 100644 --- a/internal/appgen/source_actions.go +++ b/internal/appgen/source_actions.go @@ -752,6 +752,16 @@ func ifErrReturnInvalidForm() ast.Stmt { } } +func ifErrReturnInvalidJSONForm() ast.Stmt { + return &ast.IfStmt{ + Cond: notNil("err"), + Body: block( + writeNoStoreJSONErrorStmt(sel("http", "StatusBadRequest"), "invalid form"), + returnBool(true), + ), + } +} + func trimHeaderCall(name string) ast.Expr { return call(sel("strings", "TrimSpace"), call(selExpr(selExpr(id("request"), "Header"), "Get"), stringLit(name))) } diff --git a/internal/appgen/source_contracts.go b/internal/appgen/source_contracts.go index 069cb961..68e24682 100644 --- a/internal/appgen/source_contracts.go +++ b/internal/appgen/source_contracts.go @@ -143,7 +143,7 @@ func contractDecodeInputStmts(exposure BackendContractExposure, values ast.Expr) return []ast.Stmt{ define([]ast.Expr{id("values")}, values), define([]ast.Expr{id("input"), id("err")}, call(sel(contractDecoderName(exposure)), id("values"))), - ifErrReturnInvalidForm(), + ifErrReturnInvalidJSONForm(), } } diff --git a/internal/appgen/testdata/generated_go_golden/app.go.golden b/internal/appgen/testdata/generated_go_golden/app.go.golden index 227c0d08..08417f98 100644 --- a/internal/appgen/testdata/generated_go_golden/app.go.golden +++ b/internal/appgen/testdata/generated_go_golden/app.go.golden @@ -130,7 +130,7 @@ func commandPatientsCreatePatientPOSTPatients(contractRegistry *gowdkcontracts.R values := gowdkform.FromURLValues(request.PostForm) input, err := decodeContractPatientsCreatePatientInput(values) if err != nil { - gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form") return true } result, events, err := gowdkcontracts.CaptureCommandEventsForRole[patients.CreatePatient, patients.CreatePatientResult](ctx, contractRegistry, gowdkcontracts.RoleWeb, input) @@ -158,7 +158,7 @@ func queryPatientsGetPatientPageGETPatients(contractRegistry *gowdkcontracts.Reg values := gowdkform.FromURLValues(request.URL.Query()) input, err := decodeContractPatientsGetPatientPageInput(values) if err != nil { - gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form") return true } result, err := gowdkcontracts.ExecuteQueryForRole[patients.GetPatientPage, patients.PatientPageData](ctx, contractRegistry, gowdkcontracts.RoleWeb, input) diff --git a/internal/project/config.go b/internal/project/config.go index d433a6be..77e976e3 100644 --- a/internal/project/config.go +++ b/internal/project/config.go @@ -158,7 +158,9 @@ func parseConfigLiteral(expression ast.Expr, imports map[string]string) (gowdk.C return gowdk.Config{}, false, false, err } case "Addons": - config.Addons, needsExecutableLoad = parseAddons(keyValue.Value, imports) + addons, addonsNeedExecutableLoad := parseAddons(keyValue.Value, imports) + config.Addons = addons + needsExecutableLoad = needsExecutableLoad || addonsNeedExecutableLoad } } return config, needsExecutableLoad, true, nil diff --git a/internal/project/config_test.go b/internal/project/config_test.go index dcef90ca..8c7962aa 100644 --- a/internal/project/config_test.go +++ b/internal/project/config_test.go @@ -446,6 +446,49 @@ var Config = gowdk.Config{ } } +func TestLoadConfigFileKeepsExecutableFallbackWithBuiltInAddons(t *testing.T) { + root := t.TempDir() + repoRoot := repositoryRoot(t) + writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/site + +go 1.22 + +require github.com/cssbruno/gowdk v0.0.0 + +replace github.com/cssbruno/gowdk => `+repoRoot+` +`) + path := filepath.Join(root, DefaultConfigFile) + writeTestFile(t, path, `package app + +import ( + "os" + + "github.com/cssbruno/gowdk" + contractsaddon "github.com/cssbruno/gowdk/addons/contracts" +) + +var Config = gowdk.Config{ + AppName: os.Getenv("GOWDK_TEST_APP_NAME"), + Addons: []gowdk.Addon{ + contractsaddon.Addon(), + }, +} +`) + tidyTestModule(t, root) + t.Setenv("GOWDK_TEST_APP_NAME", "Executable Contracts App") + + config, err := LoadConfigFile(path) + if err != nil { + t.Fatal(err) + } + if config.AppName != "Executable Contracts App" { + t.Fatalf("expected executable config to load app name, got %q", config.AppName) + } + if !config.HasFeature(gowdk.FeatureContracts) { + t.Fatalf("expected executable config to keep contracts addon, got %#v", config.Addons) + } +} + func TestLoadConfigFileReadsImportableExternalAddon(t *testing.T) { root := t.TempDir() repoRoot := repositoryRoot(t) diff --git a/runtime/contracts/contracts.go b/runtime/contracts/contracts.go index 48b6696b..0f5a6ef1 100644 --- a/runtime/contracts/contracts.go +++ b/runtime/contracts/contracts.go @@ -130,10 +130,11 @@ type PresentationFanout interface { SendPresentationEvents(context.Context, []EventEnvelope) error } -// SeenStore records durable event IDs that have already been accepted for -// worker dispatch within an adapter-defined deduplication window. +// SeenStore records durable event IDs that have already been successfully +// dispatched and acknowledged within an adapter-defined deduplication window. type SeenStore interface { - MarkIfNew(context.Context, string) (bool, error) + Seen(context.Context, string) (bool, error) + MarkSeen(context.Context, string) error } // CommandEventSink receives events captured from a successful command. The diff --git a/runtime/contracts/fileoutbox/fileoutbox_test.go b/runtime/contracts/fileoutbox/fileoutbox_test.go index 83a47f35..846961ea 100644 --- a/runtime/contracts/fileoutbox/fileoutbox_test.go +++ b/runtime/contracts/fileoutbox/fileoutbox_test.go @@ -357,24 +357,27 @@ func TestReceiveEventBatchRequiresDecoder(t *testing.T) { } } -func TestSeenStoreMarksNewOnceAndPersists(t *testing.T) { +func TestSeenStoreSeenMarkSeenAndPersists(t *testing.T) { path := filepath.Join(t.TempDir(), "seen.jsonl") store := NewSeenStore(path) store.now = func() time.Time { return time.Unix(123, 0).UTC() } - fresh, err := store.MarkIfNew(context.Background(), "event-1") - if err != nil || !fresh { - t.Fatalf("first mark fresh=%v err=%v, want true nil", fresh, err) + alreadySeen, err := store.Seen(context.Background(), "event-1") + if err != nil || alreadySeen { + t.Fatalf("initial seen event-1 seen=%v err=%v, want false nil", alreadySeen, err) } - fresh, err = store.MarkIfNew(context.Background(), "event-1") - if err != nil || fresh { - t.Fatalf("second mark fresh=%v err=%v, want false nil", fresh, err) + if err := store.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("mark seen: %v", err) + } + alreadySeen, err = store.Seen(context.Background(), "event-1") + if err != nil || !alreadySeen { + t.Fatalf("seen event-1 after mark seen=%v err=%v, want true nil", alreadySeen, err) } reopened := NewSeenStore(path) - fresh, err = reopened.MarkIfNew(context.Background(), "event-1") - if err != nil || fresh { - t.Fatalf("reopened mark fresh=%v err=%v, want false nil", fresh, err) + alreadySeen, err = reopened.Seen(context.Background(), "event-1") + if err != nil || !alreadySeen { + t.Fatalf("reopened seen event-1 seen=%v err=%v, want true nil", alreadySeen, err) } records, err := reopened.readRecordsLocked() if err != nil { @@ -385,18 +388,32 @@ func TestSeenStoreMarksNewOnceAndPersists(t *testing.T) { } } +func TestSeenStoreMarkIfNew(t *testing.T) { + path := filepath.Join(t.TempDir(), "seen.jsonl") + store := NewSeenStore(path) + + fresh, err := store.MarkIfNew(context.Background(), "event-1") + if err != nil || !fresh { + t.Fatalf("first mark fresh=%v err=%v, want true nil", fresh, err) + } + fresh, err = store.MarkIfNew(context.Background(), "event-1") + if err != nil || fresh { + t.Fatalf("second mark fresh=%v err=%v, want false nil", fresh, err) + } +} + func TestSeenStoreEvictsOldestRecord(t *testing.T) { path := filepath.Join(t.TempDir(), "seen.jsonl") store := NewSeenStore(path, WithSeenLimit(1)) - if fresh, err := store.MarkIfNew(context.Background(), "event-1"); err != nil || !fresh { - t.Fatalf("mark event-1 fresh=%v err=%v, want true nil", fresh, err) + if err := store.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("mark event-1: %v", err) } - if fresh, err := store.MarkIfNew(context.Background(), "event-2"); err != nil || !fresh { - t.Fatalf("mark event-2 fresh=%v err=%v, want true nil", fresh, err) + if err := store.MarkSeen(context.Background(), "event-2"); err != nil { + t.Fatalf("mark event-2: %v", err) } - fresh, err := store.MarkIfNew(context.Background(), "event-1") - if err != nil || !fresh { - t.Fatalf("event-1 should be new after eviction, fresh=%v err=%v", fresh, err) + alreadySeen, err := store.Seen(context.Background(), "event-1") + if err != nil || alreadySeen { + t.Fatalf("event-1 should be evicted, seen=%v err=%v", alreadySeen, err) } } diff --git a/runtime/contracts/fileoutbox/seenstore.go b/runtime/contracts/fileoutbox/seenstore.go index 4cb98986..6e68f972 100644 --- a/runtime/contracts/fileoutbox/seenstore.go +++ b/runtime/contracts/fileoutbox/seenstore.go @@ -58,6 +58,51 @@ func NewSeenStore(path string, options ...SeenOption) *SeenStore { return store } +// Seen reports whether id is present in the retained file window. +func (store *SeenStore) Seen(ctx context.Context, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if id == "" { + return false, errors.New("event id is required") + } + store.mu.Lock() + defer store.mu.Unlock() + + records, err := store.readRecordsLocked() + if err != nil { + return false, err + } + for _, record := range records { + if record.ID == id { + return true, nil + } + } + return false, nil +} + +// MarkSeen records id in the retained file window. +func (store *SeenStore) MarkSeen(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + if id == "" { + return errors.New("event id is required") + } + store.mu.Lock() + defer store.mu.Unlock() + + records, err := store.readRecordsLocked() + if err != nil { + return err + } + records = store.markSeenLocked(records, id) + if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil { + return err + } + return store.writeRecordsLocked(records) +} + // MarkIfNew records id and reports whether it was not already present in the // retained file window. func (store *SeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) { @@ -79,16 +124,28 @@ func (store *SeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) return false, nil } } - records = append(records, SeenRecord{ID: id, SeenAt: store.now().UTC()}) - if len(records) > store.limit { - records = append([]SeenRecord(nil), records[len(records)-store.limit:]...) - } + records = store.markSeenLocked(records, id) if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil { return false, err } return true, store.writeRecordsLocked(records) } +func (store *SeenStore) markSeenLocked(records []SeenRecord, id string) []SeenRecord { + seenAt := store.now().UTC() + out := records[:0] + for _, record := range records { + if record.ID != id { + out = append(out, record) + } + } + out = append(out, SeenRecord{ID: id, SeenAt: seenAt}) + if len(out) > store.limit { + return append([]SeenRecord(nil), out[len(out)-store.limit:]...) + } + return out +} + func (store *SeenStore) readRecordsLocked() ([]SeenRecord, error) { file, err := os.Open(store.path) if err != nil { diff --git a/runtime/contracts/redisstream/redisstream_test.go b/runtime/contracts/redisstream/redisstream_test.go index dbcdac2d..86134e46 100644 --- a/runtime/contracts/redisstream/redisstream_test.go +++ b/runtime/contracts/redisstream/redisstream_test.go @@ -90,6 +90,26 @@ type fakeSeenClient struct { ttl time.Duration } +func (client *fakeSeenClient) Exists(ctx context.Context, keys ...string) *redis.IntCmd { + var count int64 + for _, key := range keys { + if client.values[key] { + count++ + } + } + return redis.NewIntResult(count, nil) +} + +func (client *fakeSeenClient) Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd { + client.key = key + client.ttl = expiration + if client.values == nil { + client.values = map[string]bool{} + } + client.values[key] = true + return redis.NewStatusResult("OK", nil) +} + func (client *fakeSeenClient) SetNX(ctx context.Context, key string, value any, expiration time.Duration) *redis.BoolCmd { client.key = key client.ttl = expiration @@ -103,7 +123,27 @@ func (client *fakeSeenClient) SetNX(ctx context.Context, key string, value any, return redis.NewBoolResult(true, nil) } -func TestSeenStoreUsesSetNXWithTTL(t *testing.T) { +func TestSeenStoreUsesExistsAndSetWithTTL(t *testing.T) { + client := &fakeSeenClient{} + store := newSeenStore(client, "seen:", time.Minute) + + alreadySeen, err := store.Seen(context.Background(), "event-1") + if err != nil || alreadySeen { + t.Fatalf("initial seen seen=%v err=%v, want false nil", alreadySeen, err) + } + if err := store.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("mark seen: %v", err) + } + if client.key != "seen:event-1" || client.ttl != time.Minute { + t.Fatalf("unexpected SET key/ttl: key=%q ttl=%s", client.key, client.ttl) + } + alreadySeen, err = store.Seen(context.Background(), "event-1") + if err != nil || !alreadySeen { + t.Fatalf("seen after mark seen=%v err=%v, want true nil", alreadySeen, err) + } +} + +func TestSeenStoreMarkIfNewUsesSetNX(t *testing.T) { client := &fakeSeenClient{} store := newSeenStore(client, "seen:", time.Minute) @@ -111,9 +151,6 @@ func TestSeenStoreUsesSetNXWithTTL(t *testing.T) { if err != nil || !fresh { t.Fatalf("first mark fresh=%v err=%v, want true nil", fresh, err) } - if client.key != "seen:event-1" || client.ttl != time.Minute { - t.Fatalf("unexpected SETNX key/ttl: key=%q ttl=%s", client.key, client.ttl) - } fresh, err = store.MarkIfNew(context.Background(), "event-1") if err != nil || fresh { t.Fatalf("second mark fresh=%v err=%v, want false nil", fresh, err) diff --git a/runtime/contracts/redisstream/seenstore.go b/runtime/contracts/redisstream/seenstore.go index da74e37d..c0c3948b 100644 --- a/runtime/contracts/redisstream/seenstore.go +++ b/runtime/contracts/redisstream/seenstore.go @@ -14,10 +14,12 @@ const ( ) type seenClient interface { + Exists(ctx context.Context, keys ...string) *redis.IntCmd + Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd SetNX(ctx context.Context, key string, value any, expiration time.Duration) *redis.BoolCmd } -// SeenStore records delivered event IDs in Redis with SETNX and a TTL-backed +// SeenStore records delivered event IDs in Redis with a TTL-backed // deduplication window. type SeenStore struct { client seenClient @@ -41,6 +43,35 @@ func newSeenStore(client seenClient, prefix string, ttl time.Duration) *SeenStor return &SeenStore{client: client, prefix: prefix, ttl: ttl} } +// Seen reports whether id is present inside the TTL window. +func (store *SeenStore) Seen(ctx context.Context, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if store.client == nil { + return false, errors.New("redis seen store client is required") + } + if id == "" { + return false, errors.New("event id is required") + } + count, err := store.client.Exists(ctx, store.prefix+id).Result() + return count > 0, err +} + +// MarkSeen records id inside the TTL window. +func (store *SeenStore) MarkSeen(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + if store.client == nil { + return errors.New("redis seen store client is required") + } + if id == "" { + return errors.New("event id is required") + } + return store.client.Set(ctx, store.prefix+id, "1", store.ttl).Err() +} + // MarkIfNew records id with SETNX and returns false when the ID already exists // inside the TTL window. func (store *SeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) { diff --git a/runtime/contracts/seenstore.go b/runtime/contracts/seenstore.go index 846a16e0..f566101c 100644 --- a/runtime/contracts/seenstore.go +++ b/runtime/contracts/seenstore.go @@ -32,6 +32,37 @@ func NewMemorySeenStore(limit int) *MemorySeenStore { } } +// Seen reports whether id is present in the current memory window. +func (store *MemorySeenStore) Seen(ctx context.Context, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if id == "" { + return false, errors.New("event id is required") + } + store.mu.Lock() + defer store.mu.Unlock() + if element := store.items[id]; element != nil { + store.order.MoveToBack(element) + return true, nil + } + return false, nil +} + +// MarkSeen records id in the current memory window. +func (store *MemorySeenStore) MarkSeen(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + if id == "" { + return errors.New("event id is required") + } + store.mu.Lock() + defer store.mu.Unlock() + store.markSeenLocked(id) + return nil +} + // MarkIfNew records id and reports whether it had not been seen inside the // current memory window. func (store *MemorySeenStore) MarkIfNew(ctx context.Context, id string) (bool, error) { @@ -47,6 +78,15 @@ func (store *MemorySeenStore) MarkIfNew(ctx context.Context, id string) (bool, e store.order.MoveToBack(element) return false, nil } + store.markSeenLocked(id) + return true, nil +} + +func (store *MemorySeenStore) markSeenLocked(id string) { + if element := store.items[id]; element != nil { + store.order.MoveToBack(element) + return + } element := store.order.PushBack(id) store.items[id] = element for len(store.items) > store.limit { @@ -57,5 +97,4 @@ func (store *MemorySeenStore) MarkIfNew(ctx context.Context, id string) (bool, e delete(store.items, oldest.Value.(string)) store.order.Remove(oldest) } - return true, nil } diff --git a/runtime/contracts/seenstore_test.go b/runtime/contracts/seenstore_test.go index 79bec007..46e109e4 100644 --- a/runtime/contracts/seenstore_test.go +++ b/runtime/contracts/seenstore_test.go @@ -25,9 +25,32 @@ func TestEnsureEventIDsAssignsAndPreservesIDs(t *testing.T) { } } -func TestMemorySeenStoreMarksNewOnceAndEvictsOldest(t *testing.T) { +func TestMemorySeenStoreSeenMarkSeenAndEvictsOldest(t *testing.T) { store := NewMemorySeenStore(1) + alreadySeen, err := store.Seen(context.Background(), "event-1") + if err != nil || alreadySeen { + t.Fatalf("initial seen event-1 seen=%v err=%v, want false nil", alreadySeen, err) + } + if err := store.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("mark event-1: %v", err) + } + alreadySeen, err = store.Seen(context.Background(), "event-1") + if err != nil || !alreadySeen { + t.Fatalf("seen event-1 after mark seen=%v err=%v, want true nil", alreadySeen, err) + } + if err := store.MarkSeen(context.Background(), "event-2"); err != nil { + t.Fatalf("mark event-2: %v", err) + } + alreadySeen, err = store.Seen(context.Background(), "event-1") + if err != nil || alreadySeen { + t.Fatalf("event-1 should be evicted, seen=%v err=%v", alreadySeen, err) + } +} + +func TestMemorySeenStoreMarkIfNew(t *testing.T) { + store := NewMemorySeenStore(10) + fresh, err := store.MarkIfNew(context.Background(), "event-1") if err != nil || !fresh { t.Fatalf("first mark event-1 fresh=%v err=%v, want true nil", fresh, err) @@ -36,12 +59,4 @@ func TestMemorySeenStoreMarksNewOnceAndEvictsOldest(t *testing.T) { if err != nil || fresh { t.Fatalf("second mark event-1 fresh=%v err=%v, want false nil", fresh, err) } - fresh, err = store.MarkIfNew(context.Background(), "event-2") - if err != nil || !fresh { - t.Fatalf("mark event-2 fresh=%v err=%v, want true nil", fresh, err) - } - fresh, err = store.MarkIfNew(context.Background(), "event-1") - if err != nil || !fresh { - t.Fatalf("event-1 should be new again after eviction, fresh=%v err=%v", fresh, err) - } } diff --git a/runtime/contracts/worker.go b/runtime/contracts/worker.go index ab678e54..09078314 100644 --- a/runtime/contracts/worker.go +++ b/runtime/contracts/worker.go @@ -93,7 +93,7 @@ func dispatchEventBatch(ctx context.Context, registry *Registry, role Role, batc if len(batch.Events) == 0 { return ackEventBatch(ctx, batch) } - events, err := unseenEvents(ctx, role, batch.Events, seen) + events, deliveredIDs, err := unseenEvents(ctx, role, batch.Events, seen) if err != nil { return err } @@ -112,30 +112,53 @@ func dispatchEventBatch(ctx context.Context, registry *Registry, role Role, batc logWorkerDispatchFailure(err) return nil } - return ackEventBatch(ctx, batch) + if err := ackEventBatch(ctx, batch); err != nil { + return err + } + return markSeenEvents(ctx, seen, deliveredIDs) } -func unseenEvents(ctx context.Context, role Role, events []EventEnvelope, seen SeenStore) ([]EventEnvelope, error) { +func unseenEvents(ctx context.Context, role Role, events []EventEnvelope, seen SeenStore) ([]EventEnvelope, []string, error) { if seen == nil { - return events, nil + return events, nil, nil } out := make([]EventEnvelope, 0, len(events)) + deliveredIDs := make([]string, 0, len(events)) + pending := map[string]bool{} for _, event := range events { if event.ID == "" { out = append(out, event) continue } - fresh, err := seen.MarkIfNew(ctx, event.ID) + if pending[event.ID] { + logWorkerDedupSkip(event, role) + continue + } + alreadySeen, err := seen.Seen(ctx, event.ID) if err != nil { - return nil, err + return nil, nil, err } - if !fresh { + if alreadySeen { logWorkerDedupSkip(event, role) continue } + pending[event.ID] = true + deliveredIDs = append(deliveredIDs, event.ID) out = append(out, event) } - return out, nil + return out, deliveredIDs, nil +} + +func markSeenEvents(ctx context.Context, seen SeenStore, ids []string) error { + if seen == nil { + return nil + } + for _, id := range ids { + if err := seen.MarkSeen(ctx, id); err != nil { + return err + } + } + return nil } func logWorkerDedupSkip(event EventEnvelope, role Role) { diff --git a/runtime/contracts/worker_test.go b/runtime/contracts/worker_test.go index 57083d0f..6af8dfa8 100644 --- a/runtime/contracts/worker_test.go +++ b/runtime/contracts/worker_test.go @@ -81,8 +81,8 @@ func TestRunEventWorkerWithSeenStoreAcksDuplicateWithoutDispatch(t *testing.T) { return nil }, RoleWorker)) seen := NewMemorySeenStore(10) - if fresh, err := seen.MarkIfNew(context.Background(), "event-1"); err != nil || !fresh { - t.Fatalf("prime seen store fresh=%v err=%v, want true nil", fresh, err) + if err := seen.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("prime seen store: %v", err) } var acked, nacked int source := &scriptedEventSource{ @@ -124,8 +124,8 @@ func TestRunEventWorkerWithSeenStoreDispatchesOnlyNewEvents(t *testing.T) { return nil }, RoleWorker)) seen := NewMemorySeenStore(10) - if fresh, err := seen.MarkIfNew(context.Background(), "event-1"); err != nil || !fresh { - t.Fatalf("prime seen store fresh=%v err=%v, want true nil", fresh, err) + if err := seen.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("prime seen store: %v", err) } source := &scriptedEventSource{ batches: []EventBatch{{ @@ -144,9 +144,107 @@ func TestRunEventWorkerWithSeenStoreDispatchesOnlyNewEvents(t *testing.T) { if strings.Join(handled, ",") != "new" { t.Fatalf("handled = %#v, want only new event", handled) } - fresh, err := seen.MarkIfNew(context.Background(), "event-2") - if err != nil || fresh { - t.Fatalf("event-2 should be recorded as seen, fresh=%v err=%v", fresh, err) + alreadySeen, err := seen.Seen(context.Background(), "event-2") + if err != nil || !alreadySeen { + t.Fatalf("event-2 should be recorded as seen, seen=%v err=%v", alreadySeen, err) + } +} + +func TestRunEventWorkerWithSeenStoreDoesNotMarkNackedDispatch(t *testing.T) { + registry := NewRegistry() + subscriberErr := errors.New("subscriber unavailable") + var handled int + must(t, RegisterDomainEvent[patientCreated](registry, func(ctx context.Context, event patientCreated) error { + handled++ + if handled == 1 { + return subscriberErr + } + return nil + }, RoleWorker)) + var acked, nacked int + seen := NewMemorySeenStore(10) + event := EventEnvelope{ + ID: "event-1", + Category: DomainEvent, + Type: typeName[patientCreated](), + Value: patientCreated{ID: "patient-1"}, + } + source := &scriptedEventSource{ + batches: []EventBatch{ + { + Events: []EventEnvelope{event}, + Ack: func(ctx context.Context) error { + acked++ + return nil + }, + Nack: func(ctx context.Context, err error) error { + nacked++ + return nil + }, + }, + { + Events: []EventEnvelope{event}, + Ack: func(ctx context.Context) error { + acked++ + return nil + }, + Nack: func(ctx context.Context, err error) error { + nacked++ + return nil + }, + }, + }, + err: ErrEventSourceClosed, + } + + if err := RunEventWorkerWithSeenStore(context.Background(), registry, source, seen); err != nil { + t.Fatalf("run event worker: %v", err) + } + if handled != 2 { + t.Fatalf("handled = %d, want failed attempt plus redelivery", handled) + } + if acked != 1 || nacked != 1 { + t.Fatalf("acked=%d nacked=%d, want acked=1 nacked=1", acked, nacked) + } + alreadySeen, err := seen.Seen(context.Background(), "event-1") + if err != nil || !alreadySeen { + t.Fatalf("event-1 should be marked after successful redelivery, seen=%v err=%v", alreadySeen, err) + } +} + +func TestRunEventWorkerWithSeenStoreDoesNotMarkAckFailure(t *testing.T) { + registry := NewRegistry() + var handled int + must(t, RegisterDomainEvent[patientCreated](registry, func(ctx context.Context, event patientCreated) error { + handled++ + return nil + }, RoleWorker)) + seen := NewMemorySeenStore(10) + ackErr := errors.New("ack unavailable") + source := &scriptedEventSource{ + batches: []EventBatch{{ + Events: []EventEnvelope{{ + ID: "event-1", + Category: DomainEvent, + Type: typeName[patientCreated](), + Value: patientCreated{ID: "patient-1"}, + }}, + Ack: func(ctx context.Context) error { + return ackErr + }, + }}, + } + + err := RunEventWorkerWithSeenStore(context.Background(), registry, source, seen) + if !errors.Is(err, ackErr) { + t.Fatalf("run event worker error = %v, want ack error", err) + } + if handled != 1 { + t.Fatalf("handled = %d, want 1", handled) + } + alreadySeen, seenErr := seen.Seen(context.Background(), "event-1") + if seenErr != nil || alreadySeen { + t.Fatalf("event-1 should not be marked after ack failure, seen=%v err=%v", alreadySeen, seenErr) } } From bd29f75f4b6e15fae0420e49f4b354d1bc2a1f88 Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 09:44:42 -0300 Subject: [PATCH 4/5] fix(contracts): address adapter review followups --- CHANGELOG.md | 8 +- docs/reference/contracts.md | 12 +- internal/appgen/appgen_test.go | 118 +++++++++++++++++- internal/appgen/source_actions.go | 18 ++- internal/appgen/source_contracts.go | 2 +- .../generated_go_golden/app.go.golden | 4 +- runtime/contracts/fileoutbox/fileoutbox.go | 14 ++- .../contracts/fileoutbox/fileoutbox_test.go | 89 +++++++++++++ runtime/contracts/natsbroker/natsbroker.go | 13 +- .../contracts/natsbroker/natsbroker_test.go | 23 ++++ 10 files changed, 283 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d943037e..9f7574ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,13 +49,17 @@ packages, and tooling contracts may change before a stable release. - Generated `g:command` and `g:query` contract web adapters now use one JSON response contract: success writes the command/query result as no-store JSON, and failures write `{"error":"..."}` as no-store JSON with ordinary 5xx - details redacted unless the handler returns an explicit `response.HandlerError`. + details redacted unless the handler returns an explicit + `response.HandlerError`; command form parse, oversized body, CSRF, and input + decode failures use the same JSON error shape. - Contract event envelopes now carry stable IDs for durable delivery. Workers can opt into deduplication with `RunEventWorkerWithSeenStore` or `RunEventWorkerForRoleWithSeenStore`; duplicate IDs are acked without subscriber dispatch inside the configured window, and fresh IDs are marked seen only after dispatch and source ack succeed. Runtime includes bounded - in-memory, file-backed, and Redis TTL seen-store adapters. + in-memory, file-backed, and Redis TTL seen-store adapters. File outbox records + keep unique row IDs separate from event IDs, and NATS batch drains preserve + already-decoded events if a later drained message cannot be decoded. ### Known Gaps diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index e59126f5..10debb8e 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -488,7 +488,8 @@ command, or a test fixture until split worker generation is designed. Dependency-free adapters: - `runtime/contracts/fileoutbox` stores JSON Lines records on disk and - implements both `Outbox` and `EventSource`. + implements both `Outbox` and `EventSource`. Each record has its own durable + record ID plus the event envelope ID used by worker deduplication. - `runtime/contracts/membroker` provides an in-memory `Broker` and `EventSource` for tests, local development, and single-process apps. - `runtime/contracts/sse` provides an `http.Handler` and @@ -600,7 +601,10 @@ if err := gowdkapp.RunContractEventWorker(ctx, events); err != nil { This adapter uses core NATS publish/subscribe. It does not provide durable replay for offline subscribers. Use Redis Streams, the file outbox, or a -custom JetStream adapter when events must survive worker downtime. +custom JetStream adapter when events must survive worker downtime. When a batch +drain encounters a later malformed message after already decoding earlier +messages, the adapter returns the decoded events so they can still be +dispatched. ### SSE Presentation Fanout @@ -706,7 +710,9 @@ Current behavior: directly as JSON. Error responses are `application/json` with `{"error":"..."}` and `Cache-Control: no-store`; ordinary 5xx errors use the generic HTTP status text, while `response.NewHandlerError(status, message, - cause)` can opt into an explicit client-safe status and message. + cause)` can opt into an explicit client-safe status and message. Form parse, + oversized body, CSRF, and typed input decode failures use the same JSON error + shape. - When the scanner can see the exported command input struct fields, generated adapters parse submitted form values, allow only the scanned fields, decode supported scalar fields, and pass the typed command input to the registry. diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index fc8b2f50..99699825 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -698,9 +698,11 @@ func TestGenerateWiresCSRFForCommandContracts(t *testing.T) { `func commandPatientsCreatePatientPOSTPatients(contractRegistry *gowdkcontracts.Registry) gowdkruntime.BackendHandler`, `request.Body = http.MaxBytesReader(response, request.Body, maxActionBodyBytes)`, `if err := request.ParseForm(); err != nil`, + `gowdkresponse.WriteNoStoreJSONError(response, http.StatusRequestEntityTooLarge, "request body too large")`, + `gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form")`, `if csrfValidator != nil {`, `err := csrfValidator.Validate(request)`, - `gowdkresponse.WriteNoStoreError(response, http.StatusForbidden, "invalid csrf token")`, + `gowdkresponse.WriteNoStoreJSONError(response, http.StatusForbidden, "invalid csrf token")`, `input := patients.CreatePatient{}`, `gowdkcontracts.CaptureCommandEventsForRole[patients.CreatePatient, patients.CreatePatientResult]`, `gowdkcontracts.DispatchCommandEvents(ctx, currentContractEventSink(), contractRegistry, gowdkcontracts.RoleWeb, events)`, @@ -3996,6 +3998,25 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData t.Fatalf("command JSON error leaked handler detail: %s", commandPayload) } + commandParseResponse, err := waitForHTTPStatus("http://"+addr+"/patients", http.MethodPost, "%zz") + if err != nil { + t.Fatal(err) + } + commandParsePayload, err := io.ReadAll(commandParseResponse.Body) + _ = commandParseResponse.Body.Close() + if err != nil { + t.Fatal(err) + } + if commandParseResponse.StatusCode != http.StatusBadRequest { + t.Fatalf("expected command parse error status 400, got %d: %s", commandParseResponse.StatusCode, commandParsePayload) + } + if commandParseResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("expected command parse JSON error content type, got %q", commandParseResponse.Header.Get("Content-Type")) + } + if strings.TrimSpace(string(commandParsePayload)) != `{"error":"invalid form"}` { + t.Fatalf("unexpected command parse JSON error payload: %s", commandParsePayload) + } + commandDecodeResponse, err := waitForHTTPStatus("http://"+addr+"/patients", http.MethodPost, "name=Ada&age=not-int") if err != nil { t.Fatal(err) @@ -4058,6 +4079,101 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData } } +func TestGeneratedBinaryContractCommandCSRFReturnsJSONError(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + binaryPath := filepath.Join(root, "site") + writeTestFile(t, filepath.Join(outputDir, "patients", "index.html"), "
Patients page
") + + program := &gwdkir.Program{ContractRefs: []gwdkir.ContractReference{{ + Kind: gwdkir.ContractCommand, + Name: "patients.CreatePatient", + ImportAlias: "patients", + ImportPath: "gowdk-generated-app/patients", + Type: "CreatePatient", + Result: "CreatePatientResult", + Method: http.MethodPost, + Path: "/patients", + Status: gwdkir.ContractBindingBound, + Handler: "HandleCreatePatient", + Register: "Register", + OwnerKind: gwdkir.SourcePage, + OwnerID: "patients", + }}} + if _, err := GenerateWithOptions(outputDir, appDir, Options{ + Config: gowdk.Config{Build: gowdk.BuildConfig{CSRF: gowdk.CSRFConfig{ + Enabled: true, + SecretEnv: "GOWDK_TEST_CSRF_SECRET", + Insecure: true, + }}}, + IR: program, + }); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(appDir, "patients", "patients.go"), `package patients + +import ( + "context" + + "github.com/cssbruno/gowdk/runtime/contracts" +) + +type CreatePatient struct{} + +type CreatePatientResult struct { + ID string `+"`json:\"id\"`"+` +} + +func Register(registry *contracts.Registry) { + contracts.RegisterCommand[CreatePatient, CreatePatientResult](registry, HandleCreatePatient) +} + +func HandleCreatePatient(ctx context.Context, command CreatePatient) (CreatePatientResult, error) { + return CreatePatientResult{ID: "patient-1"}, nil +} +`) + if _, err := BuildBinary(appDir, binaryPath); err != nil { + t.Fatal(err) + } + + addr := freeAddr(t) + command := exec.Command(binaryPath) + command.Env = append(os.Environ(), + "GOWDK_ADDR="+addr, + "GOWDK_TEST_CSRF_SECRET="+strings.Repeat("s", 32), + ) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _, _ = command.Process.Wait() + }() + + response, err := waitForHTTPStatus("http://"+addr+"/patients", http.MethodPost, "name=Ada") + if err != nil { + t.Fatal(err) + } + payload, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusForbidden { + t.Fatalf("expected missing csrf token to return 403, got %d: %s", response.StatusCode, payload) + } + if response.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("expected csrf JSON error content type, got %q", response.Header.Get("Content-Type")) + } + if strings.TrimSpace(string(payload)) != `{"error":"invalid csrf token"}` { + t.Fatalf("unexpected csrf JSON error payload: %s", payload) + } + if cache := response.Header.Get("Cache-Control"); cache != "no-store" { + t.Fatalf("expected no-store on invalid csrf response, got %q", cache) + } +} + func TestGeneratedBinaryRegisteredGuardsAllowRequestTimeRoutes(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") diff --git a/internal/appgen/source_actions.go b/internal/appgen/source_actions.go index bbb968e5..6d82e959 100644 --- a/internal/appgen/source_actions.go +++ b/internal/appgen/source_actions.go @@ -180,6 +180,18 @@ func actionCaseStmts(action ActionEndpoint, csrf bool, rateLimit bool) []ast.Stm } func actionParseFormStmts(csrf bool) []ast.Stmt { + return actionParseFormStmtsWithErrors(csrf, false) +} + +func contractParseFormStmts(csrf bool) []ast.Stmt { + return actionParseFormStmtsWithErrors(csrf, true) +} + +func actionParseFormStmtsWithErrors(csrf bool, jsonErrors bool) []ast.Stmt { + writeError := writeNoStoreErrorStmt + if jsonErrors { + writeError = writeNoStoreJSONErrorStmt + } stmts := []ast.Stmt{ assign([]ast.Expr{selExpr(id("request"), "Body")}, call(sel("http", "MaxBytesReader"), id("response"), selExpr(id("request"), "Body"), id("maxActionBodyBytes"))), &ast.IfStmt{ @@ -189,11 +201,11 @@ func actionParseFormStmts(csrf bool) []ast.Stmt { &ast.IfStmt{ Cond: call(sel("strings", "Contains"), call(selExpr(id("err"), "Error")), stringLit("request body too large")), Body: block( - writeNoStoreErrorStmt(sel("http", "StatusRequestEntityTooLarge"), "request body too large"), + writeError(sel("http", "StatusRequestEntityTooLarge"), "request body too large"), returnBool(true), ), }, - writeNoStoreErrorStmt(sel("http", "StatusBadRequest"), "invalid form"), + writeError(sel("http", "StatusBadRequest"), "invalid form"), returnBool(true), ), }, @@ -205,7 +217,7 @@ func actionParseFormStmts(csrf bool) []ast.Stmt { Init: define([]ast.Expr{id("err")}, call(selExpr(id("csrfValidator"), "Validate"), id("request"))), Cond: notNil("err"), Body: block( - writeNoStoreErrorStmt(sel("http", "StatusForbidden"), "invalid csrf token"), + writeError(sel("http", "StatusForbidden"), "invalid csrf token"), returnBool(true), ), }), diff --git a/internal/appgen/source_contracts.go b/internal/appgen/source_contracts.go index 68e24682..55253589 100644 --- a/internal/appgen/source_contracts.go +++ b/internal/appgen/source_contracts.go @@ -118,7 +118,7 @@ func executableQueryContractStmts(exposure BackendContractExposure) []ast.Stmt { func contractInputStmts(exposure BackendContractExposure, csrf bool) []ast.Stmt { switch exposure.Endpoint.Kind { case BackendEndpointCommand: - stmts := actionParseFormStmts(csrf) + stmts := contractParseFormStmts(csrf) if len(exposure.InputFields) == 0 { stmts = append(stmts, define([]ast.Expr{id("input")}, &ast.CompositeLit{Type: sel(exposure.ImportAlias, exposure.Type)})) return stmts diff --git a/internal/appgen/testdata/generated_go_golden/app.go.golden b/internal/appgen/testdata/generated_go_golden/app.go.golden index 08417f98..72f6e069 100644 --- a/internal/appgen/testdata/generated_go_golden/app.go.golden +++ b/internal/appgen/testdata/generated_go_golden/app.go.golden @@ -121,10 +121,10 @@ func commandPatientsCreatePatientPOSTPatients(contractRegistry *gowdkcontracts.R request.Body = http.MaxBytesReader(response, request.Body, maxActionBodyBytes) if err := request.ParseForm(); err != nil { if strings.Contains(err.Error(), "request body too large") { - gowdkresponse.WriteNoStoreError(response, http.StatusRequestEntityTooLarge, "request body too large") + gowdkresponse.WriteNoStoreJSONError(response, http.StatusRequestEntityTooLarge, "request body too large") return true } - gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form") return true } values := gowdkform.FromURLValues(request.PostForm) diff --git a/runtime/contracts/fileoutbox/fileoutbox.go b/runtime/contracts/fileoutbox/fileoutbox.go index 34c1d21a..c2a9e386 100644 --- a/runtime/contracts/fileoutbox/fileoutbox.go +++ b/runtime/contracts/fileoutbox/fileoutbox.go @@ -26,6 +26,7 @@ type Decoder = contracts.EventDecoder // Record is one durable outbox row stored as a JSON Lines object. type Record struct { ID string `json:"id"` + EventID string `json:"eventId,omitempty"` StoredAt time.Time `json:"storedAt"` Category contracts.EventCategory `json:"category"` Type string `json:"type"` @@ -140,7 +141,8 @@ func (store *Store) StoreEvents(ctx context.Context, events []contracts.EventEnv return err } record := Record{ - ID: event.ID, + ID: newRecordID(), + EventID: event.ID, StoredAt: store.now().UTC(), Category: event.Category, Type: event.Type, @@ -255,8 +257,12 @@ func (store *Store) decodeRecordsLocked(records []Record) ([]contracts.EventEnve continue } decoded[record.ID] = true + eventID := record.EventID + if eventID == "" { + eventID = record.ID + } events = append(events, contracts.EventEnvelope{ - ID: record.ID, + ID: eventID, Category: record.Category, Type: record.Type, Value: value, @@ -265,6 +271,10 @@ func (store *Store) decodeRecordsLocked(records []Record) ([]contracts.EventEnve return events, decoded, failed, failure } +func newRecordID() string { + return "record-" + contracts.NewEventID() +} + func (store *Store) readRecordsLocked() ([]Record, error) { return store.readRecordsFromPathLocked(store.path) } diff --git a/runtime/contracts/fileoutbox/fileoutbox_test.go b/runtime/contracts/fileoutbox/fileoutbox_test.go index 846961ea..b6bcd253 100644 --- a/runtime/contracts/fileoutbox/fileoutbox_test.go +++ b/runtime/contracts/fileoutbox/fileoutbox_test.go @@ -25,6 +25,7 @@ func TestStoreEventsAppendsDurableRecords(t *testing.T) { store.now = func() time.Time { return time.Unix(123, 0).UTC() } err := store.StoreEvents(context.Background(), []contracts.EventEnvelope{{ + ID: "event-1", Category: contracts.DomainEvent, Type: patientCreatedType, Value: patientCreated{ID: "patient-1"}, @@ -46,6 +47,12 @@ func TestStoreEventsAppendsDurableRecords(t *testing.T) { if records[0].ID == "" { t.Fatalf("expected durable record ID to be assigned: %#v", records[0]) } + if records[0].EventID != "event-1" { + t.Fatalf("record event ID = %q, want event-1", records[0].EventID) + } + if records[0].ID == records[0].EventID { + t.Fatalf("record ID should be distinct from event ID: %#v", records[0]) + } var value patientCreated if err := json.Unmarshal(records[0].Value, &value); err != nil { t.Fatalf("unmarshal record value: %v", err) @@ -122,6 +129,88 @@ func TestReceiveEventBatchNackKeepsRecords(t *testing.T) { } } +func TestReceiveEventBatchAckKeepsDuplicateEventIDRowsDistinct(t *testing.T) { + path := filepath.Join(t.TempDir(), "outbox.jsonl") + store := New(path, WithBatchSize(1), WithJSONTypeDecoder[patientCreated]()) + if err := store.StoreEvents(context.Background(), []contracts.EventEnvelope{ + {ID: "event-1", Category: contracts.DomainEvent, Type: patientCreatedType, Value: patientCreated{ID: "patient-1"}}, + {ID: "event-1", Category: contracts.DomainEvent, Type: patientCreatedType, Value: patientCreated{ID: "patient-2"}}, + }); err != nil { + t.Fatalf("store events: %v", err) + } + + first, err := store.ReceiveEventBatch(context.Background()) + if err != nil { + t.Fatalf("receive first batch: %v", err) + } + if len(first.Events) != 1 || first.Events[0].ID != "event-1" { + t.Fatalf("unexpected first batch: %#v", first.Events) + } + if err := first.Ack(context.Background()); err != nil { + t.Fatalf("ack first batch: %v", err) + } + records, err := store.Records(context.Background()) + if err != nil { + t.Fatalf("records after first ack: %v", err) + } + if len(records) != 1 { + t.Fatalf("len(records) after first ack = %d, want 1: %#v", len(records), records) + } + if records[0].EventID != "event-1" || records[0].ID == "event-1" { + t.Fatalf("remaining record should keep duplicate event ID with distinct record ID: %#v", records[0]) + } + + second, err := store.ReceiveEventBatch(context.Background()) + if err != nil { + t.Fatalf("receive second batch: %v", err) + } + if len(second.Events) != 1 || second.Events[0].ID != "event-1" { + t.Fatalf("unexpected second batch: %#v", second.Events) + } +} + +func TestReceiveEventBatchNackKeepsDuplicateEventIDRowsDistinct(t *testing.T) { + path := filepath.Join(t.TempDir(), "outbox.jsonl") + store := New(path, WithBatchSize(1), WithJSONTypeDecoder[patientCreated]()) + if err := store.StoreEvents(context.Background(), []contracts.EventEnvelope{ + {ID: "event-1", Category: contracts.DomainEvent, Type: patientCreatedType, Value: patientCreated{ID: "patient-1"}}, + {ID: "event-1", Category: contracts.DomainEvent, Type: patientCreatedType, Value: patientCreated{ID: "patient-2"}}, + }); err != nil { + t.Fatalf("store events: %v", err) + } + + first, err := store.ReceiveEventBatch(context.Background()) + if err != nil { + t.Fatalf("receive first batch: %v", err) + } + nackErr := errors.New("subscriber failed") + if err := first.Nack(context.Background(), nackErr); err != nil { + t.Fatalf("nack first batch: %v", err) + } + records, err := store.Records(context.Background()) + if err != nil { + t.Fatalf("records after first nack: %v", err) + } + if len(records) != 2 { + t.Fatalf("len(records) after first nack = %d, want 2: %#v", len(records), records) + } + var retried, untouched int + for _, record := range records { + if record.EventID != "event-1" || record.ID == "event-1" { + t.Fatalf("unexpected duplicate event record identity: %#v", record) + } + if record.Attempts == 1 && record.LastError == nackErr.Error() { + retried++ + } + if record.Attempts == 0 && record.LastError == "" { + untouched++ + } + } + if retried != 1 || untouched != 1 { + t.Fatalf("retry metadata should affect one row only, retried=%d untouched=%d records=%#v", retried, untouched, records) + } +} + func TestReceiveEventBatchMovesRecordToDeadLetterAfterMaxAttempts(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "outbox.jsonl") diff --git a/runtime/contracts/natsbroker/natsbroker.go b/runtime/contracts/natsbroker/natsbroker.go index 3f9ad403..958aa73d 100644 --- a/runtime/contracts/natsbroker/natsbroker.go +++ b/runtime/contracts/natsbroker/natsbroker.go @@ -128,20 +128,25 @@ func (broker *Broker) ReceiveEventBatch(ctx context.Context) (contracts.EventBat if err != nil { return contracts.EventBatch{}, err } + events := drainAvailableEvents(ctx, first, broker.batchSize, broker.tryNextMessage) + return contracts.EventBatch{Events: events}, nil +} + +func drainAvailableEvents(ctx context.Context, first contracts.EventEnvelope, batchSize int, next func(context.Context) (contracts.EventEnvelope, bool, error)) []contracts.EventEnvelope { events := []contracts.EventEnvelope{first} - for len(events) < broker.batchSize { + for len(events) < batchSize { pollCtx, cancel := context.WithTimeout(ctx, time.Millisecond) - event, ok, err := broker.tryNextMessage(pollCtx) + event, ok, err := next(pollCtx) cancel() if err != nil { - return contracts.EventBatch{}, err + return events } if !ok { break } events = append(events, event) } - return contracts.EventBatch{Events: events}, nil + return events } // Close unsubscribes the receive subscription. diff --git a/runtime/contracts/natsbroker/natsbroker_test.go b/runtime/contracts/natsbroker/natsbroker_test.go index 72b4c190..18c8c68b 100644 --- a/runtime/contracts/natsbroker/natsbroker_test.go +++ b/runtime/contracts/natsbroker/natsbroker_test.go @@ -1,7 +1,9 @@ package natsbroker import ( + "context" "encoding/json" + "errors" "strings" "testing" @@ -61,6 +63,27 @@ func TestDecodePayloadWithRegisteredDecoder(t *testing.T) { } } +func TestDrainAvailableEventsReturnsAccumulatedEventsOnLaterError(t *testing.T) { + decodeErr := errors.New("decode failed") + first := contracts.EventEnvelope{ID: "event-1", Category: contracts.IntegrationEvent, Type: "PatientCreated", Value: patientCreated{ID: "patient-1"}} + second := contracts.EventEnvelope{ID: "event-2", Category: contracts.IntegrationEvent, Type: "PatientCreated", Value: patientCreated{ID: "patient-2"}} + calls := 0 + + events := drainAvailableEvents(context.Background(), first, 3, func(ctx context.Context) (contracts.EventEnvelope, bool, error) { + calls++ + if calls == 1 { + return second, true, nil + } + return contracts.EventEnvelope{}, false, decodeErr + }) + if len(events) != 2 { + t.Fatalf("len(events) = %d, want 2: %#v", len(events), events) + } + if events[0].ID != "event-1" || events[1].ID != "event-2" { + t.Fatalf("unexpected event IDs: %#v", events) + } +} + func TestValidateRequiresConnectionAndSubject(t *testing.T) { if err := New(nil, "").validate(); err == nil || !strings.Contains(err.Error(), "nats connection is required") { t.Fatalf("validate error = %v, want connection required", err) From c4c36a8550c09767689fe15b85851e5fb87350c1 Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 10:23:07 -0300 Subject: [PATCH 5/5] fix(contracts): write file outbox state atomically --- CHANGELOG.md | 5 +- docs/reference/contracts.md | 13 +-- runtime/contracts/fileoutbox/fileoutbox.go | 63 ++---------- .../contracts/fileoutbox/fileoutbox_test.go | 96 +++++++++++++++++++ runtime/contracts/fileoutbox/jsonlines.go | 49 ++++++++++ runtime/contracts/fileoutbox/seenstore.go | 35 ++----- 6 files changed, 174 insertions(+), 87 deletions(-) create mode 100644 runtime/contracts/fileoutbox/jsonlines.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7574ea..cd5e4c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,9 @@ packages, and tooling contracts may change before a stable release. subscriber dispatch inside the configured window, and fresh IDs are marked seen only after dispatch and source ack succeed. Runtime includes bounded in-memory, file-backed, and Redis TTL seen-store adapters. File outbox records - keep unique row IDs separate from event IDs, and NATS batch drains preserve - already-decoded events if a later drained message cannot be decoded. + keep unique row IDs separate from event IDs, file-backed outbox/dead-letter + and seen-store updates use temp-file replacement, and NATS batch drains + preserve already-decoded events if a later drained message cannot be decoded. ### Known Gaps diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index 10debb8e..da536686 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -306,11 +306,12 @@ err = contracts.RunEventWorker(ctx, r, outbox) ``` The file outbox implements both `contracts.Outbox` and -`contracts.EventSource`. It appends captured envelopes as JSON Lines records, -decodes records through explicitly registered decoders, removes records only -after worker `Ack`, and keeps records after `Nack` for retry. Nack records the -attempt count, last attempt time, and last error in the durable record. It is -useful for local development, small single-host deployments, and tests. +`contracts.EventSource`. It stores captured envelopes as JSON Lines records, +rewrites pending and dead-letter files through temp-file replacement, decodes +records through explicitly registered decoders, removes records only after +worker `Ack`, and keeps records after `Nack` for retry. Nack records the attempt +count, last attempt time, and last error in the durable record. It is useful for +local development, small single-host deployments, and tests. When `WithDeadLetter(path, maxAttempts)` is configured, records move to the dead-letter JSON Lines file after the configured failed delivery count. Applications that need database transactions, cross-process locking, retry @@ -848,7 +849,7 @@ Use `g:on:*` for local UI/component events and `g:command` for backend intent. `PublishEnvelope`, `PublishEnvelopes`, and role-filtered variants. - `runtime/contracts/fileoutbox` provides a dependency-free JSON Lines adapter that implements `contracts.Outbox` and `contracts.EventSource`, including - nack retry metadata and an opt-in dead-letter file. + atomic file replacement, nack retry metadata, and an opt-in dead-letter file. - `contracts.NewMemorySeenStore`, `fileoutbox.NewSeenStore`, and `redisstream.NewSeenStore` provide deduplication windows for event workers. - External broker adapters can implement the dependency-free `Broker` diff --git a/runtime/contracts/fileoutbox/fileoutbox.go b/runtime/contracts/fileoutbox/fileoutbox.go index c2a9e386..b066fef8 100644 --- a/runtime/contracts/fileoutbox/fileoutbox.go +++ b/runtime/contracts/fileoutbox/fileoutbox.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "sync" "time" @@ -51,6 +50,7 @@ type Store struct { maxAttempts int decoders map[string]Decoder now func() time.Time + rename func(string, string) error } // Option configures a Store. @@ -104,6 +104,7 @@ func New(path string, options ...Option) *Store { batchSize: defaultBatchSize, decoders: map[string]Decoder{}, now: time.Now, + rename: os.Rename, } for _, option := range options { if option != nil { @@ -124,36 +125,26 @@ func (store *Store) StoreEvents(ctx context.Context, events []contracts.EventEnv store.mu.Lock() defer store.mu.Unlock() - if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil { - return err - } - file, err := os.OpenFile(store.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + records, err := store.readRecordsLocked() if err != nil { return err } - - encoder := json.NewEncoder(file) for _, event := range events { event = contracts.EnsureEventID(event) value, err := json.Marshal(event.Value) if err != nil { - file.Close() return err } - record := Record{ + records = append(records, Record{ ID: newRecordID(), EventID: event.ID, StoredAt: store.now().UTC(), Category: event.Category, Type: event.Type, Value: value, - } - if err := encoder.Encode(record); err != nil { - file.Close() - return err - } + }) } - return file.Close() + return store.writeRecordsLocked(records) } // Records returns all currently pending outbox records. @@ -361,52 +352,16 @@ func (store *Store) markRecordsFailedLocked(mark map[string]bool, cause error) e } func (store *Store) writeRecordsLocked(records []Record) error { - if len(records) == 0 { - if err := os.Remove(store.path); err != nil && !os.IsNotExist(err) { - return err - } - return nil - } - if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil { - return err - } - temp, err := os.CreateTemp(filepath.Dir(store.path), ".gowdk-outbox-*") - if err != nil { - return err - } - tempName := temp.Name() - encoder := json.NewEncoder(temp) - for _, record := range records { - if err := encoder.Encode(record); err != nil { - temp.Close() - os.Remove(tempName) - return err - } - } - if err := temp.Close(); err != nil { - os.Remove(tempName) - return err - } - return os.Rename(tempName, store.path) + return writeJSONLinesAtomic(store.path, ".gowdk-outbox-*", records, store.rename) } func (store *Store) appendRecordsToPathLocked(path string, records []Record) error { if len(records) == 0 { return nil } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + existing, err := store.readRecordsFromPathLocked(path) if err != nil { return err } - encoder := json.NewEncoder(file) - for _, record := range records { - if err := encoder.Encode(record); err != nil { - file.Close() - return err - } - } - return file.Close() + return writeJSONLinesAtomic(path, ".gowdk-outbox-*", append(existing, records...), store.rename) } diff --git a/runtime/contracts/fileoutbox/fileoutbox_test.go b/runtime/contracts/fileoutbox/fileoutbox_test.go index b6bcd253..2f899163 100644 --- a/runtime/contracts/fileoutbox/fileoutbox_test.go +++ b/runtime/contracts/fileoutbox/fileoutbox_test.go @@ -65,6 +65,74 @@ func TestStoreEventsAppendsDurableRecords(t *testing.T) { } } +func TestStoreEventsFailedReplacementPreservesExistingRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "outbox.jsonl") + store := New(path) + if err := store.StoreEvents(context.Background(), []contracts.EventEnvelope{{ + ID: "event-1", + Category: contracts.DomainEvent, + Type: patientCreatedType, + Value: patientCreated{ID: "patient-1"}, + }}); err != nil { + t.Fatalf("store initial event: %v", err) + } + + renameErr := errors.New("rename failed") + store.rename = func(_, _ string) error { return renameErr } + err := store.StoreEvents(context.Background(), []contracts.EventEnvelope{{ + ID: "event-2", + Category: contracts.DomainEvent, + Type: patientCreatedType, + Value: patientCreated{ID: "patient-2"}, + }}) + if !errors.Is(err, renameErr) { + t.Fatalf("store replacement error = %v, want %v", err, renameErr) + } + + records, err := store.Records(context.Background()) + if err != nil { + t.Fatalf("records after failed replacement: %v", err) + } + if len(records) != 1 || records[0].EventID != "event-1" { + t.Fatalf("failed replacement should preserve only original record: %#v", records) + } +} + +func TestAppendRecordsToPathFailedReplacementPreservesExistingRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "deadletter.jsonl") + store := New(filepath.Join(t.TempDir(), "outbox.jsonl")) + if err := store.appendRecordsToPathLocked(path, []Record{{ + ID: "record-1", + EventID: "event-1", + Category: contracts.DomainEvent, + Type: patientCreatedType, + Value: json.RawMessage(`{"id":"patient-1"}`), + }}); err != nil { + t.Fatalf("append initial record: %v", err) + } + + renameErr := errors.New("rename failed") + store.rename = func(_, _ string) error { return renameErr } + err := store.appendRecordsToPathLocked(path, []Record{{ + ID: "record-2", + EventID: "event-2", + Category: contracts.DomainEvent, + Type: patientCreatedType, + Value: json.RawMessage(`{"id":"patient-2"}`), + }}) + if !errors.Is(err, renameErr) { + t.Fatalf("append replacement error = %v, want %v", err, renameErr) + } + + records, err := store.readRecordsFromPathLocked(path) + if err != nil { + t.Fatalf("records after failed append replacement: %v", err) + } + if len(records) != 1 || records[0].EventID != "event-1" { + t.Fatalf("failed append replacement should preserve only original record: %#v", records) + } +} + func TestReceiveEventBatchDecodesAndAcksRecords(t *testing.T) { path := filepath.Join(t.TempDir(), "outbox.jsonl") store := New(path, WithJSONTypeDecoder[patientCreated]()) @@ -491,6 +559,34 @@ func TestSeenStoreMarkIfNew(t *testing.T) { } } +func TestSeenStoreFailedReplacementPreservesExistingRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "seen.jsonl") + store := NewSeenStore(path) + if err := store.MarkSeen(context.Background(), "event-1"); err != nil { + t.Fatalf("mark initial seen: %v", err) + } + + renameErr := errors.New("rename failed") + store.rename = func(_, _ string) error { return renameErr } + err := store.MarkSeen(context.Background(), "event-2") + if !errors.Is(err, renameErr) { + t.Fatalf("mark replacement error = %v, want %v", err, renameErr) + } + + reopened := NewSeenStore(path) + event1Seen, err := reopened.Seen(context.Background(), "event-1") + if err != nil { + t.Fatalf("seen event-1 after failed replacement: %v", err) + } + event2Seen, err := reopened.Seen(context.Background(), "event-2") + if err != nil { + t.Fatalf("seen event-2 after failed replacement: %v", err) + } + if !event1Seen || event2Seen { + t.Fatalf("failed replacement should preserve event-1 only, event1=%v event2=%v", event1Seen, event2Seen) + } +} + func TestSeenStoreEvictsOldestRecord(t *testing.T) { path := filepath.Join(t.TempDir(), "seen.jsonl") store := NewSeenStore(path, WithSeenLimit(1)) diff --git a/runtime/contracts/fileoutbox/jsonlines.go b/runtime/contracts/fileoutbox/jsonlines.go new file mode 100644 index 00000000..951f4985 --- /dev/null +++ b/runtime/contracts/fileoutbox/jsonlines.go @@ -0,0 +1,49 @@ +package fileoutbox + +import ( + "encoding/json" + "os" + "path/filepath" +) + +func writeJSONLinesAtomic[T any](path, pattern string, records []T, rename func(string, string) error) error { + if len(records) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(path), pattern) + if err != nil { + return err + } + tempName := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + + encoder := json.NewEncoder(temp) + for _, record := range records { + if err := encoder.Encode(record); err != nil { + _ = temp.Close() + return err + } + } + if err := temp.Close(); err != nil { + return err + } + if rename == nil { + rename = os.Rename + } + if err := rename(tempName, path); err != nil { + return err + } + cleanup = false + return nil +} diff --git a/runtime/contracts/fileoutbox/seenstore.go b/runtime/contracts/fileoutbox/seenstore.go index 6e68f972..535f8a55 100644 --- a/runtime/contracts/fileoutbox/seenstore.go +++ b/runtime/contracts/fileoutbox/seenstore.go @@ -24,10 +24,11 @@ type SeenRecord struct { // SeenStore records delivered event IDs in a JSON Lines file. It is intended // for local single-binary apps that also use fileoutbox. type SeenStore struct { - mu sync.Mutex - path string - limit int - now func() time.Time + mu sync.Mutex + path string + limit int + now func() time.Time + rename func(string, string) error } // SeenOption configures a SeenStore. @@ -46,9 +47,10 @@ func WithSeenLimit(limit int) SeenOption { // NewSeenStore creates a file-backed seen store at path. func NewSeenStore(path string, options ...SeenOption) *SeenStore { store := &SeenStore{ - path: path, - limit: defaultSeenLimit, - now: time.Now, + path: path, + limit: defaultSeenLimit, + now: time.Now, + rename: os.Rename, } for _, option := range options { if option != nil { @@ -176,22 +178,5 @@ func (store *SeenStore) readRecordsLocked() ([]SeenRecord, error) { } func (store *SeenStore) writeRecordsLocked(records []SeenRecord) error { - if len(records) == 0 { - if err := os.Remove(store.path); err != nil && !os.IsNotExist(err) { - return err - } - return nil - } - file, err := os.OpenFile(store.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) - if err != nil { - return err - } - encoder := json.NewEncoder(file) - for _, record := range records { - if err := encoder.Encode(record); err != nil { - file.Close() - return err - } - } - return file.Close() + return writeJSONLinesAtomic(store.path, ".gowdk-seen-*", records, store.rename) }