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

schema.ts

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

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