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

schema.ts

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

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