Files
spoon/packages/backend/convex/schema.ts
T
2026-06-22 10:37:26 -04:00

764 lines
23 KiB
TypeScript

import { authTables } from '@convex-dev/auth/server';
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
const applicationTables = {
/*
* Below is the users table definition from authTables
* You can add additional fields here. You can also remove
* the users table here & create a 'profiles' table if you
* prefer to keep auth data separate from application data.
*/
users: defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
email: v.optional(v.string()),
emailVerificationTime: v.optional(v.number()),
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()),
/* Fields below here are custom & not defined in authTables */
isAdmin: v.optional(v.boolean()),
role: v.optional(v.union(v.literal('owner'), v.literal('member'))),
lastSeenAt: v.optional(v.number()),
themePreference: v.optional(
v.union(v.literal('light'), v.literal('dark'), v.literal('system')),
),
})
.index('email', ['email'])
.index('phone', ['phone'])
/* Indexes below here are custom & not defined in authTables */
.index('name', ['name']),
gitConnections: defineTable({
userId: v.id('users'),
provider: v.union(
v.literal('github'),
v.literal('gitea'),
v.literal('gitlab'),
v.literal('other'),
),
providerAccountId: v.optional(v.string()),
displayName: v.string(),
username: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
installationId: v.optional(v.string()),
scopes: v.optional(v.array(v.string())),
status: v.union(
v.literal('active'),
v.literal('needs_reauth'),
v.literal('revoked'),
),
connectedAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider'])
.index('by_status', ['status']),
spoons: defineTable({
ownerId: v.id('users'),
name: v.string(),
description: v.optional(v.string()),
provider: v.union(
v.literal('github'),
v.literal('gitea'),
v.literal('gitlab'),
v.literal('other'),
),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
upstreamDefaultBranch: v.string(),
upstreamUrl: v.string(),
forkOwner: v.optional(v.string()),
forkRepo: v.optional(v.string()),
forkDefaultBranch: v.optional(v.string()),
forkUrl: v.optional(v.string()),
visibility: v.union(
v.literal('public'),
v.literal('private'),
v.literal('internal'),
v.literal('unknown'),
),
maintenanceMode: v.union(
v.literal('watch'),
v.literal('auto_pr'),
v.literal('paused'),
),
syncCadence: v.union(
v.literal('daily'),
v.literal('weekly'),
v.literal('manual'),
),
productionRefStrategy: v.union(
v.literal('default_branch'),
v.literal('latest_release'),
v.literal('tag_pattern'),
),
tagPattern: v.optional(v.string()),
status: v.union(
v.literal('draft'),
v.literal('active'),
v.literal('needs_connection'),
v.literal('paused'),
v.literal('archived'),
),
lastCheckedAt: v.optional(v.number()),
lastUpstreamCommit: v.optional(v.string()),
lastForkCommit: v.optional(v.string()),
connectionId: v.optional(v.id('gitConnections')),
githubInstallationId: v.optional(v.string()),
githubRepositoryId: v.optional(v.number()),
upstreamRepositoryId: v.optional(v.number()),
syncStatus: v.optional(
v.union(
v.literal('unknown'),
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('checking'),
v.literal('conflict'),
v.literal('error'),
),
),
upstreamAheadBy: v.optional(v.number()),
forkAheadBy: v.optional(v.number()),
lastMergeBaseCommit: v.optional(v.string()),
lastSyncRunId: v.optional(v.id('syncRuns')),
lastAiReviewId: v.optional(v.id('aiReviews')),
lastGithubRefreshAt: v.optional(v.number()),
lastSuccessfulRefreshAt: v.optional(v.number()),
lastError: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_owner_provider', ['ownerId', 'provider'])
.index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']),
spoonRepositoryStates: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFullName: v.string(),
forkFullName: v.string(),
upstreamDefaultBranch: v.string(),
forkDefaultBranch: v.string(),
upstreamHeadSha: v.optional(v.string()),
forkHeadSha: v.optional(v.string()),
mergeBaseSha: v.optional(v.string()),
upstreamAheadBy: v.number(),
forkAheadBy: v.number(),
status: v.union(
v.literal('up_to_date'),
v.literal('behind'),
v.literal('ahead'),
v.literal('diverged'),
v.literal('unknown'),
),
openForkPullRequestCount: v.number(),
openUpstreamPullRequestCount: v.number(),
lastCommitAt: v.optional(v.number()),
rawCompareUrl: v.optional(v.string()),
refreshedAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_status', ['ownerId', 'status']),
spoonCommits: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
sha: v.string(),
side: v.union(v.literal('upstream'), v.literal('fork')),
message: v.string(),
authorName: v.optional(v.string()),
authorEmail: v.optional(v.string()),
authorLogin: v.optional(v.string()),
committedAt: v.optional(v.number()),
htmlUrl: v.optional(v.string()),
filesChanged: v.optional(v.number()),
additions: v.optional(v.number()),
deletions: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon_side', ['spoonId', 'side'])
.index('by_owner', ['ownerId'])
.index('by_sha', ['spoonId', 'sha'])
.index('by_committed', ['spoonId', 'committedAt']),
spoonPullRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
githubId: v.number(),
number: v.number(),
repoFullName: v.string(),
scope: v.union(
v.literal('fork'),
v.literal('upstream'),
v.literal('from_fork_to_upstream'),
),
title: v.string(),
state: v.union(v.literal('open'), v.literal('closed'), v.literal('merged')),
draft: v.boolean(),
authorLogin: v.optional(v.string()),
baseRef: v.string(),
headRef: v.string(),
headRepoFullName: v.optional(v.string()),
htmlUrl: v.string(),
createdAtGithub: v.optional(v.number()),
updatedAtGithub: v.optional(v.number()),
mergedAtGithub: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_spoon_scope', ['spoonId', 'scope'])
.index('by_owner', ['ownerId'])
.index('by_github_id', ['githubId'])
.index('by_state', ['spoonId', 'state']),
syncRuns: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
threadId: v.optional(v.id('threads')),
kind: v.union(
v.literal('scheduled_check'),
v.literal('manual_check'),
v.literal('upstream_update'),
v.literal('merge_attempt'),
v.literal('ai_review'),
),
status: v.union(
v.literal('queued'),
v.literal('running'),
v.literal('clean'),
v.literal('conflict'),
v.literal('needs_review'),
v.literal('failed'),
v.literal('merged'),
),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
summary: v.optional(v.string()),
aiAssessment: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
error: v.optional(v.string()),
decision: v.optional(
v.union(
v.literal('auto_synced'),
v.literal('thread_created'),
v.literal('ignored'),
v.literal('failed'),
),
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']),
aiReviews: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
syncRunId: v.optional(v.id('syncRuns')),
model: v.string(),
status: v.union(
v.literal('queued'),
v.literal('running'),
v.literal('completed'),
v.literal('failed'),
),
reviewType: v.union(
v.literal('upstream_update'),
v.literal('manual_prompt'),
v.literal('merge_safety'),
),
inputSummary: v.string(),
outputSummary: v.optional(v.string()),
risk: v.union(
v.literal('unknown'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
),
compatible: v.boolean(),
requiresHumanReview: v.boolean(),
recommendedAction: v.union(
v.literal('sync'),
v.literal('open_review_pr'),
v.literal('manual_review'),
v.literal('do_not_sync'),
v.literal('unknown'),
),
potentialConflicts: v.optional(v.array(v.string())),
importantFiles: v.optional(v.array(v.string())),
reasoningSummary: v.optional(v.string()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_status', ['ownerId', 'status'])
.index('by_sync_run', ['syncRunId'])
.index('by_created', ['createdAt']),
spoonSettings: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
autoRefreshEnabled: v.boolean(),
autoReviewEnabled: v.boolean(),
autoSyncEnabled: v.boolean(),
requireAiLowRiskForSync: v.boolean(),
requireCleanCompareForSync: v.boolean(),
ignoredFilePatterns: v.optional(v.array(v.string())),
importantFilePatterns: v.optional(v.array(v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
spoonRemotes: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
label: v.string(),
url: v.string(),
remoteName: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
userAiSettings: defineTable({
userId: v.id('users'),
provider: v.literal('openai'),
encryptedApiKey: v.optional(v.string()),
apiKeyPreview: v.optional(v.string()),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider']),
aiProviderProfiles: defineTable({
ownerId: v.id('users'),
name: v.string(),
provider: v.union(
v.literal('openai'),
v.literal('anthropic'),
v.literal('google'),
v.literal('openrouter'),
v.literal('requesty'),
v.literal('litellm'),
v.literal('cloudflare_ai_gateway'),
v.literal('custom_openai_compatible'),
v.literal('opencode_openai_login'),
),
authType: v.union(
v.literal('api_key'),
v.literal('opencode_auth_json'),
v.literal('none'),
),
encryptedSecret: v.optional(v.string()),
secretPreview: v.optional(v.string()),
baseUrl: v.optional(v.string()),
defaultModel: v.string(),
modelOptions: v.optional(v.array(v.string())),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
enabled: v.boolean(),
isDefault: v.optional(v.boolean()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_owner_provider', ['ownerId', 'provider'])
.index('by_owner_enabled', ['ownerId', 'enabled']),
agentRequests: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
agentJobId: v.optional(v.id('agentJobs')),
prompt: v.string(),
requestType: v.optional(
v.union(
v.literal('manual_prompt'),
v.literal('upstream_review'),
v.literal('future_code_change'),
),
),
priority: v.optional(
v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
),
source: v.optional(v.union(v.literal('user'), v.literal('system'))),
status: v.union(
v.literal('draft'),
v.literal('queued'),
v.literal('running'),
v.literal('changes_ready'),
v.literal('merge_request_opened'),
v.literal('failed'),
v.literal('cancelled'),
),
targetBranch: v.optional(v.string()),
selectedSecretIds: v.optional(v.array(v.id('spoonSecrets'))),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
mergeRequestUrl: v.optional(v.string()),
summary: v.optional(v.string()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_created', ['createdAt']),
spoonSecrets: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
name: v.string(),
encryptedValue: v.string(),
valuePreview: v.optional(v.string()),
description: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_name', ['spoonId', 'name']),
spoonAgentSettings: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
enabled: v.boolean(),
runtime: v.optional(
v.union(v.literal('opencode'), v.literal('openai_direct')),
),
defaultBaseBranch: v.optional(v.string()),
branchPrefix: v.string(),
installCommand: v.optional(v.string()),
checkCommand: v.optional(v.string()),
testCommand: v.optional(v.string()),
agentModel: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
maxJobDurationMs: v.number(),
maxOutputBytes: v.number(),
envFilePath: v.optional(
v.union(
v.literal('.env'),
v.literal('.env.local'),
v.literal('.env.production'),
v.literal('.env.production.local'),
v.literal('custom'),
),
),
customEnvFilePath: v.optional(v.string()),
materializeEnvFileByDefault: v.optional(v.boolean()),
autoDetectCommands: v.optional(v.boolean()),
allowUserFileEditing: v.optional(v.boolean()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
agentJobs: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
agentRequestId: v.id('agentRequests'),
threadId: v.optional(v.id('threads')),
jobType: v.optional(
v.union(
v.literal('user_change'),
v.literal('maintenance_review'),
v.literal('conflict_resolution'),
),
),
status: v.union(
v.literal('queued'),
v.literal('claimed'),
v.literal('preparing'),
v.literal('running'),
v.literal('checks_running'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('failed'),
v.literal('cancelled'),
v.literal('timed_out'),
),
prompt: v.string(),
runtime: v.optional(
v.union(v.literal('openai_direct'), v.literal('opencode')),
),
workspaceStatus: v.optional(
v.union(
v.literal('not_started'),
v.literal('starting'),
v.literal('active'),
v.literal('idle'),
v.literal('stopped'),
v.literal('expired'),
v.literal('failed'),
),
),
baseBranch: v.string(),
workBranch: v.string(),
opencodeSessionId: v.optional(v.string()),
containerId: v.optional(v.string()),
workspaceUrl: v.optional(v.string()),
workspaceExpiresAt: v.optional(v.number()),
lastHeartbeatAt: v.optional(v.number()),
envFilePath: v.optional(v.string()),
materializeEnvFile: v.optional(v.boolean()),
githubInstallationId: v.optional(v.string()),
forkOwner: v.string(),
forkRepo: v.string(),
forkUrl: v.string(),
upstreamOwner: v.string(),
upstreamRepo: v.string(),
selectedSecretIds: v.array(v.id('spoonSecrets')),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
model: v.string(),
reasoningEffort: v.union(
v.literal('none'),
v.literal('minimal'),
v.literal('low'),
v.literal('medium'),
v.literal('high'),
v.literal('xhigh'),
),
commitSha: v.optional(v.string()),
pullRequestUrl: v.optional(v.string()),
pullRequestNumber: v.optional(v.number()),
summary: v.optional(v.string()),
error: v.optional(v.string()),
claimedBy: v.optional(v.string()),
claimedAt: v.optional(v.number()),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_spoon', ['spoonId'])
.index('by_request', ['agentRequestId'])
.index('by_status', ['status'])
.index('by_claim', ['status', 'createdAt']),
agentJobMessages: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
role: v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system'),
v.literal('tool'),
),
content: v.string(),
status: v.union(
v.literal('queued'),
v.literal('streaming'),
v.literal('completed'),
v.literal('failed'),
),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentWorkspaceChanges: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
path: v.string(),
source: v.union(
v.literal('user'),
v.literal('agent'),
v.literal('command'),
),
changeType: v.union(
v.literal('added'),
v.literal('modified'),
v.literal('deleted'),
v.literal('renamed'),
),
diff: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_path', ['jobId', 'path']),
agentJobEvents: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
level: v.union(
v.literal('debug'),
v.literal('info'),
v.literal('warn'),
v.literal('error'),
),
phase: v.union(
v.literal('queued'),
v.literal('clone'),
v.literal('plan'),
v.literal('edit'),
v.literal('install'),
v.literal('check'),
v.literal('test'),
v.literal('commit'),
v.literal('push'),
v.literal('pr'),
v.literal('cleanup'),
),
message: v.string(),
metadata: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
agentJobArtifacts: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
kind: v.union(
v.literal('plan'),
v.literal('diff'),
v.literal('test_output'),
v.literal('summary'),
v.literal('error'),
v.literal('pr_body'),
),
title: v.string(),
content: v.string(),
contentType: v.union(
v.literal('text/markdown'),
v.literal('text/plain'),
v.literal('application/json'),
v.literal('text/x-diff'),
),
createdAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId']),
threads: defineTable({
ownerId: v.id('users'),
spoonId: v.optional(v.id('spoons')),
title: v.string(),
summary: v.optional(v.string()),
source: v.union(
v.literal('user_request'),
v.literal('upstream_update'),
v.literal('merge_conflict'),
v.literal('manual_review'),
v.literal('system'),
),
status: v.union(
v.literal('open'),
v.literal('queued'),
v.literal('running'),
v.literal('waiting_for_user'),
v.literal('changes_ready'),
v.literal('draft_pr_opened'),
v.literal('resolved'),
v.literal('ignored'),
v.literal('failed'),
v.literal('cancelled'),
),
priority: v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.optional(v.string()),
forkHeadAtCreation: v.optional(v.string()),
mergeBaseAtCreation: v.optional(v.string()),
relatedSyncRunId: v.optional(v.id('syncRuns')),
relatedAgentRequestId: v.optional(v.id('agentRequests')),
latestAgentJobId: v.optional(v.id('agentJobs')),
maintenanceOutcome: v.optional(
v.union(
v.literal('auto_synced'),
v.literal('sync_recommended'),
v.literal('ignored'),
v.literal('review_pr_recommended'),
v.literal('manual_review_required'),
v.literal('conflict_resolution_required'),
v.literal('failed'),
v.literal('unknown'),
),
),
ignoredCommitShas: v.optional(v.array(v.string())),
ignoredReason: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
resolvedAt: v.optional(v.number()),
})
.index('by_owner', ['ownerId'])
.index('by_owner_status', ['ownerId', 'status'])
.index('by_spoon', ['spoonId'])
.index('by_source', ['ownerId', 'source'])
.index('by_created', ['createdAt']),
threadMessages: defineTable({
threadId: v.id('threads'),
ownerId: v.id('users'),
spoonId: v.optional(v.id('spoons')),
role: v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system'),
v.literal('tool'),
),
content: v.string(),
status: v.union(
v.literal('queued'),
v.literal('streaming'),
v.literal('completed'),
v.literal('failed'),
),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_thread', ['threadId'])
.index('by_owner', ['ownerId']),
ignoredUpstreamChanges: defineTable({
spoonId: v.id('spoons'),
ownerId: v.id('users'),
upstreamFrom: v.optional(v.string()),
upstreamTo: v.string(),
commitShas: v.array(v.string()),
reason: v.string(),
decidedBy: v.union(v.literal('agent'), v.literal('user')),
threadId: v.optional(v.id('threads')),
createdAt: v.number(),
})
.index('by_spoon', ['spoonId'])
.index('by_owner', ['ownerId'])
.index('by_upstream_to', ['spoonId', 'upstreamTo']),
};
export default defineSchema({
...authTables,
...applicationTables,
});