@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,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+
8
+ class APIError(Exception):
9
+ """Base class for all API errors."""
10
+
11
+ status: int
12
+ body: Any
13
+ data: Any
14
+ response: httpx.Response
15
+ message: str
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ status: int,
21
+ body: Any,
22
+ data: Any,
23
+ response: httpx.Response,
24
+ message: str,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.body = body
29
+ self.data = data
30
+ self.response = response
31
+ self.message = message
32
+
33
+ def __repr__(self) -> str:
34
+ cls = self.__class__.__name__
35
+ return f"{cls}(status={self.status}, message={self.message!r})"
36
+
37
+
38
+ class BadRequestError(APIError):
39
+ """HTTP 400 Bad Request."""
40
+
41
+
42
+ class UnauthorizedError(APIError):
43
+ """HTTP 401 Unauthorized."""
44
+
45
+
46
+ class ForbiddenError(APIError):
47
+ """HTTP 403 Forbidden."""
48
+
49
+
50
+ class NotFoundError(APIError):
51
+ """HTTP 404 Not Found."""
52
+
53
+
54
+ class ConflictError(APIError):
55
+ """HTTP 409 Conflict."""
56
+
57
+
58
+ class UnprocessableEntityError(APIError):
59
+ """HTTP 422 Unprocessable Entity."""
60
+
61
+
62
+ class RateLimitError(APIError):
63
+ """HTTP 429 Too Many Requests."""
64
+
65
+
66
+ class InternalServerError(APIError):
67
+ """HTTP 500 Internal Server Error."""
68
+
69
+
70
+ class BadGatewayError(APIError):
71
+ """HTTP 502 Bad Gateway."""
72
+
73
+
74
+ class ServiceUnavailableError(APIError):
75
+ """HTTP 503 Service Unavailable."""
76
+
77
+
78
+ class GatewayTimeoutError(APIError):
79
+ """HTTP 504 Gateway Timeout."""
80
+
81
+
82
+ class APIStatusError(APIError):
83
+ """Fallback for any 4xx/5xx status not covered by a named subclass."""
84
+
85
+
86
+ _STATUS_ERROR_MAP: dict[int, type[APIError]] = {
87
+ 400: BadRequestError,
88
+ 401: UnauthorizedError,
89
+ 403: ForbiddenError,
90
+ 404: NotFoundError,
91
+ 409: ConflictError,
92
+ 422: UnprocessableEntityError,
93
+ 429: RateLimitError,
94
+ 500: InternalServerError,
95
+ 502: BadGatewayError,
96
+ 503: ServiceUnavailableError,
97
+ 504: GatewayTimeoutError,
98
+ }
99
+
100
+ __all__ = [
101
+ "APIError",
102
+ "APIStatusError",
103
+ "BadGatewayError",
104
+ "BadRequestError",
105
+ "ConflictError",
106
+ "ForbiddenError",
107
+ "GatewayTimeoutError",
108
+ "InternalServerError",
109
+ "NotFoundError",
110
+ "RateLimitError",
111
+ "ServiceUnavailableError",
112
+ "UnauthorizedError",
113
+ "UnprocessableEntityError",
114
+ "_STATUS_ERROR_MAP",
115
+ ]
@@ -0,0 +1,578 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import threading
6
+ import time
7
+ from collections import OrderedDict
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+
13
+ def _now() -> float:
14
+ return time.monotonic()
15
+
16
+
17
+ _PATH_PARAM_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}|:([a-zA-Z_][a-zA-Z0-9_]*)")
18
+
19
+
20
+ @dataclass
21
+ class _StaleEntry:
22
+ by: list[str] = field(default_factory=list)
23
+ seq: int = 0
24
+ until: float = 0.0
25
+
26
+
27
+ @dataclass
28
+ class _RequestMeta:
29
+ selector: str
30
+ is_stale: bool
31
+ invalidated_by: list[str]
32
+ seq_snapshot: int
33
+
34
+
35
+ def path_matches_pattern(
36
+ path: str,
37
+ pattern: str,
38
+ _cache: dict[str, re.Pattern[str]] = {},
39
+ ) -> bool:
40
+ """Match *path* against *pattern* containing ``{name}`` / ``:name`` placeholders."""
41
+ if pattern not in _cache:
42
+ escaped_parts: list[str] = []
43
+ last_end = 0
44
+ for match in _PATH_PARAM_RE.finditer(pattern):
45
+ escaped_parts.append(re.escape(pattern[last_end : match.start()]))
46
+ escaped_parts.append("[^/]+")
47
+ last_end = match.end()
48
+ escaped_parts.append(re.escape(pattern[last_end:]))
49
+ _cache[pattern] = re.compile(f"^{''.join(escaped_parts)}$")
50
+ return bool(_cache[pattern].match(path))
51
+
52
+
53
+ def interpolate_path(template: str, params: dict[str, str] | None) -> str:
54
+ """Substitute ``{name}`` / ``:name`` placeholders in *template* using *params*.
55
+
56
+ Raises ValueError if any placeholder has no matching key in *params*.
57
+ """
58
+ if not params:
59
+ if _PATH_PARAM_RE.search(template) is not None:
60
+ raise ValueError(f"Missing path params for template {template!r}")
61
+ return template
62
+
63
+ def _sub(match: re.Match[str]) -> str:
64
+ key = match.group(1) or match.group(2)
65
+ if key not in params or params[key] is None:
66
+ raise ValueError(f"Missing path param: {key}")
67
+ return quote(str(params[key]), safe="")
68
+
69
+ return _PATH_PARAM_RE.sub(_sub, template)
70
+
71
+
72
+ def _has_unresolved(value: str) -> bool:
73
+ return _PATH_PARAM_RE.search(value) is not None
74
+
75
+
76
+ def resolve_invalidation_targets_for_mutation(
77
+ targets: list[str],
78
+ params: dict[str, str] | None,
79
+ ) -> list[str]:
80
+ """Expand ``METHOD /path/{id}`` template invalidation targets to concrete selectors.
81
+
82
+ Targets without placeholders are passed through. Targets whose placeholders cannot
83
+ be resolved against *params* are dropped (no point storing a half-templated key).
84
+ """
85
+ resolved: list[str] = []
86
+ for entry in targets:
87
+ space_idx = entry.find(" ")
88
+ if space_idx == -1:
89
+ resolved.append(entry)
90
+ continue
91
+ method = entry[:space_idx]
92
+ template = entry[space_idx + 1 :]
93
+ if not _has_unresolved(template):
94
+ resolved.append(entry)
95
+ continue
96
+ try:
97
+ concrete = interpolate_path(template, params)
98
+ except ValueError:
99
+ continue
100
+ resolved.append(f"{method} {concrete}")
101
+ return resolved
102
+
103
+
104
+ def resolve_invalidation_targets(
105
+ invalidate: list[str],
106
+ stale_map: "OrderedDict[str, _StaleEntry] | OrderedDict[str, Any]",
107
+ ) -> list[str]:
108
+ """Return *stale_map* keys matching any 'METHOD /pattern' invalidation entry.
109
+
110
+ Kept for back-compat with existing call sites that pass an already-built map.
111
+ """
112
+ matched: list[str] = []
113
+ for entry in invalidate:
114
+ for key in list(stale_map.keys()):
115
+ parts = key.split(" ", 1)
116
+ if len(parts) != 2:
117
+ continue
118
+ key_method, key_path = parts
119
+ entry_parts = entry.split(" ", 1)
120
+ if len(entry_parts) != 2:
121
+ continue
122
+ entry_method, entry_pattern = entry_parts
123
+ if key_method != entry_method:
124
+ continue
125
+ if path_matches_pattern(key_path, entry_pattern):
126
+ matched.append(key)
127
+ return matched
128
+
129
+
130
+ def lookup_stale(key: str, stale_map: "OrderedDict[str, Any]") -> bool:
131
+ """Return True if *key* exists in *stale_map* and has not yet expired."""
132
+ entry = stale_map.get(key)
133
+ if entry is None:
134
+ return False
135
+ until = entry.until if isinstance(entry, _StaleEntry) else entry["until"]
136
+ return _now() < until
137
+
138
+
139
+ class _StaleTracker:
140
+ """Async-aware stale tracker backed by ``asyncio.Lock``."""
141
+
142
+ def __init__(
143
+ self,
144
+ stale_time: float,
145
+ stale_max_entries: int = 1000,
146
+ max_sources_per_target: int = 16,
147
+ ) -> None:
148
+ """ stale_time == 0 disables the tracker (no stale window applied). """
149
+ self._enabled = stale_time > 0
150
+ self._stale_time = max(stale_time, 0.0)
151
+ self._max_entries = max(stale_max_entries, 1)
152
+ self._max_sources = max(max_sources_per_target, 1)
153
+ self._map: OrderedDict[str, _StaleEntry] = OrderedDict()
154
+ self._lock = asyncio.Lock()
155
+ self._seq = 0
156
+
157
+ @property
158
+ def enabled(self) -> bool:
159
+ return self._enabled
160
+
161
+ async def invalidate(self, patterns: list[str]) -> None:
162
+ """Mark every existing key matching *patterns* as freshly stale."""
163
+ if not self._enabled:
164
+ return
165
+ async with self._lock:
166
+ self._evict_expired()
167
+ targets = resolve_invalidation_targets(patterns, self._map)
168
+ self._seq += 1
169
+ until = _now() + self._stale_time
170
+ for t in targets:
171
+ self._upsert(t, until, self._seq, mutation_selector=None)
172
+ self._enforce_capacity()
173
+
174
+ async def mark_stale(
175
+ self,
176
+ invalidate: list[str],
177
+ params: dict[str, str] | None,
178
+ mutation_selector: str,
179
+ ) -> None:
180
+ """Bump seq, expand templated invalidation entries, store stale keys."""
181
+ if not self._enabled or not invalidate:
182
+ return
183
+ async with self._lock:
184
+ self._seq += 1
185
+ until = _now() + self._stale_time
186
+ resolved = resolve_invalidation_targets_for_mutation(invalidate, params)
187
+ for target in resolved:
188
+ self._upsert(
189
+ target, until, self._seq, mutation_selector=mutation_selector,
190
+ )
191
+ self._enforce_capacity()
192
+
193
+ async def mark(self, key: str) -> None:
194
+ """Force *key* into the stale map. Used by SSE/WS open hooks and tests."""
195
+ if not self._enabled:
196
+ return
197
+ async with self._lock:
198
+ self._seq += 1
199
+ until = _now() + self._stale_time
200
+ self._upsert(key, until, self._seq, mutation_selector=None)
201
+ self._enforce_capacity()
202
+
203
+ async def is_stale(self, method_or_key: str, path: str | None = None) -> bool:
204
+ """Return True if (method, path) is currently stale.
205
+
206
+ Accepts either ``is_stale("GET /users")`` or ``is_stale("GET", "/users")``.
207
+ Walks pattern entries too.
208
+ """
209
+ if not self._enabled:
210
+ return False
211
+ if path is None:
212
+ method, concrete_path = self._split_key(method_or_key)
213
+ else:
214
+ method, concrete_path = method_or_key, path
215
+ async with self._lock:
216
+ now = _now()
217
+ _by, is_stale = self._lookup_locked(
218
+ concrete_selector=f"{method} {concrete_path}",
219
+ concrete_path=concrete_path,
220
+ method=method,
221
+ now=now,
222
+ )
223
+ return is_stale
224
+
225
+ async def lookup_stale(
226
+ self,
227
+ concrete_selector: str,
228
+ concrete_path: str,
229
+ method: str,
230
+ ) -> tuple[list[str], bool]:
231
+ """Return (invalidated_by_list, is_stale): exact + pattern lookup with sweep."""
232
+ if not self._enabled:
233
+ return [], False
234
+ async with self._lock:
235
+ return self._lookup_locked(concrete_selector, concrete_path, method, _now())
236
+
237
+ async def clear_stale(
238
+ self,
239
+ concrete_selector: str,
240
+ concrete_path: str,
241
+ method: str,
242
+ seq_snapshot: int,
243
+ ) -> None:
244
+ """Drop seq-older entries matching *concrete_path* (exact + pattern keys)."""
245
+ if not self._enabled:
246
+ return
247
+ async with self._lock:
248
+ exact = self._map.get(concrete_selector)
249
+ if exact and exact.seq <= seq_snapshot:
250
+ del self._map[concrete_selector]
251
+ to_delete: list[str] = []
252
+ for key, entry in self._map.items():
253
+ if entry.seq > seq_snapshot:
254
+ continue
255
+ key_method, key_pattern = self._split_key(key)
256
+ if "{" not in key_pattern and ":" not in key_pattern:
257
+ continue
258
+ if key_method != method:
259
+ continue
260
+ if path_matches_pattern(concrete_path, key_pattern):
261
+ to_delete.append(key)
262
+ for key in to_delete:
263
+ del self._map[key]
264
+
265
+ async def build_request_meta(
266
+ self,
267
+ concrete_selector: str,
268
+ concrete_path: str,
269
+ method: str,
270
+ ) -> _RequestMeta | None:
271
+ if not self._enabled:
272
+ return None
273
+ async with self._lock:
274
+ by, is_stale = self._lookup_locked(
275
+ concrete_selector, concrete_path, method, _now(),
276
+ )
277
+ return _RequestMeta(
278
+ selector=concrete_selector,
279
+ is_stale=is_stale,
280
+ invalidated_by=by,
281
+ seq_snapshot=self._seq,
282
+ )
283
+
284
+ def _upsert(
285
+ self,
286
+ key: str,
287
+ until: float,
288
+ seq: int,
289
+ mutation_selector: str | None,
290
+ ) -> None:
291
+ existing = self._map.get(key)
292
+ if existing is not None:
293
+ existing.until = until
294
+ existing.seq = seq
295
+ if mutation_selector and mutation_selector not in existing.by:
296
+ if len(existing.by) < self._max_sources:
297
+ existing.by.append(mutation_selector)
298
+ self._map.move_to_end(key)
299
+ return
300
+ by = [mutation_selector] if mutation_selector else []
301
+ self._map[key] = _StaleEntry(by=by, seq=seq, until=until)
302
+ self._map.move_to_end(key)
303
+
304
+ def _lookup_locked(
305
+ self,
306
+ concrete_selector: str,
307
+ concrete_path: str,
308
+ method: str,
309
+ now: float,
310
+ ) -> tuple[list[str], bool]:
311
+ all_by: list[str] = []
312
+ exact = self._map.get(concrete_selector)
313
+ if exact is not None:
314
+ if exact.until > now:
315
+ all_by.extend(exact.by)
316
+ else:
317
+ del self._map[concrete_selector]
318
+ expired: list[str] = []
319
+ for key, entry in self._map.items():
320
+ if entry.until <= now:
321
+ expired.append(key)
322
+ continue
323
+ if "{" not in key and ":" not in key:
324
+ continue
325
+ if key == concrete_selector:
326
+ continue
327
+ key_method, key_pattern = self._split_key(key)
328
+ if key_method != method:
329
+ continue
330
+ if path_matches_pattern(concrete_path, key_pattern):
331
+ all_by.extend(entry.by)
332
+ for key in expired:
333
+ del self._map[key]
334
+ deduped: list[str] = []
335
+ seen: set[str] = set()
336
+ for src in all_by:
337
+ if src in seen:
338
+ continue
339
+ seen.add(src)
340
+ deduped.append(src)
341
+ return deduped, len(deduped) > 0
342
+
343
+ def _evict_expired(self) -> None:
344
+ now = _now()
345
+ expired = [k for k, v in self._map.items() if now >= v.until]
346
+ for k in expired:
347
+ del self._map[k]
348
+
349
+ def _enforce_capacity(self) -> None:
350
+ if len(self._map) <= self._max_entries:
351
+ return
352
+ self._evict_expired()
353
+ target = max(self._max_entries // 2, 1)
354
+ while len(self._map) > target:
355
+ self._map.popitem(last=False)
356
+
357
+ @staticmethod
358
+ def _split_key(key: str) -> tuple[str, str]:
359
+ space_idx = key.find(" ")
360
+ if space_idx == -1:
361
+ return key, ""
362
+ return key[:space_idx], key[space_idx + 1 :]
363
+
364
+
365
+ class _StaleTrackerSync:
366
+ """Sync variant backed by ``threading.Lock``; mirrors :class:`_StaleTracker`."""
367
+
368
+ def __init__(
369
+ self,
370
+ stale_time: float,
371
+ stale_max_entries: int = 1000,
372
+ max_sources_per_target: int = 16,
373
+ ) -> None:
374
+ """ stale_time == 0 disables the tracker (no stale window applied). """
375
+ self._enabled = stale_time > 0
376
+ self._stale_time = max(stale_time, 0.0)
377
+ self._max_entries = max(stale_max_entries, 1)
378
+ self._max_sources = max(max_sources_per_target, 1)
379
+ self._map: OrderedDict[str, _StaleEntry] = OrderedDict()
380
+ self._lock = threading.Lock()
381
+ self._seq = 0
382
+
383
+ @property
384
+ def enabled(self) -> bool:
385
+ return self._enabled
386
+
387
+ def invalidate(self, patterns: list[str]) -> None:
388
+ if not self._enabled:
389
+ return
390
+ with self._lock:
391
+ self._evict_expired()
392
+ targets = resolve_invalidation_targets(patterns, self._map)
393
+ self._seq += 1
394
+ until = _now() + self._stale_time
395
+ for t in targets:
396
+ self._upsert(t, until, self._seq, None)
397
+ self._enforce_capacity()
398
+
399
+ def mark_stale(
400
+ self,
401
+ invalidate: list[str],
402
+ params: dict[str, str] | None,
403
+ mutation_selector: str,
404
+ ) -> None:
405
+ if not self._enabled or not invalidate:
406
+ return
407
+ with self._lock:
408
+ self._seq += 1
409
+ until = _now() + self._stale_time
410
+ resolved = resolve_invalidation_targets_for_mutation(invalidate, params)
411
+ for target in resolved:
412
+ self._upsert(target, until, self._seq, mutation_selector)
413
+ self._enforce_capacity()
414
+
415
+ def mark(self, key: str) -> None:
416
+ if not self._enabled:
417
+ return
418
+ with self._lock:
419
+ self._seq += 1
420
+ until = _now() + self._stale_time
421
+ self._upsert(key, until, self._seq, None)
422
+ self._enforce_capacity()
423
+
424
+ def is_stale(self, method_or_key: str, path: str | None = None) -> bool:
425
+ if not self._enabled:
426
+ return False
427
+ if path is None:
428
+ method, concrete_path = _StaleTracker._split_key(method_or_key)
429
+ else:
430
+ method, concrete_path = method_or_key, path
431
+ with self._lock:
432
+ _by, is_stale = self._lookup_locked(
433
+ f"{method} {concrete_path}", concrete_path, method, _now(),
434
+ )
435
+ return is_stale
436
+
437
+ def lookup_stale(
438
+ self,
439
+ concrete_selector: str,
440
+ concrete_path: str,
441
+ method: str,
442
+ ) -> tuple[list[str], bool]:
443
+ if not self._enabled:
444
+ return [], False
445
+ with self._lock:
446
+ return self._lookup_locked(concrete_selector, concrete_path, method, _now())
447
+
448
+ def clear_stale(
449
+ self,
450
+ concrete_selector: str,
451
+ concrete_path: str,
452
+ method: str,
453
+ seq_snapshot: int,
454
+ ) -> None:
455
+ if not self._enabled:
456
+ return
457
+ with self._lock:
458
+ exact = self._map.get(concrete_selector)
459
+ if exact and exact.seq <= seq_snapshot:
460
+ del self._map[concrete_selector]
461
+ to_delete: list[str] = []
462
+ for key, entry in self._map.items():
463
+ if entry.seq > seq_snapshot:
464
+ continue
465
+ key_method, key_pattern = _StaleTracker._split_key(key)
466
+ if "{" not in key_pattern and ":" not in key_pattern:
467
+ continue
468
+ if key_method != method:
469
+ continue
470
+ if path_matches_pattern(concrete_path, key_pattern):
471
+ to_delete.append(key)
472
+ for key in to_delete:
473
+ del self._map[key]
474
+
475
+ def build_request_meta(
476
+ self,
477
+ concrete_selector: str,
478
+ concrete_path: str,
479
+ method: str,
480
+ ) -> _RequestMeta | None:
481
+ if not self._enabled:
482
+ return None
483
+ with self._lock:
484
+ by, is_stale = self._lookup_locked(
485
+ concrete_selector, concrete_path, method, _now(),
486
+ )
487
+ return _RequestMeta(
488
+ selector=concrete_selector,
489
+ is_stale=is_stale,
490
+ invalidated_by=by,
491
+ seq_snapshot=self._seq,
492
+ )
493
+
494
+ def _upsert(
495
+ self,
496
+ key: str,
497
+ until: float,
498
+ seq: int,
499
+ mutation_selector: str | None,
500
+ ) -> None:
501
+ existing = self._map.get(key)
502
+ if existing is not None:
503
+ existing.until = until
504
+ existing.seq = seq
505
+ if mutation_selector and mutation_selector not in existing.by:
506
+ if len(existing.by) < self._max_sources:
507
+ existing.by.append(mutation_selector)
508
+ self._map.move_to_end(key)
509
+ return
510
+ by = [mutation_selector] if mutation_selector else []
511
+ self._map[key] = _StaleEntry(by=by, seq=seq, until=until)
512
+ self._map.move_to_end(key)
513
+
514
+ def _lookup_locked(
515
+ self,
516
+ concrete_selector: str,
517
+ concrete_path: str,
518
+ method: str,
519
+ now: float,
520
+ ) -> tuple[list[str], bool]:
521
+ all_by: list[str] = []
522
+ exact = self._map.get(concrete_selector)
523
+ if exact is not None:
524
+ if exact.until > now:
525
+ all_by.extend(exact.by)
526
+ else:
527
+ del self._map[concrete_selector]
528
+ expired: list[str] = []
529
+ for key, entry in self._map.items():
530
+ if entry.until <= now:
531
+ expired.append(key)
532
+ continue
533
+ if "{" not in key and ":" not in key:
534
+ continue
535
+ if key == concrete_selector:
536
+ continue
537
+ key_method, key_pattern = _StaleTracker._split_key(key)
538
+ if key_method != method:
539
+ continue
540
+ if path_matches_pattern(concrete_path, key_pattern):
541
+ all_by.extend(entry.by)
542
+ for key in expired:
543
+ del self._map[key]
544
+ deduped: list[str] = []
545
+ seen: set[str] = set()
546
+ for src in all_by:
547
+ if src in seen:
548
+ continue
549
+ seen.add(src)
550
+ deduped.append(src)
551
+ return deduped, len(deduped) > 0
552
+
553
+ def _evict_expired(self) -> None:
554
+ now = _now()
555
+ expired = [k for k, v in self._map.items() if now >= v.until]
556
+ for k in expired:
557
+ del self._map[k]
558
+
559
+ def _enforce_capacity(self) -> None:
560
+ if len(self._map) <= self._max_entries:
561
+ return
562
+ self._evict_expired()
563
+ target = max(self._max_entries // 2, 1)
564
+ while len(self._map) > target:
565
+ self._map.popitem(last=False)
566
+
567
+
568
+ __all__ = [
569
+ "_RequestMeta",
570
+ "_StaleEntry",
571
+ "_StaleTracker",
572
+ "_StaleTrackerSync",
573
+ "interpolate_path",
574
+ "lookup_stale",
575
+ "path_matches_pattern",
576
+ "resolve_invalidation_targets",
577
+ "resolve_invalidation_targets_for_mutation",
578
+ ]