Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/backend/src/lib/supabase/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface Database {
github_token_encrypted: string | null;
github_token_expires_at: string | null;
github_token_refreshed_at: string | null;
provider_connections: Json | null;
created_at: string;
updated_at: string;
};
Expand All @@ -35,6 +36,7 @@ export interface Database {
github_token_encrypted?: string | null;
github_token_expires_at?: string | null;
github_token_refreshed_at?: string | null;
provider_connections?: Json | null;
created_at?: string;
updated_at?: string;
};
Expand All @@ -49,6 +51,7 @@ export interface Database {
github_token_encrypted?: string | null;
github_token_expires_at?: string | null;
github_token_refreshed_at?: string | null;
provider_connections?: Json | null;
created_at?: string;
updated_at?: string;
};
Expand Down
121 changes: 121 additions & 0 deletions apps/backend/src/services/build-cache.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Tests for BuildCacheService (#660)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BuildCacheService } from './build-cache.service';
import type { GeneratedFile } from '@craft/types';

const makeFiles = (entries: Array<[string, string]>): GeneratedFile[] =>
entries.map(([path, content]) => ({ path, content, type: 'config' as const }));

const makeSupabase = (storedHash: string | null) => ({
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: storedHash
? { customization_config: { _buildCacheHash: storedHash } }
: { customization_config: {} },
}),
}),
});

describe('BuildCacheService', () => {
let service: BuildCacheService;

beforeEach(() => {
service = new BuildCacheService();
});

// ── computeContentHash ────────────────────────────────────────────────────

it('produces a deterministic hex hash for a set of files', () => {
const files = makeFiles([['a.ts', 'hello'], ['b.ts', 'world']]);
const hash1 = service.computeContentHash(files);
const hash2 = service.computeContentHash(files);
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
});

it('produces the same hash regardless of file order', () => {
const files1 = makeFiles([['a.ts', 'hello'], ['b.ts', 'world']]);
const files2 = makeFiles([['b.ts', 'world'], ['a.ts', 'hello']]);
expect(service.computeContentHash(files1)).toBe(service.computeContentHash(files2));
});

it('produces a different hash when file content changes', () => {
const original = makeFiles([['a.ts', 'hello']]);
const changed = makeFiles([['a.ts', 'hello changed']]);
expect(service.computeContentHash(original)).not.toBe(service.computeContentHash(changed));
});

it('produces a different hash when a file is added', () => {
const before = makeFiles([['a.ts', 'hello']]);
const after = makeFiles([['a.ts', 'hello'], ['b.ts', 'new']]);
expect(service.computeContentHash(before)).not.toBe(service.computeContentHash(after));
});

// ── checkCache ────────────────────────────────────────────────────────────

it('returns cache miss when no previous hash is stored', async () => {
const supabase = makeSupabase(null) as any;
const files = makeFiles([['index.ts', 'code']]);
const result = await service.checkCache(supabase, 'dep-1', files);

expect(result.status).toBe('miss');
expect(result.previousHash).toBeNull();
expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
});

it('returns cache hit when hash matches stored hash', async () => {
const files = makeFiles([['index.ts', 'code']]);
const hash = service.computeContentHash(files);
const supabase = makeSupabase(hash) as any;

const result = await service.checkCache(supabase, 'dep-1', files);

expect(result.status).toBe('hit');
expect(result.previousHash).toBe(hash);
expect(result.contentHash).toBe(hash);
});

it('returns cache miss when hash differs from stored hash', async () => {
const supabase = makeSupabase('old-hash-value') as any;
const files = makeFiles([['index.ts', 'new code']]);

const result = await service.checkCache(supabase, 'dep-1', files);

expect(result.status).toBe('miss');
expect(result.previousHash).toBe('old-hash-value');
});

// ── storeHash ─────────────────────────────────────────────────────────────

it('merges _buildCacheHash into existing customization_config', async () => {
const mockUpdate = vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({}),
});
const supabase = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(),
update: mockUpdate,
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { customization_config: { existingKey: 'value' } },
}),
}),
} as any;

await service.storeHash(supabase, 'dep-1', 'abc123');

expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
customization_config: expect.objectContaining({
existingKey: 'value',
_buildCacheHash: 'abc123',
}),
}),
);
});
});
106 changes: 106 additions & 0 deletions apps/backend/src/services/build-cache.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* BuildCacheService
*
* Computes a deterministic content hash of generated template code and
* decides whether the Vercel build cache should be invalidated.
*
* Strategy:
* - Hash all generated file paths + contents with SHA-256.
* - Compare against the previously stored hash for the deployment.
* - If the hash changed → cache miss → trigger a fresh Vercel build.
* - If the hash is unchanged → cache hit → skip the build.
*
* The hash is stored in the `deployments` table under the
* `customization_config` JSONB column at key `_buildCacheHash` so no
* schema migration is required.
*
* Cache status is surfaced in deployment logs via DeploymentLogsService.
*/

import { createHash } from 'crypto';
import type { GeneratedFile } from '@craft/types';
import type { SupabaseClient } from '@supabase/supabase-js';

export type CacheStatus = 'hit' | 'miss';

export interface CacheCheckResult {
status: CacheStatus;
contentHash: string;
previousHash: string | null;
}

export class BuildCacheService {
/**
* Compute a deterministic SHA-256 hash over all generated files.
* Files are sorted by path so the hash is order-independent.
*/
computeContentHash(files: GeneratedFile[]): string {
const hash = createHash('sha256');
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
for (const file of sorted) {
hash.update(file.path);
hash.update('\0');
hash.update(file.content);
hash.update('\0');
}
return hash.digest('hex');
}

/**
* Check whether the build cache is valid for a deployment.
*
* Returns:
* - status: 'hit' → code unchanged, skip build
* - status: 'miss' → code changed or first build, proceed with build
* - contentHash: the newly computed hash
* - previousHash: the hash stored from the last build (null if none)
*/
async checkCache(
supabase: SupabaseClient,
deploymentId: string,
files: GeneratedFile[],
): Promise<CacheCheckResult> {
const contentHash = this.computeContentHash(files);

const { data } = await supabase
.from('deployments')
.select('customization_config')
.eq('id', deploymentId)
.single();

const previousHash: string | null =
(data?.customization_config as any)?._buildCacheHash ?? null;

const status: CacheStatus = previousHash === contentHash ? 'hit' : 'miss';

return { status, contentHash, previousHash };
}

/**
* Persist the content hash after a successful build so future runs can
* compare against it.
*/
async storeHash(
supabase: SupabaseClient,
deploymentId: string,
contentHash: string,
): Promise<void> {
// Merge _buildCacheHash into the existing customization_config JSONB
const { data } = await supabase
.from('deployments')
.select('customization_config')
.eq('id', deploymentId)
.single();

const existing = (data?.customization_config as Record<string, unknown>) ?? {};

await supabase
.from('deployments')
.update({
customization_config: { ...existing, _buildCacheHash: contentHash },
})
.eq('id', deploymentId);
}
}

export const buildCacheService = new BuildCacheService();
20 changes: 18 additions & 2 deletions apps/backend/src/services/deployment-pipeline.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ import type { TemplateFamilyId } from './code-generator.service';
import { syntaxValidator, type SyntaxValidator } from './syntax-validator';
import { artifactSigningService, ArtifactSigningService } from './artifact-signing.service';
import { deploymentUpdateService, DeploymentUpdateService } from './deployment-update.service';
import { githubCommitStatusService, GitHubCommitStatusService } from './github-commit-status.service';

import { buildCacheService, BuildCacheService } from './build-cache.service';
// ── Request / result types ────────────────────────────────────────────────────

export interface DeploymentPipelineRequest {
Expand Down Expand Up @@ -246,6 +245,20 @@ export class DeploymentPipelineService {
{ correlationId, fileCount: generationResult.generatedFiles.length },
);

// ── Step 2d: Build cache check ────────────────────────────────────────
const cacheResult = await this._buildCacheService.checkCache(
supabase,
deploymentId,
generationResult.generatedFiles,
);
await this.log(
deploymentId,
'validating',
`Build cache ${cacheResult.status}: hash=${cacheResult.contentHash.slice(0, 12)}`,
'info',
{ correlationId, cacheStatus: cacheResult.status, contentHash: cacheResult.contentHash },
);

// ── Step 2c: Sign artifact ─────────────────────────────────────────────
await this.setStatus(deploymentId, 'signing');
await this.log(deploymentId, 'signing', 'Signing generated artifact', 'info', { correlationId });
Expand Down Expand Up @@ -478,6 +491,9 @@ export class DeploymentPipelineService {
})
.eq('id', deploymentId);

// Store the content hash so future deployments can detect cache hits
await this._buildCacheService.storeHash(supabase, deploymentId, cacheResult.contentHash);

await this.log(
deploymentId,
'completed',
Expand Down
Loading
Loading