jskelet 0.6.3 → 0.6.5

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 (154) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +633 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +700 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +351 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +708 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +355 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +965 -356
  134. package/src/server/og-raster.mjs +21 -0
  135. package/src/server/port-guard.js +255 -255
  136. package/src/server/prewarm.js +1082 -1082
  137. package/src/server/redis.js +588 -588
  138. package/src/server/render.js +910 -910
  139. package/src/server/router.js +157 -157
  140. package/src/server/status-page.js +265 -265
  141. package/src/server/upstream-limiter.js +376 -376
  142. package/src/server/upstream-tracking.js +166 -166
  143. package/src/shared/cookie-domain.js +66 -66
  144. package/src/start.mjs +22 -22
  145. package/src/templates/layout.ejs +30 -30
  146. package/src/templates/layout.jsk +30 -30
  147. package/src/version.mjs +31 -31
  148. package/src/views/components/loader.js +101 -101
  149. package/src/views/helpers/html.js +102 -102
  150. package/src/views/helpers/tags.js +375 -375
  151. package/types/config/defaults.d.ts +6 -0
  152. package/types/config/index.d.ts +6 -0
  153. package/types/server/cache-control.d.ts +28 -0
  154. package/types/server/og-image.d.ts +51 -3
@@ -1,553 +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 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
- }
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
+ }