From 2cbcd2368a0c287e2ff23b14c8f6616f78a34838 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 6 Jan 2026 05:17:29 -0800 Subject: [PATCH] fix: replace schema version symlinks with HTTP middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace filesystem symlinks (v2, v2.5, v1) with dynamic HTTP middleware that resolves version aliases at request time. This improves Docker compatibility where symlinks can cause issues. Changes: - Add version alias middleware that rewrites /v2.5/* → /2.5.1/* - Fix version matching bug: use semver parsing instead of string prefix matching - Add caching for version directory listing (60s TTL) - Add directory redirect middleware to serve /schemas/2.6.0/ → /schemas/2.6.0/index.json - Add schema discovery endpoint at GET /schemas/ with available versions and aliases - Remove createSymlink() function from build script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .changeset/tame-hornets-peel.md | 2 + scripts/build-schemas.cjs | 117 +++++------ server/src/http.ts | 158 ++++++++++++++- .../integration/schema-versioning.test.ts | 181 ++++++++++++++++++ 4 files changed, 393 insertions(+), 65 deletions(-) create mode 100644 .changeset/tame-hornets-peel.md create mode 100644 server/tests/integration/schema-versioning.test.ts diff --git a/.changeset/tame-hornets-peel.md b/.changeset/tame-hornets-peel.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/tame-hornets-peel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 9db4d8d07c..b915b37b32 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -41,16 +41,16 @@ function getVersion() { } /** - * Find the latest released version directory in dist/schemas/ - * Returns null if no released versions exist + * Get all released version directories in dist/schemas/ + * Returns array sorted by semver (descending) */ -function findLatestReleasedVersion() { +function getAllReleasedVersions() { if (!fs.existsSync(DIST_DIR)) { - return null; + return []; } const entries = fs.readdirSync(DIST_DIR, { withFileTypes: true }); - const versionDirs = entries + return entries .filter(e => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)) .map(e => e.name) .sort((a, b) => { @@ -61,8 +61,34 @@ function findLatestReleasedVersion() { if (aMinor !== bMinor) return bMinor - aMinor; return bPatch - aPatch; }); +} - return versionDirs[0] || null; +/** + * Find the latest released version directory in dist/schemas/ + * Returns null if no released versions exist + */ +function findLatestReleasedVersion() { + const versions = getAllReleasedVersions(); + return versions[0] || null; +} + +/** + * Get the latest patch version for each minor version series + * e.g., for [2.6.0, 2.5.1, 2.5.0], returns { '2.6': '2.6.0', '2.5': '2.5.1' } + */ +function getLatestPatchPerMinor() { + const versions = getAllReleasedVersions(); + const latestPerMinor = {}; + + for (const version of versions) { + const minor = getMinorVersion(version); + // Since versions are sorted descending, first one wins + if (!latestPerMinor[minor]) { + latestPerMinor[minor] = version; + } + } + + return latestPerMinor; } function getMajorVersion(version) { @@ -128,17 +154,6 @@ function copyAndTransformSchemas(sourceDir, targetDir, version) { } } -function createSymlink(target, linkPath) { - // Remove existing symlink if it exists - if (fs.existsSync(linkPath)) { - fs.unlinkSync(linkPath); - } - - // Create relative symlink - const relativePath = path.relative(path.dirname(linkPath), target); - fs.symlinkSync(relativePath, linkPath, 'dir'); -} - function updateSourceRegistry(version) { const registryPath = path.join(SOURCE_DIR, 'index.json'); const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); @@ -323,15 +338,8 @@ async function main() { const { successCount, errorCount } = await generateBundledSchemas(SOURCE_DIR, bundledDir, version); console.log(` ✓ Bundled ${successCount} schemas${errorCount > 0 ? ` (${errorCount} failed)` : ''}`); - // Update major version symlink (v2 -> 2.5.0) - const majorLink = path.join(DIST_DIR, `v${majorVersion}`); - console.log(`🔗 Updating symlink: v${majorVersion} → ${version}`); - createSymlink(versionDir, majorLink); - - // Update minor version symlink (v2.5 -> 2.5.0) - const minorLink = path.join(DIST_DIR, `v${minorVersion}`); - console.log(`🔗 Updating symlink: v${minorVersion} → ${version}`); - createSymlink(versionDir, minorLink); + // Note: Version aliases (v2, v2.5, v1, latest) are handled by HTTP middleware + // No symlinks needed - the server rewrites /schemas/v2.5/* to /schemas/2.5.1/* // Also update latest/ to match the release const latestDir = path.join(DIST_DIR, 'latest'); @@ -346,11 +354,6 @@ async function main() { const latestBundledDir = path.join(latestDir, 'bundled'); await generateBundledSchemas(SOURCE_DIR, latestBundledDir, 'latest'); - // Create v1 symlink pointing to latest/ for backward compatibility - const v1Link = path.join(DIST_DIR, 'v1'); - console.log(`🔗 Creating symlink: v1 → latest (backward compatibility)`); - createSymlink(latestDir, v1Link); - // Stage the new versioned directory for git commit // This is needed for the changesets workflow to include it in the version commit console.log(`📝 Staging dist/schemas/${version}/ for git commit`); @@ -361,15 +364,21 @@ async function main() { console.log(` (git add skipped - not in git context or git not available)`); } + // Show available paths (aliases are handled by HTTP middleware) + const latestPerMinor = getLatestPatchPerMinor(); console.log(''); console.log('✅ Release build complete!'); console.log(''); console.log('Released paths:'); console.log(` /schemas/${version}/ - Exact version (pin for production)`); console.log(` /schemas/${version}/bundled/ - Bundled schemas (no $ref)`); - console.log(` /schemas/v${minorVersion}/ - Minor alias → ${version}`); - console.log(` /schemas/v${majorVersion}/ - Major alias → ${version}`); console.log(` /schemas/latest/ - Development (matches release)`); + console.log(''); + console.log('Version aliases (handled by HTTP middleware):'); + console.log(` /schemas/v${majorVersion}/ - Major alias → latest ${majorVersion}.x.x`); + for (const [minor, patchVersion] of Object.entries(latestPerMinor)) { + console.log(` /schemas/v${minor}/ - Minor alias → ${patchVersion}`); + } console.log(` /schemas/v1/ - Backward compatibility → latest`); } else { @@ -392,46 +401,26 @@ async function main() { const { successCount, errorCount } = await generateBundledSchemas(SOURCE_DIR, bundledDir, 'latest'); console.log(` ✓ Bundled ${successCount} schemas${errorCount > 0 ? ` (${errorCount} failed)` : ''}`); - // Symlinks for v2, v2.5, v1 should point to the latest RELEASED version - // Only create/update these symlinks if we have a released version - if (latestReleasedVersion) { - const releasedVersionDir = path.join(DIST_DIR, latestReleasedVersion); - const releasedMajor = getMajorVersion(latestReleasedVersion); - const releasedMinor = getMinorVersion(latestReleasedVersion); - - const majorLink = path.join(DIST_DIR, `v${releasedMajor}`); - if (!fs.existsSync(majorLink)) { - console.log(`🔗 Creating symlink: v${releasedMajor} → ${latestReleasedVersion}`); - createSymlink(releasedVersionDir, majorLink); - } - - const minorLink = path.join(DIST_DIR, `v${releasedMinor}`); - if (!fs.existsSync(minorLink)) { - console.log(`🔗 Creating symlink: v${releasedMinor} → ${latestReleasedVersion}`); - createSymlink(releasedVersionDir, minorLink); - } - - } - - // v1 always points to latest/ (not a released version) - const v1Link = path.join(DIST_DIR, 'v1'); - if (!fs.existsSync(v1Link)) { - console.log(`🔗 Creating symlink: v1 → latest`); - createSymlink(latestDir, v1Link); - } + // Note: Version aliases (v2, v2.5, v1) are handled by HTTP middleware + // No symlinks needed - the server rewrites URLs dynamically + // Show available paths + const latestPerMinor = getLatestPatchPerMinor(); console.log(''); console.log('✅ Development build complete!'); console.log(''); console.log('Available paths:'); console.log(` /schemas/latest/ - Development schemas (just rebuilt)`); - console.log(` /schemas/v1/ - Backward compatibility → latest`); if (latestReleasedVersion) { const releasedMajor = getMajorVersion(latestReleasedVersion); - const releasedMinor = getMinorVersion(latestReleasedVersion); console.log(` /schemas/${latestReleasedVersion}/ - Latest release (unchanged)`); - console.log(` /schemas/v${releasedMinor}/ - Minor alias → ${latestReleasedVersion}`); - console.log(` /schemas/v${releasedMajor}/ - Major alias → ${latestReleasedVersion}`); + console.log(''); + console.log('Version aliases (handled by HTTP middleware):'); + console.log(` /schemas/v${releasedMajor}/ - Major alias → latest ${releasedMajor}.x.x`); + for (const [minor, patchVersion] of Object.entries(latestPerMinor)) { + console.log(` /schemas/v${minor}/ - Minor alias → ${patchVersion}`); + } + console.log(` /schemas/v1/ - Backward compatibility → latest`); } else { console.log(''); console.log('⚠️ No released versions found. Run with --release to create one:'); diff --git a/server/src/http.ts b/server/src/http.ts index 3a5d748cfa..be4de88906 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -449,7 +449,163 @@ export class HTTPServer { const distPath = process.env.NODE_ENV === 'production' ? __dirname : path.join(__dirname, "../../dist"); - this.app.use('/schemas', express.static(path.join(distPath, 'schemas'))); + const schemasPath = path.join(distPath, 'schemas'); + + // Cache for schema version directories (refreshed every 60 seconds) + let versionCache: { versions: string[], timestamp: number } | null = null; + const CACHE_TTL_MS = 60 * 1000; + + async function getSchemaVersions(): Promise { + const now = Date.now(); + if (versionCache && (now - versionCache.timestamp) < CACHE_TTL_MS) { + return versionCache.versions; + } + + const entries = await fs.readdir(schemasPath, { withFileTypes: true }); + const versions = entries + .filter(e => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)) + .map(e => e.name) + .sort((a, b) => { + // Sort by semver (descending) + const [aMajor, aMinor, aPatch] = a.split('.').map(Number); + const [bMajor, bMinor, bPatch] = b.split('.').map(Number); + if (aMajor !== bMajor) return bMajor - aMajor; + if (aMinor !== bMinor) return bMinor - aMinor; + return bPatch - aPatch; + }); + + versionCache = { versions, timestamp: now }; + return versions; + } + + function parseSemver(version: string): { major: number, minor: number, patch: number } { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; + } + + function findMatchingVersion(versions: string[], requestedMajor: number, requestedMinor?: number): string | undefined { + // Find the latest version that matches the requested major (and optionally minor) + return versions.find(v => { + const { major, minor } = parseSemver(v); + if (major !== requestedMajor) return false; + if (requestedMinor !== undefined && minor !== requestedMinor) return false; + return true; + }); + } + + // Middleware to resolve version aliases (e.g., v2.5 → 2.5.1) + // This handles cases where symlinks don't exist (e.g., in Docker) + this.app.use('/schemas', async (req, res, next) => { + // Match version alias patterns: /v2/, /v2.5/, /v2.6/, /v1/ + const versionMatch = req.path.match(/^\/v(\d+)(?:\.(\d+))?(\/.*)?$/); + if (!versionMatch) { + return next(); + } + + const requestedMajor = parseInt(versionMatch[1], 10); + const requestedMinor = versionMatch[2] ? parseInt(versionMatch[2], 10) : undefined; + const restOfPath = versionMatch[3] || '/'; + + // Special case: v1 always points to latest + if (requestedMajor === 1 && requestedMinor === undefined) { + req.url = '/latest' + restOfPath; + return next(); + } + + try { + const versions = await getSchemaVersions(); + const targetVersion = findMatchingVersion(versions, requestedMajor, requestedMinor); + + if (targetVersion) { + req.url = '/' + targetVersion + restOfPath; + } + } catch { + // If we can't read the directory, let static middleware handle it + } + next(); + }); + + // Redirect version directory requests to index.json + // e.g., /schemas/2.6.0/ → /schemas/2.6.0/index.json + this.app.use('/schemas', (req, res, next) => { + // Match paths like /2.6.0/ or /latest/ (directory requests) + if (req.path.match(/^\/(\d+\.\d+\.\d+|latest)\/$/)) { + return res.redirect(req.path + 'index.json'); + } + next(); + }); + + // Schema discovery endpoint - returns available versions and aliases + this.app.get('/schemas/', async (req, res) => { + try { + const versions = await getSchemaVersions(); + const latestPerMinor: Record = {}; + let latestMajorVersion: string | undefined; + + for (const version of versions) { + const { major, minor } = parseSemver(version); + const minorKey = `${major}.${minor}`; + + // First version in sorted list is the overall latest + if (!latestMajorVersion) { + latestMajorVersion = version; + } + + // Track latest patch for each minor + if (!latestPerMinor[minorKey]) { + latestPerMinor[minorKey] = version; + } + } + + // Build aliases list + const aliases: Array<{ alias: string, resolves_to: string, path: string }> = []; + + // v1 -> latest (backward compatibility) + aliases.push({ + alias: "v1", + resolves_to: "latest", + path: "/schemas/v1/" + }); + + // Major version aliases (e.g., v2 -> 2.6.0) + if (latestMajorVersion) { + const { major } = parseSemver(latestMajorVersion); + aliases.push({ + alias: `v${major}`, + resolves_to: latestMajorVersion, + path: `/schemas/v${major}/` + }); + } + + // Minor version aliases (e.g., v2.5 -> 2.5.1) + for (const [minorKey, version] of Object.entries(latestPerMinor)) { + aliases.push({ + alias: `v${minorKey}`, + resolves_to: version, + path: `/schemas/v${minorKey}/` + }); + } + + // Sort aliases for consistent output + aliases.sort((a, b) => a.alias.localeCompare(b.alias, undefined, { numeric: true })); + + res.json({ + versions: versions.map(v => ({ + version: v, + path: `/schemas/${v}/` + })), + aliases, + latest: { + path: "/schemas/latest/", + note: "Development version, may differ from released versions" + } + }); + } catch (error) { + res.status(500).json({ error: "Failed to list schema versions" }); + } + }); + + this.app.use('/schemas', express.static(schemasPath)); // Serve other static files (robots.txt, images, etc.) const staticPath = process.env.NODE_ENV === 'production' diff --git a/server/tests/integration/schema-versioning.test.ts b/server/tests/integration/schema-versioning.test.ts new file mode 100644 index 0000000000..7d0e1f39cf --- /dev/null +++ b/server/tests/integration/schema-versioning.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; + +/** + * Tests for schema version aliasing logic. + * + * These tests verify the pure functions used by the HTTP middleware + * without requiring the full server stack. + */ +describe("Schema Versioning Middleware", () => { + + describe("parseSemver and findMatchingVersion logic", () => { + // Test the core version matching logic in isolation + + function parseSemver(version: string): { major: number, minor: number, patch: number } { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; + } + + function findMatchingVersion(versions: string[], requestedMajor: number, requestedMinor?: number): string | undefined { + // Sort versions by semver descending + const sorted = [...versions].sort((a, b) => { + const [aMajor, aMinor, aPatch] = a.split('.').map(Number); + const [bMajor, bMinor, bPatch] = b.split('.').map(Number); + if (aMajor !== bMajor) return bMajor - aMajor; + if (aMinor !== bMinor) return bMinor - aMinor; + return bPatch - aPatch; + }); + + return sorted.find(v => { + const { major, minor } = parseSemver(v); + if (major !== requestedMajor) return false; + if (requestedMinor !== undefined && minor !== requestedMinor) return false; + return true; + }); + } + + it("should correctly parse semver versions", () => { + expect(parseSemver("2.5.1")).toEqual({ major: 2, minor: 5, patch: 1 }); + expect(parseSemver("12.0.0")).toEqual({ major: 12, minor: 0, patch: 0 }); + expect(parseSemver("2.50.3")).toEqual({ major: 2, minor: 50, patch: 3 }); + }); + + it("should find latest major version correctly", () => { + const versions = ["2.5.0", "2.5.1", "2.6.0", "12.0.0"]; + + // v2 should find 2.6.0 (latest 2.x.x) + expect(findMatchingVersion(versions, 2)).toBe("2.6.0"); + + // v12 should find 12.0.0 + expect(findMatchingVersion(versions, 12)).toBe("12.0.0"); + + // v3 should return undefined (no 3.x.x versions) + expect(findMatchingVersion(versions, 3)).toBeUndefined(); + }); + + it("should find latest minor version correctly", () => { + const versions = ["2.5.0", "2.5.1", "2.6.0"]; + + // v2.5 should find 2.5.1 (latest 2.5.x) + expect(findMatchingVersion(versions, 2, 5)).toBe("2.5.1"); + + // v2.6 should find 2.6.0 + expect(findMatchingVersion(versions, 2, 6)).toBe("2.6.0"); + + // v2.7 should return undefined + expect(findMatchingVersion(versions, 2, 7)).toBeUndefined(); + }); + + it("should NOT confuse v2.5 with v2.50 (the original bug)", () => { + const versions = ["2.5.0", "2.5.1", "2.50.0"]; + + // v2.5 should find 2.5.1, NOT 2.50.0 + expect(findMatchingVersion(versions, 2, 5)).toBe("2.5.1"); + + // v2.50 should find 2.50.0 + expect(findMatchingVersion(versions, 2, 50)).toBe("2.50.0"); + }); + + it("should NOT confuse v2 with v12 (string prefix bug)", () => { + const versions = ["2.6.0", "12.0.0"]; + + // v2 should find 2.6.0, NOT 12.0.0 + expect(findMatchingVersion(versions, 2)).toBe("2.6.0"); + + // v12 should find 12.0.0 + expect(findMatchingVersion(versions, 12)).toBe("12.0.0"); + }); + }); + + describe("version alias regex matching", () => { + const regex = /^\/v(\d+)(?:\.(\d+))?(\/.*)?$/; + + it("should match major version aliases", () => { + const match = "/v2/core/product.json".match(regex); + expect(match).not.toBeNull(); + expect(match![1]).toBe("2"); + expect(match![2]).toBeUndefined(); + expect(match![3]).toBe("/core/product.json"); + }); + + it("should match minor version aliases", () => { + const match = "/v2.5/core/product.json".match(regex); + expect(match).not.toBeNull(); + expect(match![1]).toBe("2"); + expect(match![2]).toBe("5"); + expect(match![3]).toBe("/core/product.json"); + }); + + it("should match two-digit minor versions", () => { + const match = "/v2.50/core/product.json".match(regex); + expect(match).not.toBeNull(); + expect(match![1]).toBe("2"); + expect(match![2]).toBe("50"); + expect(match![3]).toBe("/core/product.json"); + }); + + it("should match two-digit major versions", () => { + const match = "/v12/core/product.json".match(regex); + expect(match).not.toBeNull(); + expect(match![1]).toBe("12"); + expect(match![2]).toBeUndefined(); + expect(match![3]).toBe("/core/product.json"); + }); + + it("should NOT match full semver versions (those are served directly)", () => { + const match = "/v2.5.1/core/product.json".match(regex); + // This should NOT match because we have three parts (major.minor.patch) + // The regex expects at most major.minor + expect(match).toBeNull(); + }); + + it("should match root paths", () => { + const match = "/v2/".match(regex); + expect(match).not.toBeNull(); + expect(match![1]).toBe("2"); + expect(match![3]).toBe("/"); + }); + + it("should match paths without trailing content", () => { + const matchWithSlash = "/v2.5/".match(regex); + expect(matchWithSlash).not.toBeNull(); + expect(matchWithSlash![3]).toBe("/"); + + // Without trailing slash - the regex allows this + const matchWithout = "/v2.5".match(regex); + expect(matchWithout).not.toBeNull(); + expect(matchWithout![3]).toBeUndefined(); + }); + }); + + describe("directory redirect regex", () => { + const regex = /^\/(\d+\.\d+\.\d+|latest)\/$/; + + it("should match semver directory paths", () => { + expect("/2.6.0/".match(regex)).not.toBeNull(); + expect("/2.5.1/".match(regex)).not.toBeNull(); + expect("/12.0.0/".match(regex)).not.toBeNull(); + }); + + it("should match latest directory path", () => { + expect("/latest/".match(regex)).not.toBeNull(); + }); + + it("should NOT match version alias paths (handled by alias middleware)", () => { + // These are rewritten by the alias middleware first + expect("/v2/".match(regex)).toBeNull(); + expect("/v2.5/".match(regex)).toBeNull(); + }); + + it("should NOT match file paths", () => { + expect("/2.6.0/index.json".match(regex)).toBeNull(); + expect("/2.6.0/core/product.json".match(regex)).toBeNull(); + expect("/latest/index.json".match(regex)).toBeNull(); + }); + + it("should NOT match paths without trailing slash", () => { + expect("/2.6.0".match(regex)).toBeNull(); + expect("/latest".match(regex)).toBeNull(); + }); + }); +});