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