Skip to content

Commit cff4f84

Browse files
fix: cache readToolkitData lookups and align category sources
Deduplicate production readToolkitData calls so generateMetadata and Page share one in-flight read, recover failed cache entries, and drop stale "others" references from broken-link-check and sidebar sync docs. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9bb92bc commit cff4f84

6 files changed

Lines changed: 129 additions & 49 deletions

File tree

‎app/_lib/toolkit-data.ts‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,23 @@ type ToolkitDataMap = {
128128
* unbounded process-global cache.
129129
*/
130130
const loadsByDataDir = new Map<string, Promise<ToolkitDataMap>>();
131+
/**
132+
* One in-flight/resolved read per (dataDir, normalized lookup key) in
133+
* production. `readToolkitData`'s direct-file fast path re-reads and
134+
* re-parses on every call without this — `loadAllToolkitData` only caches
135+
* the directory scan fallback, so generateMetadata + Page for the same
136+
* toolkit would each pay for a full file read during static generation.
137+
*
138+
* Skipped in development (generator can refresh JSON while `next dev` runs)
139+
* and when callers pass an explicit `dataDir` (tests use scratch fixtures
140+
* and expect fresh reads after mutating files).
141+
*/
142+
const readsByLookupKey = new Map<string, Promise<ToolkitData | null>>();
131143
const DEFAULT_DATA_DIR = resolveToolkitDataDir();
132144

145+
const readLookupCacheKey = (dataDir: string, toolkitId: string): string =>
146+
`${dataDir}\0${normalizeToolkitId(toolkitId)}`;
147+
133148
const loadAllToolkitDataUncached = async (
134149
dataDir: string
135150
): Promise<ToolkitDataMap> => {
@@ -206,19 +221,13 @@ export const loadAllToolkitData = cache(
206221
}
207222
);
208223

209-
export const readToolkitData = async (
224+
const readToolkitDataUncached = async (
210225
toolkitId: string,
211-
options?: ToolkitDataOptions
226+
dataDir: string
212227
): Promise<ToolkitData | null> => {
213228
// Normalize the toolkit ID to lowercase alphanumeric
214229
const normalizedId = normalizeToolkitId(toolkitId);
215230

216-
// Guard against empty normalized ID (e.g., input was only special characters)
217-
if (!normalizedId) {
218-
return null;
219-
}
220-
221-
const dataDir = resolveDataDir(options);
222231
// The API route normally receives the normalized toolkit id. Keep that
223232
// common path O(1), especially on a cold serverless instance: eagerly
224233
// loading every toolkit JSON file just to serve one toolkit adds tens of
@@ -238,6 +247,41 @@ export const readToolkitData = async (
238247
);
239248
};
240249

250+
export function readToolkitData(
251+
toolkitId: string,
252+
options?: ToolkitDataOptions
253+
): Promise<ToolkitData | null> {
254+
const normalizedId = normalizeToolkitId(toolkitId);
255+
256+
// Guard against empty normalized ID (e.g., input was only special characters)
257+
if (!normalizedId) {
258+
return Promise.resolve(null);
259+
}
260+
261+
const dataDir = resolveDataDir(options);
262+
263+
if (
264+
process.env.NODE_ENV === "development" ||
265+
options?.dataDir !== undefined
266+
) {
267+
return readToolkitDataUncached(toolkitId, dataDir);
268+
}
269+
270+
const key = readLookupCacheKey(dataDir, toolkitId);
271+
let promise = readsByLookupKey.get(key);
272+
if (!promise) {
273+
promise = readToolkitDataUncached(toolkitId, dataDir);
274+
readsByLookupKey.set(key, promise);
275+
276+
promise.catch(() => {
277+
if (readsByLookupKey.get(key) === promise) {
278+
readsByLookupKey.delete(key);
279+
}
280+
});
281+
}
282+
return promise;
283+
}
284+
241285
export const readToolkitIndex = async (
242286
options?: ToolkitDataOptions
243287
): Promise<ToolkitIndex | null> => {

‎app/en/resources/integrations/_lib/toolkit-docs-page.tsx‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@ type ToolkitDocsParams = {
1313
};
1414

1515
export function createToolkitDocsPage(category: IntegrationCategory) {
16-
// readToolkitData is itself backed by a shared, process-wide cache (see
17-
// loadAllToolkitData in app/_lib/toolkit-data.ts), so generateMetadata and
18-
// Page below calling it separately for the same toolkitId costs one map
19-
// lookup each rather than a second file read — no per-factory cache needed
20-
// here.
2116
const getToolkitData = (toolkitId: string) => readToolkitData(toolkitId);
2217

2318
const generateStaticParams = async () =>

‎tests/broken-link-check.test.ts‎

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { join } from "node:path";
33
import fg from "fast-glob";
44
import { scanURLs, validateFiles } from "next-validate-link";
55
import { expect, test } from "vitest";
6+
import {
7+
INTEGRATION_CATEGORIES,
8+
normalizeToolkitId,
9+
} from "@/toolkit-docs-generator/src/shared/toolkit-primitives";
610

711
const TIMEOUT = 30_000;
812

@@ -14,9 +18,8 @@ const toolkitDataDir = join(
1418
"data",
1519
"toolkits"
1620
);
17-
const TOOLKIT_ID_NORMALIZER = /[^a-z0-9]+/g;
1821
const normalizeToolkitSlug = (value: string): string =>
19-
value.toLowerCase().replace(TOOLKIT_ID_NORMALIZER, "");
22+
normalizeToolkitId(value);
2023

2124
function getDocsLinkSlug(docsLink?: string | null): string | null {
2225
if (!docsLink) {
@@ -113,18 +116,6 @@ function toToolAnchorId(value: string): string {
113116
}
114117

115118
const SUPPORTED_LOCALES = ["en", "es", "pt-BR"] as const;
116-
const SUPPORTED_INTEGRATION_CATEGORIES = [
117-
"productivity",
118-
"development",
119-
"social",
120-
"databases",
121-
"search",
122-
"sales",
123-
"payments",
124-
"customer-support",
125-
"entertainment",
126-
"others",
127-
] as const;
128119

129120
function validateToolkitIntegrationRoute(
130121
urlPath: string,
@@ -155,9 +146,7 @@ function validateToolkitIntegrationRoute(
155146
return false;
156147
}
157148

158-
if (
159-
!(SUPPORTED_INTEGRATION_CATEGORIES as readonly string[]).includes(category)
160-
) {
149+
if (!(INTEGRATION_CATEGORIES as readonly string[]).includes(category)) {
161150
return false;
162151
}
163152

‎tests/toolkit-data-cache.test.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
4-
import { afterAll, describe, expect, test } from "vitest";
4+
import { afterAll, describe, expect, test, vi } from "vitest";
55
import { readToolkitData, readToolkitIndex } from "@/app/_lib/toolkit-data";
66

77
/**
@@ -131,6 +131,57 @@ describe("readToolkitData direct-file fast path", () => {
131131
});
132132
});
133133

134+
describe("readToolkitData production lookup cache", () => {
135+
test("repeated lookups for the same toolkit share one in-flight promise", async () => {
136+
const dataDir = makeFixtureDir();
137+
dirsToClean.push(dataDir);
138+
139+
vi.stubEnv("NODE_ENV", "production");
140+
vi.stubEnv("TOOLKIT_DATA_DIR", dataDir);
141+
142+
try {
143+
const firstPromise = readToolkitData("ValidToolkitOne");
144+
const secondPromise = readToolkitData("ValidToolkitOne");
145+
146+
expect(firstPromise).toBe(secondPromise);
147+
148+
const [first, second] = await Promise.all([firstPromise, secondPromise]);
149+
expect(first?.id).toBe("ValidToolkitOne");
150+
expect(second?.id).toBe("ValidToolkitOne");
151+
} finally {
152+
vi.unstubAllEnvs();
153+
}
154+
});
155+
156+
test("a transient direct read failure can recover after the file is repaired", async () => {
157+
const dataDir = makeFixtureDir();
158+
dirsToClean.push(dataDir);
159+
writeFileSync(
160+
join(dataDir, "corrupttoolkit.json"),
161+
"{ this is not valid json"
162+
);
163+
164+
vi.stubEnv("NODE_ENV", "production");
165+
vi.stubEnv("TOOLKIT_DATA_DIR", dataDir);
166+
167+
try {
168+
await expect(readToolkitData("CorruptToolkit")).rejects.toThrow(
169+
join(dataDir, "corrupttoolkit.json")
170+
);
171+
172+
writeFileSync(
173+
join(dataDir, "corrupttoolkit.json"),
174+
validToolkitJson("RecoveredToolkit", "recovered-toolkit")
175+
);
176+
177+
const recovered = await readToolkitData("RecoveredToolkit");
178+
expect(recovered?.id).toBe("RecoveredToolkit");
179+
} finally {
180+
vi.unstubAllEnvs();
181+
}
182+
});
183+
});
184+
134185
describe("readToolkitIndex schema validation", () => {
135186
const dataDir = mkdtempSync(join(tmpdir(), "toolkit-index-schema-test-"));
136187
dirsToClean.push(dataDir);

‎toolkit-docs-generator/scripts/README-sync-toolkit-sidebar.md‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,24 @@ This script synchronizes the sidebar navigation with available toolkit JSON data
66

77
```bash
88
# Run the sync (updates sidebar navigation)
9-
npx tsx .github/scripts/sync-toolkit-sidebar.ts
9+
npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts
1010

1111
# Dry run (shows what would change without making changes)
12-
npx tsx .github/scripts/sync-toolkit-sidebar.ts --dry-run
12+
npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts --dry-run
1313

1414
# Verbose output
15-
npx tsx .github/scripts/sync-toolkit-sidebar.ts --verbose
15+
npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts --verbose
1616

1717
# Both flags
18-
npx tsx .github/scripts/sync-toolkit-sidebar.ts --dry-run --verbose
18+
npx tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts --dry-run --verbose
1919
```
2020

2121
## What it does
2222

2323
1. Reads toolkit JSON files from `toolkit-docs-generator/data/toolkits/`.
2424
2. Maps toolkits to categories using the design system catalog.
2525
3. Creates or updates `_meta.tsx` files for each category folder.
26-
4. Handles the "Others" category for toolkits not in the design system.
26+
4. Skips toolkits without a recognized integration category.
2727
5. Updates the main integrations `_meta.tsx`.
2828

2929
## When to run
@@ -37,7 +37,8 @@ Run this script when:
3737

3838
## Category mapping
3939

40-
Toolkits are mapped to categories based on `@arcadeai/design-system`:
40+
Toolkits are mapped to categories based on `@arcadeai/design-system` and
41+
`INTEGRATION_CATEGORIES` in `toolkit-docs-generator/src/shared/toolkit-primitives.ts`:
4142

4243
| Category | Display name |
4344
| --- | --- |
@@ -50,18 +51,18 @@ Toolkits are mapped to categories based on `@arcadeai/design-system`:
5051
| sales | Sales |
5152
| entertainment | Entertainment |
5253
| payments | Payments & Finance |
53-
| others | Others |
5454

55-
Toolkits not found in the design system are placed in the "Others" category.
55+
Toolkits with an unrecognized category fail loudly instead of being routed to a
56+
catch-all bucket.
5657

5758
## Testing
5859

5960
```bash
6061
# Run tests
61-
npx vitest run .github/scripts/__tests__/sync-toolkit-sidebar.test.ts
62+
pnpm vitest run toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts
6263

6364
# Watch mode
64-
npx vitest watch .github/scripts/__tests__/sync-toolkit-sidebar.test.ts
65+
pnpm vitest watch toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts
6566
```
6667

6768
## Output
@@ -74,16 +75,16 @@ The script prints a summary of changes:
7475
Total toolkits: 96
7576
7677
Categories created (1):
77-
+ others
78+
+ sales
7879
7980
Categories updated (7):
8081
~ productivity
8182
~ development
8283
~ customer-support
8384
~ search
84-
~ sales
8585
~ social
8686
~ payments
87+
~ entertainment
8788
8889
====================================
8990
```

‎toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -418,14 +418,14 @@ describe("groupByCategory", () => {
418418
expect(result.size).toBe(0);
419419
});
420420

421-
it("should handle 'others' category", () => {
421+
it("should group toolkits by category", () => {
422422
const toolkits: ToolkitInfo[] = [
423-
{ id: "custom", slug: "custom", label: "Custom", category: "others" },
423+
{ id: "custom", slug: "custom", label: "Custom", category: "payments" },
424424
];
425425

426426
const result = groupByCategory(toolkits);
427-
expect(result.has("others")).toBe(true);
428-
expect(result.get("others")).toHaveLength(1);
427+
expect(result.has("payments")).toBe(true);
428+
expect(result.get("payments")).toHaveLength(1);
429429
});
430430
});
431431

@@ -521,11 +521,11 @@ describe("generateCategoryMeta", () => {
521521
id: "test",
522522
slug: "test",
523523
label: 'Test "Quoted" Label',
524-
category: "others",
524+
category: "productivity",
525525
},
526526
];
527527

528-
const result = generateCategoryMeta(toolkits, "others", "/preview");
528+
const result = generateCategoryMeta(toolkits, "productivity", "/preview");
529529

530530
expect(result).toContain('title: "Test \\"Quoted\\" Label"');
531531
});

0 commit comments

Comments
 (0)