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

schema.ts

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

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