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

schema.ts

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

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