Skip to content

Commit b505d50

Browse files
committed
Fix cache rebuild guard to use birthtimeMs instead of mtimeMs
1 parent 5669050 commit b505d50

File tree

2 files changed

+72
-14
lines changed

2 files changed

+72
-14
lines changed

packages/sdk/src/internal/resources.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,10 @@ export const versionResource = async (catalogDir: string, id: string) => {
2020
throw new Error(`No resource found with id: ${id}`);
2121
}
2222

23-
// Event that is in the route of the project
2423
const file = matchedFiles[0];
25-
// Handle both forward and back slashes for cross-platform compatibility (Windows uses \, Unix uses /)
2624
const sourceDirectory = dirname(file).replace(/[/\\]versioned[/\\][^/\\]+[/\\]/, path.sep);
2725
const { data: { version = '0.0.1' } = {} } = matter.read(file);
2826
const targetDirectory = getVersionedDirectory(sourceDirectory, version);
29-
3027
fsSync.mkdirSync(targetDirectory, { recursive: true });
3128

3229
const ignoreListToCopy = ['events', 'commands', 'queries', 'versioned'];
@@ -72,7 +69,6 @@ export const versionResource = async (catalogDir: string, id: string) => {
7269
}
7370
// 2. Add the newly created versioned copy
7471
if (rootParsed) {
75-
const { data: { version: ver = '0.0.1' } = {} } = rootParsed;
7672
const versionedIndexFile = fsSync.existsSync(join(targetDirectory, 'index.mdx'))
7773
? join(targetDirectory, 'index.mdx')
7874
: join(targetDirectory, 'index.md');
@@ -95,13 +91,12 @@ export const writeResource = async (
9591
) => {
9692
const path = options.path || `/${resource.id}`;
9793
const fullPath = join(catalogDir, path);
98-
const format = options.format || 'mdx';
9994

10095
// Create directory if it doesn't exist
10196
fsSync.mkdirSync(fullPath, { recursive: true });
10297

10398
// Create or get lock file path
104-
const lockPath = join(fullPath, `index.${format}`);
99+
const lockPath = join(fullPath, `index.${options.format || 'mdx'}`);
105100

106101
// Ensure the file exists before attempting to lock it
107102
if (!fsSync.existsSync(lockPath)) {

packages/sdk/src/internal/utils.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ interface FileIndexEntry {
1616
let _fileIndexCache: Map<string, FileIndexEntry[]> | null = null;
1717
let _fileIndexCatalogDir: string | null = null;
1818
let _matterCache: Map<string, matter.GrayMatterFile<string>> | null = null;
19-
let _fileIndexMtimeMs: number = 0;
19+
// Tracks the creation time (birthtimeMs) of the catalog directory at last cache build.
20+
// birthtimeMs only changes when the directory is deleted and recreated (e.g. test teardown),
21+
// making it a reliable guard that avoids spurious rebuilds from nested write operations.
22+
let _fileIndexDirBirthtimeMs: number = 0;
2023

2124
function buildFileCache(catalogDir: string): void {
2225
const files = globSync('**/index.{md,mdx}', {
@@ -53,9 +56,9 @@ function buildFileCache(catalogDir: string): void {
5356
_fileIndexCatalogDir = catalogDir;
5457
_matterCache = matterResults;
5558
try {
56-
_fileIndexMtimeMs = fsSync.statSync(catalogDir).mtimeMs;
59+
_fileIndexDirBirthtimeMs = fsSync.statSync(catalogDir).birthtimeMs;
5760
} catch {
58-
_fileIndexMtimeMs = 0;
61+
_fileIndexDirBirthtimeMs = 0;
5962
}
6063
}
6164

@@ -64,10 +67,12 @@ function ensureFileCache(catalogDir: string): void {
6467
buildFileCache(catalogDir);
6568
return;
6669
}
67-
// Check if catalog dir was recreated (e.g. tests wiping and recreating)
70+
// Rebuild if the catalog directory was deleted and recreated (birthtimeMs changes on recreation).
71+
// Unlike mtimeMs, birthtimeMs is unaffected by nested file/directory writes, so it won't
72+
// trigger spurious rebuilds during normal catalog operations.
6873
try {
69-
const currentMtime = fsSync.statSync(catalogDir).mtimeMs;
70-
if (currentMtime !== _fileIndexMtimeMs) {
74+
const currentBirthtime = fsSync.statSync(catalogDir).birthtimeMs;
75+
if (currentBirthtime !== _fileIndexDirBirthtimeMs) {
7176
buildFileCache(catalogDir);
7277
}
7378
} catch {
@@ -198,7 +203,48 @@ export const findFileById = async (catalogDir: string, id: string, version?: str
198203
return undefined;
199204
};
200205

206+
/**
207+
* Converts a glob pattern to a RegExp. Handles `**`, `*`, `{a,b}` and `.` escaping.
208+
* Sufficient for the limited patterns used in getFiles.
209+
*/
210+
function globToRegex(pattern: string): RegExp {
211+
const normalized = pattern.replace(/\\/g, '/');
212+
const regexStr = normalized
213+
.replace(/[.+^${}()|[\]\\]/g, (ch) => {
214+
// Keep { } and handle them specially below; escape everything else
215+
if (ch === '{' || ch === '}') return ch;
216+
return `\\${ch}`;
217+
})
218+
.replace(/\{([^}]+)\}/g, (_, choices) => `(${choices.split(',').join('|')})`)
219+
.replace(/\*\*/g, '\u0000') // temp placeholder
220+
.replace(/\*/g, '[^/]*')
221+
.replace(/\u0000\//g, '(?:.+/)?') // **/ → optional nested path
222+
.replace(/\u0000/g, '.*'); // remaining ** (at end)
223+
return new RegExp(`^${regexStr}$`, 'i');
224+
}
225+
201226
export const getFiles = async (pattern: string, ignore: string | string[] = '') => {
227+
// Fast path: if the file index cache is warm for this catalog dir, filter cached
228+
// paths by the pattern instead of performing an expensive glob on the file system.
229+
// Only applies when the pattern targets index.{md,mdx} files — the cache only
230+
// stores those files, so non-index patterns (e.g. teams/*.md) must fall through.
231+
if (_fileIndexCache && _matterCache && _fileIndexCatalogDir) {
232+
const normalizedCatalogDir = normalize(_fileIndexCatalogDir).replace(/\\/g, '/');
233+
const normalizedPattern = normalize(pattern).replace(/\\/g, '/');
234+
if (
235+
normalizedPattern.startsWith(normalizedCatalogDir) &&
236+
normalizedPattern.includes('index.{md,mdx}')
237+
) {
238+
const ignoreList = (Array.isArray(ignore) ? ignore : [ignore]).filter(Boolean);
239+
const matchRegex = globToRegex(normalizedPattern);
240+
const ignoreRegexes = ignoreList.map((ig) => globToRegex(ig.replace(/\\/g, '/')));
241+
return Array.from(_matterCache.keys())
242+
.map((p) => p.replace(/\\/g, '/'))
243+
.filter((p) => matchRegex.test(p) && !ignoreRegexes.some((ig) => ig.test(p)))
244+
.map(normalize);
245+
}
246+
}
247+
202248
try {
203249
// 1. Normalize the input pattern to handle mixed separators potentially
204250
const normalizedInputPattern = normalize(pattern);
@@ -252,10 +298,27 @@ export const readMdxFile = async (path: string) => {
252298
};
253299

254300
export const searchFilesForId = async (files: string[], id: string, version?: string) => {
255-
// Escape the id to avoid regex issues
301+
// Fast path: if the file index cache is warm we can resolve by id directly
302+
// without reading any files from disk — O(1) map lookup + set intersection.
303+
if (_fileIndexCache) {
304+
const entries = _fileIndexCache.get(id);
305+
if (entries) {
306+
const filesSet = new Set(files.map(normalize));
307+
return entries
308+
.filter((e) => {
309+
if (!filesSet.has(e.path)) return false;
310+
if (version && e.version !== version) return false;
311+
return true;
312+
})
313+
.map((e) => e.path);
314+
}
315+
// id not found in cache means no match in these files
316+
return [];
317+
}
318+
319+
// Slow path: read each file from disk and match by id/version regex
256320
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
257321
const idRegex = new RegExp(`^id:\\s*(['"]|>-)?\\s*${escapedId}['"]?\\s*$`, 'm');
258-
259322
const versionRegex = new RegExp(`^version:\\s*['"]?${version}['"]?\\s*$`, 'm');
260323

261324
const matches = files.map((file) => {

0 commit comments

Comments
 (0)