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