tina4-nodejs 3.13.92 → 3.13.95

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 (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -121,6 +121,10 @@ interface CacheEntry {
121
121
  contentType: string;
122
122
  statusCode: number;
123
123
  expiresAt: number;
124
+ /** RFC 9111 s4.1 — the field names the origin nominated in its Vary header. */
125
+ vary?: string[];
126
+ /** The values those fields had on the request that caused this to be stored. */
127
+ varyValues?: Record<string, string | undefined>;
124
128
  }
125
129
 
126
130
  interface DirectEntry {
@@ -150,6 +154,20 @@ interface CacheBackend {
150
154
  set(key: string, value: unknown, ttl: number): Promise<void>;
151
155
  delete(key: string): Promise<boolean>;
152
156
  clear(): Promise<void>;
157
+ /**
158
+ * Evict expired entries and return HOW MANY were actually evicted.
159
+ *
160
+ * REQUIRED, not optional. It used to be neither declared nor implemented, so
161
+ * the module-level sweep() found no backend method and returned a permanent
162
+ * 0: the one API whose job is reclaiming expired space did nothing and
163
+ * reported success. Declaring it here makes "every provider can sweep" a
164
+ * compile-time fact instead of a runtime hope.
165
+ *
166
+ * 0 is the HONEST answer on redis/valkey/memcached/mongodb - they expire
167
+ * entries server-side, so there is nothing left for us to evict. It is the
168
+ * WRONG answer for memory, file and database, which own their own expiry.
169
+ */
170
+ sweep(): Promise<number>;
153
171
  stats(): Promise<{ hits: number; misses: number; size: number; backend: string }>;
154
172
  name(): string;
155
173
  /**
@@ -216,6 +234,23 @@ class MemoryBackend implements CacheBackend {
216
234
  this.misses = 0;
217
235
  }
218
236
 
237
+ /**
238
+ * Drop expired entries and count them. `expiresAt > 0` is load-bearing: an
239
+ * entry stored with ttl <= 0 is permanent and carries 0, so a bare
240
+ * `now > expiresAt` would evict every permanent entry on the first sweep.
241
+ */
242
+ async sweep(): Promise<number> {
243
+ const now = Date.now();
244
+ let evicted = 0;
245
+ for (const [key, entry] of this.store) {
246
+ if (entry.expiresAt > 0 && now > entry.expiresAt) {
247
+ this.store.delete(key);
248
+ evicted++;
249
+ }
250
+ }
251
+ return evicted;
252
+ }
253
+
219
254
  async stats() {
220
255
  // Sweep expired
221
256
  const now = Date.now();
@@ -411,7 +446,7 @@ class RespClient {
411
446
  * cache op is one async round-trip. AUTH + SELECT db run once on connect so a
412
447
  * dead port OR wrong credentials fail the availability probe and fall back to
413
448
  * file. Mirrors the Python master's _RedisBackend semantics (prefix, SETEX,
414
- * scoped KEYS+DEL clear, DBSIZE stats).
449
+ * scoped SCAN+DEL clear, DBSIZE stats).
415
450
  */
416
451
  class RedisBackend implements CacheBackend {
417
452
  protected host: string;
@@ -508,29 +543,99 @@ class RedisBackend implements CacheBackend {
508
543
  return result === "1";
509
544
  }
510
545
 
546
+ /**
547
+ * Remove EVERY entry this cache can serve, and nothing else.
548
+ *
549
+ * SCAN, not KEYS: clear() runs on EVERY WRITE in persistent DB-cache mode,
550
+ * and KEYS is O(N) over the whole keyspace and blocks the entire server for
551
+ * its duration. Redis's own documentation says to prefer SCAN in production.
552
+ * The cursor loop is scoped to our prefix, so another application sharing the
553
+ * server is untouched - FLUSHALL/FLUSHDB would take their data with it and is
554
+ * never used here.
555
+ *
556
+ * The scan runs to cursor 0, so a keyspace larger than one page is fully
557
+ * cleared; stopping at the first page would leave later entries readable and
558
+ * still look green on a small test.
559
+ */
560
+ /**
561
+ * Walk every key under OUR prefix, handing each page to `onPage`.
562
+ *
563
+ * ONE walk drives both clear() and stats(), so the two can never disagree
564
+ * about what this cache holds - which is exactly how stats() came to report a
565
+ * different keyspace from the one clear() empties.
566
+ *
567
+ * SCAN, not KEYS: clear() runs on EVERY WRITE in persistent DB-cache mode,
568
+ * and KEYS is O(N) over the whole keyspace and blocks the entire server for
569
+ * its duration. Redis's own documentation says to prefer SCAN. The walk is
570
+ * scoped to our prefix, so another application sharing the server is
571
+ * untouched, and it runs to cursor 0 so a keyspace larger than one page is
572
+ * fully covered.
573
+ */
574
+ private async walkPrefixedKeys(onPage: (keys: string[]) => Promise<void>): Promise<void> {
575
+ let cursor = "0";
576
+ do {
577
+ const reply = await this.client.command(
578
+ "SCAN", cursor, "MATCH", this.prefix + "*", "COUNT", "500",
579
+ );
580
+ // A SCAN reply is a 2-element multi-bulk: [next cursor, [key, ...]].
581
+ if (!Array.isArray(reply) || reply.length !== 2) break;
582
+ cursor = typeof reply[0] === "string" ? reply[0] : "0";
583
+ const page = reply[1];
584
+ if (Array.isArray(page) && page.length > 0) {
585
+ const keys = page.filter((k): k is string => typeof k === "string");
586
+ if (keys.length > 0) await onPage(keys);
587
+ }
588
+ } while (cursor !== "0");
589
+ }
590
+
511
591
  async clear(): Promise<void> {
512
592
  this.hits = 0;
513
593
  this.misses = 0;
514
- // Scoped KEYS + DEL of our namespace only (never FLUSHALL — the harness is
515
- // shared with other agents, and so are real deployments).
516
594
  try {
517
- const keys = await this.client.command("KEYS", this.prefix + "*");
518
- if (Array.isArray(keys) && keys.length > 0) {
519
- const flat = keys.filter((k): k is string => typeof k === "string");
520
- if (flat.length > 0) await this.client.command("DEL", ...flat);
521
- }
595
+ await this.walkPrefixedKeys(async (keys) => {
596
+ await this.client.command("DEL", ...keys);
597
+ });
522
598
  } catch {
523
599
  /* best effort */
524
600
  }
525
601
  }
526
602
 
603
+ /**
604
+ * Nothing to do: redis/valkey expires entries SERVER-SIDE (the TTL set by SETEX), so by the
605
+ * time a sweep runs there is nothing left for us to evict. 0 is the honest
606
+ * count, not a stub - inventing a number here would be a lie, and scanning
607
+ * the keyspace to "prove" it would cost a full scan to always return 0.
608
+ */
609
+ async sweep(): Promise<number> {
610
+ return 0;
611
+ }
612
+
613
+ /**
614
+ * Report OUR entries, not the whole server's.
615
+ *
616
+ * This used to read DBSIZE, which counts the WHOLE database index - so on a
617
+ * shared Redis it included every key any other tenant had written. MEASURED
618
+ * before the fix: three of our writes plus two foreign keys reported size 5.
619
+ * Every other backend here is scoped (memory counts its own map, file its own
620
+ * directory, mongo its own collection, database its own table, memcached its
621
+ * own write log), and Ruby/Python had the same rule broken the other way
622
+ * round, returning a constant 0. Both fail the same rule: the number must
623
+ * describe THIS cache.
624
+ *
625
+ * The count comes from the same scoped walk clear() uses. Keys are deduped
626
+ * through a Set because SCAN may return a given key more than once across
627
+ * iterations (Redis guarantees at-least-once, not exactly-once).
628
+ */
527
629
  async stats() {
528
- let size = 0;
529
- const keys = await this.respCommand("DBSIZE");
530
- // DBSIZE counts the whole DB index; with our isolated index that's accurate
531
- // enough for the size figure. Fall back to 0 on error.
532
- if (keys !== null && /^\d+$/.test(keys)) size = parseInt(keys, 10);
533
- return { hits: this.hits, misses: this.misses, size, backend: this._name };
630
+ const seen = new Set<string>();
631
+ try {
632
+ await this.walkPrefixedKeys(async (keys) => {
633
+ for (const key of keys) seen.add(key);
634
+ });
635
+ } catch {
636
+ /* a failed walk reports 0 rather than a number from somewhere else */
637
+ }
638
+ return { hits: this.hits, misses: this.misses, size: seen.size, backend: this._name };
534
639
  }
535
640
 
536
641
  name() { return this._name; }
@@ -579,7 +684,23 @@ class FileBackend implements CacheBackend {
579
684
  return undefined;
580
685
  }
581
686
  this.hits++;
582
- return data.value ?? data;
687
+ // A cached null must come back as NULL, not as the storage envelope.
688
+ //
689
+ // This was `data.value ?? data`. `data` is the envelope
690
+ // {key, value, expiresAt}, so whenever the stored value was null the
691
+ // `??` fell through and handed the caller that OBJECT - which is truthy -
692
+ // where the caller had stored nothing. Every `if (cached)` then took the
693
+ // hit branch with a meaningless object, so the cache turned "this lookup
694
+ // found nothing" into "this lookup found something". Caching a negative
695
+ // lookup is the most common reason to cache a null at all, so it was
696
+ // wrong exactly where the feature gets used.
697
+ //
698
+ // The test is the ENVELOPE SHAPE, never the value's truthiness: false, 0,
699
+ // "" and [] are values, and a truthiness check would break all of them.
700
+ // The non-envelope fallback stays for a file this backend did not write.
701
+ const isEnvelope = data !== null && typeof data === "object"
702
+ && "value" in data && "expiresAt" in data;
703
+ return isEnvelope ? data.value : data;
583
704
  } catch {
584
705
  this.misses++;
585
706
  return undefined;
@@ -648,6 +769,28 @@ class FileBackend implements CacheBackend {
648
769
  return { hits: this.hits, misses: this.misses, size: count, backend: "file" };
649
770
  }
650
771
 
772
+ /**
773
+ * Delete expired cache files and count them. `expiresAt > 0` is load-bearing:
774
+ * a no-TTL entry is stored with 0 and must survive every sweep.
775
+ */
776
+ async sweep(): Promise<number> {
777
+ const now = Date.now() / 1000;
778
+ let evicted = 0;
779
+ try {
780
+ for (const f of fs.readdirSync(this.dir).filter((n) => n.endsWith(".json"))) {
781
+ const p = path.join(this.dir, f);
782
+ try {
783
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
784
+ if (data.expiresAt > 0 && now > data.expiresAt) {
785
+ fs.unlinkSync(p);
786
+ evicted++;
787
+ }
788
+ } catch { /* an unreadable file is not ours to count */ }
789
+ }
790
+ } catch { /* no cache directory yet */ }
791
+ return evicted;
792
+ }
793
+
651
794
  name() { return "file"; }
652
795
  }
653
796
 
@@ -705,8 +848,25 @@ class MemcachedClient {
705
848
  }
706
849
  }
707
850
 
708
- /** Send one command, resolve with the reply read until `terminator`. */
709
- async command(payload: string, terminator: string): Promise<string> {
851
+ /**
852
+ * Send one command, resolve with the reply read until `terminator`.
853
+ *
854
+ * Commands are SERIALISED: the socket carries one reply stream with no
855
+ * request ids, so two commands in flight resolve each other's replies. That
856
+ * matters more than it looks - every key now carries a generation READ FROM
857
+ * THE SERVER, so a crossed reply computes the WRONG key and silently reads or
858
+ * overwrites the wrong entry. One command at a time on one socket.
859
+ */
860
+ command(payload: string, terminator: string): Promise<string> {
861
+ const next = this.chain.then(() => this.send(payload, terminator));
862
+ // The chain must never reject, or every later command inherits the failure.
863
+ this.chain = next.then(() => undefined, () => undefined);
864
+ return next;
865
+ }
866
+
867
+ private chain: Promise<unknown> = Promise.resolve();
868
+
869
+ private async send(payload: string, terminator: string): Promise<string> {
710
870
  await this.connect();
711
871
  if (!this.sock || this.sock.destroyed) return "";
712
872
  return new Promise<string>((resolve) => {
@@ -779,12 +939,49 @@ class MemcachedBackend implements CacheBackend {
779
939
  return this.available;
780
940
  }
781
941
 
782
- private mcKey(key: string): string {
783
- return this.prefix + crypto.createHash("sha256").update(key).digest("hex");
942
+ /**
943
+ * The SHARED namespace generation counter. clear() bumps it and every real
944
+ * key carries it, so one bump orphans every entry for every instance at once.
945
+ */
946
+ private genKey = this.prefix + "generation";
947
+
948
+ /**
949
+ * Read the SHARED namespace generation from the SERVER.
950
+ *
951
+ * memcached has no KEYS scan and no prefix delete, so the only way to
952
+ * invalidate globally without destroying other tenants is the documented
953
+ * namespace idiom: every real key carries a generation, and clear() bumps it.
954
+ * Every instance then computes a different key, and the old entries become
955
+ * unreachable at once, expiring under the server's own TTL/LRU.
956
+ *
957
+ * The generation is read from the server on EVERY key computation,
958
+ * deliberately. Caching it in-process would reintroduce exactly the bug this
959
+ * fixes: an instance holding a stale generation keeps computing the OLD key,
960
+ * the old key still holds the old value, so it serves a stale hit after
961
+ * another instance cleared. One extra round trip on a sub-millisecond local
962
+ * service is the price of cross-instance invalidation.
963
+ */
964
+ private async generation(): Promise<string> {
965
+ const resp = await this.client.command(`get ${this.genKey}\r\n`, "END\r\n");
966
+ if (resp.startsWith("VALUE")) {
967
+ const idx = resp.indexOf("\r\n");
968
+ const nbytes = parseInt(resp.slice(0, idx).split(/\s+/)[3], 10);
969
+ if (idx !== -1 && Number.isFinite(nbytes)) return resp.slice(idx + 2, idx + 2 + nbytes);
970
+ }
971
+ return "0";
972
+ }
973
+
974
+ /**
975
+ * Hash to a safe, bounded key (memcached keys: no spaces/control bytes, <=250
976
+ * chars). The generation sits IN the key, so a clear() on ANY instance orphans
977
+ * it for every instance at once.
978
+ */
979
+ private async mcKey(key: string): Promise<string> {
980
+ return `${this.prefix}${await this.generation()}:${crypto.createHash("sha256").update(key).digest("hex")}`;
784
981
  }
785
982
 
786
983
  async get(key: string): Promise<unknown | undefined> {
787
- const resp = await this.client.command(`get ${this.mcKey(key)}\r\n`, "END\r\n");
984
+ const resp = await this.client.command(`get ${await this.mcKey(key)}\r\n`, "END\r\n");
788
985
  if (resp.startsWith("VALUE")) {
789
986
  try {
790
987
  const idx = resp.indexOf("\r\n");
@@ -801,36 +998,126 @@ class MemcachedBackend implements CacheBackend {
801
998
  return undefined;
802
999
  }
803
1000
 
1001
+ /**
1002
+ * Keys THIS backend wrote, mapped to the moment each expires (0 = never).
1003
+ * Memcached has no KEYS/prefix scan, so neither a scoped count nor a scoped
1004
+ * clear can be driven from the server - both come from this log.
1005
+ */
1006
+ private own = new Map<string, number>();
1007
+
1008
+ /**
1009
+ * memcached's 30-day cliff: an exptime AT OR BELOW 2592000 is RELATIVE
1010
+ * seconds, anything ABOVE it is an ABSOLUTE UNIX TIMESTAMP.
1011
+ *
1012
+ * The ttl used to be interpolated raw, so any TINA4_CACHE_TTL over 30 days
1013
+ * made every write vanish the instant it landed - the caller wrote a number
1014
+ * of seconds and the server read a date in 1970. memcached still answers
1015
+ * STORED, so it presented as a 100% miss rate with nothing logged: a cache
1016
+ * that looks like it is working and never returns a hit.
1017
+ *
1018
+ * CONVERT, never CLAMP. Clamping to 2592000 also makes the entry survive and
1019
+ * is also wrong - it silently discards more than half the lifetime the
1020
+ * operator explicitly configured, which is the same class of silent-wrong-
1021
+ * answer as the bug it would be replacing.
1022
+ */
1023
+ private static readonly MAX_RELATIVE_EXPTIME = 2592000;
1024
+
1025
+ private exptimeFor(ttl: number): number {
1026
+ if (ttl <= 0) return 0;
1027
+ if (ttl > MemcachedBackend.MAX_RELATIVE_EXPTIME) {
1028
+ return Math.floor(Date.now() / 1000) + ttl;
1029
+ }
1030
+ return ttl;
1031
+ }
1032
+
804
1033
  async set(key: string, value: unknown, ttl: number): Promise<void> {
805
1034
  const data = JSON.stringify(value);
806
- const exptime = ttl > 0 ? ttl : 0;
807
- const payload = `set ${this.mcKey(key)} 0 ${exptime} ${Buffer.byteLength(data)}\r\n${data}\r\n`;
1035
+ const exptime = this.exptimeFor(ttl);
1036
+ const mcKey = await this.mcKey(key);
1037
+ const payload = `set ${mcKey} 0 ${exptime} ${Buffer.byteLength(data)}\r\n${data}\r\n`;
808
1038
  await this.client.command(payload, "\r\n");
1039
+ // THE WRITE LOG KEEPS THE RAW ttl, never the converted exptime. This line
1040
+ // turns its number into a wall-clock deadline, so feeding it a converted
1041
+ // absolute timestamp would compute Date.now() + <unix timestamp> * 1000 -
1042
+ // about 166 years out - and the log would then never expire anything, so
1043
+ // stats() would report expired entries as live forever.
1044
+ this.own.set(mcKey, ttl > 0 ? Date.now() + ttl * 1000 : 0);
809
1045
  }
810
1046
 
811
1047
  async delete(key: string): Promise<boolean> {
812
- const resp = await this.client.command(`delete ${this.mcKey(key)}\r\n`, "\r\n");
1048
+ const mcKey = await this.mcKey(key);
1049
+ const resp = await this.client.command(`delete ${mcKey}\r\n`, "\r\n");
1050
+ this.own.delete(mcKey);
813
1051
  return resp.startsWith("DELETED");
814
1052
  }
815
1053
 
1054
+ /**
1055
+ * Invalidate EVERY entry this cache can serve, on EVERY instance.
1056
+ *
1057
+ * Two wrong answers were shipped before this one. `flush_all` wipes EVERY key
1058
+ * on the instance including every other application's - cacheClear() is
1059
+ * public API, so calling it destroyed other tenants' data. Deleting only the
1060
+ * keys THIS process wrote fixed that but broke the contract the other way: a
1061
+ * second instance kept serving rows the first had just invalidated, because
1062
+ * it had never seen those keys.
1063
+ *
1064
+ * The namespace generation does both. Bumping the shared counter orphans
1065
+ * every previously-written entry for every instance at once, and touches
1066
+ * nothing outside our own prefix. The orphans are reclaimed by memcached's
1067
+ * own TTL and LRU - unreachable is what "removed" means for a cache.
1068
+ *
1069
+ * The local write log is still cleared so stats() reports honestly, and its
1070
+ * keys are deleted eagerly so the space comes back immediately rather than
1071
+ * waiting for eviction.
1072
+ */
816
1073
  async clear(): Promise<void> {
817
1074
  this.hits = 0;
818
1075
  this.misses = 0;
819
- // No flush_all the harness is shared with other agents. We rely on TTL
820
- // expiry + per-key deletes (parity with the Python master's clear, which
821
- // also avoids destructive flushes when keys can't be enumerated cheaply).
1076
+ for (const mcKey of this.own.keys()) {
1077
+ await this.client.command(`delete ${mcKey}\r\n`, "\r\n");
1078
+ }
1079
+ this.own.clear();
1080
+ // incr is atomic, so two instances clearing at once still both advance.
1081
+ const bumped = await this.client.command(`incr ${this.genKey} 1\r\n`, "\r\n");
1082
+ if (!/^\d+/.test(bumped.trim())) {
1083
+ // No counter yet: create it. `add` fails harmlessly if another instance
1084
+ // created it in the gap, and the incr then applies on top of theirs.
1085
+ await this.client.command(`add ${this.genKey} 0 0 1\r\n1\r\n`, "\r\n");
1086
+ await this.client.command(`incr ${this.genKey} 1\r\n`, "\r\n");
1087
+ }
822
1088
  }
823
1089
 
1090
+ /**
1091
+ * Report OUR entries, not the whole server's.
1092
+ *
1093
+ * This used to read memcached's `curr_items`, a GLOBAL counter that includes
1094
+ * every key written by every other tenant of that server. Every other backend
1095
+ * here is scoped - memory counts its own map, redis/valkey scan their own
1096
+ * prefix, file counts its own directory, mongo its own collection, database
1097
+ * its own table. Memcached was the only one leaking.
1098
+ *
1099
+ * The count comes from our own write log, filtered by the TTLs we set. That
1100
+ * is exact for the keys this process wrote; a key EVICTED early under memory
1101
+ * pressure is invisible to us and would be over-counted, which is a far
1102
+ * smaller and more honest error than counting another application's keys.
1103
+ */
824
1104
  async stats() {
825
- let size = 0;
826
- const resp = await this.client.command(`stats\r\n`, "END\r\n");
827
- for (const line of resp.split("\r\n")) {
828
- if (line.startsWith("STAT curr_items ")) {
829
- const n = parseInt(line.split(/\s+/)[2], 10);
830
- if (!isNaN(n)) size = n;
831
- }
1105
+ const now = Date.now();
1106
+ // Drop the expired ones so the log cannot grow without bound.
1107
+ for (const [k, expires] of this.own) {
1108
+ if (expires !== 0 && expires <= now) this.own.delete(k);
832
1109
  }
833
- return { hits: this.hits, misses: this.misses, size, backend: "memcached" };
1110
+ return { hits: this.hits, misses: this.misses, size: this.own.size, backend: "memcached" };
1111
+ }
1112
+
1113
+ /**
1114
+ * Nothing to do: memcached expires entries SERVER-SIDE (the exptime set on each key), so by the
1115
+ * time a sweep runs there is nothing left for us to evict. 0 is the honest
1116
+ * count, not a stub - inventing a number here would be a lie, and scanning
1117
+ * the keyspace to "prove" it would cost a full scan to always return 0.
1118
+ */
1119
+ async sweep(): Promise<number> {
1120
+ return 0;
834
1121
  }
835
1122
 
836
1123
  name() { return "memcached"; }
@@ -971,6 +1258,16 @@ class MongoBackend implements CacheBackend {
971
1258
  return { hits: this.hits, misses: this.misses, size, backend: "mongodb" };
972
1259
  }
973
1260
 
1261
+ /**
1262
+ * Nothing to do: mongodb expires entries SERVER-SIDE (the TTL index on expiresAt), so by the
1263
+ * time a sweep runs there is nothing left for us to evict. 0 is the honest
1264
+ * count, not a stub - inventing a number here would be a lie, and scanning
1265
+ * the keyspace to "prove" it would cost a full scan to always return 0.
1266
+ */
1267
+ async sweep(): Promise<number> {
1268
+ return 0;
1269
+ }
1270
+
974
1271
  name() { return "mongodb"; }
975
1272
  }
976
1273
 
@@ -1114,6 +1411,42 @@ class DatabaseBackend implements CacheBackend {
1114
1411
  return { hits: this.hits, misses: this.misses, size, backend: "database" };
1115
1412
  }
1116
1413
 
1414
+ /**
1415
+ * Delete expired rows and return how many went.
1416
+ *
1417
+ * The network providers return 0 because they expire entries SERVER-SIDE -
1418
+ * nothing was evicted because there was nothing left to evict, and 0 is the
1419
+ * honest answer there. A SQL TABLE EXPIRES NOTHING BY ITSELF. Before this
1420
+ * override the database backend had no sweep at all, so expired rows were
1421
+ * removed only when someone happened to read that exact key again: the table
1422
+ * grew without bound while the one API whose job is reclaiming that space
1423
+ * reported success having done nothing.
1424
+ *
1425
+ * `expires_at > 0` is load-bearing: an entry stored with ttl <= 0 is
1426
+ * permanent and carries 0, so a bare `now > expires_at` would evict every
1427
+ * permanent entry on the first sweep.
1428
+ */
1429
+ async sweep(): Promise<number> {
1430
+ if (!this.db) return 0;
1431
+ const now = Date.now() / 1000;
1432
+ try {
1433
+ const row = await this.db.fetchOne(
1434
+ "SELECT COUNT(*) AS c FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?",
1435
+ [now],
1436
+ );
1437
+ const expired = row && row.c != null ? Number(row.c) : 0;
1438
+ if (expired > 0) {
1439
+ await this.db.execute(
1440
+ "DELETE FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?", [now],
1441
+ );
1442
+ try { this.db.commit(); } catch { /* sqlite autocommits */ }
1443
+ }
1444
+ return expired;
1445
+ } catch {
1446
+ return 0;
1447
+ }
1448
+ }
1449
+
1117
1450
  name() { return "database"; }
1118
1451
  }
1119
1452
 
@@ -1173,8 +1506,18 @@ export async function createBackend(config?: {
1173
1506
  }
1174
1507
  case "file":
1175
1508
  return new FileBackend(cacheDir(), maxEntries);
1176
- default:
1509
+ case "memory":
1510
+ case "":
1177
1511
  return new MemoryBackend(maxEntries);
1512
+ default:
1513
+ // An UNRECOGNISED name THROWS, naming the bad value and the valid ones —
1514
+ // the contract the session layer already settled on. Falling through to
1515
+ // memory turned a typo (TINA4_CACHE_BACKEND=redsi) into a running app
1516
+ // with a per-process cache while the operator believed it was Redis.
1517
+ throw new Error(
1518
+ `Unknown cache backend '${backendName}'. Valid backends: ` +
1519
+ "memory, file, redis, valkey, memcached, mongodb, database.",
1520
+ );
1178
1521
  }
1179
1522
 
1180
1523
  // Wait for the async connect/probe to settle before deciding availability.
@@ -1216,16 +1559,51 @@ export async function createBackend(config?: {
1216
1559
  */
1217
1560
  let _responseBackend: CacheBackend | null = null;
1218
1561
  let _responseBackendPromise: Promise<CacheBackend> | null = null;
1562
+ /**
1563
+ * Backends built for an EXPLICITLY configured responseCache, keyed by the
1564
+ * provider-affecting part of its config. Kept apart from the shared
1565
+ * module-level backend so a named provider is never overridden by ambient state.
1566
+ */
1567
+ const _explicitResponseBackends = new Map<string, Promise<CacheBackend>>();
1568
+
1569
+ export function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend> {
1570
+ // An EXPLICITLY requested provider gets its OWN backend; only the
1571
+ // no-argument case shares the module-level one (mirrors the Python master).
1572
+ //
1573
+ // This function used to open with `if (_responseBackend) return ...`, so the
1574
+ // memoised backend was handed back BEFORE config was ever read. Once any
1575
+ // responseCache middleware existed, every later explicitly-named provider was
1576
+ // silently ignored: the developer names a backend, the framework quietly uses
1577
+ // a different one, and the only symptom is cache behaviour that does not
1578
+ // match the configuration.
1579
+ const wantsItsOwn = config?.backend !== undefined
1580
+ || config?.cacheUrl !== undefined
1581
+ || config?.cacheDir !== undefined
1582
+ || config?.maxEntries !== undefined;
1583
+
1584
+ if (wantsItsOwn) {
1585
+ // Memoised per DISTINCT config, not per call: the middleware resolves its
1586
+ // backend on every request, so building a fresh one each time would open a
1587
+ // new connection per request. Two different configs stay two different
1588
+ // stores, which is the half that actually matters - honouring the NAME
1589
+ // while still returning the shared object would change nothing observable.
1590
+ const key = JSON.stringify([config?.backend, config?.cacheUrl, config?.cacheDir, config?.maxEntries]);
1591
+ let built = _explicitResponseBackends.get(key);
1592
+ if (!built) {
1593
+ built = createBackend({
1594
+ backend: config?.backend,
1595
+ cacheUrl: config?.cacheUrl,
1596
+ cacheDir: config?.cacheDir,
1597
+ maxEntries: config?.maxEntries,
1598
+ });
1599
+ _explicitResponseBackends.set(key, built);
1600
+ }
1601
+ return built;
1602
+ }
1219
1603
 
1220
- function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend> {
1221
1604
  if (_responseBackend) return Promise.resolve(_responseBackend);
1222
1605
  if (!_responseBackendPromise) {
1223
- _responseBackendPromise = createBackend({
1224
- backend: config?.backend,
1225
- cacheUrl: config?.cacheUrl,
1226
- cacheDir: config?.cacheDir,
1227
- maxEntries: config?.maxEntries,
1228
- }).then((b) => {
1606
+ _responseBackendPromise = createBackend().then((b) => {
1229
1607
  _responseBackend = b;
1230
1608
  return b;
1231
1609
  });
@@ -1245,6 +1623,65 @@ function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend
1245
1623
  * maxEntries. With the default `memory` backend behaviour is unchanged; a
1246
1624
  * redis/etc. backend distributes cross-instance.
1247
1625
  */
1626
+ /**
1627
+ * Response directives that let a SHARED cache store a response to a request
1628
+ * carrying Authorization (RFC 9111 s3.5).
1629
+ */
1630
+ const SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "must-revalidate"];
1631
+
1632
+ /** Case-insensitive request header lookup. */
1633
+ function requestHeader(req: { headers?: Record<string, unknown> }, name: string): string | undefined {
1634
+ const headers = req?.headers;
1635
+ if (!headers) return undefined;
1636
+ const target = name.toLowerCase();
1637
+ for (const [key, value] of Object.entries(headers)) {
1638
+ if (key.toLowerCase() === target) {
1639
+ return Array.isArray(value) ? value.join(", ") : String(value);
1640
+ }
1641
+ }
1642
+ return undefined;
1643
+ }
1644
+
1645
+ /** The lower-cased field names in a response's Vary header. */
1646
+ function varyFields(raw: unknown): string[] {
1647
+ if (raw === undefined || raw === null) return [];
1648
+ const text = Array.isArray(raw) ? raw.join(",") : String(raw);
1649
+ return text.split(",").map((f) => f.trim().toLowerCase()).filter((f) => f !== "");
1650
+ }
1651
+
1652
+ /**
1653
+ * May a SHARED cache store this response? (RFC 9111 s3, s4.1)
1654
+ *
1655
+ * s3 — "if the cache is shared: the Authorization header field is not present
1656
+ * in the request ... or a response directive is present that explicitly allows
1657
+ * shared caching". The key is method + URL only, and on Node the cache answers
1658
+ * BEFORE the auth gate, so without this an anonymous caller was served an
1659
+ * authenticated caller's body with a 200.
1660
+ *
1661
+ * s4.1 — a stored response whose Vary contains "*" "always fails to match", so
1662
+ * storing one is pointless.
1663
+ */
1664
+ function mayStore(req: { headers?: Record<string, unknown> }, vary: string[], cacheControl: unknown): boolean {
1665
+ if (vary.includes("*")) return false;
1666
+ if (requestHeader(req, "authorization") === undefined) return true;
1667
+ const cc = String(cacheControl ?? "").toLowerCase();
1668
+ return SHARED_CACHE_DIRECTIVES.some((directive) => cc.includes(directive));
1669
+ }
1670
+
1671
+ /**
1672
+ * Do the nominated request headers match the ones recorded on the entry?
1673
+ *
1674
+ * RFC 9111 s4.1 — the cache MUST NOT use a stored response unless every request
1675
+ * header field nominated by its Vary value matches. An absent field only
1676
+ * matches an absent field.
1677
+ */
1678
+ function varyMatches(entry: CacheEntry, req: { headers?: Record<string, unknown> }): boolean {
1679
+ const vary = entry.vary ?? [];
1680
+ if (vary.length === 0) return true;
1681
+ const recorded = entry.varyValues ?? {};
1682
+ return vary.every((field) => requestHeader(req, field) === recorded[field]);
1683
+ }
1684
+
1248
1685
  export function responseCache(config?: ResponseCacheConfig): Middleware {
1249
1686
  const ttl = config?.ttl
1250
1687
  ?? (process.env.TINA4_CACHE_TTL ? parseInt(process.env.TINA4_CACHE_TTL, 10) : 60);
@@ -1266,7 +1703,8 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
1266
1703
  const cacheKey = `response:GET:${req.url}`;
1267
1704
  const cached = (await backend.get(cacheKey)) as CacheEntry | undefined;
1268
1705
 
1269
- if (cached && typeof cached === "object" && typeof cached.body === "string") {
1706
+ if (cached && typeof cached === "object" && typeof cached.body === "string"
1707
+ && varyMatches(cached, req as any)) {
1270
1708
  // Cache HIT — serve from the (possibly distributed) backend.
1271
1709
  res.header("X-Cache", "HIT");
1272
1710
  // X-Cache-TTL advertises the configured cache lifetime in seconds
@@ -1282,10 +1720,14 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
1282
1720
  let captured = false;
1283
1721
 
1284
1722
  res.raw.end = function (chunk?: any, ...args: any[]) {
1285
- if (!captured && allowedCodes.has(res.raw.statusCode)) {
1723
+ const vary = varyFields(res.raw.getHeader("Vary"));
1724
+ if (!captured && allowedCodes.has(res.raw.statusCode)
1725
+ && mayStore(req as any, vary, res.raw.getHeader("Cache-Control"))) {
1286
1726
  captured = true;
1287
1727
  const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
1288
1728
  const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
1729
+ const varyValues: Record<string, string | undefined> = {};
1730
+ for (const field of vary) varyValues[field] = requestHeader(req as any, field);
1289
1731
 
1290
1732
  // backend.set is async; the captured end() must stay synchronous (Node
1291
1733
  // flushes the body here), so fire-and-forget the store. The backend's
@@ -1296,6 +1738,8 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
1296
1738
  contentType,
1297
1739
  statusCode: res.raw.statusCode,
1298
1740
  expiresAt: Date.now() + ttl * 1000,
1741
+ vary,
1742
+ varyValues,
1299
1743
  } as CacheEntry, ttl).catch(() => { /* best effort */ });
1300
1744
  }
1301
1745
 
@@ -1387,11 +1831,7 @@ export async function cacheClear(): Promise<void> {
1387
1831
 
1388
1832
  /** Remove expired entries from the cache. Returns count removed. */
1389
1833
  export async function sweep(): Promise<number> {
1390
- const backend = await _getBackend();
1391
- if (typeof (backend as any).sweep === "function") {
1392
- return (backend as any).sweep();
1393
- }
1394
- return 0;
1834
+ return (await _getBackend()).sweep();
1395
1835
  }
1396
1836
 
1397
1837
  /** Return cache statistics from the active backend. */
@@ -1409,5 +1849,8 @@ export function _resetBackend(): void {
1409
1849
  _defaultBackendPromise = null;
1410
1850
  _responseBackend = null;
1411
1851
  _responseBackendPromise = null;
1852
+ // Explicitly-configured backends are memoised too, so a reset that left them
1853
+ // behind would leak one test's provider into the next.
1854
+ _explicitResponseBackends.clear();
1412
1855
  _defaultTtl = null;
1413
1856
  }