cache-filesystem.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import createDb from 'better-sqlite3';
  2. import type { Database, Statement } from 'better-sqlite3';
  3. import os from 'node:os';
  4. import path from 'node:path';
  5. import { mkdirSync } from 'node:fs';
  6. import picocolors from 'picocolors';
  7. import { fastStringArrayJoin } from 'foxts/fast-string-array-join';
  8. import { performance } from 'node:perf_hooks';
  9. // import type { UndiciResponseData } from './fetch-retry';
  10. export interface CacheOptions<S = string> {
  11. /** Path to sqlite file dir */
  12. cachePath?: string,
  13. /** Time before deletion */
  14. tbd?: number,
  15. /** Cache table name */
  16. tableName?: string,
  17. type?: S extends string ? 'string' : 'buffer'
  18. }
  19. interface CacheApplyRawOption {
  20. ttl?: number | null,
  21. temporaryBypass?: boolean,
  22. incrementTtlWhenHit?: boolean
  23. }
  24. interface CacheApplyNonRawOption<T, S> extends CacheApplyRawOption {
  25. serializer: (value: T) => S,
  26. deserializer: (cached: S) => T
  27. }
  28. export type CacheApplyOption<T, S> = T extends S ? CacheApplyRawOption : CacheApplyNonRawOption<T, S>;
  29. export class Cache<S = string> {
  30. private db: Database;
  31. /** Time before deletion */
  32. tbd = 60 * 1000;
  33. /** SQLite file path */
  34. cachePath: string;
  35. /** Table name */
  36. tableName: string;
  37. type: S extends string ? 'string' : 'buffer';
  38. private statement: {
  39. updateTtl: Statement<[number, string]>,
  40. del: Statement<[string]>,
  41. insert: Statement<[unknown]>,
  42. get: Statement<[string], { ttl: number, value: S }>
  43. };
  44. constructor({
  45. cachePath = path.join(os.tmpdir() || '/tmp', 'hdc'),
  46. tbd,
  47. tableName = 'cache',
  48. type
  49. }: CacheOptions<S> = {}) {
  50. const start = performance.now();
  51. this.cachePath = cachePath;
  52. mkdirSync(this.cachePath, { recursive: true });
  53. if (tbd != null) this.tbd = tbd;
  54. this.tableName = tableName;
  55. if (type) {
  56. this.type = type;
  57. } else {
  58. // @ts-expect-error -- fallback type
  59. this.type = 'string';
  60. }
  61. const db = createDb(path.join(this.cachePath, 'cache.db'));
  62. db.pragma('journal_mode = WAL');
  63. db.pragma('synchronous = normal');
  64. db.pragma('temp_store = memory');
  65. db.pragma('optimize');
  66. db.prepare(`CREATE TABLE IF NOT EXISTS ${this.tableName} (key TEXT PRIMARY KEY, value ${this.type === 'string' ? 'TEXT' : 'BLOB'}, ttl REAL NOT NULL);`).run();
  67. db.prepare(`CREATE INDEX IF NOT EXISTS cache_ttl ON ${this.tableName} (ttl);`).run();
  68. /** cache stmt */
  69. this.statement = {
  70. updateTtl: db.prepare(`UPDATE ${this.tableName} SET ttl = ? WHERE key = ?;`),
  71. del: db.prepare(`DELETE FROM ${this.tableName} WHERE key = ?`),
  72. insert: db.prepare(`INSERT INTO ${this.tableName} (key, value, ttl) VALUES ($key, $value, $valid) ON CONFLICT(key) DO UPDATE SET value = $value, ttl = $valid`),
  73. get: db.prepare(`SELECT ttl, value FROM ${this.tableName} WHERE key = ? LIMIT 1`)
  74. } as const;
  75. const date = new Date();
  76. // perform purge on startup
  77. // ttl + tbd < now => ttl < now - tbd
  78. const now = date.getTime() - this.tbd;
  79. db.prepare(`DELETE FROM ${this.tableName} WHERE ttl < ?`).run(now);
  80. this.db = db;
  81. const dateString = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
  82. const lastVaccum = this.get('__LAST_VACUUM');
  83. if (lastVaccum === undefined || (lastVaccum !== dateString && date.getUTCDay() === 6)) {
  84. console.log(picocolors.magenta('[cache] vacuuming'));
  85. this.set('__LAST_VACUUM', dateString, 10 * 365 * 60 * 60 * 24 * 1000);
  86. this.db.exec('VACUUM;');
  87. }
  88. const end = performance.now();
  89. console.log(`${picocolors.gray(`[${((end - start)).toFixed(3)}ns]`)} cache initialized from ${this.tableName} @ ${this.cachePath}`);
  90. }
  91. set(key: string, value: string, ttl = 60 * 1000): void {
  92. const valid = Date.now() + ttl;
  93. this.statement.insert.run({
  94. $key: key,
  95. key,
  96. $value: value,
  97. value,
  98. $valid: valid,
  99. valid
  100. });
  101. }
  102. get(key: string): S | null {
  103. const rv = this.statement.get.get(key);
  104. if (!rv) return null;
  105. if (rv.ttl < Date.now()) {
  106. this.del(key);
  107. return null;
  108. }
  109. if (rv.value == null) {
  110. this.del(key);
  111. return null;
  112. }
  113. return rv.value;
  114. }
  115. updateTtl(key: string, ttl: number): void {
  116. this.statement.updateTtl.run(Date.now() + ttl, key);
  117. }
  118. del(key: string): void {
  119. this.statement.del.run(key);
  120. }
  121. destroy() {
  122. this.db.close();
  123. }
  124. deleteTable(tableName: string) {
  125. this.db.exec(`DROP TABLE IF EXISTS ${tableName};`);
  126. }
  127. }
  128. // process.on('exit', () => {
  129. // fsFetchCache.destroy();
  130. // });
  131. const separator = '\u0000';
  132. export const serializeArray = (arr: string[]) => fastStringArrayJoin(arr, separator);
  133. export const deserializeArray = (str: string) => str.split(separator);