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