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