@capagents/forgeqe 1.1.3 → 1.1.4
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.
- package/README.md +6 -5
- package/browserstack.example.yaml +1 -1
- package/forgeqe.example.yaml +7 -13
- package/package.json +1 -2
- package/py/forgeqe_engine/browser.py +78 -11
- package/py/forgeqe_engine/service.py +12 -0
- package/src/bi.ts +382 -302
- package/src/capabilities/guide.ts +3 -3
- package/src/cli/help.ts +8 -5
- package/src/cli/index.ts +20 -8
- package/src/config.ts +18 -38
- package/src/run.ts +5 -0
- package/testmu.example.yaml +3 -4
- package/src/cache.ts +0 -92
|
@@ -15,9 +15,9 @@ import { describeRequirement } from "./status.ts"
|
|
|
15
15
|
const root = path.resolve(import.meta.dir, "..", "..")
|
|
16
16
|
export const GUIDE_FILE = path.join(root, "docs", "user-guide.html")
|
|
17
17
|
const THEME_SOURCE = path.join(root, "docs", "security-data-flow.html")
|
|
18
|
-
const GUIDE_LOGO_LIGHT_FILE = "
|
|
19
|
-
const GUIDE_LOGO_DARK_FILE = "
|
|
20
|
-
const PARTNER_LOGO_FILE = "
|
|
18
|
+
const GUIDE_LOGO_LIGHT_FILE = path.join(root, "assets", "forgeqe-logo.png")
|
|
19
|
+
const GUIDE_LOGO_DARK_FILE = path.join(root, "assets", "forgeqe-logo-white.png")
|
|
20
|
+
const PARTNER_LOGO_FILE = path.join(root, "assets", "capgemini-logo.svg")
|
|
21
21
|
|
|
22
22
|
/** Chapters that are not a capability area: which HOW_TO_USE sections they hold, in order. */
|
|
23
23
|
const START: DocRef[] = ["What you need to run", "Quick start", "1. Install", "2. Scaffold config"].map((heading) => ({ doc: "HOW_TO_USE.md", heading }))
|
package/src/cli/help.ts
CHANGED
|
@@ -38,9 +38,11 @@ Usage:
|
|
|
38
38
|
forgeqe mcp [--repo DIR] [--profile name] [--url URL] [--headed] [--browser-provider local|browserstack|testmu]
|
|
39
39
|
ForgeQE's tools as an MCP server on stdio, for other agents
|
|
40
40
|
forgeqe mcp list the Azure DevOps, Jira and other MCP servers ForgeQE's agent gets
|
|
41
|
-
forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]
|
|
42
|
-
export ForgeQE + ADO/Jira datasets into an Evidence project and build static
|
|
43
|
-
|
|
41
|
+
forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]
|
|
42
|
+
export ForgeQE + ADO/Jira datasets into an Evidence project and build a static
|
|
43
|
+
site; without Node.js or when Evidence fails, ForgeQE's own page (--static: always,
|
|
44
|
+
--evidence: fail instead)
|
|
45
|
+
forgeqe bi init scaffold the Evidence project and install its packages
|
|
44
46
|
forgeqe bi sync refresh the CSV datasets and generated Evidence pages, without building
|
|
45
47
|
forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]
|
|
46
48
|
the tests a pull request (or the commits since BRANCH) can break; --run runs
|
|
@@ -138,8 +140,9 @@ The report is report.html (step verdicts, timeline with screenshots, timings, en
|
|
|
138
140
|
out/suites/<time>/; every run folder also has its own report.html.
|
|
139
141
|
|
|
140
142
|
Static BI (bi: in forgeqe.yaml): \`forgeqe bi\` writes curated CSV datasets from ForgeQE's own suite and readiness
|
|
141
|
-
reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project
|
|
142
|
-
\`evidence build\` to produce static
|
|
143
|
+
reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project (@evidence-dev/evidence
|
|
144
|
+
40.x, needs Node.js), and runs \`evidence sources\` and \`evidence build\` to produce a static site in the build directory;
|
|
145
|
+
when that cannot run, it writes ForgeQE's own self-contained page instead. No server stays up between runs.
|
|
143
146
|
|
|
144
147
|
Healing (forgeqe heal): ForgeQE runs the tests (the command given, code.test_command, or one the agent finds).
|
|
145
148
|
For each failing test, Scribe re-runs its flow in the real browser: when the flow still works, the test is out
|
package/src/cli/index.ts
CHANGED
|
@@ -217,7 +217,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
217
217
|
const cfg = loadConfig(configOpts(flags))
|
|
218
218
|
const trends = positionals[1] === "trends"
|
|
219
219
|
const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
220
|
-
|
|
220
|
+
const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
|
|
221
221
|
const out = (await TOOLS.find((t) => t.name === (trends ? "quality_trends" : "quality_report"))!.run(trends ? { page: positionals[2] } : { run_dir: positionals[1] }, ctx)) as { budget_exceeded?: unknown[]; pages?: { slower: unknown[]; newly_failing_rules: unknown[] }[] }
|
|
222
222
|
return out.budget_exceeded?.length || out.pages?.some((p) => p.slower.length || p.newly_failing_rules.length) ? 1 : 0
|
|
223
223
|
}
|
|
@@ -382,7 +382,7 @@ async function cmdApi(args: string[], flags: Flags): Promise<number> {
|
|
|
382
382
|
const cfg = loadConfig(configOpts(flags))
|
|
383
383
|
const action = args[0] || "list"
|
|
384
384
|
const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
385
|
-
|
|
385
|
+
const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
|
|
386
386
|
const tool = (name: string) => TOOLS.find((t) => t.name === name)!
|
|
387
387
|
const csv = (name: string) => (strFlag(flags, name) || "").split(",").map((s) => s.trim()).filter(Boolean)
|
|
388
388
|
switch (action) {
|
|
@@ -489,11 +489,16 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
|
|
|
489
489
|
dataDir: strFlag(flags, "data-dir"),
|
|
490
490
|
buildDir: strFlag(flags, "build-dir") || strFlag(flags, "out"),
|
|
491
491
|
}
|
|
492
|
+
const shown = (file: string) => path.relative(process.cwd(), file) || file
|
|
493
|
+
const printNotes = (notes: string[]) => {
|
|
494
|
+
for (const note of notes) console.log(`note ${note}`)
|
|
495
|
+
}
|
|
492
496
|
switch (action) {
|
|
493
497
|
case "init": {
|
|
494
498
|
const out = await initBiProject(cfg, overrides)
|
|
495
499
|
console.log(`Evidence project ${out.evidenceDir}`)
|
|
496
|
-
for (const file of out.files) console.log(`generated ${
|
|
500
|
+
for (const file of out.files) console.log(`generated ${shown(file)}`)
|
|
501
|
+
printNotes(out.notes)
|
|
497
502
|
return 0
|
|
498
503
|
}
|
|
499
504
|
case "sync": {
|
|
@@ -505,23 +510,30 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
|
|
|
505
510
|
console.log(`Case results ${out.caseResults}`)
|
|
506
511
|
console.log(`Readiness reports ${out.readinessReports}`)
|
|
507
512
|
console.log(`External datasets ${out.externalDatasets}`)
|
|
508
|
-
console.log(`Summary ${
|
|
513
|
+
console.log(`Summary ${shown(out.summaryFile)}`)
|
|
514
|
+
printNotes(out.notes)
|
|
509
515
|
return 0
|
|
510
516
|
}
|
|
511
517
|
case "build": {
|
|
512
|
-
const out = await buildBi(cfg, overrides)
|
|
518
|
+
const out = await buildBi(cfg, overrides, boolFlag(flags, "static") ? "static" : boolFlag(flags, "evidence") ? "evidence" : "auto")
|
|
513
519
|
console.log(`Evidence project ${out.evidenceDir}`)
|
|
514
520
|
console.log(`Static site ${out.buildDir}`)
|
|
515
|
-
console.log(`Index ${
|
|
516
|
-
console.log(`Summary ${
|
|
521
|
+
console.log(`Index ${shown(out.indexFile)}`)
|
|
522
|
+
console.log(`Summary ${shown(out.summaryFile)}`)
|
|
517
523
|
console.log(`Suite runs ${out.suites}`)
|
|
518
524
|
console.log(`Case results ${out.caseResults}`)
|
|
519
525
|
console.log(`Readiness reports ${out.readinessReports}`)
|
|
520
526
|
console.log(`External datasets ${out.externalDatasets}`)
|
|
527
|
+
if (out.engine === "evidence") {
|
|
528
|
+
console.log("Built with Evidence. Serve the folder to view it (npx serve, or evidence preview): opened from disk it cannot load its data.")
|
|
529
|
+
} else {
|
|
530
|
+
console.log(`Built with ForgeQE's own page, without Evidence (${out.fallbackReason}). It opens straight from disk.`)
|
|
531
|
+
}
|
|
532
|
+
printNotes(out.notes)
|
|
521
533
|
return 0
|
|
522
534
|
}
|
|
523
535
|
default:
|
|
524
|
-
throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]")
|
|
536
|
+
throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]")
|
|
525
537
|
}
|
|
526
538
|
}
|
|
527
539
|
|
package/src/config.ts
CHANGED
|
@@ -63,20 +63,6 @@ export type MemoryConfig = {
|
|
|
63
63
|
replay: ReplayMode
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export type CacheConfig = {
|
|
67
|
-
enabled: boolean
|
|
68
|
-
/** Redis connection string, e.g. redis://127.0.0.1:6379. */
|
|
69
|
-
redisUrl: string
|
|
70
|
-
/** Prefix for every Redis key this ForgeQE workspace writes. */
|
|
71
|
-
keyPrefix: string
|
|
72
|
-
/** Default TTL for cached JSON payloads, in seconds. */
|
|
73
|
-
defaultTtlSeconds: number
|
|
74
|
-
/** TTL for BI external dataset exports, in seconds. */
|
|
75
|
-
biTtlSeconds: number
|
|
76
|
-
/** Socket connect timeout for Redis, in milliseconds. */
|
|
77
|
-
connectTimeoutMs: number
|
|
78
|
-
}
|
|
79
|
-
|
|
80
66
|
function runtimeBin(value: unknown, base: string): string | undefined {
|
|
81
67
|
if (!value) return undefined
|
|
82
68
|
const s = String(value)
|
|
@@ -348,8 +334,6 @@ export type AppConfig = {
|
|
|
348
334
|
configPath?: string
|
|
349
335
|
/** `mcp:` in forgeqe.yaml: more MCP servers for ForgeQE's agent, by name. */
|
|
350
336
|
mcp?: Record<string, ExtraMcpServer>
|
|
351
|
-
/** Optional Redis-backed cache for deterministic external data fetches. */
|
|
352
|
-
cache?: CacheConfig
|
|
353
337
|
activeProfile: string
|
|
354
338
|
profiles: Record<string, LlmProfile>
|
|
355
339
|
llm: LlmProfile
|
|
@@ -410,22 +394,6 @@ function domainConfig(raw: unknown): DomainConfig | undefined {
|
|
|
410
394
|
return { industry, regulations, ...(d.notes ? { notes: String(d.notes).trim() } : {}) }
|
|
411
395
|
}
|
|
412
396
|
|
|
413
|
-
function cacheConfig(raw: unknown): CacheConfig | undefined {
|
|
414
|
-
const c = asRecord(raw)
|
|
415
|
-
const asked = raw !== undefined || Boolean(process.env.FORGEQE_REDIS_URL)
|
|
416
|
-
if (!asked || c.enabled === false) return undefined
|
|
417
|
-
const redisUrl = String(c.redis_url || c.url || process.env.FORGEQE_REDIS_URL || "").trim()
|
|
418
|
-
if (!redisUrl) throw new Error('cache.redis_url is required when cache is enabled. Example: cache: { redis_url: "redis://127.0.0.1:6379" }')
|
|
419
|
-
return {
|
|
420
|
-
enabled: true,
|
|
421
|
-
redisUrl,
|
|
422
|
-
keyPrefix: String(c.key_prefix || "forgeqe").trim() || "forgeqe",
|
|
423
|
-
defaultTtlSeconds: positiveInt(c.default_ttl_seconds, 3600, "cache.default_ttl_seconds"),
|
|
424
|
-
biTtlSeconds: positiveInt(c.bi_ttl_seconds, 900, "cache.bi_ttl_seconds"),
|
|
425
|
-
connectTimeoutMs: positiveInt(c.connect_timeout_ms, 3000, "cache.connect_timeout_ms"),
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
397
|
export function replayMode(raw: unknown): ReplayMode {
|
|
430
398
|
const mode = String(raw || "verify").toLowerCase()
|
|
431
399
|
if (mode === "trust" || mode === "off") return mode
|
|
@@ -471,12 +439,12 @@ const REMOTE_BROWSER_DEFAULTS: Record<RemoteBrowserProvider, Pick<BrowserRemoteC
|
|
|
471
439
|
browserstack: {
|
|
472
440
|
usernameEnv: "BROWSERSTACK_USERNAME",
|
|
473
441
|
accessKeyEnv: "BROWSERSTACK_ACCESS_KEY",
|
|
474
|
-
endpoint: "wss://cdp.browserstack.com/
|
|
442
|
+
endpoint: "wss://cdp.browserstack.com/puppeteer",
|
|
475
443
|
},
|
|
476
444
|
testmu: {
|
|
477
445
|
usernameEnv: "LT_USERNAME",
|
|
478
446
|
accessKeyEnv: "LT_ACCESS_KEY",
|
|
479
|
-
endpoint: "wss://cdp.lambdatest.com/
|
|
447
|
+
endpoint: "wss://cdp.lambdatest.com/puppeteer",
|
|
480
448
|
},
|
|
481
449
|
}
|
|
482
450
|
|
|
@@ -545,6 +513,10 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
|
|
|
545
513
|
const defaults = REMOTE_BROWSER_DEFAULTS[provider]
|
|
546
514
|
const browser = String(remote.browser || "").trim()
|
|
547
515
|
if (!browser) throw new Error(`${where}.remote.browser is required when browser.provider is ${provider}.`)
|
|
516
|
+
// The engine drives the browser over the Chrome DevTools Protocol, so the grid must hand back a Chromium browser.
|
|
517
|
+
if (!/chrome|edge|chromium/i.test(browser) || /^(playwright|pw)-/i.test(browser)) {
|
|
518
|
+
throw new Error(`${where}.remote.browser is "${browser}": ForgeQE drives remote browsers over the Chrome DevTools Protocol, so use chrome or edge.`)
|
|
519
|
+
}
|
|
548
520
|
const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
|
|
549
521
|
const os = String(remote.os || "").trim() || undefined
|
|
550
522
|
const osVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
|
|
@@ -555,12 +527,16 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
|
|
|
555
527
|
if (provider === "testmu" && !platform && !os) {
|
|
556
528
|
throw new Error(`${where}.remote.platform is required when browser.provider is testmu, or set ${where}.remote.os (and optionally os_version).`)
|
|
557
529
|
}
|
|
530
|
+
const endpoint = String(remote.endpoint || defaults.endpoint).trim().replace(/\/+$/, "")
|
|
531
|
+
if (/\/playwright$/i.test(endpoint)) {
|
|
532
|
+
throw new Error(`${where}.remote.endpoint ${endpoint} speaks Playwright's own protocol; ForgeQE needs the CDP endpoint (${defaults.endpoint}).`)
|
|
533
|
+
}
|
|
558
534
|
const extra = objectMap(remote.extra ?? remote.capabilities, `${where}.remote.extra`)
|
|
559
535
|
return {
|
|
560
536
|
provider,
|
|
561
537
|
usernameEnv: String(remote.username_env || remote.usernameEnv || defaults.usernameEnv),
|
|
562
538
|
accessKeyEnv: String(remote.access_key_env || remote.accessKeyEnv || defaults.accessKeyEnv),
|
|
563
|
-
endpoint
|
|
539
|
+
endpoint,
|
|
564
540
|
browser,
|
|
565
541
|
browserVersion,
|
|
566
542
|
...(os ? { os } : {}),
|
|
@@ -779,7 +755,6 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
779
755
|
const config: AppConfig = {
|
|
780
756
|
configPath,
|
|
781
757
|
mcp: extraMcpServers(yaml.mcp, base),
|
|
782
|
-
cache: cacheConfig(yaml.cache),
|
|
783
758
|
activeProfile,
|
|
784
759
|
profiles,
|
|
785
760
|
llm: profiles[activeProfile] || { ...NO_PROFILE },
|
|
@@ -1076,9 +1051,12 @@ function apiConfig(a: Record<string, unknown>): ApiConfig {
|
|
|
1076
1051
|
}
|
|
1077
1052
|
}
|
|
1078
1053
|
|
|
1054
|
+
/** The tables forgeqe bi always exports; a dataset id may not reuse one. */
|
|
1055
|
+
export const BI_BUILTIN_TABLES = ["suites", "case_results", "step_results", "writebacks", "readiness", "readiness_stories", "readiness_cases", "external_datasets"]
|
|
1056
|
+
|
|
1079
1057
|
function biConfig(b: Record<string, unknown>, base: string, outDir: string, configPath: string | undefined): BiConfig {
|
|
1080
1058
|
const evidenceDir = path.resolve(base, String(b.evidence_dir || path.join(".", "evidence")))
|
|
1081
|
-
const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe"
|
|
1059
|
+
const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe")
|
|
1082
1060
|
const title = String(b.title || "ForgeQE BI").trim() || "ForgeQE BI"
|
|
1083
1061
|
const datasets = Object.entries(asRecord(b.datasets)).map(([id, value]) => biDataset(id, asRecord(value)))
|
|
1084
1062
|
return {
|
|
@@ -1091,7 +1069,9 @@ function biConfig(b: Record<string, unknown>, base: string, outDir: string, conf
|
|
|
1091
1069
|
}
|
|
1092
1070
|
|
|
1093
1071
|
function biDataset(id: string, raw: Record<string, unknown>): BiDatasetConfig {
|
|
1094
|
-
|
|
1072
|
+
// The id is the dataset's table name in Evidence's SQL (forgeqe.<id>), so it must be a plain identifier.
|
|
1073
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(id)) throw new Error(`bi.datasets.${id}: ids may only use letters, digits and _, and must not start with a digit.`)
|
|
1074
|
+
if (BI_BUILTIN_TABLES.includes(id)) throw new Error(`bi.datasets.${id}: ${id} is one of ForgeQE's own BI tables; choose another id.`)
|
|
1095
1075
|
const title = String(raw.title || id.replace(/[-_]+/g, " ").trim()).trim()
|
|
1096
1076
|
const kind = String(raw.kind || "").trim().toLowerCase()
|
|
1097
1077
|
if (kind === "ado_wiql") {
|
package/src/run.ts
CHANGED
|
@@ -181,6 +181,11 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
181
181
|
|
|
182
182
|
const missing = missingKey(opts.config.llm)
|
|
183
183
|
if (missing) throw new Error(missing)
|
|
184
|
+
// A remote grid's keys are checked here, before the engine starts, rather than failing inside it.
|
|
185
|
+
if (browser.remote) {
|
|
186
|
+
const unset = [browser.remote.usernameEnv, browser.remote.accessKeyEnv].filter((name) => !process.env[name]?.trim())
|
|
187
|
+
if (unset.length) throw new Error(`${browser.provider}: set ${unset.join(" and ")} in the environment, or save them with "forgeqe auth login ${browser.provider}".`)
|
|
188
|
+
}
|
|
184
189
|
const log = (text: string) => opts.onEvent?.({ type: "log", level: "info", text })
|
|
185
190
|
const python = await ensureEngine(log)
|
|
186
191
|
// The browser agent's added instructions: forgeqe.yaml's, prompts/browser-extra.md, and the product knowledge that matches the goal.
|
package/testmu.example.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
provider: testmu
|
|
2
2
|
remote:
|
|
3
|
-
browser:
|
|
3
|
+
browser: Chrome
|
|
4
4
|
browser_version: latest
|
|
5
5
|
platform: Windows 11
|
|
6
6
|
username_env: LT_USERNAME
|
|
@@ -9,6 +9,5 @@ remote:
|
|
|
9
9
|
build: nightly
|
|
10
10
|
name: checkout smoke
|
|
11
11
|
local: false
|
|
12
|
-
extra:
|
|
13
|
-
|
|
14
|
-
console: true
|
|
12
|
+
extra: # merged into LT:Options, so keys go straight here
|
|
13
|
+
console: true
|
package/src/cache.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto"
|
|
2
|
-
|
|
3
|
-
import { createClient } from "redis"
|
|
4
|
-
|
|
5
|
-
import type { CacheConfig } from "./config.ts"
|
|
6
|
-
|
|
7
|
-
type CacheAdapter = {
|
|
8
|
-
get(key: string): Promise<string | null>
|
|
9
|
-
set(key: string, value: string, ttlSeconds: number): Promise<void>
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let factoryOverride: ((config: CacheConfig) => Promise<CacheAdapter | null>) | undefined
|
|
13
|
-
const adapters = new Map<string, Promise<CacheAdapter | null>>()
|
|
14
|
-
|
|
15
|
-
function keyFor(config: CacheConfig): string {
|
|
16
|
-
return `${config.redisUrl}\n${config.keyPrefix}\n${config.connectTimeoutMs}`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function fullKey(config: CacheConfig, namespace: string, parts: unknown[]): string {
|
|
20
|
-
const digest = createHash("sha256").update(JSON.stringify(parts)).digest("hex")
|
|
21
|
-
return `${config.keyPrefix}:${namespace}:${digest}`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function connectRedis(config: CacheConfig): Promise<CacheAdapter | null> {
|
|
25
|
-
const client = createClient({
|
|
26
|
-
url: config.redisUrl,
|
|
27
|
-
socket: { connectTimeout: config.connectTimeoutMs },
|
|
28
|
-
})
|
|
29
|
-
client.on("error", () => {})
|
|
30
|
-
try {
|
|
31
|
-
await client.connect()
|
|
32
|
-
} catch {
|
|
33
|
-
try {
|
|
34
|
-
client.disconnect()
|
|
35
|
-
} catch {}
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
38
|
-
return {
|
|
39
|
-
async get(key) {
|
|
40
|
-
try {
|
|
41
|
-
return await client.get(key)
|
|
42
|
-
} catch {
|
|
43
|
-
return null
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
async set(key, value, ttlSeconds) {
|
|
47
|
-
try {
|
|
48
|
-
await client.set(key, value, { EX: ttlSeconds })
|
|
49
|
-
} catch {}
|
|
50
|
-
},
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function adapter(config: CacheConfig | undefined): Promise<CacheAdapter | null> {
|
|
55
|
-
if (!config?.enabled || !config.redisUrl) return null
|
|
56
|
-
const id = keyFor(config)
|
|
57
|
-
if (!adapters.has(id)) {
|
|
58
|
-
adapters.set(id, (factoryOverride || connectRedis)(config))
|
|
59
|
-
}
|
|
60
|
-
return adapters.get(id)!
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function cachedJson<T>(
|
|
64
|
-
config: CacheConfig | undefined,
|
|
65
|
-
opts: {
|
|
66
|
-
namespace: string
|
|
67
|
-
keyParts: unknown[]
|
|
68
|
-
ttlSeconds?: number
|
|
69
|
-
load: () => Promise<T>
|
|
70
|
-
},
|
|
71
|
-
): Promise<T> {
|
|
72
|
-
const ttlSeconds = opts.ttlSeconds || config?.defaultTtlSeconds || 3600
|
|
73
|
-
const store = await adapter(config)
|
|
74
|
-
if (!store) return opts.load()
|
|
75
|
-
const key = fullKey(config!, opts.namespace, opts.keyParts)
|
|
76
|
-
const hit = await store.get(key)
|
|
77
|
-
if (hit !== null) {
|
|
78
|
-
try {
|
|
79
|
-
return JSON.parse(hit) as T
|
|
80
|
-
} catch {
|
|
81
|
-
// Bad JSON should not break the current run; reload and overwrite it.
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
const value = await opts.load()
|
|
85
|
-
await store.set(key, JSON.stringify(value), ttlSeconds)
|
|
86
|
-
return value
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function setCacheFactoryForTests(factory?: (config: CacheConfig) => Promise<CacheAdapter | null>): void {
|
|
90
|
-
factoryOverride = factory
|
|
91
|
-
adapters.clear()
|
|
92
|
-
}
|