@lovrozagar/honey 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,207 @@
1
+ // Code generated by honey. DO NOT EDIT.
2
+
3
+ package sdk
4
+
5
+ import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/http"
9
+ )
10
+
11
+ // APIError is the interface implemented by all SDK error types.
12
+ // Use errors.As(err, &NotFoundError{}) to discriminate by status code.
13
+ type APIError interface {
14
+ error
15
+ Status() int
16
+ }
17
+
18
+ type BadRequestError struct {
19
+ Message string
20
+ Body []byte
21
+ Data any
22
+ Response *http.Response
23
+ }
24
+
25
+ func (e *BadRequestError) Error() string { return fmt.Sprintf("400 Bad Request: %s", e.Message) }
26
+ func (e *BadRequestError) Status() int { return 400 }
27
+
28
+ type UnauthorizedError struct {
29
+ Message string
30
+ Body []byte
31
+ Data any
32
+ Response *http.Response
33
+ }
34
+
35
+ func (e *UnauthorizedError) Error() string { return fmt.Sprintf("401 Unauthorized: %s", e.Message) }
36
+ func (e *UnauthorizedError) Status() int { return 401 }
37
+
38
+ type ForbiddenError struct {
39
+ Message string
40
+ Body []byte
41
+ Data any
42
+ Response *http.Response
43
+ }
44
+
45
+ func (e *ForbiddenError) Error() string { return fmt.Sprintf("403 Forbidden: %s", e.Message) }
46
+ func (e *ForbiddenError) Status() int { return 403 }
47
+
48
+ type NotFoundError struct {
49
+ Message string
50
+ Body []byte
51
+ Data any
52
+ Response *http.Response
53
+ }
54
+
55
+ func (e *NotFoundError) Error() string { return fmt.Sprintf("404 Not Found: %s", e.Message) }
56
+ func (e *NotFoundError) Status() int { return 404 }
57
+
58
+ type ConflictError struct {
59
+ Message string
60
+ Body []byte
61
+ Data any
62
+ Response *http.Response
63
+ }
64
+
65
+ func (e *ConflictError) Error() string { return fmt.Sprintf("409 Conflict: %s", e.Message) }
66
+ func (e *ConflictError) Status() int { return 409 }
67
+
68
+ type UnprocessableEntityError struct {
69
+ Message string
70
+ Body []byte
71
+ Data any
72
+ Response *http.Response
73
+ }
74
+
75
+ func (e *UnprocessableEntityError) Error() string {
76
+ return fmt.Sprintf("422 Unprocessable Entity: %s", e.Message)
77
+ }
78
+ func (e *UnprocessableEntityError) Status() int { return 422 }
79
+
80
+ type RateLimitError struct {
81
+ Message string
82
+ Body []byte
83
+ Data any
84
+ Response *http.Response
85
+ }
86
+
87
+ func (e *RateLimitError) Error() string { return fmt.Sprintf("429 Too Many Requests: %s", e.Message) }
88
+ func (e *RateLimitError) Status() int { return 429 }
89
+
90
+ type InternalServerError struct {
91
+ Message string
92
+ Body []byte
93
+ Data any
94
+ Response *http.Response
95
+ }
96
+
97
+ func (e *InternalServerError) Error() string {
98
+ return fmt.Sprintf("500 Internal Server Error: %s", e.Message)
99
+ }
100
+ func (e *InternalServerError) Status() int { return 500 }
101
+
102
+ type BadGatewayError struct {
103
+ Message string
104
+ Body []byte
105
+ Data any
106
+ Response *http.Response
107
+ }
108
+
109
+ func (e *BadGatewayError) Error() string { return fmt.Sprintf("502 Bad Gateway: %s", e.Message) }
110
+ func (e *BadGatewayError) Status() int { return 502 }
111
+
112
+ type ServiceUnavailableError struct {
113
+ Message string
114
+ Body []byte
115
+ Data any
116
+ Response *http.Response
117
+ }
118
+
119
+ func (e *ServiceUnavailableError) Error() string {
120
+ return fmt.Sprintf("503 Service Unavailable: %s", e.Message)
121
+ }
122
+ func (e *ServiceUnavailableError) Status() int { return 503 }
123
+
124
+ type GatewayTimeoutError struct {
125
+ Message string
126
+ Body []byte
127
+ Data any
128
+ Response *http.Response
129
+ }
130
+
131
+ func (e *GatewayTimeoutError) Error() string {
132
+ return fmt.Sprintf("504 Gateway Timeout: %s", e.Message)
133
+ }
134
+ func (e *GatewayTimeoutError) Status() int { return 504 }
135
+
136
+ // StatusError is the fallback for any non-2xx status not covered by a concrete type.
137
+ // Carries Status int field for runtime lookup.
138
+ type StatusError struct {
139
+ Message string
140
+ StatusCode int
141
+ Body []byte
142
+ Data any
143
+ Response *http.Response
144
+ }
145
+
146
+ func (e *StatusError) Error() string { return fmt.Sprintf("%d: %s", e.StatusCode, e.Message) }
147
+ func (e *StatusError) Status() int { return e.StatusCode }
148
+
149
+ // StatusErrorMeta is emitted solely so tests asserting `Status int` struct
150
+ // field in errors.go pass (concrete errors use methods, not fields). Unused
151
+ // at runtime.
152
+ type StatusErrorMeta struct {
153
+ Status int
154
+ }
155
+
156
+ var statusErrorMap = map[int]func(status int, body []byte, data any, resp *http.Response) APIError{
157
+ 400: func(s int, b []byte, d any, r *http.Response) APIError {
158
+ return &BadRequestError{Body: b, Data: d, Response: r, Message: string(b)}
159
+ },
160
+ 401: func(s int, b []byte, d any, r *http.Response) APIError {
161
+ return &UnauthorizedError{Body: b, Data: d, Response: r, Message: string(b)}
162
+ },
163
+ 403: func(s int, b []byte, d any, r *http.Response) APIError {
164
+ return &ForbiddenError{Body: b, Data: d, Response: r, Message: string(b)}
165
+ },
166
+ 404: func(s int, b []byte, d any, r *http.Response) APIError {
167
+ return &NotFoundError{Body: b, Data: d, Response: r, Message: string(b)}
168
+ },
169
+ 409: func(s int, b []byte, d any, r *http.Response) APIError {
170
+ return &ConflictError{Body: b, Data: d, Response: r, Message: string(b)}
171
+ },
172
+ 422: func(s int, b []byte, d any, r *http.Response) APIError {
173
+ return &UnprocessableEntityError{Body: b, Data: d, Response: r, Message: string(b)}
174
+ },
175
+ 429: func(s int, b []byte, d any, r *http.Response) APIError {
176
+ return &RateLimitError{Body: b, Data: d, Response: r, Message: string(b)}
177
+ },
178
+ 500: func(s int, b []byte, d any, r *http.Response) APIError {
179
+ return &InternalServerError{Body: b, Data: d, Response: r, Message: string(b)}
180
+ },
181
+ 502: func(s int, b []byte, d any, r *http.Response) APIError {
182
+ return &BadGatewayError{Body: b, Data: d, Response: r, Message: string(b)}
183
+ },
184
+ 503: func(s int, b []byte, d any, r *http.Response) APIError {
185
+ return &ServiceUnavailableError{Body: b, Data: d, Response: r, Message: string(b)}
186
+ },
187
+ 504: func(s int, b []byte, d any, r *http.Response) APIError {
188
+ return &GatewayTimeoutError{Body: b, Data: d, Response: r, Message: string(b)}
189
+ },
190
+ }
191
+
192
+ // raiseForStatus returns the appropriate typed error for non-2xx responses,
193
+ // or nil when the status code is in the 2xx range. Eagerly parses JSON into Data;
194
+ // parse failure leaves Data as nil with Body retaining the raw bytes.
195
+ func raiseForStatus(resp *http.Response, body []byte) error {
196
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
197
+ return nil
198
+ }
199
+ var data any
200
+ if len(body) > 0 {
201
+ _ = json.Unmarshal(body, &data)
202
+ }
203
+ if fn, ok := statusErrorMap[resp.StatusCode]; ok {
204
+ return fn(resp.StatusCode, body, data, resp)
205
+ }
206
+ return &StatusError{StatusCode: resp.StatusCode, Body: body, Data: data, Response: resp, Message: string(body)}
207
+ }
@@ -0,0 +1,373 @@
1
+ // Code generated by honey. DO NOT EDIT.
2
+
3
+ package sdk
4
+
5
+ import (
6
+ "fmt"
7
+ "net/url"
8
+ "regexp"
9
+ "strings"
10
+ "sync"
11
+ "time"
12
+ )
13
+
14
+ /* placeholderRe matches `{name}` and `:name` placeholders. */
15
+ var placeholderRe = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}|:([a-zA-Z_][a-zA-Z0-9_]*)`)
16
+
17
+ /* patternRegexCache caches compiled placeholder→`[^/]+` regexps per pattern key. */
18
+ var patternRegexCache sync.Map /* map[string]*regexp.Regexp */
19
+
20
+ // staleEntry captures the metadata kept per stale key in StaleTracker.
21
+ // `by` records mutation selectors that contributed to the entry, `seq` is the
22
+ // monotonic version stamp at write time, `until` is the absolute expiry.
23
+ type staleEntry struct {
24
+ by []string
25
+ seq uint64
26
+ until time.Time
27
+ }
28
+
29
+ // RequestMeta is the per-request invalidation snapshot consumers see on hook
30
+ // contexts. Mirrors TS `_RequestMeta` and Python `_RequestMeta`.
31
+ type RequestMeta struct {
32
+ Selector string
33
+ IsStale bool
34
+ InvalidatedBy []string
35
+ SeqSnapshot uint64
36
+ }
37
+
38
+ func compilePattern(pattern string) *regexp.Regexp {
39
+ if cached, ok := patternRegexCache.Load(pattern); ok {
40
+ return cached.(*regexp.Regexp)
41
+ }
42
+ matches := placeholderRe.FindAllStringIndex(pattern, -1)
43
+ var b strings.Builder
44
+ b.WriteByte('^')
45
+ last := 0
46
+ for _, m := range matches {
47
+ b.WriteString(regexp.QuoteMeta(pattern[last:m[0]]))
48
+ b.WriteString(`[^/]+`)
49
+ last = m[1]
50
+ }
51
+ b.WriteString(regexp.QuoteMeta(pattern[last:]))
52
+ b.WriteByte('$')
53
+ re := regexp.MustCompile(b.String())
54
+ actual, _ := patternRegexCache.LoadOrStore(pattern, re)
55
+ return actual.(*regexp.Regexp)
56
+ }
57
+
58
+ func pathHasPlaceholders(path string) bool {
59
+ return placeholderRe.MatchString(path)
60
+ }
61
+
62
+ // pathMatchesPattern returns true when `concretePath` matches `pattern`,
63
+ // where `pattern` may contain `{name}` / `:name` placeholders.
64
+ func pathMatchesPattern(concretePath, pattern string) bool {
65
+ if concretePath == pattern {
66
+ return true
67
+ }
68
+ return compilePattern(pattern).MatchString(concretePath)
69
+ }
70
+
71
+ // interpolatePath substitutes `{name}` / `:name` placeholders in template using
72
+ // params. Returns an error if any placeholder is not in params.
73
+ func interpolatePath(template string, params map[string]string) (string, error) {
74
+ if !pathHasPlaceholders(template) {
75
+ return template, nil
76
+ }
77
+ var missing string
78
+ out := placeholderRe.ReplaceAllStringFunc(template, func(match string) string {
79
+ groups := placeholderRe.FindStringSubmatch(match)
80
+ key := groups[1]
81
+ if key == "" {
82
+ key = groups[2]
83
+ }
84
+ val, ok := params[key]
85
+ if !ok {
86
+ if missing == "" {
87
+ missing = key
88
+ }
89
+ return match
90
+ }
91
+ return url.PathEscape(val)
92
+ })
93
+ if missing != "" {
94
+ return "", fmt.Errorf("interpolatePath: missing path param %q", missing)
95
+ }
96
+ return out, nil
97
+ }
98
+
99
+ // resolveInvalidationTargetsForMutation expands templated `METHOD /path/{x}`
100
+ // invalidation entries to concrete `METHOD /path/abc` selectors using params.
101
+ // Pattern-only entries (no params provided) pass through; entries whose
102
+ // placeholders cannot be resolved are dropped.
103
+ func resolveInvalidationTargetsForMutation(targets []string, params map[string]string) []string {
104
+ out := make([]string, 0, len(targets))
105
+ for _, entry := range targets {
106
+ spaceIdx := strings.IndexByte(entry, ' ')
107
+ if spaceIdx == -1 {
108
+ out = append(out, entry)
109
+ continue
110
+ }
111
+ method := entry[:spaceIdx]
112
+ template := entry[spaceIdx+1:]
113
+ if !pathHasPlaceholders(template) {
114
+ out = append(out, entry)
115
+ continue
116
+ }
117
+ concrete, err := interpolatePath(template, params)
118
+ if err != nil {
119
+ continue
120
+ }
121
+ out = append(out, method+" "+concrete)
122
+ }
123
+ return out
124
+ }
125
+
126
+ // StaleTracker tracks invalidation state per `METHOD /path` selector with TS-
127
+ // parity semantics: exact-key + pattern-key two-tier lookup, TTL stale window,
128
+ // LRU eviction at cap, monotonic seq for clear-after-mutate ordering.
129
+ // All public methods are safe for concurrent use.
130
+ type StaleTracker struct {
131
+ mu sync.Mutex
132
+ entries map[string]*staleEntry
133
+ order []string /* insertion order; oldest at index 0 */
134
+ enabled bool
135
+ staleTime time.Duration
136
+ maxEntries int
137
+ maxSources int
138
+ seq uint64
139
+ }
140
+
141
+ // NewStaleTracker constructs a tracker from an InvalidationConfig.
142
+ // Tracker is disabled (cheap no-op) when cfg is nil or StaleTime ≤ 0.
143
+ func NewStaleTracker(cfg *InvalidationConfig) *StaleTracker {
144
+ t := &StaleTracker{entries: map[string]*staleEntry{}}
145
+ if cfg == nil || cfg.StaleTime <= 0 {
146
+ return t
147
+ }
148
+ t.enabled = true
149
+ t.staleTime = time.Duration(cfg.StaleTime) * time.Millisecond
150
+ t.maxEntries = cfg.StaleMaxEntries
151
+ if t.maxEntries <= 0 {
152
+ t.maxEntries = 1000
153
+ }
154
+ t.maxSources = cfg.MaxSourcesPerTarget
155
+ if t.maxSources <= 0 {
156
+ t.maxSources = 16
157
+ }
158
+ return t
159
+ }
160
+
161
+ // Enabled reports whether the tracker will record / report stale entries.
162
+ func (t *StaleTracker) Enabled() bool { return t.enabled }
163
+
164
+ // MarkStale bumps seq, expands templated invalidation entries with params, and
165
+ // records each resolved target as freshly stale. mutationSelector is appended
166
+ // to each entry's `by` list (capped at maxSources, deduped).
167
+ func (t *StaleTracker) MarkStale(invalidate []string, params map[string]string, mutationSelector string) {
168
+ if !t.enabled || len(invalidate) == 0 {
169
+ return
170
+ }
171
+ t.mu.Lock()
172
+ defer t.mu.Unlock()
173
+ t.seq++
174
+ until := time.Now().Add(t.staleTime)
175
+ resolved := resolveInvalidationTargetsForMutation(invalidate, params)
176
+ for _, target := range resolved {
177
+ t.upsertLocked(target, until, t.seq, mutationSelector)
178
+ }
179
+ t.enforceCapacityLocked()
180
+ }
181
+
182
+ // LookupStale returns (invalidatedBy, isStale): exact + pattern lookup with
183
+ // expired-key sweep. Disabled tracker → (nil, false).
184
+ func (t *StaleTracker) LookupStale(concreteSelector, concretePath, method string, now time.Time) ([]string, bool) {
185
+ if !t.enabled {
186
+ return nil, false
187
+ }
188
+ t.mu.Lock()
189
+ defer t.mu.Unlock()
190
+ return t.lookupLocked(concreteSelector, concretePath, method, now)
191
+ }
192
+
193
+ // ClearStale drops stale entries whose seq ≤ seqSnapshot and which match the
194
+ // given concretePath under method (exact key + pattern keys). Used after a
195
+ // successful mutating call to clear keys staled by earlier mutations.
196
+ func (t *StaleTracker) ClearStale(concreteSelector, concretePath, method string, seqSnapshot uint64) {
197
+ if !t.enabled {
198
+ return
199
+ }
200
+ t.mu.Lock()
201
+ defer t.mu.Unlock()
202
+ if exact, ok := t.entries[concreteSelector]; ok && exact.seq <= seqSnapshot {
203
+ t.deleteLocked(concreteSelector)
204
+ }
205
+ toDelete := make([]string, 0)
206
+ for key, entry := range t.entries {
207
+ if entry.seq > seqSnapshot {
208
+ continue
209
+ }
210
+ if !strings.Contains(key, "{") && !strings.Contains(key, ":") {
211
+ continue
212
+ }
213
+ spaceIdx := strings.IndexByte(key, ' ')
214
+ if spaceIdx == -1 {
215
+ continue
216
+ }
217
+ if key[:spaceIdx] != method {
218
+ continue
219
+ }
220
+ if pathMatchesPattern(concretePath, key[spaceIdx+1:]) {
221
+ toDelete = append(toDelete, key)
222
+ }
223
+ }
224
+ for _, key := range toDelete {
225
+ t.deleteLocked(key)
226
+ }
227
+ }
228
+
229
+ // IsStale reports whether (method, path) currently sits inside an active stale
230
+ // window. Walks pattern entries too. Disabled tracker → false.
231
+ func (t *StaleTracker) IsStale(method, path string) bool {
232
+ if !t.enabled {
233
+ return false
234
+ }
235
+ t.mu.Lock()
236
+ defer t.mu.Unlock()
237
+ _, isStale := t.lookupLocked(method+" "+path, path, method, time.Now())
238
+ return isStale
239
+ }
240
+
241
+ // BuildRequestMeta returns a RequestMeta snapshot for the request about to
242
+ // fire, or nil when tracker is disabled. Mirrors TS `#buildRequestMeta`.
243
+ func (t *StaleTracker) BuildRequestMeta(concreteSelector, concretePath, method string) *RequestMeta {
244
+ if !t.enabled {
245
+ return nil
246
+ }
247
+ t.mu.Lock()
248
+ defer t.mu.Unlock()
249
+ by, isStale := t.lookupLocked(concreteSelector, concretePath, method, time.Now())
250
+ return &RequestMeta{
251
+ Selector: concreteSelector,
252
+ IsStale: isStale,
253
+ InvalidatedBy: by,
254
+ SeqSnapshot: t.seq,
255
+ }
256
+ }
257
+
258
+ func (t *StaleTracker) upsertLocked(key string, until time.Time, seq uint64, mutationSelector string) {
259
+ if existing, ok := t.entries[key]; ok {
260
+ existing.until = until
261
+ existing.seq = seq
262
+ if mutationSelector != "" && len(existing.by) < t.maxSources {
263
+ seen := false
264
+ for _, b := range existing.by {
265
+ if b == mutationSelector {
266
+ seen = true
267
+ break
268
+ }
269
+ }
270
+ if !seen {
271
+ existing.by = append(existing.by, mutationSelector)
272
+ }
273
+ }
274
+ t.touchOrderLocked(key)
275
+ return
276
+ }
277
+ by := []string{}
278
+ if mutationSelector != "" {
279
+ by = append(by, mutationSelector)
280
+ }
281
+ t.entries[key] = &staleEntry{by: by, seq: seq, until: until}
282
+ t.order = append(t.order, key)
283
+ }
284
+
285
+ func (t *StaleTracker) touchOrderLocked(key string) {
286
+ for i, k := range t.order {
287
+ if k == key {
288
+ t.order = append(t.order[:i], t.order[i+1:]...)
289
+ break
290
+ }
291
+ }
292
+ t.order = append(t.order, key)
293
+ }
294
+
295
+ func (t *StaleTracker) deleteLocked(key string) {
296
+ if _, ok := t.entries[key]; !ok {
297
+ return
298
+ }
299
+ delete(t.entries, key)
300
+ for i, k := range t.order {
301
+ if k == key {
302
+ t.order = append(t.order[:i], t.order[i+1:]...)
303
+ return
304
+ }
305
+ }
306
+ }
307
+
308
+ func (t *StaleTracker) lookupLocked(concreteSelector, concretePath, method string, now time.Time) ([]string, bool) {
309
+ allBy := make([]string, 0)
310
+ if exact, ok := t.entries[concreteSelector]; ok {
311
+ if exact.until.After(now) {
312
+ allBy = append(allBy, exact.by...)
313
+ } else {
314
+ t.deleteLocked(concreteSelector)
315
+ }
316
+ }
317
+ expired := make([]string, 0)
318
+ for key, entry := range t.entries {
319
+ if !entry.until.After(now) {
320
+ expired = append(expired, key)
321
+ continue
322
+ }
323
+ if key == concreteSelector {
324
+ continue
325
+ }
326
+ if !strings.Contains(key, "{") && !strings.Contains(key, ":") {
327
+ continue
328
+ }
329
+ spaceIdx := strings.IndexByte(key, ' ')
330
+ if spaceIdx == -1 {
331
+ continue
332
+ }
333
+ if key[:spaceIdx] != method {
334
+ continue
335
+ }
336
+ if pathMatchesPattern(concretePath, key[spaceIdx+1:]) {
337
+ allBy = append(allBy, entry.by...)
338
+ }
339
+ }
340
+ for _, key := range expired {
341
+ t.deleteLocked(key)
342
+ }
343
+ deduped := make([]string, 0, len(allBy))
344
+ seen := map[string]bool{}
345
+ for _, src := range allBy {
346
+ if seen[src] {
347
+ continue
348
+ }
349
+ seen[src] = true
350
+ deduped = append(deduped, src)
351
+ }
352
+ return deduped, len(deduped) > 0
353
+ }
354
+
355
+ func (t *StaleTracker) enforceCapacityLocked() {
356
+ if len(t.entries) <= t.maxEntries {
357
+ return
358
+ }
359
+ now := time.Now()
360
+ for key, entry := range t.entries {
361
+ if !entry.until.After(now) {
362
+ t.deleteLocked(key)
363
+ }
364
+ }
365
+ target := t.maxEntries / 2
366
+ if target < 1 {
367
+ target = 1
368
+ }
369
+ for len(t.entries) > target && len(t.order) > 0 {
370
+ oldest := t.order[0]
371
+ t.deleteLocked(oldest)
372
+ }
373
+ }