From 55dbb182955e40ff86defead4acd60dd276ef87c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 14:12:24 +0200 Subject: [PATCH] docs(19.3): add pattern map --- .../19.3-PATTERNS.md | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md new file mode 100644 index 0000000..9327fa6 --- /dev/null +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md @@ -0,0 +1,292 @@ +# Phase 19.3: Framework transport and infrastructure for WavePath - Pattern Map + +**Mapped:** 2026-10-15 +**Files analyzed:** 27 (new or modified, across summercms.go and 6 sibling repos) +**Analogs found:** 25 / 27 + +All analog paths below are git-tracked source (framework paths are relative to `summercms.go/`; sibling repo paths are relative to `summercms/`). + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `modules/cooler/README.md` | doc | - | `modules/postcard/README.md` | exact | +| `modules/cooler/cooler.go` (Cache, Install, FromApp) | service | request-response | `modules/postcard/mailer.go` (`Activate`, `driverFromApp`, `app.Publish[Mailer]`) | role-match | +| `modules/cooler/memory.go` (otter driver) | service | CRUD (in-proc) | `modules/surf/limiter_store.go` `MemoryStore` | role-match | +| `modules/cooler/redis.go` | service/config | request-response | `modules/postcard/drivers.go` (driver from config) | partial | +| `modules/cooler/ratelimit.go` (Redis `surf.Store`) | store | atomic counter | `modules/surf/limiter_store.go` | exact (semantics) | +| `modules/cooler/sitecache.go` | service | event-driven (after-commit HTTP) | `../sm-journal-plugin/classes/site_cache.go` | exact (move) | +| `modules/cooler/*_test.go` (miniredis + Docker Redis) | test | - | `modules/postcard/mailpit_test.go`, `modules/beachcomber/postgres_test.go` (TestMain) | exact | +| `modules/surf/serve.go` (cooler.Install + publish Store) | wiring | boot | same file, `publishUploads` (lines 51-56, 125-140) | exact | +| `modules/surf/router.go` (`BuildRouter` store lookup, `cors`/`turnstile` factories, OPTIONS routes) | router | request-response | same file lines 467-500 (`throttle` factory) | exact | +| `modules/surf/cors.go` (hardening + profiles) | middleware | request-response | same file | exact | +| `modules/surf/apidocs.go` | route/handler | request-response | `modules/surf/uploads.go` (`mountUploads`, opt-in route), `BucketProvider` type-assert in router.go 121-126 | role-match | +| `modules/bouncer/cookie.go` | utility + handler | request-response | `modules/bouncer/refresh.go`; secure check `modules/cabana/auth.go` 503-517 | role-match | +| `modules/bouncer/turnstile.go` | service + middleware | request-response (outbound HTTP) | `../sm-journal-plugin/classes/site_cache.go` (bounded outbound POST, error redaction) | partial | +| `modules/lagoon/attach/bucket.go` (s3 guard, `public_url`) | config | file-I/O | same file lines 37-80 | exact | +| `modules/lagoon/attach/s3_on.go` / `s3_off.go` | config (build tag) | - | blank imports in `bucket.go` lines 13-14 | partial | +| `modules/lagoon/attach/diskext.go` + `store.go` edit | utility | transform | `store.go` lines 130-160 | exact | +| `modules/lagoon/attach/file.go` (URL with public_url) | model | transform | `bucket.go` `PublicPathPrefix` lines 17-35 | exact | +| `internal/build/manifest.go` (`build.tags`) | config | file-I/O | same file `LoadManifest`/`SaveManifest` lines 40-103 | exact | +| `internal/build/build.go` (`-tags`) | utility | batch | same file line 49 | exact | +| `modules/pact/capabilities.go` (new `ReorderAfterSave` hook) | interface | event-driven | same file lines 748-765 (`FormAfter*`) | exact | +| `modules/cabana/reorder.go` (call hook in tx) | controller | CRUD | `modules/cabana/crud.go` `deleteRecord` 1025-1040 | exact | +| `../sm-journal-plugin/plugin.go` + `classes/site_cache.go` + `controllers/site_cache.go` | plugin wiring | event-driven | themselves (alias keys) | exact | +| `../sm-menu-plugin/controllers/menus.go`, `menuentries.go` | controller hooks | event-driven | `../sm-journal-plugin/controllers/categories.go` 64-88 | exact | +| `../sm-faq-plugin/controllers/faqs.go` | controller hooks | event-driven | same as above | exact | +| `../vue-{journal,menu,faq}-layer/server/api/__site__/cache/purge.post.ts` | route (Nitro) | request-response | `../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` + `app/utils/siteCache.ts` | exact | +| `../vue-{journal,menu,faq}-layer/nuxt.config.ts` (runtimeConfig + routeRules) | config | - | `../vue-journal-layer/nuxt.config.ts` lines 27-51 | exact | +| `.planning/notes/-cooler-redis-otter.md` (decision note) | doc | - | existing `.planning/notes/*` | role-match | +| `README.md` root table row, `docs/` pages | doc | - | `README.md` line 110 (postcard row) | exact | + +## Pattern Assignments + +### `modules/cooler/README.md` (doc) +**Analog:** `modules/postcard/README.md` lines 1-30. Structure to copy: +```markdown +# postcard + +Transactional mail from plugin-owned Markdown templates ... SMTP driver. + +`import "git.golem15.com/golem15/summercms/modules/postcard"` + +**Name:** What you send home in summer; it delivers the application's mail. + +## Overview +## Role in the framework +## Features +## Usage +``` +Then API reference, Configuration (`redis.url`, `cache.driver`, `cache.prefix`, `cache.memory.max_entries`, `http.rate_limit.store`, `site_cache.purge_url`, `site_cache.purge_token`), Dependencies (go-redis v9, otter v2), Testing. Root `README.md` row format (line 110): +```markdown +| [postcard](modules/postcard/README.md) | | | +``` + +### `modules/cooler/cooler.go` (service, publish at boot) +**Analog:** `modules/postcard/mailer.go` lines 85-110 +```go +func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error { + if app == nil { + return fmt.Errorf("postcard: app is nil") + } + driver, opts, err := driverFromApp(app) + if err != nil { + return err + } + m := &mailer{ ... } + if err := app.Publish[Mailer](m); err != nil { + return fmt.Errorf("postcard: %w", err) + } + return nil +} +``` +Copy: nil-app guard, `driverFromApp`-style config read (default `memory`), publish under the interface type (`app.Publish[cooler.Cache]`), errors prefixed `cooler:`. Never put `redis.url` in an error (RESEARCH Pitfall 7). + +### `modules/cooler/ratelimit.go` (Redis `surf.Store`) +**Analog:** `modules/surf/limiter_store.go` lines 11-13 (interface) and 68-93 (semantics to match) +```go +type Store interface { + Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) +} +... + if !ok { + e = &counterEntry{count: 0, resetAt: now.Add(decay)} + s.entries[key] = e + } + retryAfter := e.resetAt.Sub(now) + if retryAfter < 0 { retryAfter = 0 } + if e.count >= max { return false, e.count, retryAfter } + e.count++ + return true, e.count, retryAfter +``` +Implement structurally (do NOT import surf). Lua script from RESEARCH Pattern 2; fallback to a `MemoryStore` passed in by surf on Redis error. Keys `rl:`. + +### `modules/cooler/memory.go` +**Analog:** `MemoryStore` (same file above) for lifecycle (constructor + `Close`/stop). Use otter v2 per RESEARCH "Code Examples"; keys `cache:`. + +### `modules/cooler/sitecache.go` (move) +**Analog:** `../sm-journal-plugin/classes/site_cache.go` (whole file, ~185 lines). Copy verbatim, changing: key constants to `site_cache.purge_url` / `site_cache.purge_token`; log prefix `journal:` to `cooler:`; drop Journal-specific doc wording. Key pieces: +```go +const ( + SiteCachePurgeTimeout = 2 * time.Second + siteCacheAnswerLimit = 4 << 10 +) +const SiteCachePurgeMinTokenLength = 32 + +func NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error) // nil,nil when both empty; loopback-only http +func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) { + if p == nil { return } + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SiteCachePurgeTimeout) + ... + req.Header.Set("Authorization", "Bearer "+p.token) + ... + log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", purgeTransportError(err)) +} +func PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string) { + if p == nil { return } + lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) { p.Purge(ctx, kind, slug) }) +} +``` +Add a context-only helper (cabana tx lookup) modelled on `../sm-journal-plugin/controllers/site_cache.go`: +```go +purger, ok := a.Lookup[*classes.SiteCachePurger]() +if !ok || purger == nil { return nil, nil } +tx, ok := cabana.TxFromContext(ctx) +``` +Note: cooler importing cabana would risk a cycle (cabana is heavy); prefer the helper taking `*gorm.DB` and let plugins call `cabana.TxFromContext` themselves, as Journal does. Add burst coalescing (RESEARCH Pattern 8). + +### `modules/cooler/*_test.go` +**Docker analog:** `modules/postcard/mailpit_test.go` lines 35-60 (per-test `testcontainers.Run` with `testing.Short()` skip): +```go +if testing.Short() { t.Skip("requires testcontainers mailpit") } +ctr, err := testcontainers.Run(ctx, mailpitImage, + testcontainers.WithExposedPorts("1025/tcp", "8025/tcp"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("1025/tcp"), ...), +) +t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) +``` +Use `redis:7-alpine`, port `6379/tcp`. If a TestMain is used instead, copy `modules/beachcomber/postgres_test.go` lines 29-54 (`testShort()` with `flag.Parse()`). Fast loop: miniredis. + +### `modules/surf/serve.go` (boot wiring) +**Analog:** same file lines 51-59: +```go +bucket, err := publishUploads(ctx, app) +if err != nil { return err } +defer bucket.Close() +r, err := BuildRouter(app, plugins) +``` +Insert `cooler.Install` + `app.Publish[Store](...)` between `publishUploads` and `BuildRouter` (RESEARCH Pattern 1). `route:list` must not get this. + +### `modules/surf/router.go` +**Analog:** `BuildRouter` lines 467-500: +```go +lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted) +r.limiter = lim +if err := r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware { + return lim.Middleware(param) +}); err != nil { return nil, err } +``` +Replace store with `app.Lookup[Store]()` fallback; register `cors` and `turnstile` factories the same way. Optional providers: type-assert like `BucketProvider` (lines 121-126) for `APIDocsProvider`. `Router.compile` must add `OPTIONS` routes for chains containing `cors:

` (Pitfall 5). + +### `modules/surf/cors.go` +**Analog:** itself. Edit points: `pathScopedCORS` pattern compile loop silently `continue`s on bad regex (make `LoadCORSConfig` return error); `Vary` only when `value != "*"` (set on every dynamic answer incl. preflight via `writeOptions`); `corsAllowOrigin` returns `"*"` under `allowAny` (boot error with credentials). Profile config reuses `CORSConfig` with `cfg.LoadSection("http.cors_profiles.", &out)` as in `LoadCORSConfig`. Keep `TestCORSOptionsMatchesLaravel` (`cors_coverage_test.go` 99-147) green. + +### `modules/surf/apidocs.go` +**Analog:** `modules/surf/uploads.go` (`mountUploads` via `GroupRaw`, ~148-170, gated by `storage.uploads.serve`) for opt-in mounting; config gate `http.api_docs.enabled`. YAML via `goccy/go-yaml` `YAMLToJSON` then `json.Compact` at boot; HTML via `html/template`. + +### `modules/bouncer/cookie.go` +**Analogs:** `modules/bouncer/refresh.go` lines 12-50 (function signatures the handler wraps: `Refresh(secret, tokenString, refreshTTL, bl, grace, issuerURL)`, `RefreshAudienceFor(ctx, users, ...)`); production-secure refusal from `modules/cabana/auth.go` 503-517: +```go +if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") { + return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment") +} +``` +Note bouncer imports nothing framework-side: `LoadCookieConfig` should take plain values (or a minimal interface) rather than `*compass.Config`/`*backpack.App`, or confirm compass is acceptable. Shape from RESEARCH Pattern 3. + +### `modules/bouncer/turnstile.go` +**Analog:** outbound-POST hygiene from `site_cache.go` (`http.NewRequestWithContext`, `io.LimitReader`, `purgeTransportError` without URL). Factory registered in surf (`router.go`), not bouncer. + +### `modules/lagoon/attach/bucket.go`, `s3_on.go`, `s3_off.go` +**Analog:** `bucket.go` lines 1-80. Blank-import style (lines 13-14) moves to the tagged file: +```go +_ "gocloud.dev/blob/fileblob" +_ "gocloud.dev/blob/memblob" +``` +Insert the `s3Enabled` guard in `OpenBucket` after `normalizeBucketURL` (default branch passes `s3://` unchanged). `public_url` stored next to `publicPathPrefix` with the same `prefixMu`/getter pattern (lines 17-35). Error messages keep the `attach:` prefix and `SUMMER_...` env hint style. + +### `modules/lagoon/attach/diskext.go` + `store.go` +**Analog:** `store.go` lines 141-151: +```go +contentType := baseMediaType(http.DetectContentType(head)) +if contentType == "application/octet-stream" { + if byExt := baseMediaType(mime.TypeByExtension("." + ext)); byExt != "" { contentType = byExt } +} +... +diskName, err := newDiskName(ext) +``` +Change to `newDiskName(DiskExtension(contentType, ext))`. Regression test in `store_test.go` with widened `Limits.Extensions`. + +### `internal/build/manifest.go` / `build.go` +**Analog:** `manifest.go` 40-103: parse via `asMap/asSlice/asString` from `tree`, validate, append; `SaveManifest` writes with `fmt.Fprintf(&b, ...)` lines. Add `build.tags` in both (round-trip test). `build.go` line 49: +```go +cmd := exec.CommandContext(ctx, "go", "build", "-o", binRel, ".") +``` +insert `"-tags=" + strings.Join(tags, ",")` when non-empty. + +### `modules/pact/capabilities.go` + `modules/cabana/reorder.go` +**Analog:** pact lines 748-765: +```go +// FormAfterDelete optionally runs after delete, inside the same transaction. +type FormAfterDelete interface { + FormAfterDelete(ctx context.Context, model any) error +} +``` +Call site pattern from `crud.go` 1035-1039: +```go +if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil { + if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil { return err } +} +``` +Place in `reorderPost` inside the `Transaction` closure after the node loop (reorder.go ~343), using `txCtx`. Check for in-flight cabana edits (phase 18.1). + +### Plugin adoption: `../sm-journal-plugin` +- `plugin.go` `wireSiteCache` (lines 60-82): read framework keys first, else alias keys `classes.SiteCachePurgeURLKey`/`TokenKey` with `slog.Warn` deprecation; publish the cooler purger instead of `*classes.SiteCachePurger` (or have Install publish it and Journal only fill the alias case). Keep `classes` exported names as thin aliases/wrappers for one release (no breaking change). +- `controllers/site_cache.go`: `Lookup[*cooler.SiteCachePurger]`. + +### `../sm-menu-plugin/controllers/menus.go`, `menuentries.go`, `../sm-faq-plugin/controllers/faqs.go` +**Analog:** `../sm-journal-plugin/controllers/categories.go` lines 64-88: +```go +func (c categoriesAdminController) FormAfterCreate(ctx context.Context, model any) error { + return c.purgeAfterCommit(ctx, model) +} +func (c categoriesAdminController) purgeAfterCommit(ctx context.Context, model any) error { + cat, ok := model.(*models.Category) + ... + purger, tx := siteCachePurger(ctx, c.app) + classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "category", cat.Slug) + return nil +} +``` +Caveat: faqs.go (lines 55-60) and menuentries.go (line 190+) already implement `FormAfterCreate/Update` for validation; append the purge call after the existing check rather than adding a second method. Add `FormAfterDelete` and the new reorder hook (menuentries). Controllers need an `app` accessor like Journal's `c.app` (menuentries has `registry()`/`pool()` helpers at 76-100 to model it on). Add compile-time asserts like faqs.go 73-74. + +### `../vue-*-layer/server/api/__site__/cache/purge.post.ts` +**Analog:** `../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` (whole file) and `app/utils/siteCache.ts` (`authorizePurge`, `parsePurgeEvent`, `purgeRouteCache`). Copy into each layer with `config.siteCache.purgeToken`; menu/faq layers need their own copy of the util (they have only `server/plugins/*-client-address.ts` today). Journal layer keeps the old route as alias. + +### `../vue-*-layer/nuxt.config.ts` +**Analog:** `../vue-journal-layer/nuxt.config.ts` lines 27-51: +```ts +runtimeConfig: { journal: { apiBase: ..., cachePurgeToken: '' } }, +routeRules: { '/api/__journal__/**': { cache: false } }, +``` +Add `siteCache: { purgeToken: '' }` and `'/api/__site__/**': { cache: false }` to all three. + +## Shared Patterns + +### Publish/lookup services on the app +**Source:** `modules/postcard/mailer.go` 100; `../sm-journal-plugin/controllers/site_cache.go` 24 +`app.Publish[Iface](impl)` at boot; consumers `a.Lookup[T]()` with `ok && v != nil` check; nil service means disabled. + +### Named middleware factories +**Source:** `modules/surf/router.go` 118-123, 478-482. Apply to `cors:`, `turnstile:open|closed`. + +### Production-only refusals +**Source:** `modules/cabana/auth.go` 503-517 (`app.Config.Environment() == "production"`). Apply to cookie `secure: false`, Turnstile unconfigured closed mode. + +### Secret-safe errors and logs +**Source:** `site_cache.go` `purgeTransportError` and "error names the key, never the token". Apply to redis.url, Turnstile secret, purge token. + +### Docker-gated tests +**Source:** `modules/beachcomber/postgres_test.go` 29-54, `modules/postcard/mailpit_test.go` 35-60. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `modules/lagoon/attach/s3_on.go`/`s3_off.go` | build-tag split | - | No `//go:build` feature tags in the repo yet; use RESEARCH Pattern 5 | +| `modules/surf/apidocs.go` HTML shell | template | - | No CDN+SRI page exists; use RESEARCH Pattern 9 hashes (swagger-ui-dist 5.33.1) | + +## Metadata + +**Analog search scope:** `modules/{surf,bouncer,lagoon/attach,cabana,pact,postcard,beachcomber}`, `internal/build`, `../sm-journal-plugin`, `../sm-menu-plugin`, `../sm-faq-plugin`, `../vue-journal-layer`, `../vue-menu-layer`, `../vue-faq-layer` +**Files scanned:** ~30 +**Pattern extraction date:** 2026-10-15