Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

schema.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

schema.tsBlame4674 lines · 3 contributors
fc1817aClaude1import {
2 pgTable,
3 text,
4 timestamp,
5 uuid,
6 boolean,
7 integer,
8 uniqueIndex,
79136bbClaude9 index,
10 serial,
abfa9adClaude11 bigint,
12 jsonb,
5ca514aClaude13 numeric,
abfa9adClaude14 customType,
cc34156Claude15 primaryKey,
fc1817aClaude16} from "drizzle-orm/pg-core";
17
abfa9adClaude18// Postgres `bytea` — drizzle-orm doesn't ship a first-class bytea column, so
19// we declare one via customType that maps to Buffer on both read and write.
20// Used by `workflow_run_cache.content` where we really do need raw bytes
21// (unlike `workflow_artifacts.content`, which stayed base64-text for v1).
22const bytea = customType<{ data: Buffer; default: false }>({
23 dataType() {
24 return "bytea";
25 },
26});
27
a686079Claude28// pgvector `vector(N)` — drizzle-orm has no first-class pgvector column.
29// We map it to `number[]` and serialise/deserialise via Postgres's
30// canonical text format `[0.1,0.2,...]`. The `default: false` flag tells
31// drizzle this column has no default value.
32//
33// Migration 0057 creates the column with `vector(1024)`. If the migration
34// degraded (pgvector unavailable on the host), the column is missing and
35// every read/write throws; src/lib/semantic-index.ts catches at the
36// boundary and falls back to empty-result behaviour.
37const vector = customType<{ data: number[]; driverData: string; default: false }>({
38 dataType(config: unknown) {
39 const cfg = config as { dimensions?: number } | undefined;
40 return cfg?.dimensions ? `vector(${cfg.dimensions})` : "vector";
41 },
42 toDriver(value: number[]): string {
43 return "[" + value.join(",") + "]";
44 },
45 fromDriver(value: string | number[]): number[] {
46 if (Array.isArray(value)) return value as number[];
47 // Canonical format: "[0.1,0.2,0.3]"
48 const trimmed = String(value).trim();
49 if (!trimmed) return [];
50 const inner = trimmed.startsWith("[") && trimmed.endsWith("]")
51 ? trimmed.slice(1, -1)
52 : trimmed;
53 if (!inner) return [];
54 return inner.split(",").map((s) => parseFloat(s));
55 },
56});
57
fc1817aClaude58export const users = pgTable("users", {
59 id: uuid("id").primaryKey().defaultRandom(),
60 username: text("username").notNull().unique(),
61 email: text("email").notNull().unique(),
62 displayName: text("display_name"),
63 passwordHash: text("password_hash").notNull(),
64 avatarUrl: text("avatar_url"),
65 bio: text("bio"),
24cf2caClaude66 // Email notification preferences (Block A8). Default on; opt-out via /settings.
67 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
68 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
69 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
08420cdClaude70 // Block I7 — weekly digest opt-in.
71 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
cb5a796Claude72 // Drizzle/0066 — Pending comment moderation requests. Defaults ON so
73 // a new repo owner is alerted as soon as the first comment lands in
74 // the queue. The in-app notification is always written regardless;
75 // this controls fan-out (currently the audit/notification surface,
76 // future email).
77 notifyEmailOnPendingComment: boolean("notify_email_on_pending_comment")
78 .default(true)
79 .notNull(),
08420cdClaude80 lastDigestSentAt: timestamp("last_digest_sent_at"),
46d6165Claude81 // Block L1 — Sleep Mode. When enabled, the autopilot sleep-mode-digest
82 // task delivers a daily "what Claude shipped overnight" report at the
e1fc7dbClaude83 // user-configured UTC hour (0-23, default 9). Uses its own independent
84 // cooldown anchor (lastSleepDigestSentAt) so the weekly digest timer is
85 // not reset when a sleep-mode digest fires, and vice versa.
86 // Migration 0077 adds the column.
87 lastSleepDigestSentAt: timestamp("last_sleep_digest_sent_at"),
46d6165Claude88 sleepModeEnabled: boolean("sleep_mode_enabled").default(false).notNull(),
89 sleepModeDigestHourUtc: integer("sleep_mode_digest_hour_utc").default(9).notNull(),
534f04aClaude90 // Block M2 — Web Push per-event preferences. Default on; opt-out via /settings.
91 notifyPushOnMention: boolean("notify_push_on_mention").default(true).notNull(),
92 notifyPushOnAssign: boolean("notify_push_on_assign").default(true).notNull(),
93 notifyPushOnReviewRequest: boolean("notify_push_on_review_request")
94 .default(true)
95 .notNull(),
96 notifyPushOnDeployFailed: boolean("notify_push_on_deploy_failed")
97 .default(true)
98 .notNull(),
a4f3e24Claude99 isAdmin: boolean("is_admin").default(false).notNull(),
c63b860Claude100 // Block P2 — set when the user clicks the verification link. Soft-gate
101 // only: registration succeeds regardless; /dashboard surfaces a banner
102 // until this is non-null.
103 emailVerifiedAt: timestamp("email_verified_at"),
104 // Block P5 — Account deletion with 30-day grace period.
105 // See drizzle/0049_account_deletion.sql.
106 deletedAt: timestamp("deleted_at"),
107 deletionScheduledFor: timestamp("deletion_scheduled_for"),
108 // Block P3 — Terms of Service / Privacy Policy acceptance audit trail.
109 // Set on /register when the user ticks the accept_terms checkbox.
110 // termsVersion bumps when Terms change; UI prompts re-acceptance later.
111 termsAcceptedAt: timestamp("terms_accepted_at"),
112 termsVersion: text("terms_version"),
cd4f63bTest User113 // Block Q3 — Anonymous playground accounts. When `isPlayground=true`, the
114 // account was minted by /play with a synthetic email + random password
115 // and is hard-deleted by the autopilot `playground-purge` task once
116 // `playgroundExpiresAt` passes. Real accounts always carry false/null.
117 // See drizzle/0052_playground_accounts.sql.
118 isPlayground: boolean("is_playground").default(false).notNull(),
119 playgroundExpiresAt: timestamp("playground_expires_at"),
ee7e577Claude120 // Migration 0071 — Personal cross-repo semantic index opt-in. When true,
121 // /chat (and the /api/v2/me/chat/messages endpoint) may search across
122 // every repo the user owns OR is an accepted collaborator on. Default
123 // OFF — privacy-first; the user must explicitly enable it via
124 // /settings/personal-semantic-toggle. The personal-semantic helpers
125 // hard-refuse to return any rows while this flag is false.
126 personalSemanticIndexEnabled: boolean("personal_semantic_index_enabled")
127 .default(false)
128 .notNull(),
b12f20dClaude129 // Onboarding drip sequence (migration 0081). Stores a JSON array of string
f65f600Claude130 // keys for emails already delivered, e.g. ["welcome","day1","day3"].
131 // The autopilot `onboarding-drip` task compares this against the canonical
132 // drip schedule and sends any outstanding emails. Never null — defaults to
133 // an empty array at insert time.
134 onboardingEmailsSent: jsonb("onboarding_emails_sent").$type<string[]>().default([]).notNull(),
cc34156Claude135 // Migration 0089 — Smart morning digest (AI-curated daily notification queue).
136 // When true, the autopilot `smart-digest` task delivers one AI-curated
137 // digest notification per day at 07:00 UTC. Independent cooldown via
138 // `lastSmartDigestSentAt` so it doesn't interact with weekly email digest.
139 notifySmartDigest: boolean("notify_smart_digest").default(true).notNull(),
140 lastSmartDigestSentAt: timestamp("last_smart_digest_sent_at", { withTimezone: true }),
fc1817aClaude141 createdAt: timestamp("created_at").defaultNow().notNull(),
142 updatedAt: timestamp("updated_at").defaultNow().notNull(),
143});
144
06d5ffeClaude145export const sessions = pgTable("sessions", {
146 id: uuid("id").primaryKey().defaultRandom(),
147 userId: uuid("user_id")
148 .notNull()
149 .references(() => users.id, { onDelete: "cascade" }),
150 token: text("token").notNull().unique(),
151 expiresAt: timestamp("expires_at").notNull(),
7298a17Claude152 // B4: true when the user has entered their password but not yet their TOTP
153 // code. softAuth/requireAuth treat such sessions as anonymous; only
154 // /login/2fa can consume them. Flips to false on successful 2FA.
155 requires2fa: boolean("requires_2fa").default(false).notNull(),
ba9e143Claude156 // Migration 0077 — SOC 2 session visibility. Populated at login and
157 // refreshed on each authenticated request (best-effort, non-blocking).
158 ip: text("ip"),
159 userAgent: text("user_agent"),
160 lastSeenAt: timestamp("last_seen_at"),
06d5ffeClaude161 createdAt: timestamp("created_at").defaultNow().notNull(),
162});
163
ba9e143Claude164/**
165 * Login attempt log — SOC 2 CC6.1 account-lockout evidence.
166 * Records every login attempt (success or failure) per email + IP.
167 * After 10 failures within 1 hour the login handler blocks the email
168 * for 15 minutes and emits `auth.login.locked` to the audit log.
169 * Migration 0078.
170 */
171export const loginAttempts = pgTable(
172 "login_attempts",
173 {
174 id: uuid("id").primaryKey().defaultRandom(),
175 email: text("email").notNull(),
176 ip: text("ip").notNull(),
177 success: boolean("success").notNull().default(false),
178 createdAt: timestamp("created_at").defaultNow().notNull(),
179 },
180 (table) => [
181 index("login_attempts_email_created").on(table.email, table.createdAt),
182 index("login_attempts_ip_created").on(table.ip, table.createdAt),
183 ]
184);
185
186export type LoginAttempt = typeof loginAttempts.$inferSelect;
187
45e31d0Claude188// @ts-ignore — self-referential FK on forkedFromId causes circular inference
fc1817aClaude189export const repositories = pgTable(
190 "repositories",
191 {
192 id: uuid("id").primaryKey().defaultRandom(),
193 name: text("name").notNull(),
7437605Claude194 // ownerId = creator / user-owner. Always set (for attribution + user
195 // namespace uniqueness). For org-owned repos, also represents "created by".
fc1817aClaude196 ownerId: uuid("owner_id")
197 .notNull()
198 .references(() => users.id),
7437605Claude199 // Block B2: nullable org owner. When set, the repo lives in the org
200 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
201 orgId: uuid("org_id"),
fc1817aClaude202 description: text("description"),
203 isPrivate: boolean("is_private").default(false).notNull(),
3ef4c9dClaude204 isArchived: boolean("is_archived").default(false).notNull(),
71cd5ecClaude205 isTemplate: boolean("is_template").default(false).notNull(),
fc1817aClaude206 defaultBranch: text("default_branch").default("main").notNull(),
207 diskPath: text("disk_path").notNull(),
45e31d0Claude208 forkedFromId: uuid("forked_from_id"),
fc1817aClaude209 createdAt: timestamp("created_at").defaultNow().notNull(),
210 updatedAt: timestamp("updated_at").defaultNow().notNull(),
211 pushedAt: timestamp("pushed_at"),
212 starCount: integer("star_count").default(0).notNull(),
213 forkCount: integer("fork_count").default(0).notNull(),
79136bbClaude214 issueCount: integer("issue_count").default(0).notNull(),
534f04aClaude215 // Block M5: autopilot stale-sweep opt-out flags. Default true — the
216 // 2-stage close (poke at 7d/30d, close at 14d/60d after poke) runs on
217 // every repo unless an owner disables it via repo-settings.
218 autoCloseStalePrs: boolean("auto_close_stale_prs").default(true).notNull(),
219 autoCloseStaleIssues: boolean("auto_close_stale_issues")
220 .default(true)
221 .notNull(),
4bbacbeClaude222 // Migration 0062 — opt-out flag for per-branch preview URLs. Default on;
223 // owners can disable via repo-settings. When false, post-receive skips
224 // the enqueuePreviewBuild call entirely.
225 previewBuildsEnabled: boolean("preview_builds_enabled")
226 .default(true)
227 .notNull(),
0c3eee5Claude228 // Migration 0065 — opt-in flag for the AI test generator autopilot task.
229 // Default false (off) because writing tests against unreviewed code can
230 // produce noise; owners must explicitly enable it via repo-settings.
231 autoGenerateTests: boolean("auto_generate_tests")
232 .default(false)
233 .notNull(),
79ed944Claude234 // Migration 0067 — opt-in flag for auto-provisioning a runnable PR
235 // sandbox on every PR open. Default false (off) because each sandbox
236 // burns compute; owners must explicitly enable it via repo-settings.
237 autoPrSandbox: boolean("auto_pr_sandbox").default(false).notNull(),
9b3a183Claude238 // Migration 0072 — opt-in flag for hosted-in-browser dev environments
239 // (cloud Codespaces alternative). Default false (off) because each
240 // env burns a container until the idle sweep tears it down; owners
241 // must explicitly enable per-repo via repo-settings.
242 devEnvsEnabled: boolean("dev_envs_enabled").default(false).notNull(),
44f1a02Claude243 dataRegion: text("data_region").default("us").notNull(),
1df50d5Claude244 previewBuildCommand: text("preview_build_command"),
245 previewOutputDir: text("preview_output_dir").default("dist"),
641aa42Claude246 // Migration 0088 — smart empty states. Set to true once the onboarding
247 // card has been dismissed by the repo owner. Generated on first push.
248 onboardingShown: boolean("onboarding_shown").default(false).notNull(),
7f992cdClaude249 depUpdaterEnabled: boolean("dep_updater_enabled").default(false).notNull(),
fc1817aClaude250 },
7437605Claude251 (table) => [
252 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
253 // Matches the partial index in migration 0004.
254 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
255 index("repos_org").on(table.orgId),
256 ]
fc1817aClaude257);
258
3ef4c9dClaude259/**
260 * Per-repository gate + auto-repair configuration.
261 * Every new repo is created with all gates ENABLED by default —
262 * the "full green ecosystem" default. Owners can manually opt-out per setting.
263 */
264export const repoSettings = pgTable("repo_settings", {
265 id: uuid("id").primaryKey().defaultRandom(),
266 repositoryId: uuid("repository_id")
267 .notNull()
268 .unique()
269 .references(() => repositories.id, { onDelete: "cascade" }),
270 // Gates
271 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
272 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
273 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
274 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
275 dependencyScanEnabled: boolean("dependency_scan_enabled").default(true).notNull(),
276 lintEnabled: boolean("lint_enabled").default(true).notNull(),
277 typeCheckEnabled: boolean("type_check_enabled").default(true).notNull(),
278 testEnabled: boolean("test_enabled").default(true).notNull(),
279 // Auto-repair
280 autoFixEnabled: boolean("auto_fix_enabled").default(true).notNull(),
281 autoMergeResolveEnabled: boolean("auto_merge_resolve_enabled").default(true).notNull(),
282 autoFormatEnabled: boolean("auto_format_enabled").default(true).notNull(),
283 // AI features
284 aiCommitMessagesEnabled: boolean("ai_commit_messages_enabled").default(true).notNull(),
285 aiPrSummaryEnabled: boolean("ai_pr_summary_enabled").default(true).notNull(),
286 aiChangelogEnabled: boolean("ai_changelog_enabled").default(true).notNull(),
287 // Deploy
288 autoDeployEnabled: boolean("auto_deploy_enabled").default(true).notNull(),
289 deployRequireAllGreen: boolean("deploy_require_all_green").default(true).notNull(),
290 createdAt: timestamp("created_at").defaultNow().notNull(),
291 updatedAt: timestamp("updated_at").defaultNow().notNull(),
292});
293
294/**
295 * Branch protection rules — enforced on push and merge.
296 * Every repo's default branch gets a protection rule on creation.
297 */
298export const branchProtection = pgTable(
299 "branch_protection",
300 {
301 id: uuid("id").primaryKey().defaultRandom(),
302 repositoryId: uuid("repository_id")
303 .notNull()
304 .references(() => repositories.id, { onDelete: "cascade" }),
305 pattern: text("pattern").notNull(), // branch name or glob (e.g. "main", "release/*")
306 requirePullRequest: boolean("require_pull_request").default(true).notNull(),
307 requireGreenGates: boolean("require_green_gates").default(true).notNull(),
308 requireAiApproval: boolean("require_ai_approval").default(true).notNull(),
309 requireHumanReview: boolean("require_human_review").default(false).notNull(),
310 requiredApprovals: integer("required_approvals").default(0).notNull(),
311 allowForcePush: boolean("allow_force_push").default(false).notNull(),
312 allowDeletion: boolean("allow_deletion").default(false).notNull(),
313 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
4626e61Claude314 // K2 — Auto-merge opt-in. When true, the autopilot ticker may auto-merge
315 // a PR whose base branch matches this rule, provided every gate the
316 // manual-merge path enforces is green. Default-deny.
317 enableAutoMerge: boolean("enable_auto_merge").default(false).notNull(),
3ef4c9dClaude318 createdAt: timestamp("created_at").defaultNow().notNull(),
319 updatedAt: timestamp("updated_at").defaultNow().notNull(),
320 },
321 (table) => [
322 uniqueIndex("branch_protection_repo_pattern").on(
323 table.repositoryId,
324 table.pattern
325 ),
326 ]
327);
328
329/**
330 * Gate run history. Every push + every PR creates gate_runs entries —
331 * one per configured gate. Serves as the source of truth for "is this green?".
332 */
333export const gateRuns = pgTable(
334 "gate_runs",
335 {
336 id: uuid("id").primaryKey().defaultRandom(),
337 repositoryId: uuid("repository_id")
338 .notNull()
339 .references(() => repositories.id, { onDelete: "cascade" }),
340 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
341 onDelete: "cascade",
342 }),
343 commitSha: text("commit_sha").notNull(),
344 ref: text("ref").notNull(),
345 gateName: text("gate_name").notNull(), // e.g. "GateTest", "AI Review", "Secret Scan", "Type Check"
346 status: text("status").notNull(), // pending, running, passed, failed, skipped, repaired
347 summary: text("summary"),
348 details: text("details"), // JSON: per-check output, affected files, etc
349 repairAttempted: boolean("repair_attempted").default(false).notNull(),
350 repairSucceeded: boolean("repair_succeeded").default(false).notNull(),
351 repairCommitSha: text("repair_commit_sha"),
352 durationMs: integer("duration_ms"),
353 createdAt: timestamp("created_at").defaultNow().notNull(),
354 completedAt: timestamp("completed_at"),
355 },
356 (table) => [
357 index("gate_runs_repo_sha").on(table.repositoryId, table.commitSha),
358 index("gate_runs_pr").on(table.pullRequestId),
359 index("gate_runs_created").on(table.createdAt),
360 ]
361);
362
363/**
364 * In-app notifications. Powered by the activity feed + explicit mentions.
365 */
366export const notifications = pgTable(
367 "notifications",
368 {
369 id: uuid("id").primaryKey().defaultRandom(),
370 userId: uuid("user_id")
371 .notNull()
372 .references(() => users.id, { onDelete: "cascade" }),
373 repositoryId: uuid("repository_id").references(() => repositories.id, {
374 onDelete: "cascade",
375 }),
376 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
377 title: text("title").notNull(),
378 body: text("body"),
379 url: text("url"), // link to the relevant page
380 readAt: timestamp("read_at"),
381 createdAt: timestamp("created_at").defaultNow().notNull(),
382 },
383 (table) => [
384 index("notifications_user_unread").on(table.userId, table.readAt),
385 index("notifications_user_created").on(table.userId, table.createdAt),
386 ]
387);
388
389/**
390 * Releases — named snapshots of a repo at a tag/commit.
391 * AI-generated changelogs bundled in notes field.
392 */
393export const releases = pgTable(
394 "releases",
395 {
396 id: uuid("id").primaryKey().defaultRandom(),
397 repositoryId: uuid("repository_id")
398 .notNull()
399 .references(() => repositories.id, { onDelete: "cascade" }),
400 authorId: uuid("author_id")
401 .notNull()
402 .references(() => users.id),
403 tag: text("tag").notNull(),
404 name: text("name").notNull(),
405 body: text("body"), // AI-generated release notes + changelog
406 targetCommit: text("target_commit").notNull(),
407 isDraft: boolean("is_draft").default(false).notNull(),
408 isPrerelease: boolean("is_prerelease").default(false).notNull(),
409 createdAt: timestamp("created_at").defaultNow().notNull(),
410 publishedAt: timestamp("published_at"),
411 },
412 (table) => [
413 uniqueIndex("releases_repo_tag").on(table.repositoryId, table.tag),
414 ]
415);
416
417/**
418 * Milestones — group issues + PRs toward a shared goal.
419 */
420export const milestones = pgTable(
421 "milestones",
422 {
423 id: uuid("id").primaryKey().defaultRandom(),
424 repositoryId: uuid("repository_id")
425 .notNull()
426 .references(() => repositories.id, { onDelete: "cascade" }),
427 title: text("title").notNull(),
428 description: text("description"),
429 state: text("state").notNull().default("open"), // open, closed
430 dueDate: timestamp("due_date"),
431 createdAt: timestamp("created_at").defaultNow().notNull(),
432 closedAt: timestamp("closed_at"),
433 },
434 (table) => [index("milestones_repo_state").on(table.repositoryId, table.state)]
435);
436
437/**
438 * Reactions on issues, PRs, and comments. Universal target pointer.
439 */
440export const reactions = pgTable(
441 "reactions",
442 {
443 id: uuid("id").primaryKey().defaultRandom(),
444 userId: uuid("user_id")
445 .notNull()
446 .references(() => users.id, { onDelete: "cascade" }),
447 targetType: text("target_type").notNull(), // issue, pr, issue_comment, pr_comment
448 targetId: uuid("target_id").notNull(),
449 emoji: text("emoji").notNull(), // thumbs_up, thumbs_down, rocket, heart, eyes, laugh, hooray, confused
450 createdAt: timestamp("created_at").defaultNow().notNull(),
451 },
452 (table) => [
453 uniqueIndex("reactions_unique").on(
454 table.userId,
455 table.targetType,
456 table.targetId,
457 table.emoji
458 ),
459 index("reactions_target").on(table.targetType, table.targetId),
460 ]
461);
462
463/**
464 * PR reviews (formal approve/request-changes).
465 * Separate from inline comments in pr_comments.
466 */
467export const prReviews = pgTable(
468 "pr_reviews",
469 {
470 id: uuid("id").primaryKey().defaultRandom(),
471 pullRequestId: uuid("pull_request_id")
472 .notNull()
473 .references(() => pullRequests.id, { onDelete: "cascade" }),
474 reviewerId: uuid("reviewer_id")
475 .notNull()
476 .references(() => users.id),
477 state: text("state").notNull(), // approved, changes_requested, commented
478 body: text("body"),
479 isAi: boolean("is_ai").default(false).notNull(),
480 createdAt: timestamp("created_at").defaultNow().notNull(),
481 },
482 (table) => [index("pr_reviews_pr").on(table.pullRequestId)]
483);
484
485/**
486 * Code owners — who owns which paths (auto-request review on PR).
487 * Parsed from a CODEOWNERS file at the root of the default branch.
488 */
489export const codeOwners = pgTable(
490 "code_owners",
491 {
492 id: uuid("id").primaryKey().defaultRandom(),
493 repositoryId: uuid("repository_id")
494 .notNull()
495 .references(() => repositories.id, { onDelete: "cascade" }),
496 pathPattern: text("path_pattern").notNull(),
497 ownerUsernames: text("owner_usernames").notNull(), // comma-separated
498 createdAt: timestamp("created_at").defaultNow().notNull(),
499 },
500 (table) => [index("code_owners_repo").on(table.repositoryId)]
501);
502
ec9e3e3Claude503/**
504 * PR review requests — tracks reviewers auto-assigned via CODEOWNERS
505 * or manually requested. The UNIQUE constraint prevents duplicate requests.
506 * Migration 0077.
507 */
508export const prReviewRequests = pgTable(
509 "pr_review_requests",
510 {
511 id: uuid("id").primaryKey().defaultRandom(),
512 prId: uuid("pr_id")
513 .notNull()
514 .references(() => pullRequests.id, { onDelete: "cascade" }),
515 reviewerId: uuid("reviewer_id")
516 .notNull()
517 .references(() => users.id, { onDelete: "cascade" }),
518 requestedBy: uuid("requested_by").references(() => users.id),
519 createdAt: timestamp("created_at").defaultNow().notNull(),
520 },
521 (table) => [
522 index("pr_review_requests_pr").on(table.prId),
523 index("pr_review_requests_reviewer").on(table.reviewerId),
524 ]
525);
526
3ef4c9dClaude527/**
528 * Per-repo AI chat sessions — conversational repo assistant.
529 */
530export const aiChats = pgTable(
531 "ai_chats",
532 {
533 id: uuid("id").primaryKey().defaultRandom(),
534 userId: uuid("user_id")
535 .notNull()
536 .references(() => users.id, { onDelete: "cascade" }),
537 repositoryId: uuid("repository_id").references(() => repositories.id, {
538 onDelete: "cascade",
539 }),
540 title: text("title"),
541 messages: text("messages").notNull().default("[]"), // JSON array of {role, content}
542 createdAt: timestamp("created_at").defaultNow().notNull(),
543 updatedAt: timestamp("updated_at").defaultNow().notNull(),
544 },
545 (table) => [
546 index("ai_chats_user").on(table.userId),
547 index("ai_chats_repo").on(table.repositoryId),
548 ]
549);
550
551/**
552 * Audit log — every sensitive action. Who did what, when, from where.
553 */
554export const auditLog = pgTable(
555 "audit_log",
556 {
557 id: uuid("id").primaryKey().defaultRandom(),
558 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
559 repositoryId: uuid("repository_id").references(() => repositories.id, {
560 onDelete: "set null",
561 }),
562 action: text("action").notNull(), // repo.create, repo.delete, repo.transfer, token.create, token.revoke, merge, force_push, branch_protection.update, deploy, ...
563 targetType: text("target_type"),
564 targetId: text("target_id"),
565 ip: text("ip"),
566 userAgent: text("user_agent"),
567 metadata: text("metadata"), // JSON for extra context
568 createdAt: timestamp("created_at").defaultNow().notNull(),
569 },
570 (table) => [
571 index("audit_log_user").on(table.userId),
572 index("audit_log_repo").on(table.repositoryId),
573 index("audit_log_created").on(table.createdAt),
574 ]
575);
576
577/**
9ecf5a4Claude578 * Deployments — tracks every deploy to downstream systems (Vapron, etc).
3ef4c9dClaude579 * Each deploy is gated on ALL green gates passing.
580 */
581export const deployments = pgTable(
582 "deployments",
583 {
584 id: uuid("id").primaryKey().defaultRandom(),
585 repositoryId: uuid("repository_id")
586 .notNull()
587 .references(() => repositories.id, { onDelete: "cascade" }),
588 environment: text("environment").notNull().default("production"),
589 commitSha: text("commit_sha").notNull(),
590 ref: text("ref").notNull(),
a76d984Claude591 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
3ef4c9dClaude592 blockedReason: text("blocked_reason"),
9ecf5a4Claude593 target: text("target"), // e.g. "vapron" (legacy rows: "crontech"), "fly.io"
3ef4c9dClaude594 triggeredBy: uuid("triggered_by").references(() => users.id),
a76d984Claude595 /**
596 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
597 * NULL = no wait; non-null = autopilot flips status from "waiting_timer"
598 * to "pending" once `now() >= ready_after`.
599 */
600 readyAfter: timestamp("ready_after"),
3ef4c9dClaude601 createdAt: timestamp("created_at").defaultNow().notNull(),
602 completedAt: timestamp("completed_at"),
603 },
604 (table) => [
605 index("deployments_repo").on(table.repositoryId),
606 index("deployments_created").on(table.createdAt),
607 ]
608);
609
610/**
611 * Rate-limit buckets — in-memory or persisted counter per IP / token / route.
612 */
613export const rateLimitBuckets = pgTable(
614 "rate_limit_buckets",
615 {
616 id: uuid("id").primaryKey().defaultRandom(),
617 bucketKey: text("bucket_key").notNull().unique(), // "ip:1.2.3.4:api" or "token:abc:api"
618 count: integer("count").default(0).notNull(),
619 windowStart: timestamp("window_start").defaultNow().notNull(),
620 expiresAt: timestamp("expires_at").notNull(),
621 },
622 (table) => [index("rate_limit_expires").on(table.expiresAt)]
fc1817aClaude623);
624
06d5ffeClaude625export const stars = pgTable(
626 "stars",
627 {
628 id: uuid("id").primaryKey().defaultRandom(),
629 userId: uuid("user_id")
630 .notNull()
631 .references(() => users.id, { onDelete: "cascade" }),
632 repositoryId: uuid("repository_id")
633 .notNull()
634 .references(() => repositories.id, { onDelete: "cascade" }),
635 createdAt: timestamp("created_at").defaultNow().notNull(),
636 },
79136bbClaude637 (table) => [
638 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
639 ]
640);
641
642export const issues = pgTable(
643 "issues",
644 {
645 id: uuid("id").primaryKey().defaultRandom(),
646 number: serial("number"),
647 repositoryId: uuid("repository_id")
648 .notNull()
649 .references(() => repositories.id, { onDelete: "cascade" }),
650 authorId: uuid("author_id")
651 .notNull()
652 .references(() => users.id),
653 title: text("title").notNull(),
654 body: text("body"),
655 state: text("state").notNull().default("open"), // open, closed
85c4e13Claude656 // Migration 0077 — milestone grouping. Optional; ON DELETE SET NULL so
657 // deleting a milestone never removes the issue.
658 milestoneId: uuid("milestone_id").references(() => milestones.id, {
659 onDelete: "set null",
660 }),
79136bbClaude661 createdAt: timestamp("created_at").defaultNow().notNull(),
662 updatedAt: timestamp("updated_at").defaultNow().notNull(),
663 closedAt: timestamp("closed_at"),
664 },
665 (table) => [
666 index("issues_repo_state").on(table.repositoryId, table.state),
667 index("issues_repo_number").on(table.repositoryId, table.number),
85c4e13Claude668 index("issues_milestone").on(table.milestoneId),
79136bbClaude669 ]
670);
671
672export const issueComments = pgTable(
673 "issue_comments",
674 {
675 id: uuid("id").primaryKey().defaultRandom(),
676 issueId: uuid("issue_id")
677 .notNull()
678 .references(() => issues.id, { onDelete: "cascade" }),
679 authorId: uuid("author_id")
680 .notNull()
681 .references(() => users.id),
682 body: text("body").notNull(),
cb5a796Claude683 // Comment moderation (drizzle/0066). 'approved' | 'pending' | 'rejected'
684 // | 'spam'. Default 'approved' keeps every legacy + collaborator path
685 // unchanged; the gate in `lib/comment-moderation.ts` only routes new
686 // non-collaborator comments into 'pending'.
687 moderationStatus: text("moderation_status").notNull().default("approved"),
688 moderatedAt: timestamp("moderated_at", { withTimezone: true }),
689 moderatedByUserId: uuid("moderated_by_user_id").references(() => users.id, {
690 onDelete: "set null",
691 }),
79136bbClaude692 createdAt: timestamp("created_at").defaultNow().notNull(),
693 updatedAt: timestamp("updated_at").defaultNow().notNull(),
694 },
695 (table) => [index("comments_issue").on(table.issueId)]
696);
697
698export const labels = pgTable(
699 "labels",
700 {
701 id: uuid("id").primaryKey().defaultRandom(),
702 repositoryId: uuid("repository_id")
703 .notNull()
704 .references(() => repositories.id, { onDelete: "cascade" }),
705 name: text("name").notNull(),
706 color: text("color").notNull().default("#8b949e"),
707 description: text("description"),
708 },
709 (table) => [
710 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
711 ]
712);
713
714export const issueLabels = pgTable(
715 "issue_labels",
716 {
717 id: uuid("id").primaryKey().defaultRandom(),
718 issueId: uuid("issue_id")
719 .notNull()
720 .references(() => issues.id, { onDelete: "cascade" }),
721 labelId: uuid("label_id")
722 .notNull()
723 .references(() => labels.id, { onDelete: "cascade" }),
724 },
725 (table) => [
726 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
727 ]
06d5ffeClaude728);
729
0074234Claude730export const pullRequests = pgTable(
731 "pull_requests",
732 {
733 id: uuid("id").primaryKey().defaultRandom(),
734 number: serial("number"),
735 repositoryId: uuid("repository_id")
736 .notNull()
737 .references(() => repositories.id, { onDelete: "cascade" }),
738 authorId: uuid("author_id")
739 .notNull()
740 .references(() => users.id),
741 title: text("title").notNull(),
742 body: text("body"),
743 state: text("state").notNull().default("open"), // open, closed, merged
744 baseBranch: text("base_branch").notNull(),
745 headBranch: text("head_branch").notNull(),
3ef4c9dClaude746 isDraft: boolean("is_draft").default(false).notNull(),
747 mergeStrategy: text("merge_strategy").default("merge").notNull(), // merge, squash, rebase
748 milestoneId: uuid("milestone_id"),
0074234Claude749 mergedAt: timestamp("merged_at"),
750 mergedBy: uuid("merged_by").references(() => users.id),
751 createdAt: timestamp("created_at").defaultNow().notNull(),
752 updatedAt: timestamp("updated_at").defaultNow().notNull(),
753 closedAt: timestamp("closed_at"),
6ecda8fClaude754 // AI loop columns (migration 0101). Track the autonomous issue→PR→merge loop.
755 // aiLoopAttempts: how many fix-and-retry cycles have run so far (0 = not started).
756 // aiLoopStatus: null = not started, 'running' = loop active, 'merged' = success,
757 // 'failed' = exhausted attempts or unrecoverable error.
758 aiLoopAttempts: integer("ai_loop_attempts").notNull().default(0),
759 aiLoopStatus: text("ai_loop_status"), // null | 'running' | 'merged' | 'failed'
0074234Claude760 },
761 (table) => [
762 index("prs_repo_state").on(table.repositoryId, table.state),
763 index("prs_repo_number").on(table.repositoryId, table.number),
764 ]
765);
766
767export const prComments = pgTable(
768 "pr_comments",
769 {
770 id: uuid("id").primaryKey().defaultRandom(),
771 pullRequestId: uuid("pull_request_id")
772 .notNull()
773 .references(() => pullRequests.id, { onDelete: "cascade" }),
774 authorId: uuid("author_id")
775 .notNull()
776 .references(() => users.id),
777 body: text("body").notNull(),
778 isAiReview: boolean("is_ai_review").default(false).notNull(),
779 filePath: text("file_path"),
780 lineNumber: integer("line_number"),
cb5a796Claude781 // Comment moderation (drizzle/0066). See `issueComments.moderationStatus`.
782 moderationStatus: text("moderation_status").notNull().default("approved"),
783 moderatedAt: timestamp("moderated_at", { withTimezone: true }),
784 moderatedByUserId: uuid("moderated_by_user_id").references(() => users.id, {
785 onDelete: "set null",
786 }),
0074234Claude787 createdAt: timestamp("created_at").defaultNow().notNull(),
788 updatedAt: timestamp("updated_at").defaultNow().notNull(),
789 },
790 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
791);
792
cb5a796Claude793/**
794 * Comment moderation (drizzle/0066) — per-repo allow/deny list for
795 * commenters. A 'trusted' row makes `shouldRequireApproval` short-circuit
796 * to false; a 'banned' row makes it short-circuit to "auto-reject without
797 * notifying the owner" so a single spam decision sticks.
798 *
799 * Indexed by (repository_id, commenter_user_id) for the per-comment gate
800 * lookup and by (repository_id, status) for the owner's trust-list page.
801 */
802export const repoCommenterTrust = pgTable(
803 "repo_commenter_trust",
804 {
805 id: uuid("id").primaryKey().defaultRandom(),
806 repositoryId: uuid("repository_id")
807 .notNull()
808 .references(() => repositories.id, { onDelete: "cascade" }),
809 commenterUserId: uuid("commenter_user_id")
810 .notNull()
811 .references(() => users.id, { onDelete: "cascade" }),
812 status: text("status").notNull(), // 'trusted' | 'banned'
813 grantedByUserId: uuid("granted_by_user_id").references(() => users.id, {
814 onDelete: "set null",
815 }),
816 grantedAt: timestamp("granted_at", { withTimezone: true })
817 .defaultNow()
818 .notNull(),
819 },
820 (table) => [
821 uniqueIndex("repo_commenter_trust_unique").on(
822 table.repositoryId,
823 table.commenterUserId
824 ),
825 index("repo_commenter_trust_repo_status").on(
826 table.repositoryId,
827 table.status
828 ),
829 ]
830);
831
0074234Claude832export const activityFeed = pgTable(
833 "activity_feed",
834 {
835 id: uuid("id").primaryKey().defaultRandom(),
836 repositoryId: uuid("repository_id")
837 .notNull()
838 .references(() => repositories.id, { onDelete: "cascade" }),
839 userId: uuid("user_id").references(() => users.id),
840 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
841 targetType: text("target_type"), // issue, pr, commit
842 targetId: text("target_id"),
843 metadata: text("metadata"), // JSON string for extra data
844 createdAt: timestamp("created_at").defaultNow().notNull(),
845 },
846 (table) => [
847 index("activity_repo").on(table.repositoryId),
848 index("activity_user").on(table.userId),
849 ]
850);
851
c81ab7aClaude852export const webhooks = pgTable(
853 "webhooks",
854 {
855 id: uuid("id").primaryKey().defaultRandom(),
856 repositoryId: uuid("repository_id")
857 .notNull()
858 .references(() => repositories.id, { onDelete: "cascade" }),
859 url: text("url").notNull(),
860 secret: text("secret"),
861 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
862 isActive: boolean("is_active").default(true).notNull(),
863 lastDeliveredAt: timestamp("last_delivered_at"),
864 lastStatus: integer("last_status"),
865 createdAt: timestamp("created_at").defaultNow().notNull(),
866 },
867 (table) => [index("webhooks_repo").on(table.repositoryId)]
868);
869
8405c43Claude870// Reliable webhook delivery (migration 0056). One row per (hook, event)
871// emission. Worker in src/lib/webhook-delivery.ts polls pending rows whose
872// next_attempt_at <= now() and retries with exponential backoff.
873export const webhookDeliveries = pgTable(
874 "webhook_deliveries",
875 {
876 id: uuid("id").primaryKey().defaultRandom(),
877 webhookId: uuid("webhook_id")
878 .notNull()
879 .references(() => webhooks.id, { onDelete: "cascade" }),
880 event: text("event").notNull(),
881 payload: text("payload").notNull(),
882 signature: text("signature").notNull(),
883 attemptCount: integer("attempt_count").default(0).notNull(),
884 nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
885 status: text("status").default("pending").notNull(), // pending | succeeded | failed | dead
886 lastStatusCode: integer("last_status_code"),
887 lastError: text("last_error"),
888 lastAttemptedAt: timestamp("last_attempted_at", { withTimezone: true }),
889 succeededAt: timestamp("succeeded_at", { withTimezone: true }),
890 createdAt: timestamp("created_at", { withTimezone: true })
891 .defaultNow()
892 .notNull(),
893 },
894 (table) => [
895 index("idx_webhook_deliveries_next_attempt").on(table.nextAttemptAt),
896 index("idx_webhook_deliveries_webhook_id").on(
897 table.webhookId,
898 table.createdAt
899 ),
900 ]
901);
902
c81ab7aClaude903export const apiTokens = pgTable("api_tokens", {
904 id: uuid("id").primaryKey().defaultRandom(),
905 userId: uuid("user_id")
906 .notNull()
907 .references(() => users.id, { onDelete: "cascade" }),
908 name: text("name").notNull(),
909 tokenHash: text("token_hash").notNull(),
910 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
911 scopes: text("scopes").notNull().default("repo"), // comma-separated
912 lastUsedAt: timestamp("last_used_at"),
913 expiresAt: timestamp("expires_at"),
914 createdAt: timestamp("created_at").defaultNow().notNull(),
915});
916
917export const repoTopics = pgTable(
918 "repo_topics",
919 {
920 id: uuid("id").primaryKey().defaultRandom(),
921 repositoryId: uuid("repository_id")
922 .notNull()
923 .references(() => repositories.id, { onDelete: "cascade" }),
924 topic: text("topic").notNull(),
925 },
926 (table) => [
927 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
928 index("topics_name").on(table.topic),
929 ]
930);
931
fc1817aClaude932export const sshKeys = pgTable("ssh_keys", {
933 id: uuid("id").primaryKey().defaultRandom(),
934 userId: uuid("user_id")
935 .notNull()
06d5ffeClaude936 .references(() => users.id, { onDelete: "cascade" }),
fc1817aClaude937 title: text("title").notNull(),
938 fingerprint: text("fingerprint").notNull(),
939 publicKey: text("public_key").notNull(),
940 lastUsedAt: timestamp("last_used_at"),
941 createdAt: timestamp("created_at").defaultNow().notNull(),
942});
943
845fd8aClaude944// OCI Container Registry — migration 0083_oci_registry.sql
945// oci_repositories tracks image namespaces (e.g. "alice/myapp").
946// oci_tags maps mutable tag names to a manifest digest for a repository.
947
948export const ociRepositories = pgTable(
949 "oci_repositories",
950 {
951 id: uuid("id").primaryKey().defaultRandom(),
952 ownerId: uuid("owner_id")
953 .notNull()
954 .references(() => users.id, { onDelete: "cascade" }),
955 /** Full image name in "<owner>/<image>" format. */
956 name: text("name").notNull(),
957 visibility: text("visibility").notNull().default("private"), // "public" | "private"
958 createdAt: timestamp("created_at").defaultNow().notNull(),
959 },
960 (table) => [
961 uniqueIndex("oci_repositories_owner_name").on(table.ownerId, table.name),
962 index("idx_oci_repositories_owner").on(table.ownerId),
963 ]
964);
965
966export const ociTags = pgTable(
967 "oci_tags",
968 {
969 id: uuid("id").primaryKey().defaultRandom(),
970 repositoryId: uuid("repository_id")
971 .notNull()
972 .references(() => ociRepositories.id, { onDelete: "cascade" }),
973 tag: text("tag").notNull(),
974 manifestDigest: text("manifest_digest").notNull(), // "sha256:<hex64>"
975 createdAt: timestamp("created_at").defaultNow().notNull(),
976 updatedAt: timestamp("updated_at").defaultNow().notNull(),
977 },
978 (table) => [
979 uniqueIndex("oci_tags_repo_tag").on(table.repositoryId, table.tag),
980 index("idx_oci_tags_repo").on(table.repositoryId),
981 ]
982);
983
984export type OciRepository = typeof ociRepositories.$inferSelect;
985export type NewOciRepository = typeof ociRepositories.$inferInsert;
986export type OciTag = typeof ociTags.$inferSelect;
987export type NewOciTag = typeof ociTags.$inferInsert;
988
534f04aClaude989// Block M2 — Web Push subscriptions. One row per (user, endpoint).
990// Endpoint is the browser-issued push URL; p256dh + auth are the W3C
991// payload-encryption keys (base64url). Stale endpoints (HTTP 410) are
992// deleted lazily on next `sendPushToUser` pass.
993export const pushSubscriptions = pgTable(
994 "push_subscriptions",
995 {
996 id: uuid("id").primaryKey().defaultRandom(),
997 userId: uuid("user_id")
998 .notNull()
999 .references(() => users.id, { onDelete: "cascade" }),
1000 endpoint: text("endpoint").notNull(),
1001 p256dh: text("p256dh").notNull(),
1002 auth: text("auth").notNull(),
1003 userAgent: text("user_agent"),
1004 createdAt: timestamp("created_at").defaultNow().notNull(),
1005 lastUsedAt: timestamp("last_used_at"),
1006 },
1007 (table) => [
1008 uniqueIndex("push_subscriptions_user_endpoint").on(
1009 table.userId,
1010 table.endpoint
1011 ),
1012 index("idx_push_subscriptions_user").on(table.userId),
1013 ]
1014);
1015
fc1817aClaude1016export type User = typeof users.$inferSelect;
1017export type NewUser = typeof users.$inferInsert;
1018export type Repository = typeof repositories.$inferSelect;
1019export type NewRepository = typeof repositories.$inferInsert;
06d5ffeClaude1020export type Session = typeof sessions.$inferSelect;
1021export type Star = typeof stars.$inferSelect;
1022export type SshKey = typeof sshKeys.$inferSelect;
534f04aClaude1023export type PushSubscription = typeof pushSubscriptions.$inferSelect;
1024export type NewPushSubscription = typeof pushSubscriptions.$inferInsert;
79136bbClaude1025export type Issue = typeof issues.$inferSelect;
1026export type IssueComment = typeof issueComments.$inferSelect;
1027export type Label = typeof labels.$inferSelect;
0074234Claude1028export type PullRequest = typeof pullRequests.$inferSelect;
1029export type PrComment = typeof prComments.$inferSelect;
cb5a796Claude1030export type RepoCommenterTrust = typeof repoCommenterTrust.$inferSelect;
1031export type NewRepoCommenterTrust = typeof repoCommenterTrust.$inferInsert;
0074234Claude1032export type ActivityEntry = typeof activityFeed.$inferSelect;
c81ab7aClaude1033export type Webhook = typeof webhooks.$inferSelect;
1034export type ApiToken = typeof apiTokens.$inferSelect;
1035export type RepoTopic = typeof repoTopics.$inferSelect;
3ef4c9dClaude1036export type RepoSettings = typeof repoSettings.$inferSelect;
1037export type BranchProtection = typeof branchProtection.$inferSelect;
1038export type GateRun = typeof gateRuns.$inferSelect;
1039export type Notification = typeof notifications.$inferSelect;
1040export type Release = typeof releases.$inferSelect;
1041export type Milestone = typeof milestones.$inferSelect;
1042export type Reaction = typeof reactions.$inferSelect;
1043export type PrReview = typeof prReviews.$inferSelect;
1044export type CodeOwner = typeof codeOwners.$inferSelect;
ec9e3e3Claude1045export type PrReviewRequest = typeof prReviewRequests.$inferSelect;
3ef4c9dClaude1046export type AiChat = typeof aiChats.$inferSelect;
1047export type AuditLogEntry = typeof auditLog.$inferSelect;
1048export type Deployment = typeof deployments.$inferSelect;
24cf2caClaude1049
1050/**
1051 * Saved replies — per-user canned responses, insertable into any
1052 * issue / PR comment textarea. Shortcut name must be unique per user.
1053 */
1054export const savedReplies = pgTable(
1055 "saved_replies",
1056 {
1057 id: uuid("id").primaryKey().defaultRandom(),
1058 userId: uuid("user_id")
1059 .notNull()
1060 .references(() => users.id, { onDelete: "cascade" }),
1061 shortcut: text("shortcut").notNull(),
1062 body: text("body").notNull(),
1063 createdAt: timestamp("created_at").defaultNow().notNull(),
1064 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1065 },
1066 (table) => [
1067 uniqueIndex("saved_replies_user_shortcut").on(table.userId, table.shortcut),
1068 ]
1069);
1070
1071export type SavedReply = typeof savedReplies.$inferSelect;
5cc5d95Claude1072
1073/**
1074 * Organizations (Block B1) — multi-user namespaces. Distinct from `users`.
1075 * An org has members (with org-level roles) and may contain teams.
1076 * Repos can be owned by an org via `repositories.orgId` (added in Block B2).
1077 *
1078 * Slug is globally unique against itself; collision with a username is
1079 * checked at create time in the route handler (no DB-level cross-table
1080 * uniqueness in Postgres).
1081 */
1082export const organizations = pgTable("organizations", {
1083 id: uuid("id").primaryKey().defaultRandom(),
1084 slug: text("slug").notNull().unique(),
1085 name: text("name").notNull(),
1086 description: text("description"),
1087 avatarUrl: text("avatar_url"),
1088 billingEmail: text("billing_email"),
1089 createdById: uuid("created_by_id")
1090 .notNull()
1091 .references(() => users.id, { onDelete: "restrict" }),
1092 createdAt: timestamp("created_at").defaultNow().notNull(),
1093 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1094});
1095
1096/**
1097 * Org membership. Roles: owner (full control, billing), admin (manage
1098 * members + teams + repos), member (default; can be added to teams).
1099 */
1100export const orgMembers = pgTable(
1101 "org_members",
1102 {
1103 id: uuid("id").primaryKey().defaultRandom(),
1104 orgId: uuid("org_id")
1105 .notNull()
1106 .references(() => organizations.id, { onDelete: "cascade" }),
1107 userId: uuid("user_id")
1108 .notNull()
1109 .references(() => users.id, { onDelete: "cascade" }),
1110 role: text("role").notNull().default("member"), // owner | admin | member
1111 createdAt: timestamp("created_at").defaultNow().notNull(),
1112 },
1113 (table) => [
1114 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
1115 index("org_members_user").on(table.userId),
1116 ]
1117);
1118
1119/**
1120 * Teams within an org. Slug is unique within an org.
1121 * `parentTeamId` allows nesting (GitHub-style child teams). Optional.
1122 */
1123export const teams = pgTable(
1124 "teams",
1125 {
1126 id: uuid("id").primaryKey().defaultRandom(),
1127 orgId: uuid("org_id")
1128 .notNull()
1129 .references(() => organizations.id, { onDelete: "cascade" }),
1130 slug: text("slug").notNull(),
1131 name: text("name").notNull(),
1132 description: text("description"),
1133 parentTeamId: uuid("parent_team_id"),
1134 createdAt: timestamp("created_at").defaultNow().notNull(),
1135 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1136 },
1137 (table) => [uniqueIndex("teams_org_slug").on(table.orgId, table.slug)]
1138);
1139
1140/**
1141 * Team membership. Roles: maintainer (can edit team), member (default).
1142 * A user can belong to many teams; team membership requires org membership
1143 * but that invariant is enforced at the route layer, not the DB layer.
1144 */
1145export const teamMembers = pgTable(
1146 "team_members",
1147 {
1148 id: uuid("id").primaryKey().defaultRandom(),
1149 teamId: uuid("team_id")
1150 .notNull()
1151 .references(() => teams.id, { onDelete: "cascade" }),
1152 userId: uuid("user_id")
1153 .notNull()
1154 .references(() => users.id, { onDelete: "cascade" }),
1155 role: text("role").notNull().default("member"), // maintainer | member
1156 createdAt: timestamp("created_at").defaultNow().notNull(),
1157 },
1158 (table) => [
1159 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
1160 index("team_members_user").on(table.userId),
1161 ]
1162);
1163
1164export type Organization = typeof organizations.$inferSelect;
1165export type OrgMember = typeof orgMembers.$inferSelect;
1166export type Team = typeof teams.$inferSelect;
1167export type TeamMember = typeof teamMembers.$inferSelect;
1168export type OrgRole = "owner" | "admin" | "member";
1169export type TeamRole = "maintainer" | "member";
7298a17Claude1170
1171/**
1172 * 2FA / TOTP (Block B4).
1173 *
1174 * Secret is stored in plain Base32 for now — the DB has row-level-secure
1175 * access and the app boundary is the only code that reads it. A follow-up
1176 * (B4.1) will wrap it with AES-GCM at rest once we standardise the KEK.
1177 *
1178 * `enabledAt` is set only after the user has successfully entered their
1179 * first code (confirming the authenticator was set up correctly). Rows with
1180 * `enabledAt = NULL` represent pending enrolment.
1181 */
1182export const userTotp = pgTable("user_totp", {
1183 userId: uuid("user_id")
1184 .primaryKey()
1185 .references(() => users.id, { onDelete: "cascade" }),
1186 secret: text("secret").notNull(),
1187 enabledAt: timestamp("enabled_at"),
1188 lastUsedAt: timestamp("last_used_at"),
1189 createdAt: timestamp("created_at").defaultNow().notNull(),
1190});
1191
1192/**
1193 * Recovery codes — single-use fallback when the authenticator is lost.
1194 * Stored as SHA-256 hashes; used rows are marked with `usedAt` rather than
1195 * deleted so the audit log keeps the full history.
1196 */
1197export const userRecoveryCodes = pgTable(
1198 "user_recovery_codes",
1199 {
1200 id: uuid("id").primaryKey().defaultRandom(),
1201 userId: uuid("user_id")
1202 .notNull()
1203 .references(() => users.id, { onDelete: "cascade" }),
1204 codeHash: text("code_hash").notNull(),
1205 usedAt: timestamp("used_at"),
1206 createdAt: timestamp("created_at").defaultNow().notNull(),
1207 },
1208 (table) => [
1209 index("recovery_codes_user").on(table.userId),
1210 uniqueIndex("recovery_codes_user_hash").on(table.userId, table.codeHash),
1211 ]
1212);
1213
1214export type UserTotp = typeof userTotp.$inferSelect;
1215export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
2df1f8cClaude1216
1217/**
1218 * WebAuthn passkeys (Block B5).
1219 *
1220 * Each row is one registered authenticator. The `credentialId` is the
1221 * globally-unique identifier the browser returns; `publicKey` is the
1222 * COSE-encoded public key we use to verify signatures. `counter` tracks
1223 * the authenticator's signature counter for replay-protection.
1224 *
1225 * `transports` is a JSON array (stored as text) of the
1226 * AuthenticatorTransport values ("usb" | "nfc" | "ble" | "internal" | "hybrid").
1227 */
1228export const userPasskeys = pgTable(
1229 "user_passkeys",
1230 {
1231 id: uuid("id").primaryKey().defaultRandom(),
1232 userId: uuid("user_id")
1233 .notNull()
1234 .references(() => users.id, { onDelete: "cascade" }),
1235 credentialId: text("credential_id").notNull().unique(),
1236 publicKey: text("public_key").notNull(), // base64url of COSE key
1237 counter: integer("counter").default(0).notNull(),
1238 transports: text("transports"), // JSON array string
1239 name: text("name").notNull().default("Passkey"),
1240 lastUsedAt: timestamp("last_used_at"),
1241 createdAt: timestamp("created_at").defaultNow().notNull(),
1242 },
1243 (table) => [index("passkeys_user").on(table.userId)]
1244);
1245
1246/**
1247 * Short-lived WebAuthn challenges. A row is written when we issue options
1248 * (registration or authentication) and deleted after the verify step or when
1249 * it expires (5 min). Keeping them in the DB lets us verify without sticky
1250 * sessions or client-side state.
1251 */
1252export const webauthnChallenges = pgTable(
1253 "webauthn_challenges",
1254 {
1255 id: uuid("id").primaryKey().defaultRandom(),
1256 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
1257 // For passwordless login we don't know the user yet, so userId is nullable
1258 // and we bind the challenge to a short-lived cookie token instead.
1259 sessionKey: text("session_key").notNull().unique(),
1260 challenge: text("challenge").notNull(),
1261 kind: text("kind").notNull(), // "register" | "authenticate"
1262 expiresAt: timestamp("expires_at").notNull(),
1263 createdAt: timestamp("created_at").defaultNow().notNull(),
1264 },
1265 (table) => [index("webauthn_challenges_expires").on(table.expiresAt)]
1266);
1267
1268export type UserPasskey = typeof userPasskeys.$inferSelect;
1269export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
bfdb5e7Claude1270
1271/**
1272 * OAuth 2.0 provider (Block B6).
1273 *
1274 * `oauthApps` is a third-party app registered by a developer. Each app has
1275 * a public `client_id`, a hashed `client_secret`, and one or more allowed
1276 * `redirect_uris` (newline-separated). The plaintext secret is shown to the
1277 * developer exactly once at creation and cannot be recovered; they can
1278 * rotate it instead.
1279 *
1280 * `oauthAuthorizations` is a short-lived authorization code issued after
1281 * the user consents at /oauth/authorize. Single-use: `usedAt` is set on
1282 * redemption so a replay after-the-fact fails.
1283 *
1284 * `oauthAccessTokens` is a long-lived bearer token plus an optional
1285 * refresh token. Both are stored as SHA-256 hashes; the plaintext values
1286 * are only returned to the client once in the /oauth/token response.
1287 */
1288export const oauthApps = pgTable(
1289 "oauth_apps",
1290 {
1291 id: uuid("id").primaryKey().defaultRandom(),
1292 ownerId: uuid("owner_id")
1293 .notNull()
1294 .references(() => users.id, { onDelete: "cascade" }),
1295 name: text("name").notNull(),
1296 clientId: text("client_id").notNull().unique(),
1297 clientSecretHash: text("client_secret_hash").notNull(),
1298 clientSecretPrefix: text("client_secret_prefix").notNull(), // first 8 chars for display
1299 /** Newline-separated list of allowed redirect URIs. */
1300 redirectUris: text("redirect_uris").notNull(),
1301 homepageUrl: text("homepage_url"),
1302 description: text("description"),
1303 /**
1304 * If `true`, the app must present its client_secret at /oauth/token.
1305 * Public SPA/mobile apps should set this to `false` and use PKCE.
1306 */
1307 confidential: boolean("confidential").default(true).notNull(),
1308 revokedAt: timestamp("revoked_at"),
1309 createdAt: timestamp("created_at").defaultNow().notNull(),
1310 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1311 },
1312 (table) => [index("oauth_apps_owner").on(table.ownerId)]
1313);
1314
1315export const oauthAuthorizations = pgTable(
1316 "oauth_authorizations",
1317 {
1318 id: uuid("id").primaryKey().defaultRandom(),
1319 appId: uuid("app_id")
1320 .notNull()
1321 .references(() => oauthApps.id, { onDelete: "cascade" }),
1322 userId: uuid("user_id")
1323 .notNull()
1324 .references(() => users.id, { onDelete: "cascade" }),
1325 codeHash: text("code_hash").notNull().unique(),
1326 redirectUri: text("redirect_uri").notNull(),
1327 scopes: text("scopes").notNull().default(""),
1328 codeChallenge: text("code_challenge"),
1329 codeChallengeMethod: text("code_challenge_method"), // "S256" | "plain"
1330 expiresAt: timestamp("expires_at").notNull(),
1331 usedAt: timestamp("used_at"),
1332 createdAt: timestamp("created_at").defaultNow().notNull(),
1333 },
1334 (table) => [index("oauth_authorizations_expires").on(table.expiresAt)]
1335);
1336
1337export const oauthAccessTokens = pgTable(
1338 "oauth_access_tokens",
1339 {
1340 id: uuid("id").primaryKey().defaultRandom(),
1341 appId: uuid("app_id")
1342 .notNull()
1343 .references(() => oauthApps.id, { onDelete: "cascade" }),
1344 userId: uuid("user_id")
1345 .notNull()
1346 .references(() => users.id, { onDelete: "cascade" }),
1347 accessTokenHash: text("access_token_hash").notNull().unique(),
1348 refreshTokenHash: text("refresh_token_hash").unique(),
1349 scopes: text("scopes").notNull().default(""),
1350 expiresAt: timestamp("expires_at").notNull(),
1351 refreshExpiresAt: timestamp("refresh_expires_at"),
1352 revokedAt: timestamp("revoked_at"),
1353 lastUsedAt: timestamp("last_used_at"),
1354 createdAt: timestamp("created_at").defaultNow().notNull(),
1355 },
1356 (table) => [
1357 index("oauth_access_tokens_user").on(table.userId),
1358 index("oauth_access_tokens_app").on(table.appId),
1359 index("oauth_access_tokens_expires").on(table.expiresAt),
1360 ]
1361);
1362
1363export type OauthApp = typeof oauthApps.$inferSelect;
1364export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
1365export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
eafe8c6Claude1366
1367/**
1368 * Actions-equivalent workflow runner (Block C1).
1369 *
1370 * `workflows` rows are the YAML files discovered at `.gluecron/workflows/*.yml`
1371 * on the repo's default branch. `parsed` is the normalised JSON form used by
1372 * the runner so we don't re-parse on every trigger.
1373 *
1374 * `workflow_runs` is one execution: one row per trigger event. Status
1375 * progression: queued → running → success|failure|cancelled. `conclusion`
1376 * stays null until `status` is terminal.
1377 *
1378 * `workflow_jobs` is a single job within a run — each has its own steps
1379 * array and concatenated logs. We keep logs inline for v1 (no streaming)
1380 * to avoid a fifth table; they're truncated at the runner.
1381 *
1382 * `workflow_artifacts` persist files a job uploaded. `content` is a bytea;
1383 * we'll move this to object storage once we hit size limits.
1384 */
1385export const workflows = pgTable(
1386 "workflows",
1387 {
1388 id: uuid("id").primaryKey().defaultRandom(),
1389 repositoryId: uuid("repository_id")
1390 .notNull()
1391 .references(() => repositories.id, { onDelete: "cascade" }),
1392 name: text("name").notNull(),
1393 path: text("path").notNull(), // e.g. ".gluecron/workflows/ci.yml"
1394 yaml: text("yaml").notNull(),
1395 parsed: text("parsed").notNull(), // JSON string
1396 onEvents: text("on_events").notNull().default("[]"), // JSON array of event names
1397 disabled: boolean("disabled").default(false).notNull(),
1398 createdAt: timestamp("created_at").defaultNow().notNull(),
1399 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1400 },
1401 (table) => [
1402 index("workflows_repo").on(table.repositoryId),
1403 uniqueIndex("workflows_repo_path").on(table.repositoryId, table.path),
1404 ]
1405);
1406
1407export const workflowRuns = pgTable(
1408 "workflow_runs",
1409 {
1410 id: uuid("id").primaryKey().defaultRandom(),
1411 workflowId: uuid("workflow_id")
1412 .notNull()
1413 .references(() => workflows.id, { onDelete: "cascade" }),
1414 repositoryId: uuid("repository_id")
1415 .notNull()
1416 .references(() => repositories.id, { onDelete: "cascade" }),
1417 runNumber: integer("run_number").notNull(),
1418 event: text("event").notNull(), // "push" | "pull_request" | "manual" | ...
1419 ref: text("ref"),
1420 commitSha: text("commit_sha"),
1421 triggeredBy: uuid("triggered_by").references(() => users.id, {
1422 onDelete: "set null",
1423 }),
1424 status: text("status").notNull().default("queued"), // queued|running|success|failure|cancelled
1425 conclusion: text("conclusion"),
1426 queuedAt: timestamp("queued_at").defaultNow().notNull(),
1427 startedAt: timestamp("started_at"),
1428 finishedAt: timestamp("finished_at"),
1429 createdAt: timestamp("created_at").defaultNow().notNull(),
1430 },
1431 (table) => [
1432 index("workflow_runs_repo").on(table.repositoryId),
1433 index("workflow_runs_status").on(table.status),
1434 index("workflow_runs_workflow").on(table.workflowId),
1435 ]
1436);
1437
1438export const workflowJobs = pgTable(
1439 "workflow_jobs",
1440 {
1441 id: uuid("id").primaryKey().defaultRandom(),
1442 runId: uuid("run_id")
1443 .notNull()
1444 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1445 name: text("name").notNull(),
1446 jobOrder: integer("job_order").default(0).notNull(),
1447 runsOn: text("runs_on").notNull().default("default"),
1448 status: text("status").notNull().default("queued"),
1449 conclusion: text("conclusion"),
1450 exitCode: integer("exit_code"),
1451 steps: text("steps").notNull().default("[]"), // JSON array of step results
1452 logs: text("logs").notNull().default(""),
1453 startedAt: timestamp("started_at"),
1454 finishedAt: timestamp("finished_at"),
1455 createdAt: timestamp("created_at").defaultNow().notNull(),
1456 },
1457 (table) => [index("workflow_jobs_run").on(table.runId)]
1458);
1459
1460export const workflowArtifacts = pgTable(
1461 "workflow_artifacts",
1462 {
1463 id: uuid("id").primaryKey().defaultRandom(),
1464 runId: uuid("run_id")
1465 .notNull()
1466 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1467 jobId: uuid("job_id").references(() => workflowJobs.id, {
1468 onDelete: "set null",
1469 }),
1470 name: text("name").notNull(),
1471 sizeBytes: integer("size_bytes").default(0).notNull(),
1472 contentType: text("content_type")
1473 .default("application/octet-stream")
1474 .notNull(),
1475 // bytea — drizzle doesn't have a built-in bytea type at the level we use
1476 // elsewhere; store as text (base64) for v1. Migration uses real bytea so
1477 // we can swap the column type later.
1478 content: text("content"),
1479 createdAt: timestamp("created_at").defaultNow().notNull(),
1480 },
1481 (table) => [index("workflow_artifacts_run").on(table.runId)]
1482);
1483
1484export type Workflow = typeof workflows.$inferSelect;
1485export type WorkflowRun = typeof workflowRuns.$inferSelect;
1486export type WorkflowJob = typeof workflowJobs.$inferSelect;
1487export type WorkflowArtifact = typeof workflowArtifacts.$inferSelect;
e2da5c6Claude1488
1489// ---------------------------------------------------------------------------
1490// Block C2 — Package registry (npm-compatible)
1491// ---------------------------------------------------------------------------
1492
1493export const packages = pgTable(
1494 "packages",
1495 {
1496 id: uuid("id").primaryKey().defaultRandom(),
1497 repositoryId: uuid("repository_id")
1498 .notNull()
1499 .references(() => repositories.id, { onDelete: "cascade" }),
1500 ecosystem: text("ecosystem").notNull().default("npm"), // "npm" | "container"
1501 scope: text("scope"), // "@acme" for npm; null for unscoped
1502 name: text("name").notNull(), // "my-lib" (without scope)
1503 description: text("description"),
1504 readme: text("readme"),
1505 homepage: text("homepage"),
1506 license: text("license"),
1507 visibility: text("visibility").notNull().default("public"), // "public" | "private"
1508 createdAt: timestamp("created_at").defaultNow().notNull(),
1509 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1510 },
1511 (table) => [
1512 index("packages_repo").on(table.repositoryId),
1513 uniqueIndex("packages_eco_scope_name").on(
1514 table.ecosystem,
1515 table.scope,
1516 table.name
1517 ),
1518 ]
1519);
1520
1521export const packageVersions = pgTable(
1522 "package_versions",
1523 {
1524 id: uuid("id").primaryKey().defaultRandom(),
1525 packageId: uuid("package_id")
1526 .notNull()
1527 .references(() => packages.id, { onDelete: "cascade" }),
1528 version: text("version").notNull(), // "1.2.3"
1529 shasum: text("shasum").notNull(), // sha1 (for npm compat) hex
1530 integrity: text("integrity"), // "sha512-..." base64
1531 sizeBytes: integer("size_bytes").default(0).notNull(),
1532 metadata: text("metadata").notNull().default("{}"), // package.json JSON
1533 tarball: text("tarball"), // base64-encoded; bytea in migration
1534 publishedBy: uuid("published_by").references(() => users.id, {
1535 onDelete: "set null",
1536 }),
1537 yanked: boolean("yanked").default(false).notNull(),
1538 yankedReason: text("yanked_reason"),
1539 publishedAt: timestamp("published_at").defaultNow().notNull(),
1540 },
1541 (table) => [
1542 index("package_versions_pkg").on(table.packageId),
1543 uniqueIndex("package_versions_pkg_version").on(table.packageId, table.version),
1544 ]
1545);
1546
1547export const packageTags = pgTable(
1548 "package_tags",
1549 {
1550 id: uuid("id").primaryKey().defaultRandom(),
1551 packageId: uuid("package_id")
1552 .notNull()
1553 .references(() => packages.id, { onDelete: "cascade" }),
1554 tag: text("tag").notNull(), // "latest" | "beta" | ...
1555 versionId: uuid("version_id")
1556 .notNull()
1557 .references(() => packageVersions.id, { onDelete: "cascade" }),
1558 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1559 },
1560 (table) => [
1561 uniqueIndex("package_tags_pkg_tag").on(table.packageId, table.tag),
1562 ]
1563);
1564
1565export type Package = typeof packages.$inferSelect;
1566export type PackageVersion = typeof packageVersions.$inferSelect;
1567export type PackageTag = typeof packageTags.$inferSelect;
1568
1569// ---------------------------------------------------------------------------
1570// Block C3 — Pages / static hosting
1571// ---------------------------------------------------------------------------
1572
1573export const pagesDeployments = pgTable(
1574 "pages_deployments",
1575 {
1576 id: uuid("id").primaryKey().defaultRandom(),
1577 repositoryId: uuid("repository_id")
1578 .notNull()
1579 .references(() => repositories.id, { onDelete: "cascade" }),
1580 ref: text("ref").notNull().default("refs/heads/gh-pages"),
1581 commitSha: text("commit_sha").notNull(),
1582 status: text("status").notNull().default("success"), // "success" | "failed"
1583 triggeredBy: uuid("triggered_by").references(() => users.id, {
1584 onDelete: "set null",
1585 }),
1586 createdAt: timestamp("created_at").defaultNow().notNull(),
1587 },
1588 (table) => [
1589 index("pages_deployments_repo").on(table.repositoryId),
1590 index("pages_deployments_created").on(table.createdAt),
1591 ]
1592);
1593
1594export const pagesSettings = pgTable("pages_settings", {
1595 repositoryId: uuid("repository_id")
1596 .primaryKey()
1597 .references(() => repositories.id, { onDelete: "cascade" }),
1598 enabled: boolean("enabled").default(true).notNull(),
1599 sourceBranch: text("source_branch").notNull().default("gh-pages"),
1600 sourceDir: text("source_dir").notNull().default("/"), // e.g. "/" or "/docs"
1601 customDomain: text("custom_domain"),
1602 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1603});
1604
1605export type PagesDeployment = typeof pagesDeployments.$inferSelect;
1606export type PagesSettings = typeof pagesSettings.$inferSelect;
1607
1608// ---------------------------------------------------------------------------
1609// Block C4 — Environments with protected approvals
1610// ---------------------------------------------------------------------------
1611
1612export const environments = pgTable(
1613 "environments",
1614 {
1615 id: uuid("id").primaryKey().defaultRandom(),
1616 repositoryId: uuid("repository_id")
1617 .notNull()
1618 .references(() => repositories.id, { onDelete: "cascade" }),
1619 name: text("name").notNull(), // "production" | "staging" | "preview"
1620 requireApproval: boolean("require_approval").default(false).notNull(),
1621 // JSON array of user IDs that can approve deploys.
1622 reviewers: text("reviewers").notNull().default("[]"),
1623 waitTimerMinutes: integer("wait_timer_minutes").default(0).notNull(),
1624 allowedBranches: text("allowed_branches").notNull().default("[]"), // JSON glob patterns
1625 createdAt: timestamp("created_at").defaultNow().notNull(),
1626 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1627 },
1628 (table) => [
1629 uniqueIndex("environments_repo_name").on(table.repositoryId, table.name),
1630 ]
1631);
1632
1633export const deploymentApprovals = pgTable(
1634 "deployment_approvals",
1635 {
1636 id: uuid("id").primaryKey().defaultRandom(),
1637 deploymentId: uuid("deployment_id")
1638 .notNull()
1639 .references(() => deployments.id, { onDelete: "cascade" }),
1640 userId: uuid("user_id")
1641 .notNull()
1642 .references(() => users.id, { onDelete: "cascade" }),
1643 decision: text("decision").notNull(), // "approved" | "rejected"
1644 comment: text("comment"),
1645 createdAt: timestamp("created_at").defaultNow().notNull(),
1646 },
1647 (table) => [
1648 index("deployment_approvals_deployment").on(table.deploymentId),
1649 ]
1650);
1651
1652export type Environment = typeof environments.$inferSelect;
1653export type DeploymentApproval = typeof deploymentApprovals.$inferSelect;
3cbe3d6Claude1654
1655// ---------------------------------------------------------------------------
1656// Block D — AI-native differentiation (migration 0012)
1657// ---------------------------------------------------------------------------
1658
1659// D6 — cached "explain this codebase" markdown keyed on commit sha.
1660export const codebaseExplanations = pgTable(
1661 "codebase_explanations",
1662 {
1663 id: uuid("id").primaryKey().defaultRandom(),
1664 repositoryId: uuid("repository_id")
1665 .notNull()
1666 .references(() => repositories.id, { onDelete: "cascade" }),
1667 commitSha: text("commit_sha").notNull(),
1668 summary: text("summary").notNull(),
1669 markdown: text("markdown").notNull(),
1670 model: text("model").notNull(),
1671 generatedAt: timestamp("generated_at").defaultNow().notNull(),
1672 },
1673 (table) => [
1674 uniqueIndex("codebase_explanations_repo_sha").on(
1675 table.repositoryId,
1676 table.commitSha
1677 ),
1678 ]
1679);
1680
1681// D2 — AI dependency bumper run history.
1682export const depUpdateRuns = pgTable(
1683 "dep_update_runs",
1684 {
1685 id: uuid("id").primaryKey().defaultRandom(),
1686 repositoryId: uuid("repository_id")
1687 .notNull()
1688 .references(() => repositories.id, { onDelete: "cascade" }),
1689 status: text("status").notNull().default("pending"), // pending|running|success|failed|no_updates
1690 ecosystem: text("ecosystem").notNull(), // npm|bun
1691 manifestPath: text("manifest_path").notNull(),
1692 attemptedBumps: text("attempted_bumps").notNull().default("[]"), // JSON
1693 appliedBumps: text("applied_bumps").notNull().default("[]"), // JSON
1694 branchName: text("branch_name"),
1695 prNumber: integer("pr_number"),
1696 errorMessage: text("error_message"),
1697 triggeredBy: uuid("triggered_by").references(() => users.id, {
1698 onDelete: "set null",
1699 }),
1700 createdAt: timestamp("created_at").defaultNow().notNull(),
1701 completedAt: timestamp("completed_at"),
1702 },
1703 (table) => [
1704 index("dep_update_runs_repo").on(table.repositoryId),
1705 index("dep_update_runs_created").on(table.createdAt),
1706 ]
1707);
1708
1709// D1 — code chunks for semantic search. Embedding stored as JSON-encoded
1710// number array in text to avoid requiring pgvector; cosine similarity is
1711// computed in JS. Upgrade path: ALTER COLUMN embedding TYPE vector(1024).
1712export const codeChunks = pgTable(
1713 "code_chunks",
1714 {
1715 id: uuid("id").primaryKey().defaultRandom(),
1716 repositoryId: uuid("repository_id")
1717 .notNull()
1718 .references(() => repositories.id, { onDelete: "cascade" }),
1719 commitSha: text("commit_sha").notNull(),
1720 path: text("path").notNull(),
1721 startLine: integer("start_line").notNull(),
1722 endLine: integer("end_line").notNull(),
1723 content: text("content").notNull(),
1724 embedding: text("embedding"), // JSON number[]
1725 embeddingModel: text("embedding_model"),
1726 createdAt: timestamp("created_at").defaultNow().notNull(),
1727 },
1728 (table) => [
1729 index("code_chunks_repo").on(table.repositoryId),
1730 index("code_chunks_repo_path").on(table.repositoryId, table.path),
1731 ]
1732);
1733
1734export type CodebaseExplanation = typeof codebaseExplanations.$inferSelect;
1735export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
1e162a8Claude1736
0a69faaClaude1737// ---------------------------------------------------------------------------
1738// Migration 0077 — repo_explain_cache: structured AI analysis per repo
1739// ---------------------------------------------------------------------------
1740
1741export const repoExplainCache = pgTable("repo_explain_cache", {
1742 id: serial("id").primaryKey(),
1743 repoId: uuid("repo_id")
1744 .notNull()
1745 .unique()
1746 .references(() => repositories.id, { onDelete: "cascade" }),
1747 result: jsonb("result").notNull(),
1748 createdAt: timestamp("created_at").defaultNow(),
1749});
1750
1751export type RepoExplainCache = typeof repoExplainCache.$inferSelect;
1752
1e162a8Claude1753// ---------------------------------------------------------------------------
c645a86Claude1754// Block E2 — Discussions (migration 0013 + 0077)
1e162a8Claude1755// ---------------------------------------------------------------------------
1756
c645a86Claude1757/**
1758 * Per-repo discussion categories (migration 0077).
1759 * Seeded lazily on first discussion creation: General, Q&A, Announcements, Ideas.
1760 * is_answerable = true surfaces "Mark as answer" on threads in that category.
1761 */
1762export const discussionCategories = pgTable(
1763 "discussion_categories",
1764 {
1765 id: serial("id").primaryKey(),
1766 repositoryId: uuid("repository_id")
1767 .notNull()
1768 .references(() => repositories.id, { onDelete: "cascade" }),
1769 name: text("name").notNull(),
1770 emoji: text("emoji").notNull().default("💬"),
1771 description: text("description"),
1772 isAnswerable: boolean("is_answerable").notNull().default(false),
1773 },
1774 (table) => [index("discussion_categories_repo").on(table.repositoryId)]
1775);
1776
1777export type DiscussionCategory = typeof discussionCategories.$inferSelect;
1778
1e162a8Claude1779/**
1780 * Discussions — forum-style threaded conversations attached to a repo.
1781 * Similar to GitHub Discussions: categorised + pinnable + answerable.
1782 */
1783export const discussions = pgTable(
1784 "discussions",
1785 {
1786 id: uuid("id").primaryKey().defaultRandom(),
1787 number: serial("number"),
1788 repositoryId: uuid("repository_id")
1789 .notNull()
1790 .references(() => repositories.id, { onDelete: "cascade" }),
1791 authorId: uuid("author_id")
1792 .notNull()
1793 .references(() => users.id),
1794 // one of: "general" | "q-and-a" | "ideas" | "announcements" | "show-and-tell"
1795 category: text("category").notNull().default("general"),
1796 title: text("title").notNull(),
1797 body: text("body"),
1798 state: text("state").notNull().default("open"), // open, closed
1799 locked: boolean("locked").notNull().default(false),
1800 answerCommentId: uuid("answer_comment_id"),
1801 pinned: boolean("pinned").notNull().default(false),
1802 createdAt: timestamp("created_at").defaultNow().notNull(),
1803 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1804 },
1805 (table) => [
1806 index("discussions_repo").on(table.repositoryId),
1807 uniqueIndex("discussions_repo_number").on(
1808 table.repositoryId,
1809 table.number
1810 ),
1811 ]
1812);
1813
1814export const discussionComments = pgTable(
1815 "discussion_comments",
1816 {
1817 id: uuid("id").primaryKey().defaultRandom(),
1818 discussionId: uuid("discussion_id")
1819 .notNull()
1820 .references(() => discussions.id, { onDelete: "cascade" }),
1821 parentCommentId: uuid("parent_comment_id"),
1822 authorId: uuid("author_id")
1823 .notNull()
1824 .references(() => users.id),
1825 body: text("body").notNull(),
1826 isAnswer: boolean("is_answer").notNull().default(false),
1827 createdAt: timestamp("created_at").defaultNow().notNull(),
1828 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1829 },
1830 (table) => [
1831 index("discussion_comments_discussion").on(table.discussionId),
1832 ]
1833);
1834
1835export type Discussion = typeof discussions.$inferSelect;
1836export type DiscussionComment = typeof discussionComments.$inferSelect;
3cbe3d6Claude1837export type CodeChunk = typeof codeChunks.$inferSelect;
1e162a8Claude1838
1839// ---------------------------------------------------------------------------
1840// Block E4 — Gists (migration 0014)
1841// ---------------------------------------------------------------------------
1842//
1843// User-owned small snippets/files that behave like tiny repos. DB-backed
1844// for v1 (no bare git repo): each gist owns a collection of gist_files and
1845// every edit appends a gist_revisions row with a JSON snapshot.
1846
1847export const gists = pgTable(
1848 "gists",
1849 {
1850 id: uuid("id").primaryKey().defaultRandom(),
1851 ownerId: uuid("owner_id")
1852 .notNull()
1853 .references(() => users.id, { onDelete: "cascade" }),
1854 // 8-char hex slug used in pretty URLs (e.g. /gists/a1b2c3d4).
1855 slug: text("slug").notNull().unique(),
1856 title: text("title").notNull().default(""),
1857 description: text("description").notNull().default(""),
1858 isPublic: boolean("is_public").default(true).notNull(),
1859 createdAt: timestamp("created_at").defaultNow().notNull(),
1860 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1861 },
1862 (table) => [index("gists_owner").on(table.ownerId)]
1863);
1864
1865export const gistFiles = pgTable(
1866 "gist_files",
1867 {
1868 id: uuid("id").primaryKey().defaultRandom(),
1869 gistId: uuid("gist_id")
1870 .notNull()
1871 .references(() => gists.id, { onDelete: "cascade" }),
1872 filename: text("filename").notNull(),
1873 // Optional explicit language override; falls back to filename detection.
1874 language: text("language"),
1875 content: text("content").notNull().default(""),
1876 sizeBytes: integer("size_bytes").default(0).notNull(),
1877 },
1878 (table) => [
1879 index("gist_files_gist").on(table.gistId),
1880 uniqueIndex("gist_files_gist_filename").on(table.gistId, table.filename),
1881 ]
1882);
1883
1884export const gistRevisions = pgTable(
1885 "gist_revisions",
1886 {
1887 id: uuid("id").primaryKey().defaultRandom(),
1888 gistId: uuid("gist_id")
1889 .notNull()
1890 .references(() => gists.id, { onDelete: "cascade" }),
1891 revision: integer("revision").notNull(),
1892 // JSON-encoded {filename: content} map capturing the full snapshot at
1893 // this revision. Stored as text to avoid requiring jsonb.
1894 snapshot: text("snapshot").notNull().default("{}"),
1895 authorId: uuid("author_id")
1896 .notNull()
1897 .references(() => users.id, { onDelete: "cascade" }),
1898 message: text("message"),
1899 createdAt: timestamp("created_at").defaultNow().notNull(),
1900 },
1901 (table) => [
1902 index("gist_revisions_gist_rev").on(table.gistId, table.revision),
1903 ]
1904);
1905
1906export const gistStars = pgTable(
1907 "gist_stars",
1908 {
1909 id: uuid("id").primaryKey().defaultRandom(),
1910 gistId: uuid("gist_id")
1911 .notNull()
1912 .references(() => gists.id, { onDelete: "cascade" }),
1913 userId: uuid("user_id")
1914 .notNull()
1915 .references(() => users.id, { onDelete: "cascade" }),
1916 createdAt: timestamp("created_at").defaultNow().notNull(),
1917 },
1918 (table) => [uniqueIndex("gist_stars_gist_user").on(table.gistId, table.userId)]
1919);
1920
1921export type Gist = typeof gists.$inferSelect;
1922export type GistFile = typeof gistFiles.$inferSelect;
1923export type GistRevision = typeof gistRevisions.$inferSelect;
1924export type GistStar = typeof gistStars.$inferSelect;
1925
1926// ---------------------------------------------------------------------------
1927// Block E1 — Projects / kanban (migration 0015)
1928// ---------------------------------------------------------------------------
1929
1930export const projects = pgTable(
1931 "projects",
1932 {
1933 id: uuid("id").primaryKey().defaultRandom(),
1934 number: serial("number"),
1935 repositoryId: uuid("repository_id")
1936 .notNull()
1937 .references(() => repositories.id, { onDelete: "cascade" }),
1938 ownerId: uuid("owner_id")
1939 .notNull()
1940 .references(() => users.id),
1941 title: text("title").notNull(),
1942 description: text("description").notNull().default(""),
1943 state: text("state").notNull().default("open"), // open | closed
1944 createdAt: timestamp("created_at").defaultNow().notNull(),
1945 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1946 },
1947 (table) => [
1948 index("projects_repo").on(table.repositoryId),
1949 uniqueIndex("projects_repo_number").on(table.repositoryId, table.number),
1950 ]
1951);
1952
1953export const projectColumns = pgTable(
1954 "project_columns",
1955 {
1956 id: uuid("id").primaryKey().defaultRandom(),
1957 projectId: uuid("project_id")
1958 .notNull()
1959 .references(() => projects.id, { onDelete: "cascade" }),
1960 name: text("name").notNull(),
1961 position: integer("position").notNull().default(0),
1962 createdAt: timestamp("created_at").defaultNow().notNull(),
1963 },
1964 (table) => [index("project_columns_project").on(table.projectId)]
1965);
1966
1967export const projectItems = pgTable(
1968 "project_items",
1969 {
1970 id: uuid("id").primaryKey().defaultRandom(),
1971 projectId: uuid("project_id")
1972 .notNull()
1973 .references(() => projects.id, { onDelete: "cascade" }),
1974 columnId: uuid("column_id")
1975 .notNull()
1976 .references(() => projectColumns.id, { onDelete: "cascade" }),
1977 position: integer("position").notNull().default(0),
1978 // "note" | "issue" | "pr" — application-level FK on itemId by type
1979 itemType: text("item_type").notNull().default("note"),
1980 itemId: uuid("item_id"),
1981 title: text("title").notNull().default(""),
1982 note: text("note").notNull().default(""),
1983 createdAt: timestamp("created_at").defaultNow().notNull(),
1984 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1985 },
1986 (table) => [
1987 index("project_items_project").on(table.projectId),
1988 index("project_items_column").on(table.columnId, table.position),
1989 ]
1990);
1991
1992export type Project = typeof projects.$inferSelect;
1993export type ProjectColumn = typeof projectColumns.$inferSelect;
1994export type ProjectItem = typeof projectItems.$inferSelect;
1995
1996// ---------------------------------------------------------------------------
1997// Block E3 — Wikis (migration 0016)
1998// ---------------------------------------------------------------------------
1999// DB-backed for v1; git-backed mirror is a future upgrade.
2000
2001export const wikiPages = pgTable(
2002 "wiki_pages",
2003 {
2004 id: uuid("id").primaryKey().defaultRandom(),
2005 repositoryId: uuid("repository_id")
2006 .notNull()
2007 .references(() => repositories.id, { onDelete: "cascade" }),
2008 slug: text("slug").notNull(),
2009 title: text("title").notNull(),
2010 body: text("body").notNull().default(""),
2011 revision: integer("revision").notNull().default(1),
2012 createdAt: timestamp("created_at").defaultNow().notNull(),
2013 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2014 updatedBy: uuid("updated_by").references(() => users.id),
2015 },
2016 (table) => [
2017 index("wiki_pages_repo").on(table.repositoryId),
2018 uniqueIndex("wiki_pages_repo_slug").on(table.repositoryId, table.slug),
2019 ]
2020);
2021
2022export const wikiRevisions = pgTable(
2023 "wiki_revisions",
2024 {
2025 id: uuid("id").primaryKey().defaultRandom(),
2026 pageId: uuid("page_id")
2027 .notNull()
2028 .references(() => wikiPages.id, { onDelete: "cascade" }),
2029 revision: integer("revision").notNull(),
2030 title: text("title").notNull(),
2031 body: text("body").notNull().default(""),
2032 message: text("message"),
2033 authorId: uuid("author_id")
2034 .notNull()
2035 .references(() => users.id),
2036 createdAt: timestamp("created_at").defaultNow().notNull(),
2037 },
2038 (table) => [index("wiki_revisions_page").on(table.pageId, table.revision)]
2039);
2040
2041export type WikiPage = typeof wikiPages.$inferSelect;
2042export type WikiRevision = typeof wikiRevisions.$inferSelect;
a79a9edClaude2043
2044// ---------------------------------------------------------------------------
2045// Block E5 — Merge queues (migration 0017)
2046// ---------------------------------------------------------------------------
2047
2048export const mergeQueueEntries = pgTable(
2049 "merge_queue_entries",
2050 {
2051 id: uuid("id").primaryKey().defaultRandom(),
2052 repositoryId: uuid("repository_id")
2053 .notNull()
2054 .references(() => repositories.id, { onDelete: "cascade" }),
2055 pullRequestId: uuid("pull_request_id")
2056 .notNull()
2057 .references(() => pullRequests.id, { onDelete: "cascade" }),
2058 baseBranch: text("base_branch").notNull(),
2059 // queued | running | merged | failed | dequeued
2060 state: text("state").notNull().default("queued"),
2061 position: integer("position").notNull().default(0),
2062 enqueuedBy: uuid("enqueued_by").references(() => users.id),
2063 enqueuedAt: timestamp("enqueued_at").defaultNow().notNull(),
2064 startedAt: timestamp("started_at"),
2065 finishedAt: timestamp("finished_at"),
2066 errorMessage: text("error_message"),
2067 },
2068 (table) => [
2069 index("merge_queue_repo_branch").on(
2070 table.repositoryId,
2071 table.baseBranch,
2072 table.state
2073 ),
2074 ]
2075);
2076
2077export type MergeQueueEntry = typeof mergeQueueEntries.$inferSelect;
2078
2079// ---------------------------------------------------------------------------
2080// Block E6 — Required status checks matrix (migration 0018)
2081// ---------------------------------------------------------------------------
2082
2083export const branchRequiredChecks = pgTable(
2084 "branch_required_checks",
2085 {
2086 id: uuid("id").primaryKey().defaultRandom(),
2087 branchProtectionId: uuid("branch_protection_id")
2088 .notNull()
2089 .references(() => branchProtection.id, { onDelete: "cascade" }),
2090 checkName: text("check_name").notNull(),
2091 createdAt: timestamp("created_at").defaultNow().notNull(),
2092 },
2093 (table) => [
2094 index("branch_required_checks_rule").on(table.branchProtectionId),
2095 uniqueIndex("branch_required_checks_unique").on(
2096 table.branchProtectionId,
2097 table.checkName
2098 ),
2099 ]
2100);
2101
2102export type BranchRequiredCheck = typeof branchRequiredChecks.$inferSelect;
2103
2104// ---------------------------------------------------------------------------
2105// Block E7 — Protected tags (migration 0019)
2106// ---------------------------------------------------------------------------
2107
2108export const protectedTags = pgTable(
2109 "protected_tags",
2110 {
2111 id: uuid("id").primaryKey().defaultRandom(),
2112 repositoryId: uuid("repository_id")
2113 .notNull()
2114 .references(() => repositories.id, { onDelete: "cascade" }),
2115 pattern: text("pattern").notNull(),
2116 createdAt: timestamp("created_at").defaultNow().notNull(),
2117 createdBy: uuid("created_by").references(() => users.id),
2118 },
2119 (table) => [
2120 index("protected_tags_repo").on(table.repositoryId),
2121 uniqueIndex("protected_tags_repo_pattern").on(
2122 table.repositoryId,
2123 table.pattern
2124 ),
2125 ]
2126);
2127
2128export type ProtectedTag = typeof protectedTags.$inferSelect;
8f50ed0Claude2129
2130// ---------------------------------------------------------------------------
2131// Block F — Observability + admin (migration 0020)
2132// ---------------------------------------------------------------------------
2133
2134// F1 — Traffic analytics per repo
2135export const repoTrafficEvents = pgTable(
2136 "repo_traffic_events",
2137 {
2138 id: uuid("id").primaryKey().defaultRandom(),
2139 repositoryId: uuid("repository_id")
2140 .notNull()
2141 .references(() => repositories.id, { onDelete: "cascade" }),
2142 kind: text("kind").notNull(), // view | clone | api | ui
2143 path: text("path"),
2144 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
2145 ipHash: text("ip_hash"),
2146 userAgent: text("user_agent"),
2147 referer: text("referer"),
2148 createdAt: timestamp("created_at").defaultNow().notNull(),
2149 },
2150 (table) => [
2151 index("repo_traffic_events_repo_time").on(
2152 table.repositoryId,
2153 table.createdAt
2154 ),
2155 index("repo_traffic_events_kind").on(
2156 table.repositoryId,
2157 table.kind,
2158 table.createdAt
2159 ),
2160 ]
2161);
2162
2163export type RepoTrafficEvent = typeof repoTrafficEvents.$inferSelect;
2164
2165// F3 — Admin panel (site admins + toggleable flags)
2166export const systemFlags = pgTable("system_flags", {
2167 key: text("key").primaryKey(),
2168 value: text("value").notNull().default(""),
2169 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2170 updatedBy: uuid("updated_by").references(() => users.id),
2171});
2172
2173export type SystemFlag = typeof systemFlags.$inferSelect;
2174
2175export const siteAdmins = pgTable("site_admins", {
2176 userId: uuid("user_id")
2177 .primaryKey()
2178 .references(() => users.id, { onDelete: "cascade" }),
2179 grantedAt: timestamp("granted_at").defaultNow().notNull(),
2180 grantedBy: uuid("granted_by").references(() => users.id),
2181});
2182
2183export type SiteAdmin = typeof siteAdmins.$inferSelect;
2184
509c376Claude2185// /admin/integrations — DB-stored platform integration secrets (migration 0055).
2186// Loaded into process.env at boot so the existing synchronous config getters
2187// keep working transparently. Never exposes DATABASE_URL / SELF_HOST_REPO /
2188// PORT / GIT_REPOS_PATH — those stay env-only.
2189export const systemConfig = pgTable("system_config", {
2190 key: text("key").primaryKey(),
2191 value: text("value").notNull().default(""),
2192 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2193 updatedByUserId: uuid("updated_by_user_id").references(() => users.id, {
2194 onDelete: "set null",
2195 }),
2196});
2197
2198export type SystemConfig = typeof systemConfig.$inferSelect;
2199
8f50ed0Claude2200// F4 — Billing + quotas
2201export const billingPlans = pgTable("billing_plans", {
2202 id: uuid("id").primaryKey().defaultRandom(),
2203 slug: text("slug").notNull().unique(),
2204 name: text("name").notNull(),
2205 priceCents: integer("price_cents").notNull().default(0),
2206 repoLimit: integer("repo_limit").notNull().default(10),
2207 storageMbLimit: integer("storage_mb_limit").notNull().default(1024),
2208 aiTokensMonthly: integer("ai_tokens_monthly").notNull().default(100000),
2209 bandwidthGbMonthly: integer("bandwidth_gb_monthly").notNull().default(10),
2210 privateRepos: boolean("private_repos").notNull().default(false),
2211 createdAt: timestamp("created_at").defaultNow().notNull(),
2212});
2213
2214export type BillingPlan = typeof billingPlans.$inferSelect;
2215
2216export const userQuotas = pgTable("user_quotas", {
2217 userId: uuid("user_id")
2218 .primaryKey()
2219 .references(() => users.id, { onDelete: "cascade" }),
2220 planSlug: text("plan_slug").notNull().default("free"),
2221 storageMbUsed: integer("storage_mb_used").notNull().default(0),
2222 aiTokensUsedThisMonth: integer("ai_tokens_used_this_month")
2223 .notNull()
2224 .default(0),
2225 bandwidthGbUsedThisMonth: integer("bandwidth_gb_used_this_month")
2226 .notNull()
2227 .default(0),
2228 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
2229 updatedAt: timestamp("updated_at").defaultNow().notNull(),
619109aClaude2230 // Stripe linkage (migration 0038). Null until the user first upgrades.
2231 stripeCustomerId: text("stripe_customer_id"),
2232 stripeSubscriptionId: text("stripe_subscription_id"),
2233 stripeSubscriptionStatus: text("stripe_subscription_status"),
2234 currentPeriodEnd: timestamp("current_period_end"),
8f50ed0Claude2235});
2236
2237export type UserQuota = typeof userQuotas.$inferSelect;
06139e6Claude2238
2239// Block H — App marketplace + bot identities (GitHub Apps equivalent)
2240
2241export const apps = pgTable(
2242 "apps",
2243 {
2244 id: uuid("id").primaryKey().defaultRandom(),
2245 slug: text("slug").notNull().unique(),
2246 name: text("name").notNull(),
2247 description: text("description").notNull().default(""),
2248 iconUrl: text("icon_url"),
2249 homepageUrl: text("homepage_url"),
2250 webhookUrl: text("webhook_url"),
2251 webhookSecret: text("webhook_secret"),
2252 creatorId: uuid("creator_id")
2253 .notNull()
2254 .references(() => users.id, { onDelete: "cascade" }),
2255 permissions: text("permissions").notNull().default("[]"), // JSON array
2256 defaultEvents: text("default_events").notNull().default("[]"), // JSON array
2257 isPublic: boolean("is_public").notNull().default(true),
2258 createdAt: timestamp("created_at").defaultNow().notNull(),
2259 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2260 },
2261 (table) => [index("apps_public_slug").on(table.isPublic, table.slug)]
2262);
2263
2264export type App = typeof apps.$inferSelect;
2265
2266export const appInstallations = pgTable(
2267 "app_installations",
2268 {
2269 id: uuid("id").primaryKey().defaultRandom(),
2270 appId: uuid("app_id")
2271 .notNull()
2272 .references(() => apps.id, { onDelete: "cascade" }),
2273 installedBy: uuid("installed_by")
2274 .notNull()
2275 .references(() => users.id, { onDelete: "cascade" }),
2276 targetType: text("target_type").notNull(), // user | org | repository
2277 targetId: uuid("target_id").notNull(),
2278 grantedPermissions: text("granted_permissions").notNull().default("[]"),
2279 suspendedAt: timestamp("suspended_at"),
2280 createdAt: timestamp("created_at").defaultNow().notNull(),
2281 uninstalledAt: timestamp("uninstalled_at"),
2282 },
2283 (table) => [
2284 index("app_installations_app").on(table.appId),
2285 index("app_installations_target").on(table.targetType, table.targetId),
2286 ]
2287);
2288
2289export type AppInstallation = typeof appInstallations.$inferSelect;
2290
2291export const appBots = pgTable("app_bots", {
2292 id: uuid("id").primaryKey().defaultRandom(),
2293 appId: uuid("app_id")
2294 .notNull()
2295 .unique()
2296 .references(() => apps.id, { onDelete: "cascade" }),
2297 username: text("username").notNull().unique(),
2298 displayName: text("display_name").notNull(),
2299 avatarUrl: text("avatar_url"),
2300 createdAt: timestamp("created_at").defaultNow().notNull(),
2301});
2302
2303export type AppBot = typeof appBots.$inferSelect;
2304
2305export const appInstallTokens = pgTable(
2306 "app_install_tokens",
2307 {
2308 id: uuid("id").primaryKey().defaultRandom(),
2309 installationId: uuid("installation_id")
2310 .notNull()
2311 .references(() => appInstallations.id, { onDelete: "cascade" }),
2312 tokenHash: text("token_hash").notNull().unique(),
2313 expiresAt: timestamp("expires_at").notNull(),
2314 createdAt: timestamp("created_at").defaultNow().notNull(),
2315 revokedAt: timestamp("revoked_at"),
2316 },
2317 (table) => [index("app_install_tokens_hash").on(table.tokenHash)]
2318);
2319
2320export type AppInstallToken = typeof appInstallTokens.$inferSelect;
2321
2322export const appEvents = pgTable(
2323 "app_events",
2324 {
2325 id: uuid("id").primaryKey().defaultRandom(),
2326 appId: uuid("app_id")
2327 .notNull()
2328 .references(() => apps.id, { onDelete: "cascade" }),
2329 installationId: uuid("installation_id"),
2330 kind: text("kind").notNull(), // installed | uninstalled | delivery_ok | delivery_fail
2331 payload: text("payload"),
2332 responseStatus: integer("response_status"),
2333 createdAt: timestamp("created_at").defaultNow().notNull(),
2334 },
2335 (table) => [index("app_events_app_time").on(table.appId, table.createdAt)]
2336);
2337
2338export type AppEvent = typeof appEvents.$inferSelect;
71cd5ecClaude2339
2340// ---------- Block I3 — Repository transfer history ----------
2341
2342export const repoTransfers = pgTable(
2343 "repo_transfers",
2344 {
2345 id: uuid("id").primaryKey().defaultRandom(),
2346 repositoryId: uuid("repository_id")
2347 .notNull()
2348 .references(() => repositories.id, { onDelete: "cascade" }),
2349 fromOwnerId: uuid("from_owner_id").notNull(),
2350 fromOrgId: uuid("from_org_id"),
2351 toOwnerId: uuid("to_owner_id").notNull(),
2352 toOrgId: uuid("to_org_id"),
2353 initiatedBy: uuid("initiated_by")
2354 .notNull()
2355 .references(() => users.id, { onDelete: "cascade" }),
2356 createdAt: timestamp("created_at").defaultNow().notNull(),
2357 },
2358 (table) => [
2359 index("repo_transfers_repo").on(table.repositoryId, table.createdAt),
2360 ]
2361);
2362
2363export type RepoTransfer = typeof repoTransfers.$inferSelect;
08420cdClaude2364
2365// ---------- Block I6 — Sponsors ----------
2366
2367export const sponsorshipTiers = pgTable(
2368 "sponsorship_tiers",
2369 {
2370 id: uuid("id").primaryKey().defaultRandom(),
2371 maintainerId: uuid("maintainer_id")
2372 .notNull()
2373 .references(() => users.id, { onDelete: "cascade" }),
2374 name: text("name").notNull(),
2375 description: text("description").default("").notNull(),
2376 monthlyCents: integer("monthly_cents").notNull(),
2377 oneTimeAllowed: boolean("one_time_allowed").default(true).notNull(),
2378 isActive: boolean("is_active").default(true).notNull(),
2379 createdAt: timestamp("created_at").defaultNow().notNull(),
2380 },
2381 (table) => [
2382 index("sponsor_tiers_maintainer").on(table.maintainerId, table.isActive),
2383 ]
2384);
2385
2386export type SponsorshipTier = typeof sponsorshipTiers.$inferSelect;
2387
2388export const sponsorships = pgTable(
2389 "sponsorships",
2390 {
2391 id: uuid("id").primaryKey().defaultRandom(),
2392 sponsorId: uuid("sponsor_id")
2393 .notNull()
2394 .references(() => users.id, { onDelete: "cascade" }),
2395 maintainerId: uuid("maintainer_id")
2396 .notNull()
2397 .references(() => users.id, { onDelete: "cascade" }),
2398 tierId: uuid("tier_id"),
2399 amountCents: integer("amount_cents").notNull(),
2400 kind: text("kind").notNull(), // one_time | monthly
2401 note: text("note"),
2402 isPublic: boolean("is_public").default(true).notNull(),
2403 externalRef: text("external_ref"),
2404 cancelledAt: timestamp("cancelled_at"),
2405 createdAt: timestamp("created_at").defaultNow().notNull(),
2406 },
2407 (table) => [
2408 index("sponsorships_maintainer").on(
2409 table.maintainerId,
2410 table.createdAt
2411 ),
2412 index("sponsorships_sponsor").on(table.sponsorId, table.createdAt),
2413 ]
2414);
2415
2416export type Sponsorship = typeof sponsorships.$inferSelect;
4c8f666Claude2417
2418// Block I8 — Code symbol index for xref navigation.
2419export const codeSymbols = pgTable(
2420 "code_symbols",
2421 {
2422 id: uuid("id").primaryKey().defaultRandom(),
2423 repositoryId: uuid("repository_id")
2424 .notNull()
2425 .references(() => repositories.id, { onDelete: "cascade" }),
2426 commitSha: text("commit_sha").notNull(),
2427 name: text("name").notNull(),
2428 kind: text("kind").notNull(), // function | class | interface | type | const | variable
2429 path: text("path").notNull(),
2430 line: integer("line").notNull(),
2431 signature: text("signature"),
2432 createdAt: timestamp("created_at").defaultNow().notNull(),
2433 },
2434 (table) => [
2435 index("code_symbols_repo_name_idx").on(table.repositoryId, table.name),
2436 index("code_symbols_repo_path_idx").on(table.repositoryId, table.path),
2437 ]
2438);
2439
2440export type CodeSymbol = typeof codeSymbols.$inferSelect;
4a0dea1Claude2441
2442// Block I9 — Repository mirroring. One row per mirrored repo + an
2443// append-only log of sync attempts.
2444export const repoMirrors = pgTable("repo_mirrors", {
2445 id: uuid("id").primaryKey().defaultRandom(),
2446 repositoryId: uuid("repository_id")
2447 .notNull()
2448 .unique()
2449 .references(() => repositories.id, { onDelete: "cascade" }),
2450 upstreamUrl: text("upstream_url").notNull(),
2451 intervalMinutes: integer("interval_minutes").default(1440).notNull(),
2452 lastSyncedAt: timestamp("last_synced_at"),
2453 lastStatus: text("last_status"), // "ok" | "error"
2454 lastError: text("last_error"),
2455 isEnabled: boolean("is_enabled").default(true).notNull(),
2456 createdAt: timestamp("created_at").defaultNow().notNull(),
2457 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2458});
2459
2460export type RepoMirror = typeof repoMirrors.$inferSelect;
2461
2462export const repoMirrorRuns = pgTable(
2463 "repo_mirror_runs",
2464 {
2465 id: uuid("id").primaryKey().defaultRandom(),
2466 mirrorId: uuid("mirror_id")
2467 .notNull()
2468 .references(() => repoMirrors.id, { onDelete: "cascade" }),
2469 startedAt: timestamp("started_at").defaultNow().notNull(),
2470 finishedAt: timestamp("finished_at"),
2471 status: text("status").default("running").notNull(),
2472 message: text("message"),
2473 exitCode: integer("exit_code"),
2474 },
2475 (table) => [
2476 index("repo_mirror_runs_mirror_id_idx").on(table.mirrorId, table.startedAt),
2477 ]
2478);
2479
2480export type RepoMirrorRun = typeof repoMirrorRuns.$inferSelect;
edf7c36Claude2481
2482// ----------------------------------------------------------------------------
2483// Block I10 — Enterprise SSO (OIDC)
2484// ----------------------------------------------------------------------------
2485
2486/** Site-wide SSO provider. Singleton row with id = 'default'. */
2487export const ssoConfig = pgTable("sso_config", {
2488 id: text("id").primaryKey(),
2489 enabled: boolean("enabled").default(false).notNull(),
2490 providerName: text("provider_name").default("SSO").notNull(),
2491 issuer: text("issuer"),
2492 authorizationEndpoint: text("authorization_endpoint"),
2493 tokenEndpoint: text("token_endpoint"),
2494 userinfoEndpoint: text("userinfo_endpoint"),
2495 clientId: text("client_id"),
2496 clientSecret: text("client_secret"),
2497 scopes: text("scopes").default("openid profile email").notNull(),
2498 allowedEmailDomains: text("allowed_email_domains"),
2499 autoCreateUsers: boolean("auto_create_users").default(true).notNull(),
2500 createdAt: timestamp("created_at").defaultNow().notNull(),
2501 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2502});
2503
2504export type SsoConfig = typeof ssoConfig.$inferSelect;
2505
2506/** Maps a local user to an IdP `sub` claim. */
2507export const ssoUserLinks = pgTable(
2508 "sso_user_links",
2509 {
2510 id: uuid("id").primaryKey().defaultRandom(),
2511 userId: uuid("user_id")
2512 .notNull()
2513 .references(() => users.id, { onDelete: "cascade" }),
2514 subject: text("subject").notNull().unique(),
2515 emailAtLink: text("email_at_link").notNull(),
2516 linkedAt: timestamp("linked_at").defaultNow().notNull(),
2517 },
2518 (table) => [index("sso_user_links_user_id_idx").on(table.userId)]
2519);
2520
2521export type SsoUserLink = typeof ssoUserLinks.$inferSelect;
8098672Claude2522
2523// ----------------------------------------------------------------------------
2524// Block J1 — Dependency graph
2525// ----------------------------------------------------------------------------
2526
2527/**
2528 * Last known set of dependencies parsed from manifest files. Each reindex
2529 * replaces the prior rows for that repo. One row per (ecosystem, name,
2530 * manifest_path) — same name in multiple manifests is kept.
2531 */
2532export const repoDependencies = pgTable(
2533 "repo_dependencies",
2534 {
2535 id: uuid("id").primaryKey().defaultRandom(),
2536 repositoryId: uuid("repository_id")
2537 .notNull()
2538 .references(() => repositories.id, { onDelete: "cascade" }),
2539 ecosystem: text("ecosystem").notNull(),
2540 name: text("name").notNull(),
2541 versionSpec: text("version_spec"),
2542 manifestPath: text("manifest_path").notNull(),
2543 isDev: boolean("is_dev").default(false).notNull(),
2544 commitSha: text("commit_sha").notNull(),
2545 indexedAt: timestamp("indexed_at").defaultNow().notNull(),
2546 },
2547 (table) => [
2548 index("repo_dependencies_repo_id_idx").on(
2549 table.repositoryId,
2550 table.ecosystem
2551 ),
2552 index("repo_dependencies_name_idx").on(table.name),
2553 ]
2554);
2555
2556export type RepoDependency = typeof repoDependencies.$inferSelect;
f60ccdeClaude2557
2558// ----------------------------------------------------------------------------
2559// Block J2 — Security advisories + alerts
2560// ----------------------------------------------------------------------------
2561
2562/** CVE-style package advisories. Populated via seed + admin import. */
2563export const securityAdvisories = pgTable(
2564 "security_advisories",
2565 {
2566 id: uuid("id").primaryKey().defaultRandom(),
2567 ghsaId: text("ghsa_id").unique(),
2568 cveId: text("cve_id"),
2569 summary: text("summary").notNull(),
2570 severity: text("severity").default("moderate").notNull(),
2571 ecosystem: text("ecosystem").notNull(),
2572 packageName: text("package_name").notNull(),
2573 affectedRange: text("affected_range").notNull(),
2574 fixedVersion: text("fixed_version"),
2575 referenceUrl: text("reference_url"),
2576 publishedAt: timestamp("published_at").defaultNow().notNull(),
2577 },
2578 (table) => [
2579 index("security_advisories_pkg_idx").on(
2580 table.ecosystem,
2581 table.packageName
2582 ),
2583 ]
2584);
2585
2586export type SecurityAdvisory = typeof securityAdvisories.$inferSelect;
2587
2588/** Per-repo match state. One row per (repo, advisory, manifest_path). */
2589export const repoAdvisoryAlerts = pgTable(
2590 "repo_advisory_alerts",
2591 {
2592 id: uuid("id").primaryKey().defaultRandom(),
2593 repositoryId: uuid("repository_id")
2594 .notNull()
2595 .references(() => repositories.id, { onDelete: "cascade" }),
2596 advisoryId: uuid("advisory_id")
2597 .notNull()
2598 .references(() => securityAdvisories.id, { onDelete: "cascade" }),
2599 dependencyName: text("dependency_name").notNull(),
2600 dependencyVersion: text("dependency_version"),
2601 manifestPath: text("manifest_path").notNull(),
2602 status: text("status").default("open").notNull(),
2603 dismissedReason: text("dismissed_reason"),
2604 createdAt: timestamp("created_at").defaultNow().notNull(),
2605 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2606 },
2607 (table) => [
2608 index("repo_advisory_alerts_status_idx").on(
2609 table.repositoryId,
2610 table.status
2611 ),
2612 ]
2613);
2614
2615export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
3951454Claude2616
2617// ----------------------------------------------------------------------------
2618// Block J3 — Commit signature verification (GPG + SSH)
2619// ----------------------------------------------------------------------------
2620
2621/** Per-user GPG/SSH public keys for commit signing. */
2622export const signingKeys = pgTable(
2623 "signing_keys",
2624 {
2625 id: uuid("id").primaryKey().defaultRandom(),
2626 userId: uuid("user_id")
2627 .notNull()
2628 .references(() => users.id, { onDelete: "cascade" }),
2629 keyType: text("key_type").notNull(), // 'gpg' | 'ssh'
2630 title: text("title").notNull(),
2631 fingerprint: text("fingerprint").notNull(),
2632 publicKey: text("public_key").notNull(),
2633 email: text("email"),
2634 expiresAt: timestamp("expires_at"),
2635 lastUsedAt: timestamp("last_used_at"),
2636 createdAt: timestamp("created_at").defaultNow().notNull(),
2637 },
2638 (table) => [
2639 uniqueIndex("signing_keys_fp_unique").on(table.keyType, table.fingerprint),
2640 index("signing_keys_user_idx").on(table.userId),
2641 ]
2642);
2643
2644export type SigningKey = typeof signingKeys.$inferSelect;
2645
2646/**
2647 * Cached verification result for a (repo, commit) pair. Repopulated on demand;
2648 * rows are invalidated implicitly by CASCADE when either side is removed.
2649 */
2650export const commitVerifications = pgTable(
2651 "commit_verifications",
2652 {
2653 id: uuid("id").primaryKey().defaultRandom(),
2654 repositoryId: uuid("repository_id")
2655 .notNull()
2656 .references(() => repositories.id, { onDelete: "cascade" }),
2657 commitSha: text("commit_sha").notNull(),
2658 verified: boolean("verified").default(false).notNull(),
2659 reason: text("reason").notNull(),
2660 signatureType: text("signature_type"),
2661 signerKeyId: uuid("signer_key_id").references(() => signingKeys.id, {
2662 onDelete: "set null",
2663 }),
2664 signerUserId: uuid("signer_user_id").references(() => users.id, {
2665 onDelete: "set null",
2666 }),
2667 signerFingerprint: text("signer_fingerprint"),
2668 verifiedAt: timestamp("verified_at").defaultNow().notNull(),
2669 },
2670 (table) => [
2671 uniqueIndex("commit_verifications_sha_unique").on(
2672 table.repositoryId,
2673 table.commitSha
2674 ),
2675 ]
2676);
2677
2678export type CommitVerification = typeof commitVerifications.$inferSelect;
7aa8b99Claude2679
2680// ----------------------------------------------------------------------------
2681// Block J4 — User following
2682// ----------------------------------------------------------------------------
2683
2684/**
2685 * Directed user→user follow edges. Primary key is the composite
2686 * (follower_id, following_id) at the SQL level; drizzle sees it as a
2687 * regular table with a unique index plus a secondary index on the
2688 * reverse-lookup column.
2689 */
2690export const userFollows = pgTable(
2691 "user_follows",
2692 {
2693 followerId: uuid("follower_id")
2694 .notNull()
2695 .references(() => users.id, { onDelete: "cascade" }),
2696 followingId: uuid("following_id")
2697 .notNull()
2698 .references(() => users.id, { onDelete: "cascade" }),
2699 createdAt: timestamp("created_at").defaultNow().notNull(),
2700 },
2701 (table) => [
2702 uniqueIndex("user_follows_pair_unique").on(
2703 table.followerId,
2704 table.followingId
2705 ),
2706 index("user_follows_following_idx").on(table.followingId),
2707 ]
2708);
2709
2710export type UserFollow = typeof userFollows.$inferSelect;
9ff7128Claude2711
2712// ----------------------------------------------------------------------------
2713// Block J6 — Repository rulesets
2714// ----------------------------------------------------------------------------
2715
2716/**
2717 * A ruleset groups N rules under a named policy at enforcement level active /
2718 * evaluate / disabled. Unique per (repo, name) so a repo can carry multiple
2719 * overlapping rulesets (e.g. "release branches" vs "everywhere").
2720 */
2721export const repoRulesets = pgTable(
2722 "repo_rulesets",
2723 {
2724 id: uuid("id").primaryKey().defaultRandom(),
2725 repositoryId: uuid("repository_id")
2726 .notNull()
2727 .references(() => repositories.id, { onDelete: "cascade" }),
2728 name: text("name").notNull(),
2729 enforcement: text("enforcement").default("active").notNull(),
2730 createdBy: uuid("created_by").references(() => users.id, {
2731 onDelete: "set null",
2732 }),
2733 createdAt: timestamp("created_at").defaultNow().notNull(),
2734 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2735 },
2736 (table) => [
2737 index("repo_rulesets_repo_idx").on(table.repositoryId),
2738 uniqueIndex("repo_rulesets_repo_name_unique").on(
2739 table.repositoryId,
2740 table.name
2741 ),
2742 ]
2743);
2744
2745export type RepoRuleset = typeof repoRulesets.$inferSelect;
2746
2747/** Individual rule — type tag plus JSON params. */
2748export const rulesetRules = pgTable(
2749 "ruleset_rules",
2750 {
2751 id: uuid("id").primaryKey().defaultRandom(),
2752 rulesetId: uuid("ruleset_id")
2753 .notNull()
2754 .references(() => repoRulesets.id, { onDelete: "cascade" }),
2755 ruleType: text("rule_type").notNull(),
2756 params: text("params").default("{}").notNull(),
2757 createdAt: timestamp("created_at").defaultNow().notNull(),
2758 },
2759 (table) => [index("ruleset_rules_set_idx").on(table.rulesetId)]
2760);
2761
2762export type RulesetRule = typeof rulesetRules.$inferSelect;
0cdfd89Claude2763
2764// ---------------------------------------------------------------------------
2765// Block J8 — Commit statuses.
2766// ---------------------------------------------------------------------------
2767
2768/**
2769 * External CI / automation posts per-commit (sha, context) statuses. Upsert
2770 * semantics keyed on (repository, commit_sha, context). State vocabulary:
2771 * pending | success | failure | error. Combined rollup logic lives in
2772 * `src/lib/commit-statuses.ts`.
2773 */
2774export const commitStatuses = pgTable(
2775 "commit_statuses",
2776 {
2777 id: uuid("id").primaryKey().defaultRandom(),
2778 repositoryId: uuid("repository_id")
2779 .notNull()
2780 .references(() => repositories.id, { onDelete: "cascade" }),
2781 commitSha: text("commit_sha").notNull(),
2782 state: text("state").notNull(),
2783 context: text("context").default("default").notNull(),
2784 description: text("description"),
2785 targetUrl: text("target_url"),
2786 creatorId: uuid("creator_id").references(() => users.id, {
2787 onDelete: "set null",
2788 }),
2789 createdAt: timestamp("created_at").defaultNow().notNull(),
2790 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2791 },
2792 (table) => [
2793 uniqueIndex("commit_statuses_repo_sha_context_unique").on(
2794 table.repositoryId,
2795 table.commitSha,
2796 table.context
2797 ),
2798 index("commit_statuses_repo_sha_idx").on(
2799 table.repositoryId,
2800 table.commitSha
2801 ),
2802 ]
2803);
2804
2805export type CommitStatus = typeof commitStatuses.$inferSelect;
23d1a81Claude2806
2807// ---------------------------------------------------------------------------
2808// Collaborators — per-repo role grants (read / write / admin).
2809// ---------------------------------------------------------------------------
2810
2811/**
2812 * A user granted access to a repository beyond ownership. Roles are
2813 * hierarchical — 'admin' implies write, write implies read. `invitedBy` tracks
2814 * who added them (nullable once that user is deleted); `acceptedAt` is null
2815 * until the invitee explicitly accepts. Unique per (repo, user) so a given
2816 * user has at most one role per repo.
2817 */
2818export const repoCollaborators = pgTable(
2819 "repo_collaborators",
2820 {
2821 id: uuid("id").primaryKey().defaultRandom(),
2822 repositoryId: uuid("repository_id")
2823 .notNull()
2824 .references(() => repositories.id, { onDelete: "cascade" }),
2825 userId: uuid("user_id")
2826 .notNull()
2827 .references(() => users.id, { onDelete: "cascade" }),
2828 role: text("role", { enum: ["read", "write", "admin"] })
2829 .notNull()
2830 .default("read"),
2831 invitedBy: uuid("invited_by").references(() => users.id, {
2832 onDelete: "set null",
2833 }),
2834 invitedAt: timestamp("invited_at").defaultNow().notNull(),
2835 acceptedAt: timestamp("accepted_at"),
febd4f0Claude2836 // sha256(plaintext) of the outstanding invite token. Set when the owner
2837 // sends an invite, cleared when the invitee accepts. NULL on older rows
2838 // that were auto-accepted before the email flow existed.
2839 inviteTokenHash: text("invite_token_hash"),
23d1a81Claude2840 },
2841 (table) => [
2842 uniqueIndex("repo_collaborators_repo_user_uq").on(
2843 table.repositoryId,
2844 table.userId
2845 ),
2846 index("repo_collaborators_repo_idx").on(table.repositoryId),
2847 index("repo_collaborators_user_idx").on(table.userId),
2848 ]
2849);
2850
2851export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
abfa9adClaude2852
2853// ---------------------------------------------------------------------------
2854// Workflow engine v2 — Sprint 1 additions (drizzle/0037_workflow_engine_v2.sql)
2855//
2856// Strictly additive to Block C1. The original workflow tables defined above
2857// (workflows / workflow_runs / workflow_jobs / workflow_artifacts) are locked
2858// and must not be altered in-place; the four tables below extend the runner
2859// with secrets, workflow_dispatch inputs, a content-addressable cache, and a
2860// warm-runner worker pool.
2861// ---------------------------------------------------------------------------
2862
2863/**
2864 * Per-repo encrypted secrets exposed to workflow jobs as env vars.
2865 *
2866 * `encrypted_value` is base64 of `iv || authTag || ciphertext` produced by
2867 * AES-256-GCM — the crypto lives in `src/lib/workflow-crypto.ts` (Agent 2);
2868 * the DB only stores opaque ciphertext. `name` is validated against
2869 * `[A-Z_][A-Z0-9_]*` at the write-site, not the DB. Unique per (repo, name).
2870 */
2871export const workflowSecrets = pgTable(
2872 "workflow_secrets",
2873 {
2874 id: uuid("id").primaryKey().defaultRandom(),
2875 repositoryId: uuid("repository_id")
2876 .notNull()
2877 .references(() => repositories.id, { onDelete: "cascade" }),
2878 name: text("name").notNull(),
2879 encryptedValue: text("encrypted_value").notNull(),
2880 createdBy: uuid("created_by").references(() => users.id, {
2881 onDelete: "set null",
2882 }),
2883 createdAt: timestamp("created_at").defaultNow().notNull(),
2884 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2885 },
2886 (table) => [
2887 uniqueIndex("workflow_secrets_repo_name_uq").on(
2888 table.repositoryId,
2889 table.name
2890 ),
2891 index("workflow_secrets_repo_idx").on(table.repositoryId),
2892 ]
2893);
2894
2895export type WorkflowSecret = typeof workflowSecrets.$inferSelect;
2896export type NewWorkflowSecret = typeof workflowSecrets.$inferInsert;
2897
2898/**
2899 * Parameter schema for the `workflow_dispatch` trigger. One row per input
2900 * declared in the workflow YAML. `type` is constrained to the four values
2901 * GitHub Actions supports; `options` is only meaningful for type='choice'
2902 * (a JSON array of allowed strings). `default_value` and `description` are
2903 * optional. Unique per (workflow, name) so two inputs on the same workflow
2904 * can't share an identifier.
2905 */
2906export const workflowDispatchInputs = pgTable(
2907 "workflow_dispatch_inputs",
2908 {
2909 id: uuid("id").primaryKey().defaultRandom(),
2910 workflowId: uuid("workflow_id")
2911 .notNull()
2912 .references(() => workflows.id, { onDelete: "cascade" }),
2913 name: text("name").notNull(),
2914 type: text("type", {
2915 enum: ["string", "boolean", "choice", "number"],
2916 }).notNull(),
2917 required: boolean("required").default(false).notNull(),
2918 defaultValue: text("default_value"),
2919 // JSON array of strings; only populated when type = 'choice'.
2920 options: jsonb("options"),
2921 description: text("description"),
2922 },
2923 (table) => [
2924 uniqueIndex("workflow_dispatch_inputs_wf_name_uq").on(
2925 table.workflowId,
2926 table.name
2927 ),
2928 ]
2929);
2930
2931export type WorkflowDispatchInput =
2932 typeof workflowDispatchInputs.$inferSelect;
2933export type NewWorkflowDispatchInput =
2934 typeof workflowDispatchInputs.$inferInsert;
2935
2936/**
2937 * Content-addressable cache backing the `gluecron/cache@v1` action. Rows are
2938 * keyed by user-chosen `cache_key` within a `scope` — 'repo' for repo-wide
2939 * entries, 'branch' or 'tag' when isolation is desired (with `scope_ref`
2940 * holding the branch/tag name). `content_hash` is the sha256 of `content`
2941 * so jobs can short-circuit redundant writes. `content` is real bytea; the
2942 * 100MB payload cap is enforced at the write-site, not by the DB. LRU
2943 * eviction reads (`repository_id`, `last_accessed_at`).
2944 */
2945export const workflowRunCache = pgTable(
2946 "workflow_run_cache",
2947 {
2948 id: uuid("id").primaryKey().defaultRandom(),
2949 repositoryId: uuid("repository_id")
2950 .notNull()
2951 .references(() => repositories.id, { onDelete: "cascade" }),
2952 cacheKey: text("cache_key").notNull(),
2953 // 'repo' | 'branch' | 'tag' — constraint enforced at the write-site.
2954 scope: text("scope").default("repo").notNull(),
2955 scopeRef: text("scope_ref"),
2956 contentHash: text("content_hash").notNull(),
2957 content: bytea("content").notNull(),
2958 sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
2959 createdAt: timestamp("created_at").defaultNow().notNull(),
2960 lastAccessedAt: timestamp("last_accessed_at").defaultNow().notNull(),
2961 },
2962 (table) => [
2963 uniqueIndex("workflow_run_cache_repo_key_scope_uq").on(
2964 table.repositoryId,
2965 table.cacheKey,
2966 table.scope,
2967 table.scopeRef
2968 ),
2969 index("workflow_run_cache_repo_lru_idx").on(
2970 table.repositoryId,
2971 table.lastAccessedAt
2972 ),
2973 ]
2974);
2975
2976export type WorkflowRunCache = typeof workflowRunCache.$inferSelect;
2977export type NewWorkflowRunCache = typeof workflowRunCache.$inferInsert;
2978
2979/**
2980 * Warm-runner worker registry. The scheduler reads idle rows to dispatch
2981 * queued jobs without paying cold-start cost; workers heartbeat here so
2982 * stale entries (>N seconds since `last_heartbeat_at`) can be reaped.
2983 * `worker_id` is a stable string (hostname:pid or similar) and is globally
2984 * unique so a restarted worker naturally replaces its predecessor.
2985 * `current_run_id` is set when status='busy' and cleared on completion.
2986 */
2987export const workflowRunnerPool = pgTable(
2988 "workflow_runner_pool",
2989 {
2990 id: uuid("id").primaryKey().defaultRandom(),
2991 workerId: text("worker_id").notNull().unique(),
2992 status: text("status", {
2993 enum: ["idle", "busy", "draining", "dead"],
2994 }).notNull(),
2995 currentRunId: uuid("current_run_id").references(() => workflowRuns.id, {
2996 onDelete: "set null",
2997 }),
2998 warmedAt: timestamp("warmed_at").defaultNow().notNull(),
2999 lastHeartbeatAt: timestamp("last_heartbeat_at").defaultNow().notNull(),
3000 capacity: integer("capacity").default(1).notNull(),
3001 },
3002 (table) => [index("workflow_runner_pool_status_idx").on(table.status)]
3003);
3004
3005export type WorkflowRunnerPoolEntry =
3006 typeof workflowRunnerPool.$inferSelect;
3007export type NewWorkflowRunnerPoolEntry =
3008 typeof workflowRunnerPool.$inferInsert;
e6bad81Claude3009
3010
3011/**
3012 * Repair Flywheel — every auto-repair attempt is recorded here so future
3013 * failures with the same signature can short-circuit straight to the cached
3014 * patch (Tier 0). After ~5000 entries the cache dominates and AI calls drop.
3015 * Migration: 0039_repair_flywheel.sql
3016 */
3017export const repairFlywheel = pgTable(
3018 "repair_flywheel",
3019 {
3020 id: uuid("id").primaryKey().defaultRandom(),
3021 repositoryId: uuid("repository_id").references(() => repositories.id, {
3022 onDelete: "cascade",
3023 }),
3024 // Fingerprint of the normalised failure text (variables/paths stripped).
3025 failureSignature: text("failure_signature").notNull(),
3026 // Original failure text (capped at write-site, ~4KB).
3027 failureText: text("failure_text").notNull(),
3028 // Mechanical classification or NULL if AI/human-driven.
3029 failureClassification: text("failure_classification"),
3030 // 'cached' | 'mechanical' | 'ai-sonnet' | 'human'
3031 repairTier: text("repair_tier").notNull(),
3032 patchSummary: text("patch_summary").notNull(),
3033 filesChanged: jsonb("files_changed").notNull().default([]),
3034 commitSha: text("commit_sha"),
3035 // 'pending' | 'success' | 'failed' | 'reverted'
3036 outcome: text("outcome").notNull().default("pending"),
3037 appliedAt: timestamp("applied_at").defaultNow().notNull(),
3038 outcomeAt: timestamp("outcome_at"),
3039 parentPatternId: uuid("parent_pattern_id"),
3040 cacheHitCount: integer("cache_hit_count").default(0).notNull(),
3041 isPublicPattern: boolean("is_public_pattern").default(false).notNull(),
3042 createdAt: timestamp("created_at").defaultNow().notNull(),
3043 },
3044 (table) => [
3045 index("repair_flywheel_signature_idx").on(table.failureSignature),
3046 index("repair_flywheel_repo_idx").on(table.repositoryId),
3047 index("repair_flywheel_outcome_idx").on(table.outcome),
3048 index("repair_flywheel_classification_idx").on(table.failureClassification),
3049 index("repair_flywheel_lookup_idx").on(
3050 table.repositoryId,
3051 table.failureSignature,
3052 table.outcome
3053 ),
3054 ]
3055);
3056
3057export type RepairFlywheelEntry = typeof repairFlywheel.$inferSelect;
3058export type NewRepairFlywheelEntry = typeof repairFlywheel.$inferInsert;
3059
534f04aClaude3060/**
3061 * Block M3 — AI pre-merge risk score cache.
3062 *
3063 * One row per (pull_request_id, commit_sha). The score is computed by a
3064 * transparent formula over `signals`; the LLM only writes `ai_summary`.
3065 * Pinning by head SHA means a new push invalidates the cache implicitly
3066 * (the new SHA won't have a row yet → cache miss → recompute).
3067 *
3068 * Migration: 0044_pr_risk_scores.sql
3069 */
3070export const prRiskScores = pgTable(
3071 "pr_risk_scores",
3072 {
3073 id: uuid("id").primaryKey().defaultRandom(),
3074 pullRequestId: uuid("pull_request_id")
3075 .notNull()
3076 .references(() => pullRequests.id, { onDelete: "cascade" }),
3077 commitSha: text("commit_sha").notNull(),
3078 // 0..10 integer score.
3079 score: integer("score").notNull(),
3080 // 'low' | 'medium' | 'high' | 'critical'
3081 band: text("band").notNull(),
3082 // Raw signal map — see PrRiskSignals in src/lib/pr-risk.ts.
3083 signals: jsonb("signals").notNull(),
3084 aiSummary: text("ai_summary"),
3085 generatedAt: timestamp("generated_at", { withTimezone: true })
3086 .defaultNow()
3087 .notNull(),
3088 },
3089 (table) => [
3090 uniqueIndex("pr_risk_scores_pr_sha_uq").on(
3091 table.pullRequestId,
3092 table.commitSha
3093 ),
3094 index("pr_risk_scores_pr_idx").on(table.pullRequestId),
3095 ]
3096);
3097
3098export type PrRiskScoreRow = typeof prRiskScores.$inferSelect;
3099export type NewPrRiskScoreRow = typeof prRiskScores.$inferInsert;
3100
c63b860Claude3101/**
3102 * Block P1 — Password reset tokens.
3103 *
3104 * One row per outstanding reset request. `tokenHash` is a SHA-256 of the
3105 * plaintext token mailed to the user; the plaintext never persists.
3106 * `usedAt` is flipped on consume so a replayed link cannot rotate the
3107 * password twice. `expiresAt` is enforced at consume time (1-hour TTL).
3108 *
3109 * Migration: 0047_password_reset_tokens.sql
3110 */
3111export const passwordResetTokens = pgTable(
3112 "password_reset_tokens",
3113 {
3114 id: uuid("id").primaryKey().defaultRandom(),
3115 userId: uuid("user_id")
3116 .notNull()
3117 .references(() => users.id, { onDelete: "cascade" }),
3118 tokenHash: text("token_hash").notNull().unique(),
3119 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3120 usedAt: timestamp("used_at", { withTimezone: true }),
3121 requestIp: text("request_ip"),
3122 createdAt: timestamp("created_at", { withTimezone: true })
3123 .defaultNow()
3124 .notNull(),
3125 },
3126 (table) => [
3127 index("idx_password_reset_tokens_user").on(table.userId),
3128 index("idx_password_reset_tokens_expires").on(table.expiresAt),
3129 ]
3130);
3131
3132export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
3133export type NewPasswordResetToken = typeof passwordResetTokens.$inferInsert;
3134
3135/**
3136 * Block P2 — email verification tokens.
3137 *
3138 * SHA-256 hashed at rest. `email` captured at issuance so a verification
3139 * link still resolves the right address even after a profile-email change.
3140 * Migration: drizzle/0048_email_verification.sql
3141 */
3142export const emailVerificationTokens = pgTable(
3143 "email_verification_tokens",
3144 {
3145 id: uuid("id").primaryKey().defaultRandom(),
3146 userId: uuid("user_id")
3147 .notNull()
3148 .references(() => users.id, { onDelete: "cascade" }),
3149 email: text("email").notNull(),
3150 tokenHash: text("token_hash").notNull().unique(),
3151 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3152 usedAt: timestamp("used_at", { withTimezone: true }),
3153 createdAt: timestamp("created_at", { withTimezone: true })
3154 .defaultNow()
3155 .notNull(),
3156 },
3157 (table) => [index("idx_email_verify_tokens_user").on(table.userId)]
3158);
3159
3160export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect;
3161export type NewEmailVerificationToken = typeof emailVerificationTokens.$inferInsert;
3162
cd4f63bTest User3163/**
3164 * Block Q2 — Magic-link sign-in tokens.
3165 *
3166 * Structurally identical to passwordResetTokens / emailVerificationTokens:
3167 * a short plaintext token is mailed to the user, only its sha256 hash is
3168 * persisted, the token is single-use (usedAt) and time-limited (expiresAt).
3169 *
3170 * Difference: `userId` is NULLABLE. When a user enters an email that does
3171 * NOT yet have an account, we still mint a row — consume will create the
3172 * account on click (autoCreate=true), using the click itself as proof the
3173 * recipient owns the address. See `src/lib/magic-link.ts`.
3174 *
3175 * Migration: drizzle/0051_magic_link_tokens.sql
3176 */
3177export const magicLinkTokens = pgTable(
3178 "magic_link_tokens",
3179 {
3180 id: uuid("id").primaryKey().defaultRandom(),
3181 email: text("email").notNull(),
3182 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
3183 tokenHash: text("token_hash").notNull().unique(),
3184 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3185 usedAt: timestamp("used_at", { withTimezone: true }),
3186 requestIp: text("request_ip"),
3187 createdAt: timestamp("created_at", { withTimezone: true })
3188 .defaultNow()
3189 .notNull(),
3190 },
3191 (table) => [
3192 index("idx_magic_link_tokens_email").on(table.email),
3193 index("idx_magic_link_tokens_user").on(table.userId),
3194 index("idx_magic_link_tokens_expires").on(table.expiresAt),
3195 ]
3196);
3197
3198export type MagicLinkToken = typeof magicLinkTokens.$inferSelect;
3199export type NewMagicLinkToken = typeof magicLinkTokens.$inferInsert;
3200
b1be050CC LABS App3201// ---------------------------------------------------------------------------
3202// BLOCK S4 — Synthetic monitor history.
3203//
3204// Every autopilot tick runs `runSyntheticChecks()` (see
3205// `src/lib/synthetic-monitor.ts`) and inserts one row per check here.
3206// The /admin/status page reads the most-recent row per check_name and
3207// renders the red/green dashboard. On a green->red transition we fire
3208// MONITOR_ALERT_WEBHOOK_URL so the owner sees the site is on fire.
3209// ---------------------------------------------------------------------------
3210export const syntheticChecks = pgTable(
3211 "synthetic_checks",
3212 {
3213 id: uuid("id").primaryKey().defaultRandom(),
3214 checkName: text("check_name").notNull(),
3215 status: text("status").notNull(), // "green" | "red" | "yellow"
3216 statusCode: integer("status_code"),
3217 durationMs: integer("duration_ms").notNull(),
3218 error: text("error"),
3219 checkedAt: timestamp("checked_at", { withTimezone: true })
3220 .defaultNow()
3221 .notNull(),
3222 },
3223 (table) => [
3224 index("idx_synthetic_checks_checked_at").on(table.checkedAt),
3225 index("idx_synthetic_checks_name_checked_at").on(
3226 table.checkName,
3227 table.checkedAt
3228 ),
3229 ]
3230);
3231
3232export type SyntheticCheckRow = typeof syntheticChecks.$inferSelect;
3233export type NewSyntheticCheckRow = typeof syntheticChecks.$inferInsert;
3234
a686079Claude3235// ---------------------------------------------------------------------------
3236// 0057 — Continuous semantic index (per-push embeddings).
e75eddcClaude3237// One row per (repository_id, file_path). `indexChangedFiles()` upserts on
3238// every push, `searchSemantic()` ranks by cosine distance via pgvector.
a686079Claude3239// ---------------------------------------------------------------------------
3240export const codeEmbeddings = pgTable(
3241 "code_embeddings",
3242 {
3243 id: uuid("id").primaryKey().defaultRandom(),
3244 repositoryId: uuid("repository_id")
3245 .notNull()
3246 .references(() => repositories.id, { onDelete: "cascade" }),
3247 filePath: text("file_path").notNull(),
3248 blobSha: text("blob_sha").notNull(),
3249 commitSha: text("commit_sha").notNull(),
3250 contentSnippet: text("content_snippet").notNull().default(""),
3251 embedding: vector("embedding", { dimensions: 1024 }),
3252 embeddingModel: text("embedding_model"),
3253 updatedAt: timestamp("updated_at", { withTimezone: true })
3254 .defaultNow()
3255 .notNull(),
3256 },
3257 (table) => [
3258 uniqueIndex("code_embeddings_repo_path_uniq").on(
3259 table.repositoryId,
3260 table.filePath
3261 ),
3262 index("code_embeddings_repo_idx").on(table.repositoryId),
3263 ]
3264);
3265
3266export type CodeEmbedding = typeof codeEmbeddings.$inferSelect;
3267export type NewCodeEmbedding = typeof codeEmbeddings.$inferInsert;
3268
e75eddcClaude3269// ---------------------------------------------------------------------------
3270// 0058 — Agent multiplayer v1: per-agent namespacing + leases + budget caps.
3271// See src/lib/agent-multiplayer.ts for helpers and 0058_agent_multiplayer.sql
3272// for the canonical column docs. UNIQUE partial index on (target_type,
3273// target_id) WHERE status='active' is enforced in SQL (drizzle exposes it
3274// as a regular index here).
3275// ---------------------------------------------------------------------------
3276export const agentSessions = pgTable(
3277 "agent_sessions",
3278 {
3279 id: uuid("id").primaryKey().defaultRandom(),
3280 name: text("name").notNull(),
3281 ownerUserId: uuid("owner_user_id")
3282 .notNull()
3283 .references(() => users.id, { onDelete: "cascade" }),
3284 repositoryId: uuid("repository_id").references(() => repositories.id, {
3285 onDelete: "cascade",
3286 }),
3287 tokenHash: text("token_hash").notNull().unique(),
3288 branchNamespace: text("branch_namespace").notNull(),
3289 budgetCentsPerDay: integer("budget_cents_per_day").default(500).notNull(),
3290 spentCentsToday: integer("spent_cents_today").default(0).notNull(),
3291 lastActiveAt: timestamp("last_active_at", { withTimezone: true }),
3292 createdAt: timestamp("created_at", { withTimezone: true })
3293 .defaultNow()
3294 .notNull(),
3295 },
3296 (table) => [
3297 uniqueIndex("agent_sessions_owner_name").on(table.ownerUserId, table.name),
3298 index("agent_sessions_owner").on(table.ownerUserId),
3299 index("agent_sessions_repo").on(table.repositoryId),
3300 ]
3301);
3302
3303export const agentLeases = pgTable(
3304 "agent_leases",
3305 {
3306 id: uuid("id").primaryKey().defaultRandom(),
3307 agentSessionId: uuid("agent_session_id")
3308 .notNull()
3309 .references(() => agentSessions.id, { onDelete: "cascade" }),
3310 targetType: text("target_type").notNull(),
3311 targetId: text("target_id").notNull(),
3312 acquiredAt: timestamp("acquired_at", { withTimezone: true })
3313 .defaultNow()
3314 .notNull(),
3315 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3316 status: text("status").default("active").notNull(),
3317 createdAt: timestamp("created_at", { withTimezone: true })
3318 .defaultNow()
3319 .notNull(),
3320 },
3321 (table) => [
3322 index("agent_leases_active_target").on(table.targetType, table.targetId),
3323 index("agent_leases_agent").on(table.agentSessionId, table.status),
3324 index("agent_leases_expires").on(table.expiresAt),
3325 ]
3326);
3327
3328export type AgentSession = typeof agentSessions.$inferSelect;
3329export type NewAgentSession = typeof agentSessions.$inferInsert;
3330export type AgentLease = typeof agentLeases.$inferSelect;
3331export type NewAgentLease = typeof agentLeases.$inferInsert;
3332
38d31d3Claude3333// ---------------------------------------------------------------------------
3c03977Claude3334// 0060 — AI repo rubber-duck chat. See src/lib/repo-chat.ts.
38d31d3Claude3335// ---------------------------------------------------------------------------
3336export const repoChats = pgTable(
3337 "repo_chats",
3338 {
3339 id: uuid("id").primaryKey().defaultRandom(),
3340 repositoryId: uuid("repository_id")
3341 .notNull()
3342 .references(() => repositories.id, { onDelete: "cascade" }),
3343 ownerUserId: uuid("owner_user_id")
3344 .notNull()
3345 .references(() => users.id, { onDelete: "cascade" }),
3346 title: text("title"),
3347 createdAt: timestamp("created_at", { withTimezone: true })
3348 .defaultNow()
3349 .notNull(),
3350 updatedAt: timestamp("updated_at", { withTimezone: true })
3351 .defaultNow()
3352 .notNull(),
3353 },
3354 (table) => [
3355 index("repo_chats_owner_updated").on(table.ownerUserId, table.updatedAt),
3356 index("repo_chats_repo").on(table.repositoryId),
3357 ]
3358);
3359
3360export const repoChatMessages = pgTable(
3361 "repo_chat_messages",
3362 {
3363 id: uuid("id").primaryKey().defaultRandom(),
3364 chatId: uuid("chat_id")
3365 .notNull()
3366 .references(() => repoChats.id, { onDelete: "cascade" }),
3367 role: text("role").notNull(),
3368 content: text("content").notNull(),
3369 citations: jsonb("citations").$type<Array<{ file_path: string; blob_sha: string }>>().notNull().default([]),
3370 tokenCost: integer("token_cost").notNull().default(0),
3371 createdAt: timestamp("created_at", { withTimezone: true })
3372 .defaultNow()
3373 .notNull(),
3374 },
3375 (table) => [
3376 index("repo_chat_messages_chat_created").on(table.chatId, table.createdAt),
3377 ]
3378);
3379
3380export type RepoChat = typeof repoChats.$inferSelect;
3381export type NewRepoChat = typeof repoChats.$inferInsert;
3382export type RepoChatMessage = typeof repoChatMessages.$inferSelect;
3383export type NewRepoChatMessage = typeof repoChatMessages.$inferInsert;
3384
ee7e577Claude3385// ---------------------------------------------------------------------------
3386// 0071 — Personal cross-repo chat. Same shape as repoChats/repoChatMessages
3387// but user-scoped, not repo-scoped. The retrieval layer
3388// (src/lib/personal-semantic.ts) runs searchSemantic across the union of
3389// repos the owner has access to, then attaches a repo_name annotation to
3390// each citation. Gated on users.personalSemanticIndexEnabled.
3391// ---------------------------------------------------------------------------
3392export const personalChats = pgTable(
3393 "personal_chats",
3394 {
3395 id: uuid("id").primaryKey().defaultRandom(),
3396 ownerUserId: uuid("owner_user_id")
3397 .notNull()
3398 .references(() => users.id, { onDelete: "cascade" }),
3399 title: text("title"),
3400 createdAt: timestamp("created_at", { withTimezone: true })
3401 .defaultNow()
3402 .notNull(),
3403 updatedAt: timestamp("updated_at", { withTimezone: true })
3404 .defaultNow()
3405 .notNull(),
3406 },
3407 (table) => [
3408 index("personal_chats_owner_updated").on(
3409 table.ownerUserId,
3410 table.updatedAt
3411 ),
3412 ]
3413);
3414
3415export const personalChatMessages = pgTable(
3416 "personal_chat_messages",
3417 {
3418 id: uuid("id").primaryKey().defaultRandom(),
3419 chatId: uuid("chat_id")
3420 .notNull()
3421 .references(() => personalChats.id, { onDelete: "cascade" }),
3422 role: text("role").notNull(),
3423 content: text("content").notNull(),
3424 citations: jsonb("citations")
3425 .$type<
3426 Array<{ file_path: string; blob_sha: string; repo_name: string }>
3427 >()
3428 .notNull()
3429 .default([]),
3430 tokenCost: integer("token_cost").notNull().default(0),
3431 createdAt: timestamp("created_at", { withTimezone: true })
3432 .defaultNow()
3433 .notNull(),
3434 },
3435 (table) => [
3436 index("personal_chat_messages_chat_created").on(
3437 table.chatId,
3438 table.createdAt
3439 ),
3440 ]
3441);
3442
3443export type PersonalChat = typeof personalChats.$inferSelect;
3444export type NewPersonalChat = typeof personalChats.$inferInsert;
3445export type PersonalChatMessage = typeof personalChatMessages.$inferSelect;
3446export type NewPersonalChatMessage = typeof personalChatMessages.$inferInsert;
3447
3c03977Claude3448// ---------------------------------------------------------------------------
3449// 0061 — Live co-editing on PRs. See src/lib/pr-live.ts.
3450// ---------------------------------------------------------------------------
3451export const prLiveSessions = pgTable(
3452 "pr_live_sessions",
3453 {
3454 id: uuid("id").primaryKey().defaultRandom(),
3455 prId: uuid("pr_id")
3456 .notNull()
3457 .references(() => pullRequests.id, { onDelete: "cascade" }),
3458 userId: uuid("user_id").references(() => users.id, {
3459 onDelete: "cascade",
3460 }),
3461 agentSessionId: uuid("agent_session_id").references(
3462 () => agentSessions.id,
3463 { onDelete: "cascade" }
3464 ),
3465 cursorPosition: jsonb("cursor_position"),
3466 color: text("color").notNull(),
3467 joinedAt: timestamp("joined_at", { withTimezone: true })
3468 .defaultNow()
3469 .notNull(),
3470 lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
3471 .defaultNow()
3472 .notNull(),
3473 status: text("status").default("active").notNull(),
3474 },
3475 (table) => [
3476 index("pr_live_sessions_active_pr").on(table.prId, table.lastSeenAt),
3477 index("pr_live_sessions_status_seen").on(table.status, table.lastSeenAt),
3478 ]
3479);
3480
3481export type PrLiveSession = typeof prLiveSessions.$inferSelect;
3482export type NewPrLiveSession = typeof prLiveSessions.$inferInsert;
3483
4bbacbeClaude3484// ---------------------------------------------------------------------------
3485// ---------------------------------------------------------------------------
23d0abfClaude3486// Migration 0062 — per-branch preview URLs. See src/lib/branch-previews.ts.
3487// ---------------------------------------------------------------------------
4bbacbeClaude3488export const branchPreviews = pgTable(
3489 "branch_previews",
3490 {
3491 id: uuid("id").primaryKey().defaultRandom(),
3492 repositoryId: uuid("repository_id")
3493 .notNull()
3494 .references(() => repositories.id, { onDelete: "cascade" }),
3495 branchName: text("branch_name").notNull(),
3496 commitSha: text("commit_sha").notNull(),
3497 previewUrl: text("preview_url").notNull(),
3498 status: text("status").default("building").notNull(),
3499 buildStartedAt: timestamp("build_started_at", { withTimezone: true })
3500 .defaultNow()
3501 .notNull(),
3502 buildCompletedAt: timestamp("build_completed_at", { withTimezone: true }),
3503 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3504 errorMessage: text("error_message"),
3505 createdAt: timestamp("created_at", { withTimezone: true })
3506 .defaultNow()
3507 .notNull(),
3508 },
3509 (table) => [
3510 uniqueIndex("branch_previews_repo_branch").on(
3511 table.repositoryId,
3512 table.branchName
3513 ),
3514 index("branch_previews_repo_status").on(table.repositoryId, table.status),
3515 ]
3516);
3517
3518export type BranchPreview = typeof branchPreviews.$inferSelect;
3519export type NewBranchPreview = typeof branchPreviews.$inferInsert;
3520
23d0abfClaude3521// ---------------------------------------------------------------------------
3522// 0063 — Multi-repo refactor agent. See src/lib/multi-repo-refactor.ts.
3523// ---------------------------------------------------------------------------
3524export const multiRepoRefactors = pgTable(
3525 "multi_repo_refactors",
3526 {
3527 id: uuid("id").primaryKey().defaultRandom(),
3528 ownerUserId: uuid("owner_user_id")
3529 .notNull()
3530 .references(() => users.id, { onDelete: "cascade" }),
3531 title: text("title").notNull(),
3532 description: text("description").notNull(),
3533 status: text("status").default("planning").notNull(),
3534 createdAt: timestamp("created_at").defaultNow().notNull(),
3535 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3536 },
3537 (table) => [
3538 index("multi_repo_refactors_owner").on(table.ownerUserId, table.createdAt),
3539 index("multi_repo_refactors_status").on(table.status, table.updatedAt),
3540 ]
3541);
3542
3543export const multiRepoRefactorPrs = pgTable(
3544 "multi_repo_refactor_prs",
3545 {
3546 id: uuid("id").primaryKey().defaultRandom(),
3547 refactorId: uuid("refactor_id")
3548 .notNull()
3549 .references(() => multiRepoRefactors.id, { onDelete: "cascade" }),
3550 repositoryId: uuid("repository_id")
3551 .notNull()
3552 .references(() => repositories.id, { onDelete: "cascade" }),
3553 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
3554 onDelete: "set null",
3555 }),
3556 status: text("status").default("pending").notNull(),
3557 errorMessage: text("error_message"),
3558 createdAt: timestamp("created_at").defaultNow().notNull(),
3559 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3560 },
3561 (table) => [
3562 uniqueIndex("multi_repo_refactor_prs_unique").on(
3563 table.refactorId,
3564 table.repositoryId
3565 ),
3566 index("multi_repo_refactor_prs_refactor").on(
3567 table.refactorId,
3568 table.status
3569 ),
3570 index("multi_repo_refactor_prs_repo").on(table.repositoryId),
3571 ]
3572);
3573
3574export type MultiRepoRefactor = typeof multiRepoRefactors.$inferSelect;
3575export type NewMultiRepoRefactor = typeof multiRepoRefactors.$inferInsert;
3576export type MultiRepoRefactorPr = typeof multiRepoRefactorPrs.$inferSelect;
3577export type NewMultiRepoRefactorPr = typeof multiRepoRefactorPrs.$inferInsert;
3578
e3c90cdClaude3579// ─── Chat integrations (migration 0064) ────────────────────────────────────
3580// Slack / Discord / Teams bindings. One row per (owner, kind, team, channel).
3581// `webhook_url` is the outbound Incoming-Webhook URL for posting back into
3582// the channel; `signing_secret` is the per-install verification key (HMAC for
3583// Slack, Ed25519 public key for Discord). See drizzle/0064_chat_integrations.sql
3584// for the full schema rationale.
3585export const chatIntegrations = pgTable(
3586 "chat_integrations",
3587 {
3588 id: uuid("id").primaryKey().defaultRandom(),
3589 ownerUserId: uuid("owner_user_id")
3590 .notNull()
3591 .references(() => users.id, { onDelete: "cascade" }),
3592 kind: text("kind").notNull(), // 'slack' | 'discord' | 'teams'
3593 teamId: text("team_id"),
3594 channelId: text("channel_id"),
3595 webhookUrl: text("webhook_url"),
3596 signingSecret: text("signing_secret"),
3597 enabled: boolean("enabled").default(true).notNull(),
3598 createdAt: timestamp("created_at").defaultNow().notNull(),
3599 lastUsedAt: timestamp("last_used_at"),
3600 },
3601 (table) => [
3602 index("chat_integrations_owner").on(table.ownerUserId, table.kind),
3603 ]
3604);
3605
3606export type ChatIntegration = typeof chatIntegrations.$inferSelect;
3607export type NewChatIntegration = typeof chatIntegrations.$inferInsert;
3608
0c3eee5Claude3609// ---------------------------------------------------------------------------
3610// 0065 — Per-call AI cost tracking. See src/lib/ai-cost-tracker.ts and
3611// src/routes/billing-usage.tsx. Best-effort: insert failures must NEVER
3612// throw out of the Claude caller, so every call site wraps recordAiCost in
3613// try/catch. Cents are computed at insert time from a hardcoded pricing
3614// table so historical aggregates stay stable across price changes.
3615// ---------------------------------------------------------------------------
3616export const aiCostEvents = pgTable(
3617 "ai_cost_events",
3618 {
3619 id: uuid("id").primaryKey().defaultRandom(),
3620 occurredAt: timestamp("occurred_at", { withTimezone: true })
3621 .defaultNow()
3622 .notNull(),
3623 ownerUserId: uuid("owner_user_id").references(() => users.id, {
3624 onDelete: "set null",
3625 }),
3626 repositoryId: uuid("repository_id").references(() => repositories.id, {
3627 onDelete: "set null",
3628 }),
3629 agentSessionId: uuid("agent_session_id").references(
3630 () => agentSessions.id,
3631 { onDelete: "set null" }
3632 ),
3633 model: text("model").notNull(),
3634 inputTokens: integer("input_tokens").default(0).notNull(),
3635 outputTokens: integer("output_tokens").default(0).notNull(),
3636 centsEstimate: integer("cents_estimate").default(0).notNull(),
3637 category: text("category").default("other").notNull(),
3638 sourceId: text("source_id"),
3639 sourceKind: text("source_kind"),
3640 createdAt: timestamp("created_at", { withTimezone: true })
3641 .defaultNow()
3642 .notNull(),
3643 },
3644 (table) => [
3645 index("ai_cost_events_owner_time").on(table.ownerUserId, table.occurredAt),
3646 index("ai_cost_events_repo_time").on(table.repositoryId, table.occurredAt),
3647 index("ai_cost_events_agent_time").on(
3648 table.agentSessionId,
3649 table.occurredAt
3650 ),
3651 index("ai_cost_events_category_time").on(table.category, table.occurredAt),
3652 ]
3653);
3654
3655export type AiCostEvent = typeof aiCostEvents.$inferSelect;
3656export type NewAiCostEvent = typeof aiCostEvents.$inferInsert;
3657
3658export const aiBudgets = pgTable("ai_budgets", {
3659 userId: uuid("user_id")
3660 .primaryKey()
3661 .references(() => users.id, { onDelete: "cascade" }),
3662 monthlyCents: integer("monthly_cents").default(0).notNull(),
3663 updatedAt: timestamp("updated_at", { withTimezone: true })
3664 .defaultNow()
3665 .notNull(),
3666});
3667
3668export type AiBudget = typeof aiBudgets.$inferSelect;
3669export type NewAiBudget = typeof aiBudgets.$inferInsert;
79ed944Claude3670
3671// ---------------------------------------------------------------------------
3672// Migration 0067 — per-PR runnable sandboxes. See src/lib/pr-sandbox.ts.
3673//
3674// Each open PR can hold at most one sandbox row (UNIQUE on pr_id). The row
3675// owns the lifecycle (provisioning → ready / failed / destroyed) plus the
3676// resolved playground.yml the sandbox was provisioned from. Re-provisioning
3677// the same PR upserts onto the existing row so a force-push always points
3678// at the newest head.
3679// ---------------------------------------------------------------------------
3680export const prSandboxes = pgTable(
3681 "pr_sandboxes",
3682 {
3683 id: uuid("id").primaryKey().defaultRandom(),
3684 prId: uuid("pr_id")
3685 .notNull()
3686 .references(() => pullRequests.id, { onDelete: "cascade" }),
3687 status: text("status").default("provisioning").notNull(),
3688 sandboxUrl: text("sandbox_url").notNull(),
3689 containerId: text("container_id"),
3690 playgroundYml: text("playground_yml"),
3691 provisionedAt: timestamp("provisioned_at", { withTimezone: true })
3692 .defaultNow()
3693 .notNull(),
3694 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3695 destroyedAt: timestamp("destroyed_at", { withTimezone: true }),
3696 errorMessage: text("error_message"),
3697 createdAt: timestamp("created_at", { withTimezone: true })
3698 .defaultNow()
3699 .notNull(),
3700 },
3701 (table) => [
3702 uniqueIndex("pr_sandboxes_pr_id").on(table.prId),
3703 index("pr_sandboxes_status_expires").on(table.status, table.expiresAt),
3704 ]
3705);
3706
3707export type PrSandbox = typeof prSandboxes.$inferSelect;
3708export type NewPrSandbox = typeof prSandboxes.$inferInsert;
d199847Claude3709
3710// ---------------------------------------------------------------------------
3711// 0068 — AI-tracked documentation sections. See src/lib/ai-doc-updater.ts.
3712//
3713// Stores the currently claimed source-file hash for each
3714// `<!-- gluecron:doc-track src=... -->` region in a tracked markdown file.
3715// The post-receive hook re-hashes the referenced source after every push
3716// and proposes a PR (tagged `ai:doc-update`) when the prose drifts from
3717// the truth.
3718// ---------------------------------------------------------------------------
3719export const docTracking = pgTable(
3720 "doc_tracking",
3721 {
3722 id: uuid("id").primaryKey().defaultRandom(),
3723 repositoryId: uuid("repository_id")
3724 .notNull()
3725 .references(() => repositories.id, { onDelete: "cascade" }),
3726 docPath: text("doc_path").notNull(),
3727 sectionMarker: text("section_marker").notNull(),
3728 srcPath: text("src_path").notNull(),
3729 claimedHash: text("claimed_hash").notNull(),
3730 lastCheckedAt: timestamp("last_checked_at", { withTimezone: true })
3731 .defaultNow()
3732 .notNull(),
3733 lastPrId: uuid("last_pr_id").references(() => pullRequests.id, {
3734 onDelete: "set null",
3735 }),
3736 createdAt: timestamp("created_at", { withTimezone: true })
3737 .defaultNow()
3738 .notNull(),
3739 },
3740 (table) => [
3741 uniqueIndex("doc_tracking_repo_doc_marker").on(
3742 table.repositoryId,
3743 table.docPath,
3744 table.sectionMarker
3745 ),
3746 index("doc_tracking_repo").on(table.repositoryId),
3747 ]
3748);
3749
3750export type DocTracking = typeof docTracking.$inferSelect;
3751export type NewDocTracking = typeof docTracking.$inferInsert;
c6db5eeClaude3752
3753// ---------------------------------------------------------------------------
3754// 0069 — Hosted Claude tool-use loops. See src/lib/hosted-claude-loop.ts and
3755// src/routes/claude-deploy.tsx.
3756//
3757// Users paste a Claude tool-use loop, get a hosted endpoint + budget meter.
3758// Each loop is paired to an agent_sessions row so it inherits multiplayer
3759// namespacing and the daily budget mutex.
3760// ---------------------------------------------------------------------------
3761export const hostedClaudeLoops = pgTable(
3762 "hosted_claude_loops",
3763 {
3764 id: uuid("id").primaryKey().defaultRandom(),
3765 ownerUserId: uuid("owner_user_id")
3766 .notNull()
3767 .references(() => users.id, { onDelete: "cascade" }),
3768 name: text("name").notNull(),
3769 sourceCode: text("source_code").notNull(),
3770 endpointPath: text("endpoint_path").notNull().unique(),
3771 agentSessionId: uuid("agent_session_id").references(
3772 () => agentSessions.id,
3773 { onDelete: "set null" }
3774 ),
3775 status: text("status").default("paused").notNull(),
3776 isPublic: boolean("is_public").default(false).notNull(),
3777 monthlyBudgetCents: integer("monthly_budget_cents").default(500).notNull(),
3778 lastRunAt: timestamp("last_run_at", { withTimezone: true }),
3779 totalInvocations: integer("total_invocations").default(0).notNull(),
3780 totalCentsSpent: integer("total_cents_spent").default(0).notNull(),
3781 createdAt: timestamp("created_at", { withTimezone: true })
3782 .defaultNow()
3783 .notNull(),
3784 updatedAt: timestamp("updated_at", { withTimezone: true })
3785 .defaultNow()
3786 .notNull(),
3787 },
3788 (table) => [
3789 index("hosted_claude_loops_owner").on(table.ownerUserId, table.updatedAt),
3790 index("hosted_claude_loops_status").on(table.status),
3791 ]
3792);
3793
3794export const hostedClaudeLoopRuns = pgTable(
3795 "hosted_claude_loop_runs",
3796 {
3797 id: uuid("id").primaryKey().defaultRandom(),
3798 loopId: uuid("loop_id")
3799 .notNull()
3800 .references(() => hostedClaudeLoops.id, { onDelete: "cascade" }),
3801 inputPayload: jsonb("input_payload").default({}).notNull(),
3802 outputPayload: jsonb("output_payload"),
3803 stdout: text("stdout"),
3804 stderr: text("stderr"),
3805 startedAt: timestamp("started_at", { withTimezone: true })
3806 .defaultNow()
3807 .notNull(),
3808 finishedAt: timestamp("finished_at", { withTimezone: true }),
3809 status: text("status").default("running").notNull(),
3810 centsEstimate: integer("cents_estimate").default(0).notNull(),
3811 claudeInputTokens: integer("claude_input_tokens").default(0).notNull(),
3812 claudeOutputTokens: integer("claude_output_tokens").default(0).notNull(),
3813 exitCode: integer("exit_code"),
3814 errorMessage: text("error_message"),
3815 createdAt: timestamp("created_at", { withTimezone: true })
3816 .defaultNow()
3817 .notNull(),
3818 },
3819 (table) => [
3820 index("hosted_claude_loop_runs_loop_time").on(
3821 table.loopId,
3822 table.startedAt
3823 ),
3824 index("hosted_claude_loop_runs_status").on(table.status, table.startedAt),
3825 ]
3826);
3827
3828export type HostedClaudeLoop = typeof hostedClaudeLoops.$inferSelect;
3829export type NewHostedClaudeLoop = typeof hostedClaudeLoops.$inferInsert;
3830export type HostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferSelect;
3831export type NewHostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferInsert;
5ca514aClaude3832
3833// ---------------------------------------------------------------------------
9b3a183Claude3834// 0070 — Agent Marketplace. See src/lib/agent-marketplace.ts.
5ca514aClaude3835// ---------------------------------------------------------------------------
3836export const agentMarketplaceListings = pgTable(
3837 "agent_marketplace_listings",
3838 {
3839 id: uuid("id").primaryKey().defaultRandom(),
3840 publisherUserId: uuid("publisher_user_id")
3841 .notNull()
3842 .references(() => users.id, { onDelete: "cascade" }),
3843 slug: text("slug").notNull().unique(),
3844 name: text("name").notNull(),
3845 tagline: text("tagline").default("").notNull(),
3846 description: text("description").default("").notNull(),
3847 category: text("category").default("custom").notNull(),
3848 pricingModel: text("pricing_model").default("free").notNull(),
3849 priceCents: integer("price_cents").default(0).notNull(),
3850 agentTemplate: jsonb("agent_template")
3851 .$type<{
3852 branchNamespace?: string;
3853 budgetCentsPerDay?: number;
3854 capabilities?: string[];
3855 [k: string]: unknown;
3856 }>()
3857 .default({})
3858 .notNull(),
3859 sourceUrl: text("source_url"),
3860 status: text("status").default("draft").notNull(),
3861 installCount: integer("install_count").default(0).notNull(),
3862 ratingAvg: numeric("rating_avg", { precision: 3, scale: 2 })
3863 .default("0")
3864 .notNull(),
3865 ratingCount: integer("rating_count").default(0).notNull(),
3866 createdAt: timestamp("created_at", { withTimezone: true })
3867 .defaultNow()
3868 .notNull(),
3869 updatedAt: timestamp("updated_at", { withTimezone: true })
3870 .defaultNow()
3871 .notNull(),
3872 },
3873 (table) => [
9b3a183Claude3874 index("agent_marketplace_listings_category").on(table.category, table.status),
3875 index("agent_marketplace_listings_rating").on(table.ratingAvg, table.ratingCount),
5ca514aClaude3876 index("agent_marketplace_listings_installs").on(table.installCount),
3877 index("agent_marketplace_listings_publisher").on(table.publisherUserId),
9b3a183Claude3878 index("agent_marketplace_listings_status_created").on(table.status, table.createdAt),
5ca514aClaude3879 ]
3880);
3881
3882export const agentMarketplaceInstalls = pgTable(
3883 "agent_marketplace_installs",
3884 {
3885 id: uuid("id").primaryKey().defaultRandom(),
3886 listingId: uuid("listing_id")
3887 .notNull()
3888 .references(() => agentMarketplaceListings.id, { onDelete: "cascade" }),
3889 repositoryId: uuid("repository_id")
3890 .notNull()
3891 .references(() => repositories.id, { onDelete: "cascade" }),
3892 installedByUserId: uuid("installed_by_user_id")
3893 .notNull()
3894 .references(() => users.id, { onDelete: "cascade" }),
3895 agentSessionId: uuid("agent_session_id").references(
3896 () => agentSessions.id,
3897 { onDelete: "set null" }
3898 ),
3899 status: text("status").default("active").notNull(),
3900 installedAt: timestamp("installed_at", { withTimezone: true })
3901 .defaultNow()
3902 .notNull(),
3903 lastInvokedAt: timestamp("last_invoked_at", { withTimezone: true }),
3904 createdAt: timestamp("created_at", { withTimezone: true })
3905 .defaultNow()
3906 .notNull(),
3907 },
3908 (table) => [
9b3a183Claude3909 uniqueIndex("agent_marketplace_installs_listing_repo").on(table.listingId, table.repositoryId),
5ca514aClaude3910 index("agent_marketplace_installs_repo").on(table.repositoryId, table.status),
3911 index("agent_marketplace_installs_installer").on(table.installedByUserId),
3912 ]
3913);
3914
3915export const agentMarketplaceReviews = pgTable(
3916 "agent_marketplace_reviews",
3917 {
3918 id: uuid("id").primaryKey().defaultRandom(),
3919 listingId: uuid("listing_id")
3920 .notNull()
3921 .references(() => agentMarketplaceListings.id, { onDelete: "cascade" }),
3922 reviewerUserId: uuid("reviewer_user_id")
3923 .notNull()
3924 .references(() => users.id, { onDelete: "cascade" }),
3925 rating: integer("rating").notNull(),
3926 body: text("body").default("").notNull(),
3927 createdAt: timestamp("created_at", { withTimezone: true })
3928 .defaultNow()
3929 .notNull(),
3930 },
3931 (table) => [
9b3a183Claude3932 index("agent_marketplace_reviews_listing_created").on(table.listingId, table.createdAt),
5ca514aClaude3933 index("agent_marketplace_reviews_reviewer").on(table.reviewerUserId),
3934 ]
3935);
3936
9b3a183Claude3937export type AgentMarketplaceListing = typeof agentMarketplaceListings.$inferSelect;
3938export type NewAgentMarketplaceListing = typeof agentMarketplaceListings.$inferInsert;
3939export type AgentMarketplaceInstall = typeof agentMarketplaceInstalls.$inferSelect;
3940export type NewAgentMarketplaceInstall = typeof agentMarketplaceInstalls.$inferInsert;
3941export type AgentMarketplaceReview = typeof agentMarketplaceReviews.$inferSelect;
3942export type NewAgentMarketplaceReview = typeof agentMarketplaceReviews.$inferInsert;
3943
3944// ---------------------------------------------------------------------------
3945// 0072 — per-(repo, user) cloud dev environments. See src/lib/dev-env.ts.
3946// ---------------------------------------------------------------------------
3947export const devEnvs = pgTable(
3948 "dev_envs",
3949 {
3950 id: uuid("id").primaryKey().defaultRandom(),
3951 repositoryId: uuid("repository_id")
3952 .notNull()
3953 .references(() => repositories.id, { onDelete: "cascade" }),
3954 ownerUserId: uuid("owner_user_id")
3955 .notNull()
3956 .references(() => users.id, { onDelete: "cascade" }),
3957 status: text("status").default("cold").notNull(),
3958 previewUrl: text("preview_url"),
3959 containerId: text("container_id"),
3960 machineSize: text("machine_size").default("small").notNull(),
3961 idleMinutes: integer("idle_minutes").default(30).notNull(),
3962 devYml: text("dev_yml"),
3963 errorMessage: text("error_message"),
3964 lastActiveAt: timestamp("last_active_at", { withTimezone: true })
3965 .defaultNow()
3966 .notNull(),
3967 expiresAt: timestamp("expires_at", { withTimezone: true }),
3968 createdAt: timestamp("created_at", { withTimezone: true })
3969 .defaultNow()
3970 .notNull(),
3971 },
3972 (table) => [
3973 uniqueIndex("dev_envs_repo_owner").on(table.repositoryId, table.ownerUserId),
3974 index("dev_envs_status_active").on(table.status, table.lastActiveAt),
3975 ]
3976);
3977
3978export type DevEnv = typeof devEnvs.$inferSelect;
3979export type NewDevEnv = typeof devEnvs.$inferInsert;
783dd46Claude3980
3981// ---------------------------------------------------------------------------
3982// Block ST — Server targets (drizzle/0073_server_targets.sql)
3983//
3984// Admin-managed remote boxes Gluecron can SSH into and run a deploy script
3985// on. Each target carries an encrypted private SSH key, a pinned host
3986// fingerprint, an optional repo+branch to watch, and a per-target list of
3987// env vars materialised on the box at deploy time. Customer-facing rollout
3988// is gated to a follow-up block (Block 2) that scopes by owner_user_id.
3989// ---------------------------------------------------------------------------
3990
3991export const serverTargets = pgTable(
3992 "server_targets",
3993 {
3994 id: uuid("id").primaryKey().defaultRandom(),
3995 name: text("name").notNull(),
3996 host: text("host").notNull(),
3997 port: integer("port").default(22).notNull(),
3998 sshUser: text("ssh_user").notNull(),
3999 encryptedPrivateKey: text("encrypted_private_key").notNull(),
4000 hostFingerprint: text("host_fingerprint"),
4001 deployPath: text("deploy_path").default("/var/www/app").notNull(),
4002 deployScript: text("deploy_script").default("bash deploy.sh").notNull(),
4003 watchedRepositoryId: uuid("watched_repository_id").references(
4004 () => repositories.id,
4005 { onDelete: "set null" }
4006 ),
4007 watchedBranch: text("watched_branch"),
4008 status: text("status").default("unverified").notNull(),
4009 lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
4010 createdBy: uuid("created_by").references(() => users.id, {
4011 onDelete: "set null",
4012 }),
4013 createdAt: timestamp("created_at", { withTimezone: true })
4014 .defaultNow()
4015 .notNull(),
4016 updatedAt: timestamp("updated_at", { withTimezone: true })
4017 .defaultNow()
4018 .notNull(),
4019 },
4020 (table) => [
4021 uniqueIndex("server_targets_name_uq").on(table.name),
4022 index("server_targets_watch_idx").on(
4023 table.watchedRepositoryId,
4024 table.watchedBranch
4025 ),
4026 ]
4027);
4028
4029export type ServerTarget = typeof serverTargets.$inferSelect;
4030export type NewServerTarget = typeof serverTargets.$inferInsert;
4031
4032export const serverTargetEnv = pgTable(
4033 "server_target_env",
4034 {
4035 id: uuid("id").primaryKey().defaultRandom(),
4036 targetId: uuid("target_id")
4037 .notNull()
4038 .references(() => serverTargets.id, { onDelete: "cascade" }),
4039 name: text("name").notNull(),
4040 encryptedValue: text("encrypted_value").notNull(),
4041 isSecret: boolean("is_secret").default(true).notNull(),
4042 updatedBy: uuid("updated_by").references(() => users.id, {
4043 onDelete: "set null",
4044 }),
4045 createdAt: timestamp("created_at", { withTimezone: true })
4046 .defaultNow()
4047 .notNull(),
4048 updatedAt: timestamp("updated_at", { withTimezone: true })
4049 .defaultNow()
4050 .notNull(),
4051 },
4052 (table) => [uniqueIndex("server_target_env_uq").on(table.targetId, table.name)]
4053);
4054
4055export type ServerTargetEnv = typeof serverTargetEnv.$inferSelect;
4056export type NewServerTargetEnv = typeof serverTargetEnv.$inferInsert;
4057
4058export const serverTargetDeployments = pgTable(
4059 "server_target_deployments",
4060 {
4061 id: uuid("id").primaryKey().defaultRandom(),
4062 targetId: uuid("target_id")
4063 .notNull()
4064 .references(() => serverTargets.id, { onDelete: "cascade" }),
4065 commitSha: text("commit_sha"),
4066 ref: text("ref"),
4067 status: text("status").default("pending").notNull(),
4068 exitCode: integer("exit_code"),
4069 stdout: text("stdout"),
4070 stderr: text("stderr"),
4071 triggeredBy: uuid("triggered_by").references(() => users.id, {
4072 onDelete: "set null",
4073 }),
4074 triggerSource: text("trigger_source").default("push").notNull(),
4075 startedAt: timestamp("started_at", { withTimezone: true })
4076 .defaultNow()
4077 .notNull(),
4078 finishedAt: timestamp("finished_at", { withTimezone: true }),
4079 },
4080 (table) => [
4081 index("server_target_deployments_target_idx").on(
4082 table.targetId,
4083 table.startedAt
4084 ),
4085 ]
4086);
4087
4088export type ServerTargetDeployment =
4089 typeof serverTargetDeployments.$inferSelect;
4090export type NewServerTargetDeployment =
4091 typeof serverTargetDeployments.$inferInsert;
4092
4093export const serverTargetAudit = pgTable(
4094 "server_target_audit",
4095 {
4096 id: uuid("id").primaryKey().defaultRandom(),
4097 targetId: uuid("target_id").references(() => serverTargets.id, {
4098 onDelete: "set null",
4099 }),
4100 actorId: uuid("actor_id").references(() => users.id, {
4101 onDelete: "set null",
4102 }),
4103 action: text("action").notNull(),
4104 detail: text("detail"),
4105 ip: text("ip"),
4106 createdAt: timestamp("created_at", { withTimezone: true })
4107 .defaultNow()
4108 .notNull(),
4109 },
4110 (table) => [
4111 index("server_target_audit_target_idx").on(table.targetId, table.createdAt),
4112 ]
4113);
4114
4115export type ServerTargetAudit = typeof serverTargetAudit.$inferSelect;
4116export type NewServerTargetAudit = typeof serverTargetAudit.$inferInsert;
90c7531Claude4117
4118// ---------------------------------------------------------------------------
4119// Block CW — Claude on the web (drizzle/0074_claude_web_sessions.sql)
4120//
4121// Per-repo interactive Claude Code sessions runnable from any browser.
4122// Each session owns a working dir on the web server (a fresh git clone of
4123// the repo's bare store) and persists turn-by-turn transcripts so an iPad
4124// user can resume the same conversation later from a laptop.
4125//
4126// v1 admin-only; v2 will scope by owner_user_id and isolate per-session
4127// containers. Schema is forward-compatible with both.
4128// ---------------------------------------------------------------------------
4129
4130export const claudeWebSessions = pgTable(
4131 "claude_web_sessions",
4132 {
4133 id: uuid("id").primaryKey().defaultRandom(),
4134 repositoryId: uuid("repository_id")
4135 .notNull()
4136 .references(() => repositories.id, { onDelete: "cascade" }),
4137 ownerUserId: uuid("owner_user_id")
4138 .notNull()
4139 .references(() => users.id, { onDelete: "cascade" }),
4140 title: text("title").default("New session").notNull(),
4141 branch: text("branch").default("main").notNull(),
4142 workdirPath: text("workdir_path").notNull(),
4143 claudeSessionId: text("claude_session_id"),
4144 status: text("status").default("cold").notNull(),
4145 lastActiveAt: timestamp("last_active_at", { withTimezone: true })
4146 .defaultNow()
4147 .notNull(),
4148 createdAt: timestamp("created_at", { withTimezone: true })
4149 .defaultNow()
4150 .notNull(),
4151 },
4152 (table) => [
4153 index("claude_web_sessions_repo_idx").on(
4154 table.repositoryId,
4155 table.lastActiveAt
4156 ),
4157 index("claude_web_sessions_owner_idx").on(
4158 table.ownerUserId,
4159 table.lastActiveAt
4160 ),
4161 ]
4162);
4163
4164export type ClaudeWebSession = typeof claudeWebSessions.$inferSelect;
4165export type NewClaudeWebSession = typeof claudeWebSessions.$inferInsert;
4166
4167export const claudeWebMessages = pgTable(
4168 "claude_web_messages",
4169 {
4170 id: uuid("id").primaryKey().defaultRandom(),
4171 sessionId: uuid("session_id")
4172 .notNull()
4173 .references(() => claudeWebSessions.id, { onDelete: "cascade" }),
4174 role: text("role").notNull(),
4175 body: text("body").notNull(),
4176 exitCode: integer("exit_code"),
4177 durationMs: integer("duration_ms"),
4178 createdAt: timestamp("created_at", { withTimezone: true })
4179 .defaultNow()
4180 .notNull(),
4181 },
4182 (table) => [
4183 index("claude_web_messages_session_idx").on(
4184 table.sessionId,
4185 table.createdAt
4186 ),
4187 ]
4188);
4189
4190export type ClaudeWebMessage = typeof claudeWebMessages.$inferSelect;
873f13cClaude4191
4192// ---------------------------------------------------------------------------
4193// 0077 — Status page: incident history + subscriber list.
4194//
4195// incidents: manually-filed or autopilot-detected outage records shown on
4196// the public /status page. severity: 'minor' | 'major' | 'critical'.
4197// status: 'investigating' | 'identified' | 'monitoring' | 'resolved'.
4198//
4199// status_subscribers: email addresses that have opted-in to receive alerts
4200// when a new incident is filed.
4201// ---------------------------------------------------------------------------
4202export const incidents = pgTable(
4203 "incidents",
4204 {
4205 id: uuid("id").primaryKey().defaultRandom(),
4206 title: text("title").notNull(),
4207 severity: text("severity").notNull().default("minor"),
4208 status: text("status").notNull().default("resolved"),
4209 startedAt: timestamp("started_at", { withTimezone: true })
4210 .defaultNow()
4211 .notNull(),
4212 resolvedAt: timestamp("resolved_at", { withTimezone: true }),
4213 body: text("body"),
4214 createdAt: timestamp("created_at", { withTimezone: true })
4215 .defaultNow()
4216 .notNull(),
4217 updatedAt: timestamp("updated_at", { withTimezone: true })
4218 .defaultNow()
4219 .notNull(),
4220 },
4221 (table) => [
4222 index("idx_incidents_started_at").on(table.startedAt),
4223 index("idx_incidents_status").on(table.status),
4224 ]
4225);
4226
4227export type Incident = typeof incidents.$inferSelect;
4228export type NewIncident = typeof incidents.$inferInsert;
4229
4230export const statusSubscribers = pgTable(
4231 "status_subscribers",
4232 {
4233 id: uuid("id").primaryKey().defaultRandom(),
4234 email: text("email").notNull().unique(),
4235 confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
4236 confirmToken: text("confirm_token").unique(),
4237 unsubscribeToken: text("unsubscribe_token").unique(),
4238 createdAt: timestamp("created_at", { withTimezone: true })
4239 .defaultNow()
4240 .notNull(),
4241 },
4242 (table) => [
4243 index("idx_status_subscribers_email").on(table.email),
4244 ]
4245);
4246
4247export type StatusSubscriber = typeof statusSubscribers.$inferSelect;
4248export type NewStatusSubscriber = typeof statusSubscribers.$inferInsert;
90c7531Claude4249export type NewClaudeWebMessage = typeof claudeWebMessages.$inferInsert;
9f29b65Claude4250
4251// ---------------------------------------------------------------------------
1df50d5Claude4252// Enterprise leads (contact form submissions from /enterprise)
9f29b65Claude4253// ---------------------------------------------------------------------------
4254
4255export const enterpriseLeads = pgTable(
4256 "enterprise_leads",
4257 {
4258 id: uuid("id").primaryKey().defaultRandom(),
4259 name: text("name").notNull(),
4260 company: text("company").notNull(),
4261 email: text("email").notNull(),
4262 teamSize: text("team_size").notNull(),
4263 message: text("message"),
4264 ip: text("ip"),
4265 createdAt: timestamp("created_at").defaultNow().notNull(),
4266 },
4267 (table) => [index("enterprise_leads_created").on(table.createdAt)]
4268);
4269
4270export type EnterpriseLead = typeof enterpriseLeads.$inferSelect;
4271export type NewEnterpriseLead = typeof enterpriseLeads.$inferInsert;
1df50d5Claude4272
4273// ---------------------------------------------------------------------------
4274// PR preview builder — one row per (pr_id, head_sha)
4275// ---------------------------------------------------------------------------
4276export const prPreviews = pgTable(
4277 "pr_previews",
4278 {
4279 id: serial("id").primaryKey(),
4280 repoId: uuid("repo_id")
4281 .notNull()
4282 .references(() => repositories.id, { onDelete: "cascade" }),
4283 prId: uuid("pr_id")
4284 .notNull()
4285 .references(() => pullRequests.id, { onDelete: "cascade" }),
4286 branchName: text("branch_name").notNull(),
4287 headSha: text("head_sha").notNull(),
4288 status: text("status").notNull().default("building"),
4289 buildLog: text("build_log"),
4290 previewUrl: text("preview_url"),
4291 buildCommand: text("build_command"),
4292 outputDir: text("output_dir").default("dist"),
4293 buildDurationMs: integer("build_duration_ms"),
4294 createdAt: timestamp("created_at").defaultNow(),
4295 updatedAt: timestamp("updated_at").defaultNow(),
4296 },
4297 (table) => [
4298 index("pr_previews_pr_id_idx").on(table.prId),
4299 index("pr_previews_repo_id_idx").on(table.repoId),
4300 ]
4301);
4302
4303export type PrPreview = typeof prPreviews.$inferSelect;
4304export type NewPrPreview = typeof prPreviews.$inferInsert;
3a845e4Claude4305
4306// ----------------------------------------------------------------------------
4307// Migration 0092 — Enterprise SSO (SAML 2.0 + OIDC per-org) + SCIM
4308// ----------------------------------------------------------------------------
4309
4310/**
4311 * Per-org SSO configuration. One row per org. Supports both SAML 2.0 and OIDC.
4312 * Distinct from the site-wide `sso_config` table (Block I10 / OIDC-only).
4313 */
4314export const orgSsoConfigs = pgTable("org_sso_configs", {
4315 id: uuid("id").primaryKey().defaultRandom(),
4316 orgId: uuid("org_id")
4317 .notNull()
4318 .unique()
4319 .references(() => organizations.id, { onDelete: "cascade" }),
4320 provider: text("provider").notNull().default("saml"), // 'saml' | 'oidc'
4321 // SAML fields
4322 idpEntityId: text("idp_entity_id"),
4323 idpSsoUrl: text("idp_sso_url"),
4324 idpCertificate: text("idp_certificate"), // PEM cert from IdP
4325 spEntityId: text("sp_entity_id"), // our SP entity ID
4326 // OIDC fields
4327 oidcClientId: text("oidc_client_id"),
4328 oidcClientSecret: text("oidc_client_secret"),
4329 oidcDiscoveryUrl: text("oidc_discovery_url"),
4330 // Common
4331 domainHint: text("domain_hint"), // e.g. "acme.com" — auto-routes users from this domain
4332 attributeMapping: jsonb("attribute_mapping")
4333 .$type<Record<string, string>>()
4334 .default({ email: "email", name: "name", username: "preferred_username" }),
4335 enabled: boolean("enabled").notNull().default(false),
4336 createdAt: timestamp("created_at").defaultNow(),
4337 updatedAt: timestamp("updated_at").defaultNow(),
4338});
4339
4340/**
4341 * SCIM provisioning tokens. Hashed SHA-256 bearer tokens issued per-org.
4342 * Identity providers (Okta, Azure AD) use these to provision/deprovision users.
4343 */
4344export const scimTokens = pgTable("scim_tokens", {
4345 id: uuid("id").primaryKey().defaultRandom(),
4346 orgId: uuid("org_id")
4347 .notNull()
4348 .references(() => organizations.id, { onDelete: "cascade" }),
4349 tokenHash: text("token_hash").notNull().unique(), // SHA-256 of the token
4350 createdBy: uuid("created_by")
4351 .notNull()
4352 .references(() => users.id),
4353 createdAt: timestamp("created_at").defaultNow(),
4354 lastUsedAt: timestamp("last_used_at"),
4355});
4356
4357/**
4358 * Tracks active IdP-initiated SSO sessions per org. Allows per-org
4359 * SLO (Single Logout) and session audit.
4360 */
4361export const orgSsoSessions = pgTable(
4362 "org_sso_sessions",
4363 {
4364 id: uuid("id").primaryKey().defaultRandom(),
4365 userId: uuid("user_id")
4366 .notNull()
4367 .references(() => users.id, { onDelete: "cascade" }),
4368 orgId: uuid("org_id")
4369 .notNull()
4370 .references(() => organizations.id, { onDelete: "cascade" }),
4371 idpSessionId: text("idp_session_id"),
4372 createdAt: timestamp("created_at").defaultNow(),
4373 expiresAt: timestamp("expires_at").notNull(),
4374 },
4375 (table) => [
4376 index("org_sso_sessions_user").on(table.userId),
4377 index("org_sso_sessions_expires").on(table.expiresAt),
4378 ]
4379);
4380
4381export type OrgSsoConfig = typeof orgSsoConfigs.$inferSelect;
4382export type NewOrgSsoConfig = typeof orgSsoConfigs.$inferInsert;
4383export type ScimToken = typeof scimTokens.$inferSelect;
4384export type OrgSsoSession = typeof orgSsoSessions.$inferSelect;
70e3293Claude4385
b271465Claude4386// Migration 0077 — Incident hook configs
4387// Maps a monitoring provider (PagerDuty / Datadog / Opsgenie / generic) to a
4388// specific repo. Inbound webhooks are validated against secret_hash (SHA-256
4389// of the user-chosen webhook secret passed in the ?secret= query param).
4390// ---------------------------------------------------------------------------
4391export const incidentHookConfigs = pgTable(
4392 "incident_hook_configs",
4393 {
4394 id: uuid("id").primaryKey().defaultRandom(),
4395 userId: uuid("user_id")
4396 .notNull()
4397 .references(() => users.id, { onDelete: "cascade" }),
70e3293Claude4398 repoId: uuid("repo_id")
4399 .notNull()
4400 .references(() => repositories.id, { onDelete: "cascade" }),
4401 /** 'pagerduty' | 'datadog' | 'opsgenie' | 'generic' */
4402 provider: text("provider").notNull(),
4403 /** SHA-256 hex of the user's plaintext webhook secret. */
4404 secretHash: text("secret_hash").notNull(),
4405 createdAt: timestamp("created_at").defaultNow(),
4406 },
4407 (table) => [
4408 uniqueIndex("incident_hook_configs_repo_provider").on(
4409 table.repoId,
4410 table.provider
4411 ),
4412 ]
4413);
4414
4415export type IncidentHookConfig = typeof incidentHookConfigs.$inferSelect;
4416export type NewIncidentHookConfig = typeof incidentHookConfigs.$inferInsert;
9c888c3Claude4417
4418
c9ed210Claude4419
4420/**
4421 * Cloud deploy configurations — per-repo settings for push-triggered deploys
4422 * to Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook URL.
4423 * Migration 0077.
4424 */
4425export const cloudDeployConfigs = pgTable(
4426 "cloud_deploy_configs",
4427 {
4428 id: uuid("id").primaryKey().defaultRandom(),
4429 repoId: uuid("repo_id")
4430 .notNull()
4431 .references(() => repositories.id, { onDelete: "cascade" }),
4432 // 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
4433 provider: text("provider").notNull(),
4434 // Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
4435 providerAppId: text("provider_app_id").notNull(),
4436 // AES-256-GCM encrypted via SERVER_TARGETS_KEY
4437 apiTokenEncrypted: text("api_token_encrypted").notNull(),
4438 triggerBranch: text("trigger_branch").notNull().default("main"),
4439 enabled: boolean("enabled").notNull().default(true),
4440 createdAt: timestamp("created_at").defaultNow(),
4441 },
4442 (table) => [index("cloud_deploy_configs_repo").on(table.repoId)]
4443);
4444
4445/**
4446 * Cloud deployment runs — one row per triggered deployment attempt.
4447 * Status transitions: pending -> running -> success | failed | cancelled.
4448 * Migration 0077.
4449 */
4450export const cloudDeployments = pgTable(
4451 "cloud_deployments",
4452 {
4453 id: uuid("id").primaryKey().defaultRandom(),
4454 configId: uuid("config_id")
4455 .notNull()
4456 .references(() => cloudDeployConfigs.id, { onDelete: "cascade" }),
4457 repoId: uuid("repo_id")
4458 .notNull()
4459 .references(() => repositories.id, { onDelete: "cascade" }),
4460 commitSha: text("commit_sha").notNull(),
4461 // pending | running | success | failed | cancelled
4462 status: text("status").notNull().default("pending"),
4463 providerDeployId: text("provider_deploy_id"),
4464 logUrl: text("log_url"),
4465 deployUrl: text("deploy_url"),
4466 errorMessage: text("error_message"),
4467 startedAt: timestamp("started_at").defaultNow(),
4468 completedAt: timestamp("completed_at"),
4469 durationMs: integer("duration_ms"),
4470 },
4471 (table) => [
4472 index("cloud_deployments_repo").on(table.repoId, table.startedAt),
4473 index("cloud_deployments_config").on(table.configId, table.startedAt),
4474 ]
4475);
4476
4477export type CloudDeployConfig = typeof cloudDeployConfigs.$inferSelect;
4478export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
4479export type CloudDeployment = typeof cloudDeployments.$inferSelect;
4480export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
34e63b9Claude4481// ---------------------------------------------------------------------------
4482// Recurring pattern detection (migration 0088)
4483// ---------------------------------------------------------------------------
4484
4485export const recurringPatterns = pgTable(
4486 "recurring_patterns",
4487 {
4488 id: uuid("id").primaryKey().defaultRandom(),
4489 repositoryId: uuid("repository_id")
4490 .notNull()
4491 .references(() => repositories.id, { onDelete: "cascade" }),
4492 title: text("title").notNull(),
4493 occurrences: integer("occurrences").notNull().default(1),
4494 commitShas: jsonb("commit_shas").$type<string[]>().notNull().default([]),
4495 rootCauseHypothesis: text("root_cause_hypothesis"),
4496 suggestedFile: text("suggested_file"),
4497 severity: text("severity").notNull().default("medium"),
4498 detectedAt: timestamp("detected_at", { withTimezone: true }).notNull().defaultNow(),
4499 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
4500 },
4501 (table) => [
4502 index("idx_recurring_patterns_repo").on(table.repositoryId),
4503 index("idx_recurring_patterns_expires").on(table.expiresAt),
4504 ]
4505);
4506
4507export type RecurringPattern = typeof recurringPatterns.$inferSelect;
4508export type NewRecurringPattern = typeof recurringPatterns.$inferInsert;
1d6db4dClaude4509// ---------------------------------------------------------------------------
4510// Bus Factor Cache — migration 0088
4511// Stores per-repo knowledge concentration analysis (at-risk files where one
4512// author owns >75% of commits). Refreshed on demand; 7-day soft TTL.
4513// ---------------------------------------------------------------------------
4514export const busFactorCache = pgTable("bus_factor_cache", {
4515 id: uuid("id").primaryKey().defaultRandom(),
4516 repositoryId: uuid("repository_id")
4517 .notNull()
4518 .references(() => repositories.id, { onDelete: "cascade" })
4519 .unique(),
4520 analyzedAt: timestamp("analyzed_at", { withTimezone: true })
4521 .notNull()
4522 .defaultNow(),
4523 atRiskFiles: jsonb("at_risk_files").notNull().default([]),
4524 totalFilesAnalyzed: integer("total_files_analyzed").notNull().default(0),
4525});
cc34156Claude4526// ---------------------------------------------------------------------------
4527// Migration 0088 — PR visit tracking for context-restore feature
4528// ---------------------------------------------------------------------------
4529
4530/**
4531 * pr_visits — lightweight upsert table tracking each user's last visit to a
4532 * PR. Used by `src/lib/review-context.ts` to compute what changed since the
4533 * reviewer was last here and generate a "Welcome back" context banner.
4534 */
4535export const prVisits = pgTable(
4536 "pr_visits",
4537 {
4538 prId: uuid("pr_id")
4539 .notNull()
4540 .references(() => pullRequests.id, { onDelete: "cascade" }),
4541 userId: uuid("user_id")
4542 .notNull()
4543 .references(() => users.id, { onDelete: "cascade" }),
4544 visitedAt: timestamp("visited_at", { withTimezone: true }).notNull().defaultNow(),
4545 },
4546 (t) => [primaryKey({ columns: [t.prId, t.userId] })]
4547);
4548
4549export type PrVisit = typeof prVisits.$inferSelect;
641aa42Claude4550// ---------------------------------------------------------------------------
4551// Migration 0088 — Smart empty states: repo onboarding data
4552// Generated by generateRepoOnboarding() on first push to a repo.
4553// ---------------------------------------------------------------------------
4554export const repoOnboardingData = pgTable("repo_onboarding_data", {
4555 repositoryId: uuid("repository_id")
4556 .primaryKey()
4557 .references(() => repositories.id, { onDelete: "cascade" }),
4558 detectedLanguage: text("detected_language"),
4559 detectedFramework: text("detected_framework"),
4560 suggestedReadme: text("suggested_readme"),
4561 suggestedLabels: jsonb("suggested_labels")
4562 .notNull()
4563 .default([])
4564 .$type<Array<{ name: string; color: string; description: string }>>(),
4565 suggestedGatesConfig: text("suggested_gates_config"),
4566 firstCommitSuggestions: jsonb("first_commit_suggestions")
4567 .notNull()
4568 .default([])
4569 .$type<string[]>(),
4570 generatedAt: timestamp("generated_at", { withTimezone: true })
4571 .notNull()
4572 .defaultNow(),
4573});
4574
4575export type RepoOnboardingData = typeof repoOnboardingData.$inferSelect;
4576export type NewRepoOnboardingData = typeof repoOnboardingData.$inferInsert;
6ecda8fClaude4577
4578// ---------------------------------------------------------------------------
4579// Migration 0102 — Cross-repo impact cache (15-min TTL, keyed on PR id)
4580// ---------------------------------------------------------------------------
4581export const crossRepoImpactCache = pgTable(
4582 "cross_repo_impact_cache",
4583 {
4584 id: uuid("id").primaryKey().defaultRandom(),
4585 prId: uuid("pr_id")
4586 .notNull()
4587 .references(() => pullRequests.id, { onDelete: "cascade" }),
4588 report: jsonb("report").notNull(),
4589 analyzedAt: timestamp("analyzed_at").defaultNow(),
4590 cachedUntil: timestamp("cached_until").notNull(),
4591 },
4592 (table) => [
4593 index("idx_cross_repo_impact_pr").on(table.prId),
4594 ]
4595);
4596
4597export type CrossRepoImpactCache = typeof crossRepoImpactCache.$inferSelect;
77cf834Claude4598
4599// ---------------------------------------------------------------------------
4600// Migration 0104 — Repository health score cache (6h TTL, in-memory + DB)
4601// ---------------------------------------------------------------------------
4602export const repoHealthCache = pgTable(
4603 "repo_health_cache",
4604 {
4605 id: uuid("id").primaryKey().defaultRandom(),
4606 repoId: uuid("repo_id")
4607 .notNull()
4608 .unique()
4609 .references(() => repositories.id, { onDelete: "cascade" }),
4610 score: integer("score").notNull(),
4611 breakdown: jsonb("breakdown").notNull(),
4612 computedAt: timestamp("computed_at").defaultNow(),
4613 expiresAt: timestamp("expires_at").notNull(),
4614 },
4615 (table) => [
4616 index("idx_repo_health_cache_repo").on(table.repoId),
4617 ]
4618);
4619
4620export type RepoHealthCache = typeof repoHealthCache.$inferSelect;
4621
4622// ---------------------------------------------------------------------------
4623// Migration 0103 — Workspace jobs (AI Copilot Workspace: issue → PR agent)
4624// Hot path is in-memory; this table is for auditability + restart recovery.
4625// ---------------------------------------------------------------------------
4626export const workspaceJobs = pgTable(
4627 "workspace_jobs",
4628 {
4629 id: uuid("id").primaryKey().defaultRandom(),
4630 repoId: uuid("repo_id")
4631 .notNull()
4632 .references(() => repositories.id, { onDelete: "cascade" }),
4633 issueId: uuid("issue_id")
4634 .notNull()
4635 .references(() => issues.id, { onDelete: "cascade" }),
4636 triggeredBy: uuid("triggered_by").references(() => users.id),
4637 status: text("status").notNull().default("pending"),
4638 planComment: text("plan_comment"),
4639 branchName: text("branch_name"),
4640 prNumber: integer("pr_number"),
4641 errorMessage: text("error_message"),
4642 startedAt: timestamp("started_at").defaultNow(),
4643 updatedAt: timestamp("updated_at").defaultNow(),
4644 },
4645 (table) => [
4646 index("idx_workspace_jobs_repo").on(table.repoId),
4647 index("idx_workspace_jobs_issue").on(table.issueId),
4648 ]
4649);
4650
4651export type WorkspaceJob = typeof workspaceJobs.$inferSelect;
53299fbClaude4652
4653// ---------------------------------------------------------------------------
4654// Migration 0105 — Test gap cache (2h TTL, keyed on repo_id)
4655// Stores AI-generated test gap analysis: untested source files ranked by risk.
4656// ---------------------------------------------------------------------------
4657export const testGapCache = pgTable(
4658 "test_gap_cache",
4659 {
4660 id: uuid("id").primaryKey().defaultRandom(),
4661 repoId: uuid("repo_id")
4662 .notNull()
4663 .unique()
4664 .references(() => repositories.id, { onDelete: "cascade" }),
4665 report: jsonb("report").notNull(),
4666 analyzedAt: timestamp("analyzed_at").defaultNow(),
4667 expiresAt: timestamp("expires_at").notNull(),
4668 },
4669 (table) => [
4670 index("idx_test_gap_cache_repo").on(table.repoId),
4671 ]
4672);
4673
4674export type TestGapCache = typeof testGapCache.$inferSelect;