data-source-cache.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import type {
  2. ContextLoadContext,
  3. DataSource,
  4. DataSourceLoadAdapter,
  5. } from "@/lib/novel/context-data-source"
  6. import { getDataSourceKinds } from "./source-paths"
  7. import { sha256Text } from "./fingerprint"
  8. import {
  9. CONTEXT_CACHE_SCHEMA_VERSION,
  10. type CachedArtifact,
  11. type ContextCacheScope,
  12. type ContextCacheItemTrace,
  13. type ContextSourceKind,
  14. type DependencyStamp,
  15. } from "./types"
  16. interface DataSourceCacheRegistry {
  17. refresh(): Promise<unknown>
  18. getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp>
  19. getDependencyPreview(kinds?: ContextSourceKind[], limit?: number): string[]
  20. }
  21. interface DataSourceCacheStorage {
  22. readArtifact<T>(key: string): Promise<CachedArtifact<T> | null>
  23. writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void>
  24. }
  25. export interface DataSourceCacheAdapterOptions {
  26. registry: DataSourceCacheRegistry
  27. storage: DataSourceCacheStorage
  28. forceRefresh?: boolean
  29. }
  30. export interface DataSourceCacheStats {
  31. hits: number
  32. refreshed: number
  33. failures: number
  34. }
  35. const STATIC_SOURCES = new Set([
  36. "canonRules",
  37. "writingStyle",
  38. "soulDoc",
  39. "characterAuras",
  40. "storyFrameworkBinding",
  41. "relatedSettings",
  42. ])
  43. const CHAPTER_SCOPED_SOURCES = new Set([
  44. "outline",
  45. "chapterOutline",
  46. "volumeContext",
  47. "snapshots",
  48. "recentChapterContents",
  49. "fallbackRecentSummaries",
  50. "fallbackPreviousEnding",
  51. "fallbackCharacterStates",
  52. "fallbackForeshadowingStates",
  53. "fallbackTimeline",
  54. "revisionFeedback",
  55. "cognitionText",
  56. "sectionBriefing",
  57. "retrieval",
  58. ])
  59. function canonicalize(value: unknown): unknown {
  60. if (Array.isArray(value)) return value.map(canonicalize)
  61. if (!value || typeof value !== "object") return value
  62. return Object.fromEntries(
  63. Object.entries(value as Record<string, unknown>)
  64. .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
  65. .map(([key, child]) => [key, canonicalize(child)]),
  66. )
  67. }
  68. async function sourceRequestKey(sourceName: string, context: ContextLoadContext): Promise<string> {
  69. const scope = STATIC_SOURCES.has(sourceName)
  70. ? {}
  71. : CHAPTER_SCOPED_SOURCES.has(sourceName)
  72. ? { chapterNumber: context.chapterNumber ?? null, config: context.config }
  73. : { task: context.task, chapterNumber: context.chapterNumber ?? null, config: context.config }
  74. return `data-source:${sourceName}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
  75. }
  76. function dependencyStampsMatch(cached: DependencyStamp, current: DependencyStamp): boolean {
  77. return cached.fingerprint === current.fingerprint
  78. }
  79. function cacheScopeFor(sourceName: string): ContextCacheScope {
  80. if (STATIC_SOURCES.has(sourceName)) return "static"
  81. if (CHAPTER_SCOPED_SOURCES.has(sourceName)) return "chapter"
  82. return "task"
  83. }
  84. function hasCacheableValue(value: unknown): boolean {
  85. if (typeof value === "string") return value.trim().length > 0
  86. if (Array.isArray(value)) return value.length > 0
  87. if (value && typeof value === "object") return Object.keys(value).length > 0
  88. return value !== null && value !== undefined
  89. }
  90. export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
  91. private readonly pending = new Map<string, Promise<unknown>>()
  92. private readonly stats: DataSourceCacheStats = { hits: 0, refreshed: 0, failures: 0 }
  93. private readonly traceItems: ContextCacheItemTrace[] = []
  94. constructor(private readonly options: DataSourceCacheAdapterOptions) {}
  95. async load<T>(
  96. source: DataSource<T>,
  97. context: ContextLoadContext,
  98. directLoad: () => Promise<T>,
  99. ): Promise<T> {
  100. await this.options.registry.refresh()
  101. const kinds = getDataSourceKinds(source.name)
  102. const dependencyStamp = await this.options.registry.getDependencyStamp(kinds)
  103. const dependencyPaths = this.options.registry.getDependencyPreview(kinds, 20)
  104. const key = await sourceRequestKey(source.name, context)
  105. const pending = this.pending.get(key)
  106. if (pending) return pending as Promise<T>
  107. const operation = this.loadInternal(
  108. key,
  109. source.name,
  110. dependencyStamp,
  111. dependencyPaths,
  112. directLoad,
  113. )
  114. .finally(() => this.pending.delete(key))
  115. this.pending.set(key, operation)
  116. return operation
  117. }
  118. getStats(): DataSourceCacheStats {
  119. return { ...this.stats }
  120. }
  121. getTraceItems(): ContextCacheItemTrace[] {
  122. return this.traceItems.map((item) => ({
  123. ...item,
  124. dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
  125. dependencyPaths: [...item.dependencyPaths],
  126. }))
  127. }
  128. private async loadInternal<T>(
  129. key: string,
  130. sourceName: string,
  131. dependencyStamp: DependencyStamp,
  132. dependencyPaths: string[],
  133. directLoad: () => Promise<T>,
  134. ): Promise<T> {
  135. const trace = (status: ContextCacheItemTrace["status"]): ContextCacheItemTrace => ({
  136. key,
  137. sourceName,
  138. status,
  139. dependencyStamp,
  140. dependencyPaths,
  141. dependencyPathsTruncated: dependencyStamp.sourceCount > dependencyPaths.length,
  142. })
  143. if (!this.options.forceRefresh) {
  144. try {
  145. const cached = await this.options.storage.readArtifact<T>(key)
  146. if (cached && dependencyStampsMatch(cached.dependencyStamp, dependencyStamp)) {
  147. this.stats.hits += 1
  148. this.traceItems.push(trace("hit"))
  149. return cached.value
  150. }
  151. } catch {
  152. this.stats.failures += 1
  153. this.traceItems.push(trace("failed"))
  154. }
  155. }
  156. const value = await directLoad()
  157. this.stats.refreshed += 1
  158. this.traceItems.push(trace("refreshed"))
  159. if (!hasCacheableValue(value)) return value
  160. try {
  161. await this.options.storage.writeArtifact(key, {
  162. schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
  163. key,
  164. sourceName,
  165. scope: cacheScopeFor(sourceName),
  166. value,
  167. dependencyStamp,
  168. createdAt: Date.now(),
  169. })
  170. } catch {
  171. this.stats.failures += 1
  172. this.traceItems.push(trace("failed"))
  173. }
  174. return value
  175. }
  176. }