返回 CodeWhale
facts.ts
根目录 / web / lib / facts.ts
1 import {
2 FACTS as BUILD_TIME_FACTS,
3 type PublishedReleaseFact,
4 type RepoFacts,
5 type ProviderFact,
6 } from "./facts.generated";
7
8 const KV_KEY = "facts:current";
9
10 export type { PublishedReleaseFact, RepoFacts, ProviderFact };
11
12 export const BUILD_FACTS: RepoFacts = {
13 ...BUILD_TIME_FACTS,
14 sourceRevision:
15 process.env.NEXT_PUBLIC_CODEWHALE_SOURCE_REVISION || BUILD_TIME_FACTS.sourceRevision,
16 sourceCommittedAt:
17 process.env.NEXT_PUBLIC_CODEWHALE_SOURCE_COMMITTED_AT ||
18 BUILD_TIME_FACTS.sourceCommittedAt,
19 };
20
21 interface KVNamespace {
22 get(key: string): Promise<string | null>;
23 put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
24 }
25
26 export type FactsSource = "build" | "kv";
27
28 export interface FactsResolution {
29 facts: RepoFacts;
30 source: FactsSource;
31 reason:
32 | "no-kv-snapshot"
33 | "invalid-kv-snapshot"
34 | "kv-missing-source-provenance"
35 | "build-missing-source-provenance"
36 | "same-source-revision"
37 | "kv-source-newer"
38 | "build-source-newer-or-ambiguous";
39 buildSourceRevision: string | null;
40 kvSourceRevision: string | null;
41 }
42
43 function isRecord(value: unknown): value is Record<string, unknown> {
44 return value !== null && typeof value === "object" && !Array.isArray(value);
45 }
46
47 function isPublishedRelease(value: unknown): value is PublishedReleaseFact {
48 if (!isRecord(value)) return false;
49 return (
50 typeof value.tag === "string" &&
51 typeof value.version === "string" &&
52 value.tag === `v${value.version}` &&
53 typeof value.publishedAt === "string" &&
54 Number.isFinite(Date.parse(value.publishedAt)) &&
55 value.url === `https://github.com/Hmbown/CodeWhale/releases/tag/${value.tag}`
56 );
57 }
58
59 export function isRepoFacts(value: unknown): value is RepoFacts {
60 if (!isRecord(value)) return false;
61 const sourceRevisionValid =
62 value.sourceRevision === null ||
63 (typeof value.sourceRevision === "string" && /^[0-9a-f]{40}$/i.test(value.sourceRevision));
64 const sourceCommittedAtValid =
65 value.sourceCommittedAt === null ||
66 (typeof value.sourceCommittedAt === "string" &&
67 Number.isFinite(Date.parse(value.sourceCommittedAt)));
68 const sourceProvenancePaired =
69 (value.sourceRevision === null) === (value.sourceCommittedAt === null);
70
71 return (
72 typeof value.generatedAt === "string" &&
73 sourceRevisionValid &&
74 sourceCommittedAtValid &&
75 sourceProvenancePaired &&
76 typeof value.version === "string" &&
77 Array.isArray(value.crates) &&
78 Array.isArray(value.sandboxBackends) &&
79 Array.isArray(value.providers) &&
80 value.providers.every(
81 (provider) =>
82 isRecord(provider) &&
83 typeof provider.id === "string" &&
84 typeof provider.label === "string" &&
85 typeof provider.env === "string",
86 ) &&
87 (value.defaultModel === null || typeof value.defaultModel === "string") &&
88 (value.nodeEngines === null || typeof value.nodeEngines === "string") &&
89 (value.toolCount === null || typeof value.toolCount === "number") &&
90 (value.license === null || typeof value.license === "string") &&
91 (value.latestPublishedRelease === null ||
92 isPublishedRelease(value.latestPublishedRelease))
93 );
94 }
95
96 function newestPublishedRelease(
97 build: PublishedReleaseFact | null,
98 cached: PublishedReleaseFact | null,
99 ): PublishedReleaseFact | null {
100 if (!build) return cached;
101 if (!cached) return build;
102 return Date.parse(cached.publishedAt) > Date.parse(build.publishedAt) ? cached : build;
103 }
104
105 /**
106 * Select the mechanical fact snapshot independently from the latest published
107 * release. A legacy or malformed KV value cannot replace a newer build merely
108 * because it exists. Source commit timestamps provide ordering, while an exact
109 * revision match permits the runtime snapshot for the same source.
110 */
111 export function resolveFacts(build: RepoFacts, cached: unknown): FactsResolution {
112 if (cached == null) {
113 return {
114 facts: build,
115 source: "build",
116 reason: "no-kv-snapshot",
117 buildSourceRevision: build.sourceRevision,
118 kvSourceRevision: null,
119 };
120 }
121 if (!isRepoFacts(cached)) {
122 return {
123 facts: build,
124 source: "build",
125 reason: "invalid-kv-snapshot",
126 buildSourceRevision: build.sourceRevision,
127 kvSourceRevision: null,
128 };
129 }
130
131 const latestPublishedRelease = newestPublishedRelease(
132 build.latestPublishedRelease,
133 cached.latestPublishedRelease,
134 );
135 const withLatestRelease = (facts: RepoFacts): RepoFacts => ({
136 ...facts,
137 latestPublishedRelease,
138 });
139
140 if (!cached.sourceRevision || !cached.sourceCommittedAt) {
141 return {
142 facts: withLatestRelease(build),
143 source: "build",
144 reason: "kv-missing-source-provenance",
145 buildSourceRevision: build.sourceRevision,
146 kvSourceRevision: cached.sourceRevision,
147 };
148 }
149 if (!build.sourceRevision || !build.sourceCommittedAt) {
150 return {
151 facts: withLatestRelease(build),
152 source: "build",
153 reason: "build-missing-source-provenance",
154 buildSourceRevision: build.sourceRevision,
155 kvSourceRevision: cached.sourceRevision,
156 };
157 }
158 if (cached.sourceRevision === build.sourceRevision) {
159 return {
160 facts: withLatestRelease(cached),
161 source: "kv",
162 reason: "same-source-revision",
163 buildSourceRevision: build.sourceRevision,
164 kvSourceRevision: cached.sourceRevision,
165 };
166 }
167
168 const buildTime = Date.parse(build.sourceCommittedAt);
169 const cachedTime = Date.parse(cached.sourceCommittedAt);
170 if (Number.isFinite(buildTime) && Number.isFinite(cachedTime) && cachedTime > buildTime) {
171 return {
172 facts: withLatestRelease(cached),
173 source: "kv",
174 reason: "kv-source-newer",
175 buildSourceRevision: build.sourceRevision,
176 kvSourceRevision: cached.sourceRevision,
177 };
178 }
179
180 return {
181 facts: withLatestRelease(build),
182 source: "build",
183 reason: "build-source-newer-or-ambiguous",
184 buildSourceRevision: build.sourceRevision,
185 kvSourceRevision: cached.sourceRevision,
186 };
187 }
188
189 async function getKv(): Promise<KVNamespace | undefined> {
190 if (process.env.NEXT_PHASE === "phase-production-build") {
191 return undefined;
192 }
193
194 try {
195 const mod = await import("@opennextjs/cloudflare");
196 const ctx = await mod.getCloudflareContext({ async: true });
197 return (ctx.env as { CURATED_KV?: KVNamespace }).CURATED_KV;
198 } catch {
199 return undefined;
200 }
201 }
202
203 export async function getFactsWithProvenance(): Promise<FactsResolution> {
204 try {
205 const kv = await getKv();
206 if (!kv) return resolveFacts(BUILD_FACTS, null);
207 const raw = await kv.get(KV_KEY);
208 if (!raw) return resolveFacts(BUILD_FACTS, null);
209 return resolveFacts(BUILD_FACTS, JSON.parse(raw) as unknown);
210 } catch {
211 return resolveFacts(BUILD_FACTS, null);
212 }
213 }
214
215 /** Resolved facts for the current request, with fail-safe build precedence. */
216 export async function getFacts(): Promise<RepoFacts> {
217 return (await getFactsWithProvenance()).facts;
218 }
219
219 lines TYPESCRIPT