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