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