mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 01:13:05 +02:00
443 lines
11 KiB
TypeScript
443 lines
11 KiB
TypeScript
import { getAuthUserId } from '@convex-dev/auth/server'
|
|
import type { GenericMutationCtx } from 'convex/server'
|
|
import { v } from 'convex/values'
|
|
import { pick } from 'convex-helpers'
|
|
import { api, internal } from './_generated/api'
|
|
import type { DataModel, Id } from './_generated/dataModel'
|
|
import { internalMutation, mutation, query } from './_generated/server'
|
|
import { generateSignedDownloadUrl } from './lib/r2'
|
|
import { type BuildConfigFields, type BuildFields, buildFields } from './schema'
|
|
|
|
type BuildUpdateData = {
|
|
status: string
|
|
completedAt?: number
|
|
}
|
|
|
|
export const get = query({
|
|
args: { id: v.id('builds') },
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.get(args.id)
|
|
},
|
|
})
|
|
|
|
export const getByHash = query({
|
|
args: { buildHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const build = await ctx.db
|
|
.query('builds')
|
|
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
|
|
.unique()
|
|
return build ?? null
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Computes flags string from build config.
|
|
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
|
*/
|
|
export function computeFlagsFromConfig(config: BuildConfigFields): string {
|
|
// Sort modules to ensure consistent order
|
|
return Object.keys(config.modulesExcluded)
|
|
.sort()
|
|
.filter((module) => config.modulesExcluded[module])
|
|
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
|
.join(' ')
|
|
}
|
|
|
|
/**
|
|
* Computes a stable SHA-256 hash from version, target, and flags.
|
|
* Internal helper for hash computation.
|
|
*/
|
|
async function computeBuildHashInternal(
|
|
version: string,
|
|
target: string,
|
|
flags: string
|
|
): Promise<string> {
|
|
// Input is now the exact parameters used for the build
|
|
const input = JSON.stringify({
|
|
version,
|
|
target,
|
|
flags,
|
|
})
|
|
|
|
// Use Web Crypto API for SHA-256 hashing
|
|
const encoder = new TextEncoder()
|
|
const data = encoder.encode(input)
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
|
|
return hashHex
|
|
}
|
|
|
|
/**
|
|
* Computes buildHash from build config.
|
|
* This is the single source of truth for build hash computation.
|
|
*/
|
|
export async function computeBuildHash(
|
|
config: BuildConfigFields
|
|
): Promise<{ hash: string; flags: string }> {
|
|
const flags = computeFlagsFromConfig(config)
|
|
const hash = await computeBuildHashInternal(
|
|
config.version,
|
|
config.target,
|
|
flags
|
|
)
|
|
return { hash, flags }
|
|
}
|
|
|
|
/**
|
|
* Constructs the R2 artifact URL from a build.
|
|
* Uses artifactPath if available, otherwise falls back to buildHash.uf2
|
|
* Or custom domain if R2_PUBLIC_URL is set.
|
|
*/
|
|
export function getR2ArtifactUrl(
|
|
build: Pick<BuildFields, 'buildHash' | 'artifactPath'>
|
|
): string {
|
|
const r2PublicUrl = process.env.R2_PUBLIC_URL
|
|
if (!r2PublicUrl) {
|
|
throw new Error('R2_PUBLIC_URL is not set')
|
|
}
|
|
const path = build.artifactPath || `/${build.buildHash}.uf2`
|
|
// Ensure path starts with /
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
return `${r2PublicUrl}${normalizedPath}`
|
|
}
|
|
|
|
// Internal mutation to upsert a build by buildHash
|
|
// This is the single source of truth for build creation
|
|
export const upsertBuild = internalMutation({
|
|
args: {
|
|
...pick(buildFields, ['buildHash', 'config']),
|
|
status: v.optional(v.string()),
|
|
flags: v.string(),
|
|
},
|
|
|
|
handler: async (ctx, args) => {
|
|
// Check if build already exists with this hash
|
|
const existingBuild = await ctx.db
|
|
.query('builds')
|
|
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
|
|
.unique()
|
|
|
|
const { status, buildHash, config, flags } = args
|
|
|
|
if (existingBuild) {
|
|
await ctx.db.patch(existingBuild._id, {
|
|
status: status ?? existingBuild.status,
|
|
updatedAt: Date.now(),
|
|
})
|
|
return existingBuild._id
|
|
}
|
|
|
|
// Create new build
|
|
const buildId = await ctx.db.insert('builds', {
|
|
status: 'queued',
|
|
startedAt: Date.now(),
|
|
buildHash,
|
|
updatedAt: Date.now(),
|
|
config,
|
|
})
|
|
|
|
// Dispatch GitHub workflow if needed
|
|
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
|
target: config.target,
|
|
version: config.version,
|
|
buildId,
|
|
flags,
|
|
buildHash,
|
|
})
|
|
|
|
return buildId
|
|
},
|
|
})
|
|
|
|
export const ensureBuildFromConfig = mutation({
|
|
args: {
|
|
target: v.string(),
|
|
version: v.string(),
|
|
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
|
|
profileName: v.optional(v.string()),
|
|
profileDescription: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// Construct config for the build
|
|
const config: BuildConfigFields = {
|
|
version: args.version,
|
|
modulesExcluded: args.modulesExcluded ?? {},
|
|
target: args.target,
|
|
}
|
|
|
|
// Compute build hash (single source of truth)
|
|
const { hash: buildHash, flags } = await computeBuildHash(config)
|
|
|
|
const existingBuild = await ctx.db
|
|
.query('builds')
|
|
.filter((q) => q.eq(q.field('buildHash'), buildHash))
|
|
.unique()
|
|
|
|
if (existingBuild) {
|
|
return {
|
|
buildId: existingBuild._id,
|
|
existed: true,
|
|
buildHash,
|
|
}
|
|
}
|
|
|
|
const buildId: Id<'builds'> = await ctx.runMutation(
|
|
internal.builds.upsertBuild,
|
|
{
|
|
buildHash,
|
|
flags,
|
|
config,
|
|
}
|
|
)
|
|
|
|
return {
|
|
buildId,
|
|
existed: false,
|
|
buildHash,
|
|
}
|
|
},
|
|
})
|
|
|
|
// Internal query to get build without auth checks (for webhooks)
|
|
export const getInternal = internalMutation({
|
|
args: { buildId: v.id('builds') },
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.get(args.buildId)
|
|
},
|
|
})
|
|
|
|
// Internal mutation to log errors from actions
|
|
export const logBuildError = internalMutation({
|
|
args: {
|
|
buildId: v.id('builds'),
|
|
error: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.buildId, {
|
|
status: 'failure',
|
|
completedAt: Date.now(),
|
|
})
|
|
},
|
|
})
|
|
|
|
// Internal mutation to update build status
|
|
export const updateBuildStatus = internalMutation({
|
|
args: {
|
|
...pick(buildFields, [
|
|
'status',
|
|
'completedAt',
|
|
'artifactPath',
|
|
'githubRunId',
|
|
]),
|
|
buildId: v.id('builds'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const build = await ctx.db.get(args.buildId)
|
|
if (!build) return
|
|
|
|
const updateData: BuildUpdateData & {
|
|
artifactPath?: string
|
|
githubRunId?: number
|
|
} = {
|
|
status: args.status,
|
|
}
|
|
|
|
// Only set completedAt for final statuses
|
|
if (args.status === 'success' || args.status === 'failure') {
|
|
updateData.completedAt = Date.now()
|
|
}
|
|
|
|
// Set artifactPath if provided
|
|
if (args.artifactPath !== undefined) {
|
|
updateData.artifactPath = args.artifactPath
|
|
}
|
|
|
|
// Set githubRunId if provided
|
|
if (args.githubRunId !== undefined) {
|
|
updateData.githubRunId = args.githubRunId
|
|
}
|
|
|
|
await ctx.db.patch(args.buildId, updateData)
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Helper to generate authenticated download URL
|
|
*/
|
|
async function generateAuthenticatedDownloadUrl(
|
|
ctx: GenericMutationCtx<DataModel>,
|
|
buildId: Id<'builds'>,
|
|
profileId: Id<'profiles'>,
|
|
objectKey: string,
|
|
ext: string,
|
|
filenameSuffix: string = '',
|
|
contentType: string = 'application/octet-stream',
|
|
incrementFlashCount: boolean = true
|
|
): Promise<string> {
|
|
const userId = await getAuthUserId(ctx)
|
|
if (!userId) throw new Error('Unauthorized')
|
|
|
|
// Verify profile belongs to user or is public
|
|
const profile = await ctx.db.get(profileId)
|
|
if (!profile) throw new Error('Profile not found')
|
|
|
|
// If profile is private, ensure user owns it
|
|
if (profile.isPublic === false && profile.userId !== userId) {
|
|
throw new Error('Unauthorized')
|
|
}
|
|
|
|
const build = await ctx.db.get(buildId)
|
|
if (!build) throw new Error('Build not found')
|
|
|
|
// Increment flash count for firmware downloads
|
|
if (incrementFlashCount) {
|
|
const nextCount = (profile.flashCount ?? 0) + 1
|
|
await ctx.db.patch(profileId, {
|
|
flashCount: nextCount,
|
|
updatedAt: Date.now(),
|
|
})
|
|
}
|
|
|
|
// Slugify profile name for filename
|
|
const slug = profile.name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)+/g, '')
|
|
|
|
const filename = `${slug}-${build.config.target}${filenameSuffix}.${ext}`
|
|
|
|
return await generateSignedDownloadUrl(objectKey, filename, contentType)
|
|
}
|
|
|
|
/**
|
|
* Helper to generate anonymous download URL
|
|
*/
|
|
function generateAnonymousDownloadUrlHelper(
|
|
build: BuildFields,
|
|
slug: string,
|
|
objectKey: string,
|
|
ext: string,
|
|
filenameSuffix: string = '',
|
|
contentType: string = 'application/octet-stream'
|
|
): Promise<string> {
|
|
const {
|
|
buildHash,
|
|
config: { target, version },
|
|
} = build
|
|
|
|
const pfx = slug ? `${slug}-` : ''
|
|
const filename = `${pfx}${target}-${version}-${buildHash.substring(0, 4)}${filenameSuffix}.${ext}`
|
|
|
|
return generateSignedDownloadUrl(objectKey, filename, contentType)
|
|
}
|
|
|
|
export const generateDownloadUrl = mutation({
|
|
args: {
|
|
buildId: v.id('builds'),
|
|
profileId: v.id('profiles'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const build = await ctx.db.get(args.buildId)
|
|
if (!build) throw new Error('Build not found')
|
|
|
|
// User indicated that artifactPath must be present for a valid download
|
|
if (!build.artifactPath) {
|
|
throw new Error('Build artifact path is missing')
|
|
}
|
|
|
|
let objectKey = build.artifactPath
|
|
// Remove leading slash if present
|
|
if (objectKey.startsWith('/')) {
|
|
objectKey = objectKey.substring(1)
|
|
}
|
|
|
|
// Determine extension from objectKey
|
|
const parts = objectKey.split('.')
|
|
const ext = parts.length > 1 ? parts.pop() : undefined
|
|
|
|
if (!ext) {
|
|
throw new Error('Could not determine file extension from artifact path')
|
|
}
|
|
|
|
return await generateAuthenticatedDownloadUrl(
|
|
ctx,
|
|
args.buildId,
|
|
args.profileId,
|
|
objectKey,
|
|
ext
|
|
)
|
|
},
|
|
})
|
|
|
|
export const generateAnonymousDownloadUrl = mutation({
|
|
args: {
|
|
build: v.object(buildFields),
|
|
slug: v.string(),
|
|
},
|
|
handler: async (_ctx, args) => {
|
|
let objectKey = args.build.artifactPath || ''
|
|
if (objectKey.startsWith('/')) {
|
|
objectKey = objectKey.substring(1)
|
|
}
|
|
|
|
const parts = objectKey.split('.')
|
|
const ext = parts.length > 1 ? parts.pop() : undefined
|
|
if (!ext) {
|
|
throw new Error('Could not determine file extension from artifact path')
|
|
}
|
|
|
|
return await generateAnonymousDownloadUrlHelper(
|
|
args.build,
|
|
args.slug,
|
|
objectKey,
|
|
ext
|
|
)
|
|
},
|
|
})
|
|
|
|
export const generateSourceDownloadUrl = mutation({
|
|
args: {
|
|
buildId: v.id('builds'),
|
|
profileId: v.id('profiles'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const build = await ctx.db.get(args.buildId)
|
|
if (!build) throw new Error('Build not found')
|
|
|
|
const objectKey = `${build.buildHash}.tar.gz`
|
|
|
|
return await generateAuthenticatedDownloadUrl(
|
|
ctx,
|
|
args.buildId,
|
|
args.profileId,
|
|
objectKey,
|
|
'tar.gz',
|
|
'-source',
|
|
'application/gzip',
|
|
false // Don't increment flash count for source downloads
|
|
)
|
|
},
|
|
})
|
|
|
|
export const generateAnonymousSourceDownloadUrl = mutation({
|
|
args: {
|
|
build: v.object(buildFields),
|
|
slug: v.string(),
|
|
},
|
|
handler: async (_ctx, args) => {
|
|
const objectKey = `${args.build.buildHash}.tar.gz`
|
|
|
|
return await generateAnonymousDownloadUrlHelper(
|
|
args.build,
|
|
args.slug,
|
|
objectKey,
|
|
'tar.gz',
|
|
'-source',
|
|
'application/gzip'
|
|
)
|
|
},
|
|
})
|