docs(19.3): add pattern map

This commit is contained in:
Jakub Zych
2026-10-15 14:12:24 +02:00
parent 25b97338e1
commit 55dbb18295

View File

@@ -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/<date>-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) | <summary sentence> | <mnemonic> |
```
### `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 `<prefix>rl:`.
### `modules/cooler/memory.go`
**Analog:** `MemoryStore` (same file above) for lifecycle (constructor + `Close`/stop). Use otter v2 per RESEARCH "Code Examples"; keys `<prefix>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:<p>` (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.<name>", &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:<profile>`, `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