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