Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

schema.ts

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

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