jskelet 0.6.2 → 0.6.3

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 (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,462 +1,553 @@
1
- /**
2
- * Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
3
- *
4
- * HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
5
- * girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
6
- * yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
7
- * için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
8
- * küçük olduğu için on binlerce girdi bellekte durur.
9
- *
10
- * Kazanç iki taraflı:
11
- * - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
12
- * upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
13
- * - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
14
- *
15
- * `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
16
- * genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
17
- * yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
18
- * `storeEmpty: true` verir.
19
- *
20
- * `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
21
- * en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
22
- * kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
23
- * yeter. Redis kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
24
- * çalışır.
25
- */
26
- import { getConfig } from "../config/index.js";
27
- import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
28
- import { recordDependency } from "./cache-deps.js";
29
- import { invalidateHtmlByDependency } from "./html-cache.js";
30
- import {
31
- cacheKey,
32
- onCacheEvent,
33
- publishCacheEvent,
34
- redisDropMatching,
35
- redisGetJson,
36
- redisSetJson,
37
- redisShares,
38
- } from "./redis.js";
39
-
40
- /**
41
- * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
42
- */
43
-
44
- /** @type {Map<string, DataEntry>} */
45
- const store = new Map();
46
-
47
- /** @type {Map<string, Promise<unknown>>} */
48
- const inflight = new Map();
49
-
50
- /**
51
- * Süreç ömrü boyunca biriken sayaçlar.
52
- *
53
- * Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
54
- * bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
55
- * hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
56
- * fren çağrıları yavaşlatır, sayısını azaltmaz.
57
- */
58
- const stats = {
59
- /** Taze girdiden servis edildi. */
60
- hits: 0,
61
- /** Bayat girdiden servis edildi; tazeleme arkada koştu. */
62
- stale: 0,
63
- /** Girdi yoktu, çağıran bekledi. */
64
- misses: 0,
65
- /** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
66
- coalesced: 0,
67
- /** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
68
- shared: 0,
69
- /** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
70
- produced: 0,
71
- /** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
72
- bypassed: 0,
73
- };
74
-
75
- /**
76
- * Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
77
- * script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
78
- * kod varsayılanına düşülür.
79
- *
80
- * @returns {{ maxEntries: number, staleFactor: number }}
81
- */
82
- function settings() {
83
- try {
84
- const { data } = getConfig();
85
- return {
86
- maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
87
- staleFactor: Number.isFinite(Number(data?.staleFactor))
88
- ? Number(data.staleFactor)
89
- : DEFAULT_DATA_CACHE.staleFactor,
90
- };
91
- } catch {
92
- return { ...DEFAULT_DATA_CACHE };
93
- }
94
- }
95
-
96
- /**
97
- * @param {string} key
98
- * @returns {{ value: unknown, stale: boolean } | null}
99
- */
100
- function read(key) {
101
- const entry = store.get(key);
102
- if (!entry) return null;
103
-
104
- const now = Date.now();
105
- if (now >= entry.staleUntil) {
106
- store.delete(key);
107
- return null;
108
- }
109
-
110
- // LRU: erişilen girdiyi sona taşı.
111
- store.delete(key);
112
- store.set(key, entry);
113
-
114
- return { value: entry.value, stale: now >= entry.expiresAt };
115
- }
116
-
117
- /** Girdi sınırını aşan en eski kayıtları düşürür. */
118
- function evict() {
119
- const { maxEntries } = settings();
120
- while (store.size > maxEntries) {
121
- const oldest = store.keys().next().value;
122
- if (oldest === undefined) break;
123
- store.delete(oldest);
124
- }
125
- }
126
-
127
- /**
128
- * @param {string} key
129
- * @param {unknown} value
130
- * @param {number} ttlSeconds
131
- * @param {number} staleFactor
132
- */
133
- function write(key, value, ttlSeconds, staleFactor) {
134
- const now = Date.now();
135
- const ttl = ttlSeconds * 1000;
136
-
137
- /** @type {DataEntry} */
138
- const entry = {
139
- value,
140
- expiresAt: now + ttl,
141
- staleUntil: now + ttl + ttl * staleFactor,
142
- };
143
-
144
- store.set(key, entry);
145
-
146
- // Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
147
- // bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
148
- if (redisShares("data")) {
149
- redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
150
- }
151
-
152
- evict();
153
- }
154
-
155
- /**
156
- * Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
157
- * mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
158
- * süresiz tazelik kazanır.
159
- *
160
- * @param {string} key
161
- * @param {DataEntry} entry
162
- */
163
- function promote(key, entry) {
164
- store.set(key, entry);
165
- evict();
166
- }
167
-
168
- /**
169
- * Paylaşımlı kademeden okur.
170
- *
171
- * Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
172
- * tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
173
- * yine Redis'i okur ve `producer` hiç çalışmaz.
174
- *
175
- * @param {string} key
176
- * @returns {Promise<DataEntry | null>}
177
- */
178
- async function readShared(key) {
179
- if (!redisShares("data")) return null;
180
-
181
- const entry = await redisGetJson(cacheKey("data", key));
182
- if (!entry || typeof entry.expiresAt !== "number") return null;
183
- if (Date.now() >= entry.expiresAt) return null;
184
-
185
- return /** @type {DataEntry} */ (entry);
186
- }
187
-
188
- /**
189
- * @param {string} key
190
- * @param {number} ttlSeconds
191
- * @param {() => Promise<unknown>} producer
192
- * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
193
- * @returns {Promise<unknown>}
194
- */
195
- function refresh(key, ttlSeconds, producer, options) {
196
- // Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
197
- // Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
198
- const pending = inflight.get(key);
199
- if (pending) {
200
- stats.coalesced += 1;
201
- return pending;
202
- }
203
-
204
- const staleFactor = options.staleFactor ?? settings().staleFactor;
205
-
206
- const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
207
- inflight.delete(key);
208
- });
209
-
210
- inflight.set(key, task);
211
- return task;
212
- }
213
-
214
- /**
215
- * @param {string} key
216
- * @param {number} ttlSeconds
217
- * @param {() => Promise<unknown>} producer
218
- * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
219
- * @param {number} staleFactor
220
- * @returns {Promise<unknown>}
221
- */
222
- async function produce(key, ttlSeconds, producer, options, staleFactor) {
223
- // Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
224
- // Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
225
- const shared = await readShared(key);
226
- if (shared) {
227
- stats.shared += 1;
228
- promote(key, shared);
229
- return shared.value;
230
- }
231
-
232
- stats.produced += 1;
233
- const value = await producer();
234
-
235
- const empty = value === undefined || value === null;
236
- if (!empty || options.storeEmpty === true) {
237
- write(key, value, ttlSeconds, staleFactor);
238
- }
239
-
240
- return value;
241
- }
242
-
243
- /**
244
- * Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
245
- *
246
- * @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
247
- * anahtara yazılır (`quote:v2:${symbol}`).
248
- * @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
249
- * @param {() => Promise<T>} producer
250
- * @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
251
- * `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
252
- * penceresini ayarlar (0 → bayat servis yok).
253
- * @returns {Promise<T>}
254
- * @template T
255
- */
256
- export async function withDataCache(key, ttlSeconds, producer, options = {}) {
257
- if (!ttlSeconds) {
258
- stats.bypassed += 1;
259
- return producer();
260
- }
261
-
262
- // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
263
- // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
264
- recordDependency(key);
265
-
266
- const hit = read(key);
267
-
268
- if (hit) {
269
- if (hit.stale) stats.stale += 1;
270
- else stats.hits += 1;
271
-
272
- // Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
273
- // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
274
- // olması sayfaya yansımaz.
275
- if (hit.stale) {
276
- void refresh(key, ttlSeconds, producer, options).catch((error) => {
277
- console.error(`[data-cache] background refresh failed: ${key}`, error);
278
- });
279
- }
280
- return /** @type {T} */ (hit.value);
281
- }
282
-
283
- stats.misses += 1;
284
-
285
- try {
286
- return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
287
- } catch (error) {
288
- // Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
289
- // durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
290
- // ziyaretçiye hata sayfası göstermekten iyidir.
291
- const stale = read(key);
292
- if (!stale) throw error;
293
-
294
- console.warn(
295
- `[data-cache] producer failed, serving stale value: ${key}`,
296
- error instanceof Error ? error.message : error,
297
- );
298
- return /** @type {T} */ (stale.value);
299
- }
300
- }
301
-
302
- /**
303
- * `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
304
- * `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
305
- * arasında ve TTL'li.
306
- *
307
- * @param {F} fn
308
- * @param {{ key: string, revalidate: number, storeEmpty?: boolean,
309
- * staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
310
- * @returns {F}
311
- * @template {(...args: any[]) => Promise<any>} F
312
- */
313
- export function dataCache(fn, options) {
314
- const wrapped = (...args) =>
315
- withDataCache(
316
- args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
317
- options.revalidate,
318
- () => fn(...args),
319
- options,
320
- );
321
-
322
- return /** @type {F} */ (wrapped);
323
- }
324
-
325
- /**
326
- * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
327
- * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
328
- *
329
- * Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
330
- * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
331
- * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
332
- *
333
- * `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
334
- * node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
335
- * önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
336
- *
337
- * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
338
- * @returns {number} Silinen girdi sayısı.
339
- */
340
- export function clearDataCache(prefix) {
341
- const removed = clearLocal(prefix);
342
-
343
- if (redisShares("data")) {
344
- void redisDropMatching(
345
- "data",
346
- prefix === undefined ? undefined : (key) => key.startsWith(prefix),
347
- );
348
- }
349
-
350
- // Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
351
- // kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
352
- publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
353
-
354
- return removed;
355
- }
356
-
357
- /**
358
- * Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
359
- * bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
360
- *
361
- * @param {string} [prefix]
362
- * @returns {number}
363
- */
364
- function clearLocal(prefix) {
365
- /** @type {string[]} */
366
- const removed = [];
367
-
368
- if (prefix === undefined) {
369
- removed.push(...store.keys());
370
- store.clear();
371
- } else {
372
- for (const key of store.keys()) {
373
- if (key.startsWith(prefix)) {
374
- store.delete(key);
375
- removed.push(key);
376
- }
377
- }
378
- }
379
-
380
- if (removed.length) invalidateHtmlByDependency(removed);
381
- return removed.length;
382
- }
383
-
384
- // Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
385
- // `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
386
- onCacheEvent((event) => {
387
- if (event.type === "data:drop") {
388
- if (typeof event.key !== "string") return;
389
- store.delete(event.key);
390
- invalidateHtmlByDependency([event.key]);
391
- return;
392
- }
393
-
394
- if (event.type !== "data:clear") return;
395
- clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
396
- });
397
-
398
- /**
399
- * Tek bir veri anahtarını düşürür.
400
- *
401
- * `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
402
- * `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
403
- * olarak o anahtar olmalı, komşusu değil.
404
- *
405
- * @param {string} key
406
- * @returns {boolean} Girdi var mıydı.
407
- */
408
- export function dropDataCacheKey(key) {
409
- const existed = store.delete(key);
410
-
411
- // Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
412
- // yalnızca Redis'te sıcak olabilir.
413
- invalidateHtmlByDependency([key]);
414
-
415
- if (redisShares("data")) {
416
- void redisDropMatching("data", (candidate) => candidate === key);
417
- }
418
-
419
- publishCacheEvent({ type: "data:drop", key });
420
-
421
- return existed;
422
- }
423
-
424
- /** @returns {number} */
425
- export function getDataCacheSize() {
426
- return store.size;
427
- }
428
-
429
- /**
430
- * Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
431
- * geri kalan her şey upstream'e hiç gitmemiş bir okuma.
432
- *
433
- * @returns {typeof stats & { reads: number, hitRatio: number }}
434
- * `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
435
- * upstream'e gitmediği (0–1).
436
- */
437
- export function getDataCacheStats() {
438
- const reads = stats.hits + stats.stale + stats.misses;
439
- const avoided = stats.hits + stats.stale;
440
-
441
- return {
442
- ...stats,
443
- reads,
444
- hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
445
- };
446
- }
447
-
448
- /**
449
- * Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
450
- * tamamını bir teşhis ucundan dışa vermek istenmez.
451
- *
452
- * @returns {{ key: string, stale: boolean, expiresIn: number }[]}
453
- */
454
- export function getDataCacheEntries() {
455
- const now = Date.now();
456
-
457
- return [...store.entries()].map(([key, entry]) => ({
458
- key,
459
- stale: now >= entry.expiresAt,
460
- expiresIn: Math.round((entry.expiresAt - now) / 1000),
461
- }));
462
- }
1
+ /**
2
+ * Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
3
+ *
4
+ * HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
5
+ * girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
6
+ * yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
7
+ * için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
8
+ * küçük olduğu için on binlerce girdi bellekte durur.
9
+ *
10
+ * Kazanç iki taraflı:
11
+ * - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
12
+ * upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
13
+ * - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
14
+ *
15
+ * `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
16
+ * genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
17
+ * yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
18
+ * `storeEmpty: true` verir.
19
+ *
20
+ * `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
21
+ * en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
22
+ * kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
23
+ * yeter. Redis yoksa aynı kayıt diske yazılır; süreç yeniden açılınca upstream
24
+ * yeniden çağrılmaz.
25
+ */
26
+ import { getConfig } from "../config/index.js";
27
+ import { DATA_CACHE_BYTE_BUDGET, DEFAULT_DATA_CACHE } from "../config/defaults.js";
28
+ import { recordDependency } from "./cache-deps.js";
29
+ import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
30
+ import { invalidateHtmlByDependency } from "./html-cache.js";
31
+ import {
32
+ cacheKey,
33
+ onCacheEvent,
34
+ publishCacheEvent,
35
+ redisDropMatching,
36
+ redisGetJson,
37
+ redisSetJson,
38
+ redisShares,
39
+ } from "./redis.js";
40
+
41
+ /**
42
+ * @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number }} DataEntry
43
+ */
44
+
45
+ /** @type {Map<string, DataEntry>} */
46
+ const store = new Map();
47
+
48
+ /**
49
+ * `store` içindeki JSON gövdelerin toplamı. Tam tarama yapmamak için tutulur.
50
+ * Her silme `forget` üzerinden geçer; LRU sırası değişimi sayacı oynatmaz.
51
+ */
52
+ let storedBytes = 0;
53
+
54
+ /**
55
+ * Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
56
+ * @type {number | null}
57
+ */
58
+ let byteBudgetOverride = null;
59
+
60
+ /** @type {Map<string, Promise<unknown>>} */
61
+ const inflight = new Map();
62
+
63
+ /**
64
+ * Süreç ömrü boyunca biriken sayaçlar.
65
+ *
66
+ * Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
67
+ * bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
68
+ * hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
69
+ * fren çağrıları yavaşlatır, sayısını azaltmaz.
70
+ */
71
+ const stats = {
72
+ /** Taze girdiden servis edildi. */
73
+ hits: 0,
74
+ /** Bayat girdiden servis edildi; tazeleme arkada koştu. */
75
+ stale: 0,
76
+ /** Girdi yoktu, çağıran bekledi. */
77
+ misses: 0,
78
+ /** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
79
+ coalesced: 0,
80
+ /** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
81
+ shared: 0,
82
+ /** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
83
+ produced: 0,
84
+ /** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
85
+ bypassed: 0,
86
+ };
87
+
88
+ /**
89
+ * Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
90
+ * script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
91
+ * kod varsayılanına düşülür.
92
+ *
93
+ * @returns {{ maxEntries: number, staleFactor: number }}
94
+ */
95
+ function settings() {
96
+ try {
97
+ const { data } = getConfig();
98
+ return {
99
+ maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
100
+ staleFactor: Number.isFinite(Number(data?.staleFactor))
101
+ ? Number(data.staleFactor)
102
+ : DEFAULT_DATA_CACHE.staleFactor,
103
+ };
104
+ } catch {
105
+ return { ...DEFAULT_DATA_CACHE };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {string} key
111
+ * @returns {{ value: unknown, stale: boolean } | null}
112
+ */
113
+ function read(key) {
114
+ const entry = store.get(key);
115
+ if (!entry) return null;
116
+
117
+ const now = Date.now();
118
+ if (now >= entry.staleUntil) {
119
+ forget(key);
120
+ return null;
121
+ }
122
+
123
+ // LRU: erişilen girdiyi sona taşı. Bayt sayacı değişmez.
124
+ store.delete(key);
125
+ store.set(key, entry);
126
+
127
+ return { value: entry.value, stale: now >= entry.expiresAt };
128
+ }
129
+
130
+ /**
131
+ * @param {unknown} value
132
+ * @returns {number}
133
+ */
134
+ function valueBytes(value) {
135
+ try {
136
+ const json = JSON.stringify(value);
137
+ if (typeof json !== "string") return 0;
138
+ return Buffer.byteLength(json);
139
+ } catch {
140
+ // Serileşmeyen değer yine saklanır; ölçülemediği için küçük bir pay.
141
+ return 1024;
142
+ }
143
+ }
144
+
145
+ /** @returns {number} */
146
+ function activeByteBudget() {
147
+ return byteBudgetOverride ?? DATA_CACHE_BYTE_BUDGET;
148
+ }
149
+
150
+ /**
151
+ * Store'dan silmenin tek yolu. Bayt sayacı buraya bağlı.
152
+ *
153
+ * @param {string} key
154
+ * @returns {boolean}
155
+ */
156
+ function forget(key) {
157
+ const entry = store.get(key);
158
+ if (!entry) return false;
159
+ storedBytes = Math.max(0, storedBytes - (entry.bytes || 0));
160
+ store.delete(key);
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer.
166
+ * @returns {void}
167
+ */
168
+ function evict() {
169
+ const { maxEntries } = settings();
170
+ const budget = activeByteBudget();
171
+
172
+ while (store.size > 0 && (store.size > maxEntries || storedBytes > budget)) {
173
+ const oldest = store.keys().next().value;
174
+ if (oldest === undefined) break;
175
+ if (!forget(oldest)) break;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * L1'e yazar. Bütçeden büyük tek değer saklanmaz.
181
+ *
182
+ * @param {string} key
183
+ * @param {DataEntry} entry
184
+ * @returns {boolean}
185
+ */
186
+ function install(key, entry) {
187
+ const bytes = entry.bytes || valueBytes(entry.value);
188
+ entry.bytes = bytes;
189
+ forget(key);
190
+ if (bytes > activeByteBudget()) return false;
191
+
192
+ store.set(key, entry);
193
+ storedBytes += bytes;
194
+ evict();
195
+ return store.has(key);
196
+ }
197
+
198
+ /**
199
+ * @param {string} key
200
+ * @param {unknown} value
201
+ * @param {number} ttlSeconds
202
+ * @param {number} staleFactor
203
+ */
204
+ function write(key, value, ttlSeconds, staleFactor) {
205
+ const now = Date.now();
206
+ const ttl = ttlSeconds * 1000;
207
+
208
+ /** @type {DataEntry} */
209
+ const entry = {
210
+ value,
211
+ expiresAt: now + ttl,
212
+ staleUntil: now + ttl + ttl * staleFactor,
213
+ bytes: valueBytes(value),
214
+ };
215
+
216
+ // Bütçeye sığmayan değer Redis'e de yazılmaz: bir sonraki istek onu geri
217
+ // alıp yine reddeder. Çağıran sonuç yine `producer`'ın döndürdüğüdür.
218
+ if (!install(key, entry)) return;
219
+
220
+ // Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
221
+ // bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
222
+ if (redisShares("data")) {
223
+ redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
224
+ } else if (diskShares("data")) {
225
+ diskSetJson("data", key, entry);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
231
+ * mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
232
+ * süresiz tazelik kazanır.
233
+ *
234
+ * @param {string} key
235
+ * @param {DataEntry} entry
236
+ */
237
+ function promote(key, entry) {
238
+ if (!entry.bytes) entry.bytes = valueBytes(entry.value);
239
+ install(key, entry);
240
+ }
241
+
242
+ /**
243
+ * Paylaşımlı kademeden okur.
244
+ *
245
+ * Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
246
+ * tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
247
+ * yine Redis'i okur ve `producer` hiç çalışmaz.
248
+ *
249
+ * @param {string} key
250
+ * @returns {Promise<DataEntry | null>}
251
+ */
252
+ async function readShared(key) {
253
+ const onRedis = redisShares("data");
254
+ const onDisk = diskShares("data");
255
+ if (!onRedis && !onDisk) return null;
256
+
257
+ const entry = onRedis ? await redisGetJson(cacheKey("data", key)) : await diskGetJson("data", key);
258
+ if (!entry || typeof entry.expiresAt !== "number") return null;
259
+ if (Date.now() >= entry.expiresAt) {
260
+ if (onDisk) diskDrop("data", [key]);
261
+ return null;
262
+ }
263
+
264
+ return /** @type {DataEntry} */ (entry);
265
+ }
266
+
267
+ /**
268
+ * @param {string} key
269
+ * @param {number} ttlSeconds
270
+ * @param {() => Promise<unknown>} producer
271
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
272
+ * @returns {Promise<unknown>}
273
+ */
274
+ function refresh(key, ttlSeconds, producer, options) {
275
+ // Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
276
+ // Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
277
+ const pending = inflight.get(key);
278
+ if (pending) {
279
+ stats.coalesced += 1;
280
+ return pending;
281
+ }
282
+
283
+ const staleFactor = options.staleFactor ?? settings().staleFactor;
284
+
285
+ const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
286
+ inflight.delete(key);
287
+ });
288
+
289
+ inflight.set(key, task);
290
+ return task;
291
+ }
292
+
293
+ /**
294
+ * @param {string} key
295
+ * @param {number} ttlSeconds
296
+ * @param {() => Promise<unknown>} producer
297
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
298
+ * @param {number} staleFactor
299
+ * @returns {Promise<unknown>}
300
+ */
301
+ async function produce(key, ttlSeconds, producer, options, staleFactor) {
302
+ // Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
303
+ // Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
304
+ const shared = await readShared(key);
305
+ if (shared) {
306
+ stats.shared += 1;
307
+ promote(key, shared);
308
+ return shared.value;
309
+ }
310
+
311
+ stats.produced += 1;
312
+ const value = await producer();
313
+
314
+ const empty = value === undefined || value === null;
315
+ if (!empty || options.storeEmpty === true) {
316
+ write(key, value, ttlSeconds, staleFactor);
317
+ }
318
+
319
+ return value;
320
+ }
321
+
322
+ /**
323
+ * Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
324
+ *
325
+ * @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
326
+ * anahtara yazılır (`quote:v2:${symbol}`).
327
+ * @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
328
+ * @param {() => Promise<T>} producer
329
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
330
+ * `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
331
+ * penceresini ayarlar (0 → bayat servis yok).
332
+ * @returns {Promise<T>}
333
+ * @template T
334
+ */
335
+ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
336
+ if (!ttlSeconds) {
337
+ stats.bypassed += 1;
338
+ return producer();
339
+ }
340
+
341
+ // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
342
+ // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
343
+ recordDependency(key);
344
+
345
+ const hit = read(key);
346
+
347
+ if (hit) {
348
+ if (hit.stale) stats.stale += 1;
349
+ else stats.hits += 1;
350
+
351
+ // Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
352
+ // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
353
+ // olması sayfaya yansımaz.
354
+ if (hit.stale) {
355
+ void refresh(key, ttlSeconds, producer, options).catch((error) => {
356
+ console.error(`[data-cache] background refresh failed: ${key}`, error);
357
+ });
358
+ }
359
+ return /** @type {T} */ (hit.value);
360
+ }
361
+
362
+ stats.misses += 1;
363
+
364
+ try {
365
+ return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
366
+ } catch (error) {
367
+ // Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
368
+ // durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
369
+ // ziyaretçiye hata sayfası göstermekten iyidir.
370
+ const stale = read(key);
371
+ if (!stale) throw error;
372
+
373
+ console.warn(
374
+ `[data-cache] producer failed, serving stale value: ${key}`,
375
+ error instanceof Error ? error.message : error,
376
+ );
377
+ return /** @type {T} */ (stale.value);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
383
+ * `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
384
+ * arasında ve TTL'li.
385
+ *
386
+ * @param {F} fn
387
+ * @param {{ key: string, revalidate: number, storeEmpty?: boolean,
388
+ * staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
389
+ * @returns {F}
390
+ * @template {(...args: any[]) => Promise<any>} F
391
+ */
392
+ export function dataCache(fn, options) {
393
+ const wrapped = (...args) =>
394
+ withDataCache(
395
+ args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
396
+ options.revalidate,
397
+ () => fn(...args),
398
+ options,
399
+ );
400
+
401
+ return /** @type {F} */ (wrapped);
402
+ }
403
+
404
+ /**
405
+ * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
406
+ * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
407
+ *
408
+ * Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
409
+ * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
410
+ * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
411
+ *
412
+ * `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
413
+ * node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
414
+ * önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
415
+ *
416
+ * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
417
+ * @returns {number} Silinen girdi sayısı.
418
+ */
419
+ export function clearDataCache(prefix) {
420
+ const removed = clearLocal(prefix);
421
+
422
+ const match = prefix === undefined ? undefined : (key) => key.startsWith(prefix);
423
+ if (redisShares("data")) void redisDropMatching("data", match);
424
+ else if (diskShares("data")) void diskDropMatching("data", match);
425
+
426
+ // Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
427
+ // kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
428
+ publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
429
+
430
+ return removed;
431
+ }
432
+
433
+ /**
434
+ * Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
435
+ * bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
436
+ *
437
+ * @param {string} [prefix]
438
+ * @returns {number}
439
+ */
440
+ function clearLocal(prefix) {
441
+ /** @type {string[]} */
442
+ const removed = [];
443
+
444
+ if (prefix === undefined) {
445
+ removed.push(...store.keys());
446
+ store.clear();
447
+ storedBytes = 0;
448
+ } else {
449
+ for (const key of [...store.keys()]) {
450
+ if (key.startsWith(prefix)) {
451
+ forget(key);
452
+ removed.push(key);
453
+ }
454
+ }
455
+ }
456
+
457
+ if (removed.length) invalidateHtmlByDependency(removed);
458
+ return removed.length;
459
+ }
460
+
461
+ // Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
462
+ // `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
463
+ onCacheEvent((event) => {
464
+ if (event.type === "data:drop") {
465
+ if (typeof event.key !== "string") return;
466
+ forget(event.key);
467
+ invalidateHtmlByDependency([event.key]);
468
+ return;
469
+ }
470
+
471
+ if (event.type !== "data:clear") return;
472
+ clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
473
+ });
474
+
475
+ /**
476
+ * Tek bir veri anahtarını düşürür.
477
+ *
478
+ * `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
479
+ * `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
480
+ * olarak o anahtar olmalı, komşusu değil.
481
+ *
482
+ * @param {string} key
483
+ * @returns {boolean} Girdi var mıydı.
484
+ */
485
+ export function dropDataCacheKey(key) {
486
+ const existed = forget(key);
487
+
488
+ // Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
489
+ // yalnızca Redis'te sıcak olabilir.
490
+ invalidateHtmlByDependency([key]);
491
+
492
+ if (redisShares("data")) {
493
+ void redisDropMatching("data", (candidate) => candidate === key);
494
+ } else if (diskShares("data")) {
495
+ diskDrop("data", [key]);
496
+ }
497
+
498
+ publishCacheEvent({ type: "data:drop", key });
499
+
500
+ return existed;
501
+ }
502
+
503
+ /** @returns {number} */
504
+ export function getDataCacheSize() {
505
+ return store.size;
506
+ }
507
+
508
+ /**
509
+ * Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
510
+ * tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
511
+ *
512
+ * @param {number | null} bytes
513
+ * @returns {void}
514
+ */
515
+ export function setDataCacheByteBudget(bytes) {
516
+ byteBudgetOverride = bytes == null ? null : bytes;
517
+ evict();
518
+ }
519
+
520
+ /**
521
+ * Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
522
+ * geri kalan her şey upstream'e hiç gitmemiş bir okuma.
523
+ *
524
+ * @returns {typeof stats & { reads: number, hitRatio: number }}
525
+ * `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
526
+ * upstream'e gitmediği (0–1).
527
+ */
528
+ export function getDataCacheStats() {
529
+ const reads = stats.hits + stats.stale + stats.misses;
530
+ const avoided = stats.hits + stats.stale;
531
+
532
+ return {
533
+ ...stats,
534
+ reads,
535
+ hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
536
+ };
537
+ }
538
+
539
+ /**
540
+ * Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
541
+ * tamamını bir teşhis ucundan dışa vermek istenmez.
542
+ *
543
+ * @returns {{ key: string, stale: boolean, expiresIn: number }[]}
544
+ */
545
+ export function getDataCacheEntries() {
546
+ const now = Date.now();
547
+
548
+ return [...store.entries()].map(([key, entry]) => ({
549
+ key,
550
+ stale: now >= entry.expiresAt,
551
+ expiresIn: Math.round((entry.expiresAt - now) / 1000),
552
+ }));
553
+ }