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.tsBlame2583 lines · 1 contributor
fc1817aClaude1import {
2 pgTable,
3 text,
4 timestamp,
5 uuid,
6 boolean,
7 integer,
8 uniqueIndex,
79136bbClaude9 index,
10 serial,
abfa9adClaude11 bigint,
12 jsonb,
13 customType,
fc1817aClaude14} from "drizzle-orm/pg-core";
15
abfa9adClaude16// Postgres `bytea` — drizzle-orm doesn't ship a first-class bytea column, so
17// we declare one via customType that maps to Buffer on both read and write.
18// Used by `workflow_run_cache.content` where we really do need raw bytes
19// (unlike `workflow_artifacts.content`, which stayed base64-text for v1).
20const bytea = customType<{ data: Buffer; default: false }>({
21 dataType() {
22 return "bytea";
23 },
24});
25
fc1817aClaude26export const users = pgTable("users", {
27 id: uuid("id").primaryKey().defaultRandom(),
28 username: text("username").notNull().unique(),
29 email: text("email").notNull().unique(),
30 displayName: text("display_name"),
31 passwordHash: text("password_hash").notNull(),
32 avatarUrl: text("avatar_url"),
33 bio: text("bio"),
24cf2caClaude34 // Email notification preferences (Block A8). Default on; opt-out via /settings.
35 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
36 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
37 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
08420cdClaude38 // Block I7 — weekly digest opt-in.
39 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
40 lastDigestSentAt: timestamp("last_digest_sent_at"),
a4f3e24Claude41 isAdmin: boolean("is_admin").default(false).notNull(),
fc1817aClaude42 createdAt: timestamp("created_at").defaultNow().notNull(),
43 updatedAt: timestamp("updated_at").defaultNow().notNull(),
44});
45
06d5ffeClaude46export const sessions = pgTable("sessions", {
47 id: uuid("id").primaryKey().defaultRandom(),
48 userId: uuid("user_id")
49 .notNull()
50 .references(() => users.id, { onDelete: "cascade" }),
51 token: text("token").notNull().unique(),
52 expiresAt: timestamp("expires_at").notNull(),
7298a17Claude53 // B4: true when the user has entered their password but not yet their TOTP
54 // code. softAuth/requireAuth treat such sessions as anonymous; only
55 // /login/2fa can consume them. Flips to false on successful 2FA.
56 requires2fa: boolean("requires_2fa").default(false).notNull(),
06d5ffeClaude57 createdAt: timestamp("created_at").defaultNow().notNull(),
58});
59
45e31d0Claude60// @ts-ignore — self-referential FK on forkedFromId causes circular inference
fc1817aClaude61export const repositories = pgTable(
62 "repositories",
63 {
64 id: uuid("id").primaryKey().defaultRandom(),
65 name: text("name").notNull(),
7437605Claude66 // ownerId = creator / user-owner. Always set (for attribution + user
67 // namespace uniqueness). For org-owned repos, also represents "created by".
fc1817aClaude68 ownerId: uuid("owner_id")
69 .notNull()
70 .references(() => users.id),
7437605Claude71 // Block B2: nullable org owner. When set, the repo lives in the org
72 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
73 orgId: uuid("org_id"),
fc1817aClaude74 description: text("description"),
75 isPrivate: boolean("is_private").default(false).notNull(),
3ef4c9dClaude76 isArchived: boolean("is_archived").default(false).notNull(),
71cd5ecClaude77 isTemplate: boolean("is_template").default(false).notNull(),
fc1817aClaude78 defaultBranch: text("default_branch").default("main").notNull(),
79 diskPath: text("disk_path").notNull(),
45e31d0Claude80 forkedFromId: uuid("forked_from_id"),
fc1817aClaude81 createdAt: timestamp("created_at").defaultNow().notNull(),
82 updatedAt: timestamp("updated_at").defaultNow().notNull(),
83 pushedAt: timestamp("pushed_at"),
84 starCount: integer("star_count").default(0).notNull(),
85 forkCount: integer("fork_count").default(0).notNull(),
79136bbClaude86 issueCount: integer("issue_count").default(0).notNull(),
fc1817aClaude87 },
7437605Claude88 (table) => [
89 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
90 // Matches the partial index in migration 0004.
91 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
92 index("repos_org").on(table.orgId),
93 ]
fc1817aClaude94);
95
3ef4c9dClaude96/**
97 * Per-repository gate + auto-repair configuration.
98 * Every new repo is created with all gates ENABLED by default —
99 * the "full green ecosystem" default. Owners can manually opt-out per setting.
100 */
101export const repoSettings = pgTable("repo_settings", {
102 id: uuid("id").primaryKey().defaultRandom(),
103 repositoryId: uuid("repository_id")
104 .notNull()
105 .unique()
106 .references(() => repositories.id, { onDelete: "cascade" }),
107 // Gates
108 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
109 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
110 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
111 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
112 dependencyScanEnabled: boolean("dependency_scan_enabled").default(true).notNull(),
113 lintEnabled: boolean("lint_enabled").default(true).notNull(),
114 typeCheckEnabled: boolean("type_check_enabled").default(true).notNull(),
115 testEnabled: boolean("test_enabled").default(true).notNull(),
116 // Auto-repair
117 autoFixEnabled: boolean("auto_fix_enabled").default(true).notNull(),
118 autoMergeResolveEnabled: boolean("auto_merge_resolve_enabled").default(true).notNull(),
119 autoFormatEnabled: boolean("auto_format_enabled").default(true).notNull(),
120 // AI features
121 aiCommitMessagesEnabled: boolean("ai_commit_messages_enabled").default(true).notNull(),
122 aiPrSummaryEnabled: boolean("ai_pr_summary_enabled").default(true).notNull(),
123 aiChangelogEnabled: boolean("ai_changelog_enabled").default(true).notNull(),
124 // Deploy
125 autoDeployEnabled: boolean("auto_deploy_enabled").default(true).notNull(),
126 deployRequireAllGreen: boolean("deploy_require_all_green").default(true).notNull(),
127 createdAt: timestamp("created_at").defaultNow().notNull(),
128 updatedAt: timestamp("updated_at").defaultNow().notNull(),
129});
130
131/**
132 * Branch protection rules — enforced on push and merge.
133 * Every repo's default branch gets a protection rule on creation.
134 */
135export const branchProtection = pgTable(
136 "branch_protection",
137 {
138 id: uuid("id").primaryKey().defaultRandom(),
139 repositoryId: uuid("repository_id")
140 .notNull()
141 .references(() => repositories.id, { onDelete: "cascade" }),
142 pattern: text("pattern").notNull(), // branch name or glob (e.g. "main", "release/*")
143 requirePullRequest: boolean("require_pull_request").default(true).notNull(),
144 requireGreenGates: boolean("require_green_gates").default(true).notNull(),
145 requireAiApproval: boolean("require_ai_approval").default(true).notNull(),
146 requireHumanReview: boolean("require_human_review").default(false).notNull(),
147 requiredApprovals: integer("required_approvals").default(0).notNull(),
148 allowForcePush: boolean("allow_force_push").default(false).notNull(),
149 allowDeletion: boolean("allow_deletion").default(false).notNull(),
150 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
151 createdAt: timestamp("created_at").defaultNow().notNull(),
152 updatedAt: timestamp("updated_at").defaultNow().notNull(),
153 },
154 (table) => [
155 uniqueIndex("branch_protection_repo_pattern").on(
156 table.repositoryId,
157 table.pattern
158 ),
159 ]
160);
161
162/**
163 * Gate run history. Every push + every PR creates gate_runs entries —
164 * one per configured gate. Serves as the source of truth for "is this green?".
165 */
166export const gateRuns = pgTable(
167 "gate_runs",
168 {
169 id: uuid("id").primaryKey().defaultRandom(),
170 repositoryId: uuid("repository_id")
171 .notNull()
172 .references(() => repositories.id, { onDelete: "cascade" }),
173 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
174 onDelete: "cascade",
175 }),
176 commitSha: text("commit_sha").notNull(),
177 ref: text("ref").notNull(),
178 gateName: text("gate_name").notNull(), // e.g. "GateTest", "AI Review", "Secret Scan", "Type Check"
179 status: text("status").notNull(), // pending, running, passed, failed, skipped, repaired
180 summary: text("summary"),
181 details: text("details"), // JSON: per-check output, affected files, etc
182 repairAttempted: boolean("repair_attempted").default(false).notNull(),
183 repairSucceeded: boolean("repair_succeeded").default(false).notNull(),
184 repairCommitSha: text("repair_commit_sha"),
185 durationMs: integer("duration_ms"),
186 createdAt: timestamp("created_at").defaultNow().notNull(),
187 completedAt: timestamp("completed_at"),
188 },
189 (table) => [
190 index("gate_runs_repo_sha").on(table.repositoryId, table.commitSha),
191 index("gate_runs_pr").on(table.pullRequestId),
192 index("gate_runs_created").on(table.createdAt),
193 ]
194);
195
196/**
197 * In-app notifications. Powered by the activity feed + explicit mentions.
198 */
199export const notifications = pgTable(
200 "notifications",
201 {
202 id: uuid("id").primaryKey().defaultRandom(),
203 userId: uuid("user_id")
204 .notNull()
205 .references(() => users.id, { onDelete: "cascade" }),
206 repositoryId: uuid("repository_id").references(() => repositories.id, {
207 onDelete: "cascade",
208 }),
209 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
210 title: text("title").notNull(),
211 body: text("body"),
212 url: text("url"), // link to the relevant page
213 readAt: timestamp("read_at"),
214 createdAt: timestamp("created_at").defaultNow().notNull(),
215 },
216 (table) => [
217 index("notifications_user_unread").on(table.userId, table.readAt),
218 index("notifications_user_created").on(table.userId, table.createdAt),
219 ]
220);
221
222/**
223 * Releases — named snapshots of a repo at a tag/commit.
224 * AI-generated changelogs bundled in notes field.
225 */
226export const releases = pgTable(
227 "releases",
228 {
229 id: uuid("id").primaryKey().defaultRandom(),
230 repositoryId: uuid("repository_id")
231 .notNull()
232 .references(() => repositories.id, { onDelete: "cascade" }),
233 authorId: uuid("author_id")
234 .notNull()
235 .references(() => users.id),
236 tag: text("tag").notNull(),
237 name: text("name").notNull(),
238 body: text("body"), // AI-generated release notes + changelog
239 targetCommit: text("target_commit").notNull(),
240 isDraft: boolean("is_draft").default(false).notNull(),
241 isPrerelease: boolean("is_prerelease").default(false).notNull(),
242 createdAt: timestamp("created_at").defaultNow().notNull(),
243 publishedAt: timestamp("published_at"),
244 },
245 (table) => [
246 uniqueIndex("releases_repo_tag").on(table.repositoryId, table.tag),
247 ]
248);
249
250/**
251 * Milestones — group issues + PRs toward a shared goal.
252 */
253export const milestones = pgTable(
254 "milestones",
255 {
256 id: uuid("id").primaryKey().defaultRandom(),
257 repositoryId: uuid("repository_id")
258 .notNull()
259 .references(() => repositories.id, { onDelete: "cascade" }),
260 title: text("title").notNull(),
261 description: text("description"),
262 state: text("state").notNull().default("open"), // open, closed
263 dueDate: timestamp("due_date"),
264 createdAt: timestamp("created_at").defaultNow().notNull(),
265 closedAt: timestamp("closed_at"),
266 },
267 (table) => [index("milestones_repo_state").on(table.repositoryId, table.state)]
268);
269
270/**
271 * Reactions on issues, PRs, and comments. Universal target pointer.
272 */
273export const reactions = pgTable(
274 "reactions",
275 {
276 id: uuid("id").primaryKey().defaultRandom(),
277 userId: uuid("user_id")
278 .notNull()
279 .references(() => users.id, { onDelete: "cascade" }),
280 targetType: text("target_type").notNull(), // issue, pr, issue_comment, pr_comment
281 targetId: uuid("target_id").notNull(),
282 emoji: text("emoji").notNull(), // thumbs_up, thumbs_down, rocket, heart, eyes, laugh, hooray, confused
283 createdAt: timestamp("created_at").defaultNow().notNull(),
284 },
285 (table) => [
286 uniqueIndex("reactions_unique").on(
287 table.userId,
288 table.targetType,
289 table.targetId,
290 table.emoji
291 ),
292 index("reactions_target").on(table.targetType, table.targetId),
293 ]
294);
295
296/**
297 * PR reviews (formal approve/request-changes).
298 * Separate from inline comments in pr_comments.
299 */
300export const prReviews = pgTable(
301 "pr_reviews",
302 {
303 id: uuid("id").primaryKey().defaultRandom(),
304 pullRequestId: uuid("pull_request_id")
305 .notNull()
306 .references(() => pullRequests.id, { onDelete: "cascade" }),
307 reviewerId: uuid("reviewer_id")
308 .notNull()
309 .references(() => users.id),
310 state: text("state").notNull(), // approved, changes_requested, commented
311 body: text("body"),
312 isAi: boolean("is_ai").default(false).notNull(),
313 createdAt: timestamp("created_at").defaultNow().notNull(),
314 },
315 (table) => [index("pr_reviews_pr").on(table.pullRequestId)]
316);
317
318/**
319 * Code owners — who owns which paths (auto-request review on PR).
320 * Parsed from a CODEOWNERS file at the root of the default branch.
321 */
322export const codeOwners = pgTable(
323 "code_owners",
324 {
325 id: uuid("id").primaryKey().defaultRandom(),
326 repositoryId: uuid("repository_id")
327 .notNull()
328 .references(() => repositories.id, { onDelete: "cascade" }),
329 pathPattern: text("path_pattern").notNull(),
330 ownerUsernames: text("owner_usernames").notNull(), // comma-separated
331 createdAt: timestamp("created_at").defaultNow().notNull(),
332 },
333 (table) => [index("code_owners_repo").on(table.repositoryId)]
334);
335
336/**
337 * Per-repo AI chat sessions — conversational repo assistant.
338 */
339export const aiChats = pgTable(
340 "ai_chats",
341 {
342 id: uuid("id").primaryKey().defaultRandom(),
343 userId: uuid("user_id")
344 .notNull()
345 .references(() => users.id, { onDelete: "cascade" }),
346 repositoryId: uuid("repository_id").references(() => repositories.id, {
347 onDelete: "cascade",
348 }),
349 title: text("title"),
350 messages: text("messages").notNull().default("[]"), // JSON array of {role, content}
351 createdAt: timestamp("created_at").defaultNow().notNull(),
352 updatedAt: timestamp("updated_at").defaultNow().notNull(),
353 },
354 (table) => [
355 index("ai_chats_user").on(table.userId),
356 index("ai_chats_repo").on(table.repositoryId),
357 ]
358);
359
360/**
361 * Audit log — every sensitive action. Who did what, when, from where.
362 */
363export const auditLog = pgTable(
364 "audit_log",
365 {
366 id: uuid("id").primaryKey().defaultRandom(),
367 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
368 repositoryId: uuid("repository_id").references(() => repositories.id, {
369 onDelete: "set null",
370 }),
371 action: text("action").notNull(), // repo.create, repo.delete, repo.transfer, token.create, token.revoke, merge, force_push, branch_protection.update, deploy, ...
372 targetType: text("target_type"),
373 targetId: text("target_id"),
374 ip: text("ip"),
375 userAgent: text("user_agent"),
376 metadata: text("metadata"), // JSON for extra context
377 createdAt: timestamp("created_at").defaultNow().notNull(),
378 },
379 (table) => [
380 index("audit_log_user").on(table.userId),
381 index("audit_log_repo").on(table.repositoryId),
382 index("audit_log_created").on(table.createdAt),
383 ]
384);
385
386/**
387 * Deployments — tracks every deploy to downstream systems (Crontech, etc).
388 * Each deploy is gated on ALL green gates passing.
389 */
390export const deployments = pgTable(
391 "deployments",
392 {
393 id: uuid("id").primaryKey().defaultRandom(),
394 repositoryId: uuid("repository_id")
395 .notNull()
396 .references(() => repositories.id, { onDelete: "cascade" }),
397 environment: text("environment").notNull().default("production"),
398 commitSha: text("commit_sha").notNull(),
399 ref: text("ref").notNull(),
a76d984Claude400 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
3ef4c9dClaude401 blockedReason: text("blocked_reason"),
402 target: text("target"), // e.g. "crontech", "fly.io"
403 triggeredBy: uuid("triggered_by").references(() => users.id),
a76d984Claude404 /**
405 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
406 * NULL = no wait; non-null = autopilot flips status from "waiting_timer"
407 * to "pending" once `now() >= ready_after`.
408 */
409 readyAfter: timestamp("ready_after"),
3ef4c9dClaude410 createdAt: timestamp("created_at").defaultNow().notNull(),
411 completedAt: timestamp("completed_at"),
412 },
413 (table) => [
414 index("deployments_repo").on(table.repositoryId),
415 index("deployments_created").on(table.createdAt),
416 ]
417);
418
419/**
420 * Rate-limit buckets — in-memory or persisted counter per IP / token / route.
421 */
422export const rateLimitBuckets = pgTable(
423 "rate_limit_buckets",
424 {
425 id: uuid("id").primaryKey().defaultRandom(),
426 bucketKey: text("bucket_key").notNull().unique(), // "ip:1.2.3.4:api" or "token:abc:api"
427 count: integer("count").default(0).notNull(),
428 windowStart: timestamp("window_start").defaultNow().notNull(),
429 expiresAt: timestamp("expires_at").notNull(),
430 },
431 (table) => [index("rate_limit_expires").on(table.expiresAt)]
fc1817aClaude432);
433
06d5ffeClaude434export const stars = pgTable(
435 "stars",
436 {
437 id: uuid("id").primaryKey().defaultRandom(),
438 userId: uuid("user_id")
439 .notNull()
440 .references(() => users.id, { onDelete: "cascade" }),
441 repositoryId: uuid("repository_id")
442 .notNull()
443 .references(() => repositories.id, { onDelete: "cascade" }),
444 createdAt: timestamp("created_at").defaultNow().notNull(),
445 },
79136bbClaude446 (table) => [
447 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
448 ]
449);
450
451export const issues = pgTable(
452 "issues",
453 {
454 id: uuid("id").primaryKey().defaultRandom(),
455 number: serial("number"),
456 repositoryId: uuid("repository_id")
457 .notNull()
458 .references(() => repositories.id, { onDelete: "cascade" }),
459 authorId: uuid("author_id")
460 .notNull()
461 .references(() => users.id),
462 title: text("title").notNull(),
463 body: text("body"),
464 state: text("state").notNull().default("open"), // open, closed
465 createdAt: timestamp("created_at").defaultNow().notNull(),
466 updatedAt: timestamp("updated_at").defaultNow().notNull(),
467 closedAt: timestamp("closed_at"),
468 },
469 (table) => [
470 index("issues_repo_state").on(table.repositoryId, table.state),
471 index("issues_repo_number").on(table.repositoryId, table.number),
472 ]
473);
474
475export const issueComments = pgTable(
476 "issue_comments",
477 {
478 id: uuid("id").primaryKey().defaultRandom(),
479 issueId: uuid("issue_id")
480 .notNull()
481 .references(() => issues.id, { onDelete: "cascade" }),
482 authorId: uuid("author_id")
483 .notNull()
484 .references(() => users.id),
485 body: text("body").notNull(),
486 createdAt: timestamp("created_at").defaultNow().notNull(),
487 updatedAt: timestamp("updated_at").defaultNow().notNull(),
488 },
489 (table) => [index("comments_issue").on(table.issueId)]
490);
491
492export const labels = pgTable(
493 "labels",
494 {
495 id: uuid("id").primaryKey().defaultRandom(),
496 repositoryId: uuid("repository_id")
497 .notNull()
498 .references(() => repositories.id, { onDelete: "cascade" }),
499 name: text("name").notNull(),
500 color: text("color").notNull().default("#8b949e"),
501 description: text("description"),
502 },
503 (table) => [
504 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
505 ]
506);
507
508export const issueLabels = pgTable(
509 "issue_labels",
510 {
511 id: uuid("id").primaryKey().defaultRandom(),
512 issueId: uuid("issue_id")
513 .notNull()
514 .references(() => issues.id, { onDelete: "cascade" }),
515 labelId: uuid("label_id")
516 .notNull()
517 .references(() => labels.id, { onDelete: "cascade" }),
518 },
519 (table) => [
520 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
521 ]
06d5ffeClaude522);
523
0074234Claude524export const pullRequests = pgTable(
525 "pull_requests",
526 {
527 id: uuid("id").primaryKey().defaultRandom(),
528 number: serial("number"),
529 repositoryId: uuid("repository_id")
530 .notNull()
531 .references(() => repositories.id, { onDelete: "cascade" }),
532 authorId: uuid("author_id")
533 .notNull()
534 .references(() => users.id),
535 title: text("title").notNull(),
536 body: text("body"),
537 state: text("state").notNull().default("open"), // open, closed, merged
538 baseBranch: text("base_branch").notNull(),
539 headBranch: text("head_branch").notNull(),
3ef4c9dClaude540 isDraft: boolean("is_draft").default(false).notNull(),
541 mergeStrategy: text("merge_strategy").default("merge").notNull(), // merge, squash, rebase
542 milestoneId: uuid("milestone_id"),
0074234Claude543 mergedAt: timestamp("merged_at"),
544 mergedBy: uuid("merged_by").references(() => users.id),
545 createdAt: timestamp("created_at").defaultNow().notNull(),
546 updatedAt: timestamp("updated_at").defaultNow().notNull(),
547 closedAt: timestamp("closed_at"),
548 },
549 (table) => [
550 index("prs_repo_state").on(table.repositoryId, table.state),
551 index("prs_repo_number").on(table.repositoryId, table.number),
552 ]
553);
554
555export const prComments = pgTable(
556 "pr_comments",
557 {
558 id: uuid("id").primaryKey().defaultRandom(),
559 pullRequestId: uuid("pull_request_id")
560 .notNull()
561 .references(() => pullRequests.id, { onDelete: "cascade" }),
562 authorId: uuid("author_id")
563 .notNull()
564 .references(() => users.id),
565 body: text("body").notNull(),
566 isAiReview: boolean("is_ai_review").default(false).notNull(),
567 filePath: text("file_path"),
568 lineNumber: integer("line_number"),
569 createdAt: timestamp("created_at").defaultNow().notNull(),
570 updatedAt: timestamp("updated_at").defaultNow().notNull(),
571 },
572 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
573);
574
575export const activityFeed = pgTable(
576 "activity_feed",
577 {
578 id: uuid("id").primaryKey().defaultRandom(),
579 repositoryId: uuid("repository_id")
580 .notNull()
581 .references(() => repositories.id, { onDelete: "cascade" }),
582 userId: uuid("user_id").references(() => users.id),
583 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
584 targetType: text("target_type"), // issue, pr, commit
585 targetId: text("target_id"),
586 metadata: text("metadata"), // JSON string for extra data
587 createdAt: timestamp("created_at").defaultNow().notNull(),
588 },
589 (table) => [
590 index("activity_repo").on(table.repositoryId),
591 index("activity_user").on(table.userId),
592 ]
593);
594
c81ab7aClaude595export const webhooks = pgTable(
596 "webhooks",
597 {
598 id: uuid("id").primaryKey().defaultRandom(),
599 repositoryId: uuid("repository_id")
600 .notNull()
601 .references(() => repositories.id, { onDelete: "cascade" }),
602 url: text("url").notNull(),
603 secret: text("secret"),
604 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
605 isActive: boolean("is_active").default(true).notNull(),
606 lastDeliveredAt: timestamp("last_delivered_at"),
607 lastStatus: integer("last_status"),
608 createdAt: timestamp("created_at").defaultNow().notNull(),
609 },
610 (table) => [index("webhooks_repo").on(table.repositoryId)]
611);
612
613export const apiTokens = pgTable("api_tokens", {
614 id: uuid("id").primaryKey().defaultRandom(),
615 userId: uuid("user_id")
616 .notNull()
617 .references(() => users.id, { onDelete: "cascade" }),
618 name: text("name").notNull(),
619 tokenHash: text("token_hash").notNull(),
620 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
621 scopes: text("scopes").notNull().default("repo"), // comma-separated
622 lastUsedAt: timestamp("last_used_at"),
623 expiresAt: timestamp("expires_at"),
624 createdAt: timestamp("created_at").defaultNow().notNull(),
625});
626
627export const repoTopics = pgTable(
628 "repo_topics",
629 {
630 id: uuid("id").primaryKey().defaultRandom(),
631 repositoryId: uuid("repository_id")
632 .notNull()
633 .references(() => repositories.id, { onDelete: "cascade" }),
634 topic: text("topic").notNull(),
635 },
636 (table) => [
637 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
638 index("topics_name").on(table.topic),
639 ]
640);
641
fc1817aClaude642export const sshKeys = pgTable("ssh_keys", {
643 id: uuid("id").primaryKey().defaultRandom(),
644 userId: uuid("user_id")
645 .notNull()
06d5ffeClaude646 .references(() => users.id, { onDelete: "cascade" }),
fc1817aClaude647 title: text("title").notNull(),
648 fingerprint: text("fingerprint").notNull(),
649 publicKey: text("public_key").notNull(),
650 lastUsedAt: timestamp("last_used_at"),
651 createdAt: timestamp("created_at").defaultNow().notNull(),
652});
653
654export type User = typeof users.$inferSelect;
655export type NewUser = typeof users.$inferInsert;
656export type Repository = typeof repositories.$inferSelect;
657export type NewRepository = typeof repositories.$inferInsert;
06d5ffeClaude658export type Session = typeof sessions.$inferSelect;
659export type Star = typeof stars.$inferSelect;
660export type SshKey = typeof sshKeys.$inferSelect;
79136bbClaude661export type Issue = typeof issues.$inferSelect;
662export type IssueComment = typeof issueComments.$inferSelect;
663export type Label = typeof labels.$inferSelect;
0074234Claude664export type PullRequest = typeof pullRequests.$inferSelect;
665export type PrComment = typeof prComments.$inferSelect;
666export type ActivityEntry = typeof activityFeed.$inferSelect;
c81ab7aClaude667export type Webhook = typeof webhooks.$inferSelect;
668export type ApiToken = typeof apiTokens.$inferSelect;
669export type RepoTopic = typeof repoTopics.$inferSelect;
3ef4c9dClaude670export type RepoSettings = typeof repoSettings.$inferSelect;
671export type BranchProtection = typeof branchProtection.$inferSelect;
672export type GateRun = typeof gateRuns.$inferSelect;
673export type Notification = typeof notifications.$inferSelect;
674export type Release = typeof releases.$inferSelect;
675export type Milestone = typeof milestones.$inferSelect;
676export type Reaction = typeof reactions.$inferSelect;
677export type PrReview = typeof prReviews.$inferSelect;
678export type CodeOwner = typeof codeOwners.$inferSelect;
679export type AiChat = typeof aiChats.$inferSelect;
680export type AuditLogEntry = typeof auditLog.$inferSelect;
681export type Deployment = typeof deployments.$inferSelect;
24cf2caClaude682
683/**
684 * Saved replies — per-user canned responses, insertable into any
685 * issue / PR comment textarea. Shortcut name must be unique per user.
686 */
687export const savedReplies = pgTable(
688 "saved_replies",
689 {
690 id: uuid("id").primaryKey().defaultRandom(),
691 userId: uuid("user_id")
692 .notNull()
693 .references(() => users.id, { onDelete: "cascade" }),
694 shortcut: text("shortcut").notNull(),
695 body: text("body").notNull(),
696 createdAt: timestamp("created_at").defaultNow().notNull(),
697 updatedAt: timestamp("updated_at").defaultNow().notNull(),
698 },
699 (table) => [
700 uniqueIndex("saved_replies_user_shortcut").on(table.userId, table.shortcut),
701 ]
702);
703
704export type SavedReply = typeof savedReplies.$inferSelect;
5cc5d95Claude705
706/**
707 * Organizations (Block B1) — multi-user namespaces. Distinct from `users`.
708 * An org has members (with org-level roles) and may contain teams.
709 * Repos can be owned by an org via `repositories.orgId` (added in Block B2).
710 *
711 * Slug is globally unique against itself; collision with a username is
712 * checked at create time in the route handler (no DB-level cross-table
713 * uniqueness in Postgres).
714 */
715export const organizations = pgTable("organizations", {
716 id: uuid("id").primaryKey().defaultRandom(),
717 slug: text("slug").notNull().unique(),
718 name: text("name").notNull(),
719 description: text("description"),
720 avatarUrl: text("avatar_url"),
721 billingEmail: text("billing_email"),
722 createdById: uuid("created_by_id")
723 .notNull()
724 .references(() => users.id, { onDelete: "restrict" }),
725 createdAt: timestamp("created_at").defaultNow().notNull(),
726 updatedAt: timestamp("updated_at").defaultNow().notNull(),
727});
728
729/**
730 * Org membership. Roles: owner (full control, billing), admin (manage
731 * members + teams + repos), member (default; can be added to teams).
732 */
733export const orgMembers = pgTable(
734 "org_members",
735 {
736 id: uuid("id").primaryKey().defaultRandom(),
737 orgId: uuid("org_id")
738 .notNull()
739 .references(() => organizations.id, { onDelete: "cascade" }),
740 userId: uuid("user_id")
741 .notNull()
742 .references(() => users.id, { onDelete: "cascade" }),
743 role: text("role").notNull().default("member"), // owner | admin | member
744 createdAt: timestamp("created_at").defaultNow().notNull(),
745 },
746 (table) => [
747 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
748 index("org_members_user").on(table.userId),
749 ]
750);
751
752/**
753 * Teams within an org. Slug is unique within an org.
754 * `parentTeamId` allows nesting (GitHub-style child teams). Optional.
755 */
756export const teams = pgTable(
757 "teams",
758 {
759 id: uuid("id").primaryKey().defaultRandom(),
760 orgId: uuid("org_id")
761 .notNull()
762 .references(() => organizations.id, { onDelete: "cascade" }),
763 slug: text("slug").notNull(),
764 name: text("name").notNull(),
765 description: text("description"),
766 parentTeamId: uuid("parent_team_id"),
767 createdAt: timestamp("created_at").defaultNow().notNull(),
768 updatedAt: timestamp("updated_at").defaultNow().notNull(),
769 },
770 (table) => [uniqueIndex("teams_org_slug").on(table.orgId, table.slug)]
771);
772
773/**
774 * Team membership. Roles: maintainer (can edit team), member (default).
775 * A user can belong to many teams; team membership requires org membership
776 * but that invariant is enforced at the route layer, not the DB layer.
777 */
778export const teamMembers = pgTable(
779 "team_members",
780 {
781 id: uuid("id").primaryKey().defaultRandom(),
782 teamId: uuid("team_id")
783 .notNull()
784 .references(() => teams.id, { onDelete: "cascade" }),
785 userId: uuid("user_id")
786 .notNull()
787 .references(() => users.id, { onDelete: "cascade" }),
788 role: text("role").notNull().default("member"), // maintainer | member
789 createdAt: timestamp("created_at").defaultNow().notNull(),
790 },
791 (table) => [
792 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
793 index("team_members_user").on(table.userId),
794 ]
795);
796
797export type Organization = typeof organizations.$inferSelect;
798export type OrgMember = typeof orgMembers.$inferSelect;
799export type Team = typeof teams.$inferSelect;
800export type TeamMember = typeof teamMembers.$inferSelect;
801export type OrgRole = "owner" | "admin" | "member";
802export type TeamRole = "maintainer" | "member";
7298a17Claude803
804/**
805 * 2FA / TOTP (Block B4).
806 *
807 * Secret is stored in plain Base32 for now — the DB has row-level-secure
808 * access and the app boundary is the only code that reads it. A follow-up
809 * (B4.1) will wrap it with AES-GCM at rest once we standardise the KEK.
810 *
811 * `enabledAt` is set only after the user has successfully entered their
812 * first code (confirming the authenticator was set up correctly). Rows with
813 * `enabledAt = NULL` represent pending enrolment.
814 */
815export const userTotp = pgTable("user_totp", {
816 userId: uuid("user_id")
817 .primaryKey()
818 .references(() => users.id, { onDelete: "cascade" }),
819 secret: text("secret").notNull(),
820 enabledAt: timestamp("enabled_at"),
821 lastUsedAt: timestamp("last_used_at"),
822 createdAt: timestamp("created_at").defaultNow().notNull(),
823});
824
825/**
826 * Recovery codes — single-use fallback when the authenticator is lost.
827 * Stored as SHA-256 hashes; used rows are marked with `usedAt` rather than
828 * deleted so the audit log keeps the full history.
829 */
830export const userRecoveryCodes = pgTable(
831 "user_recovery_codes",
832 {
833 id: uuid("id").primaryKey().defaultRandom(),
834 userId: uuid("user_id")
835 .notNull()
836 .references(() => users.id, { onDelete: "cascade" }),
837 codeHash: text("code_hash").notNull(),
838 usedAt: timestamp("used_at"),
839 createdAt: timestamp("created_at").defaultNow().notNull(),
840 },
841 (table) => [
842 index("recovery_codes_user").on(table.userId),
843 uniqueIndex("recovery_codes_user_hash").on(table.userId, table.codeHash),
844 ]
845);
846
847export type UserTotp = typeof userTotp.$inferSelect;
848export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
2df1f8cClaude849
850/**
851 * WebAuthn passkeys (Block B5).
852 *
853 * Each row is one registered authenticator. The `credentialId` is the
854 * globally-unique identifier the browser returns; `publicKey` is the
855 * COSE-encoded public key we use to verify signatures. `counter` tracks
856 * the authenticator's signature counter for replay-protection.
857 *
858 * `transports` is a JSON array (stored as text) of the
859 * AuthenticatorTransport values ("usb" | "nfc" | "ble" | "internal" | "hybrid").
860 */
861export const userPasskeys = pgTable(
862 "user_passkeys",
863 {
864 id: uuid("id").primaryKey().defaultRandom(),
865 userId: uuid("user_id")
866 .notNull()
867 .references(() => users.id, { onDelete: "cascade" }),
868 credentialId: text("credential_id").notNull().unique(),
869 publicKey: text("public_key").notNull(), // base64url of COSE key
870 counter: integer("counter").default(0).notNull(),
871 transports: text("transports"), // JSON array string
872 name: text("name").notNull().default("Passkey"),
873 lastUsedAt: timestamp("last_used_at"),
874 createdAt: timestamp("created_at").defaultNow().notNull(),
875 },
876 (table) => [index("passkeys_user").on(table.userId)]
877);
878
879/**
880 * Short-lived WebAuthn challenges. A row is written when we issue options
881 * (registration or authentication) and deleted after the verify step or when
882 * it expires (5 min). Keeping them in the DB lets us verify without sticky
883 * sessions or client-side state.
884 */
885export const webauthnChallenges = pgTable(
886 "webauthn_challenges",
887 {
888 id: uuid("id").primaryKey().defaultRandom(),
889 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
890 // For passwordless login we don't know the user yet, so userId is nullable
891 // and we bind the challenge to a short-lived cookie token instead.
892 sessionKey: text("session_key").notNull().unique(),
893 challenge: text("challenge").notNull(),
894 kind: text("kind").notNull(), // "register" | "authenticate"
895 expiresAt: timestamp("expires_at").notNull(),
896 createdAt: timestamp("created_at").defaultNow().notNull(),
897 },
898 (table) => [index("webauthn_challenges_expires").on(table.expiresAt)]
899);
900
901export type UserPasskey = typeof userPasskeys.$inferSelect;
902export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
bfdb5e7Claude903
904/**
905 * OAuth 2.0 provider (Block B6).
906 *
907 * `oauthApps` is a third-party app registered by a developer. Each app has
908 * a public `client_id`, a hashed `client_secret`, and one or more allowed
909 * `redirect_uris` (newline-separated). The plaintext secret is shown to the
910 * developer exactly once at creation and cannot be recovered; they can
911 * rotate it instead.
912 *
913 * `oauthAuthorizations` is a short-lived authorization code issued after
914 * the user consents at /oauth/authorize. Single-use: `usedAt` is set on
915 * redemption so a replay after-the-fact fails.
916 *
917 * `oauthAccessTokens` is a long-lived bearer token plus an optional
918 * refresh token. Both are stored as SHA-256 hashes; the plaintext values
919 * are only returned to the client once in the /oauth/token response.
920 */
921export const oauthApps = pgTable(
922 "oauth_apps",
923 {
924 id: uuid("id").primaryKey().defaultRandom(),
925 ownerId: uuid("owner_id")
926 .notNull()
927 .references(() => users.id, { onDelete: "cascade" }),
928 name: text("name").notNull(),
929 clientId: text("client_id").notNull().unique(),
930 clientSecretHash: text("client_secret_hash").notNull(),
931 clientSecretPrefix: text("client_secret_prefix").notNull(), // first 8 chars for display
932 /** Newline-separated list of allowed redirect URIs. */
933 redirectUris: text("redirect_uris").notNull(),
934 homepageUrl: text("homepage_url"),
935 description: text("description"),
936 /**
937 * If `true`, the app must present its client_secret at /oauth/token.
938 * Public SPA/mobile apps should set this to `false` and use PKCE.
939 */
940 confidential: boolean("confidential").default(true).notNull(),
941 revokedAt: timestamp("revoked_at"),
942 createdAt: timestamp("created_at").defaultNow().notNull(),
943 updatedAt: timestamp("updated_at").defaultNow().notNull(),
944 },
945 (table) => [index("oauth_apps_owner").on(table.ownerId)]
946);
947
948export const oauthAuthorizations = pgTable(
949 "oauth_authorizations",
950 {
951 id: uuid("id").primaryKey().defaultRandom(),
952 appId: uuid("app_id")
953 .notNull()
954 .references(() => oauthApps.id, { onDelete: "cascade" }),
955 userId: uuid("user_id")
956 .notNull()
957 .references(() => users.id, { onDelete: "cascade" }),
958 codeHash: text("code_hash").notNull().unique(),
959 redirectUri: text("redirect_uri").notNull(),
960 scopes: text("scopes").notNull().default(""),
961 codeChallenge: text("code_challenge"),
962 codeChallengeMethod: text("code_challenge_method"), // "S256" | "plain"
963 expiresAt: timestamp("expires_at").notNull(),
964 usedAt: timestamp("used_at"),
965 createdAt: timestamp("created_at").defaultNow().notNull(),
966 },
967 (table) => [index("oauth_authorizations_expires").on(table.expiresAt)]
968);
969
970export const oauthAccessTokens = pgTable(
971 "oauth_access_tokens",
972 {
973 id: uuid("id").primaryKey().defaultRandom(),
974 appId: uuid("app_id")
975 .notNull()
976 .references(() => oauthApps.id, { onDelete: "cascade" }),
977 userId: uuid("user_id")
978 .notNull()
979 .references(() => users.id, { onDelete: "cascade" }),
980 accessTokenHash: text("access_token_hash").notNull().unique(),
981 refreshTokenHash: text("refresh_token_hash").unique(),
982 scopes: text("scopes").notNull().default(""),
983 expiresAt: timestamp("expires_at").notNull(),
984 refreshExpiresAt: timestamp("refresh_expires_at"),
985 revokedAt: timestamp("revoked_at"),
986 lastUsedAt: timestamp("last_used_at"),
987 createdAt: timestamp("created_at").defaultNow().notNull(),
988 },
989 (table) => [
990 index("oauth_access_tokens_user").on(table.userId),
991 index("oauth_access_tokens_app").on(table.appId),
992 index("oauth_access_tokens_expires").on(table.expiresAt),
993 ]
994);
995
996export type OauthApp = typeof oauthApps.$inferSelect;
997export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
998export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
eafe8c6Claude999
1000/**
1001 * Actions-equivalent workflow runner (Block C1).
1002 *
1003 * `workflows` rows are the YAML files discovered at `.gluecron/workflows/*.yml`
1004 * on the repo's default branch. `parsed` is the normalised JSON form used by
1005 * the runner so we don't re-parse on every trigger.
1006 *
1007 * `workflow_runs` is one execution: one row per trigger event. Status
1008 * progression: queued → running → success|failure|cancelled. `conclusion`
1009 * stays null until `status` is terminal.
1010 *
1011 * `workflow_jobs` is a single job within a run — each has its own steps
1012 * array and concatenated logs. We keep logs inline for v1 (no streaming)
1013 * to avoid a fifth table; they're truncated at the runner.
1014 *
1015 * `workflow_artifacts` persist files a job uploaded. `content` is a bytea;
1016 * we'll move this to object storage once we hit size limits.
1017 */
1018export const workflows = pgTable(
1019 "workflows",
1020 {
1021 id: uuid("id").primaryKey().defaultRandom(),
1022 repositoryId: uuid("repository_id")
1023 .notNull()
1024 .references(() => repositories.id, { onDelete: "cascade" }),
1025 name: text("name").notNull(),
1026 path: text("path").notNull(), // e.g. ".gluecron/workflows/ci.yml"
1027 yaml: text("yaml").notNull(),
1028 parsed: text("parsed").notNull(), // JSON string
1029 onEvents: text("on_events").notNull().default("[]"), // JSON array of event names
1030 disabled: boolean("disabled").default(false).notNull(),
1031 createdAt: timestamp("created_at").defaultNow().notNull(),
1032 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1033 },
1034 (table) => [
1035 index("workflows_repo").on(table.repositoryId),
1036 uniqueIndex("workflows_repo_path").on(table.repositoryId, table.path),
1037 ]
1038);
1039
1040export const workflowRuns = pgTable(
1041 "workflow_runs",
1042 {
1043 id: uuid("id").primaryKey().defaultRandom(),
1044 workflowId: uuid("workflow_id")
1045 .notNull()
1046 .references(() => workflows.id, { onDelete: "cascade" }),
1047 repositoryId: uuid("repository_id")
1048 .notNull()
1049 .references(() => repositories.id, { onDelete: "cascade" }),
1050 runNumber: integer("run_number").notNull(),
1051 event: text("event").notNull(), // "push" | "pull_request" | "manual" | ...
1052 ref: text("ref"),
1053 commitSha: text("commit_sha"),
1054 triggeredBy: uuid("triggered_by").references(() => users.id, {
1055 onDelete: "set null",
1056 }),
1057 status: text("status").notNull().default("queued"), // queued|running|success|failure|cancelled
1058 conclusion: text("conclusion"),
1059 queuedAt: timestamp("queued_at").defaultNow().notNull(),
1060 startedAt: timestamp("started_at"),
1061 finishedAt: timestamp("finished_at"),
1062 createdAt: timestamp("created_at").defaultNow().notNull(),
1063 },
1064 (table) => [
1065 index("workflow_runs_repo").on(table.repositoryId),
1066 index("workflow_runs_status").on(table.status),
1067 index("workflow_runs_workflow").on(table.workflowId),
1068 ]
1069);
1070
1071export const workflowJobs = pgTable(
1072 "workflow_jobs",
1073 {
1074 id: uuid("id").primaryKey().defaultRandom(),
1075 runId: uuid("run_id")
1076 .notNull()
1077 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1078 name: text("name").notNull(),
1079 jobOrder: integer("job_order").default(0).notNull(),
1080 runsOn: text("runs_on").notNull().default("default"),
1081 status: text("status").notNull().default("queued"),
1082 conclusion: text("conclusion"),
1083 exitCode: integer("exit_code"),
1084 steps: text("steps").notNull().default("[]"), // JSON array of step results
1085 logs: text("logs").notNull().default(""),
1086 startedAt: timestamp("started_at"),
1087 finishedAt: timestamp("finished_at"),
1088 createdAt: timestamp("created_at").defaultNow().notNull(),
1089 },
1090 (table) => [index("workflow_jobs_run").on(table.runId)]
1091);
1092
1093export const workflowArtifacts = pgTable(
1094 "workflow_artifacts",
1095 {
1096 id: uuid("id").primaryKey().defaultRandom(),
1097 runId: uuid("run_id")
1098 .notNull()
1099 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1100 jobId: uuid("job_id").references(() => workflowJobs.id, {
1101 onDelete: "set null",
1102 }),
1103 name: text("name").notNull(),
1104 sizeBytes: integer("size_bytes").default(0).notNull(),
1105 contentType: text("content_type")
1106 .default("application/octet-stream")
1107 .notNull(),
1108 // bytea — drizzle doesn't have a built-in bytea type at the level we use
1109 // elsewhere; store as text (base64) for v1. Migration uses real bytea so
1110 // we can swap the column type later.
1111 content: text("content"),
1112 createdAt: timestamp("created_at").defaultNow().notNull(),
1113 },
1114 (table) => [index("workflow_artifacts_run").on(table.runId)]
1115);
1116
1117export type Workflow = typeof workflows.$inferSelect;
1118export type WorkflowRun = typeof workflowRuns.$inferSelect;
1119export type WorkflowJob = typeof workflowJobs.$inferSelect;
1120export type WorkflowArtifact = typeof workflowArtifacts.$inferSelect;
e2da5c6Claude1121
1122// ---------------------------------------------------------------------------
1123// Block C2 — Package registry (npm-compatible)
1124// ---------------------------------------------------------------------------
1125
1126export const packages = pgTable(
1127 "packages",
1128 {
1129 id: uuid("id").primaryKey().defaultRandom(),
1130 repositoryId: uuid("repository_id")
1131 .notNull()
1132 .references(() => repositories.id, { onDelete: "cascade" }),
1133 ecosystem: text("ecosystem").notNull().default("npm"), // "npm" | "container"
1134 scope: text("scope"), // "@acme" for npm; null for unscoped
1135 name: text("name").notNull(), // "my-lib" (without scope)
1136 description: text("description"),
1137 readme: text("readme"),
1138 homepage: text("homepage"),
1139 license: text("license"),
1140 visibility: text("visibility").notNull().default("public"), // "public" | "private"
1141 createdAt: timestamp("created_at").defaultNow().notNull(),
1142 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1143 },
1144 (table) => [
1145 index("packages_repo").on(table.repositoryId),
1146 uniqueIndex("packages_eco_scope_name").on(
1147 table.ecosystem,
1148 table.scope,
1149 table.name
1150 ),
1151 ]
1152);
1153
1154export const packageVersions = pgTable(
1155 "package_versions",
1156 {
1157 id: uuid("id").primaryKey().defaultRandom(),
1158 packageId: uuid("package_id")
1159 .notNull()
1160 .references(() => packages.id, { onDelete: "cascade" }),
1161 version: text("version").notNull(), // "1.2.3"
1162 shasum: text("shasum").notNull(), // sha1 (for npm compat) hex
1163 integrity: text("integrity"), // "sha512-..." base64
1164 sizeBytes: integer("size_bytes").default(0).notNull(),
1165 metadata: text("metadata").notNull().default("{}"), // package.json JSON
1166 tarball: text("tarball"), // base64-encoded; bytea in migration
1167 publishedBy: uuid("published_by").references(() => users.id, {
1168 onDelete: "set null",
1169 }),
1170 yanked: boolean("yanked").default(false).notNull(),
1171 yankedReason: text("yanked_reason"),
1172 publishedAt: timestamp("published_at").defaultNow().notNull(),
1173 },
1174 (table) => [
1175 index("package_versions_pkg").on(table.packageId),
1176 uniqueIndex("package_versions_pkg_version").on(table.packageId, table.version),
1177 ]
1178);
1179
1180export const packageTags = pgTable(
1181 "package_tags",
1182 {
1183 id: uuid("id").primaryKey().defaultRandom(),
1184 packageId: uuid("package_id")
1185 .notNull()
1186 .references(() => packages.id, { onDelete: "cascade" }),
1187 tag: text("tag").notNull(), // "latest" | "beta" | ...
1188 versionId: uuid("version_id")
1189 .notNull()
1190 .references(() => packageVersions.id, { onDelete: "cascade" }),
1191 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1192 },
1193 (table) => [
1194 uniqueIndex("package_tags_pkg_tag").on(table.packageId, table.tag),
1195 ]
1196);
1197
1198export type Package = typeof packages.$inferSelect;
1199export type PackageVersion = typeof packageVersions.$inferSelect;
1200export type PackageTag = typeof packageTags.$inferSelect;
1201
1202// ---------------------------------------------------------------------------
1203// Block C3 — Pages / static hosting
1204// ---------------------------------------------------------------------------
1205
1206export const pagesDeployments = pgTable(
1207 "pages_deployments",
1208 {
1209 id: uuid("id").primaryKey().defaultRandom(),
1210 repositoryId: uuid("repository_id")
1211 .notNull()
1212 .references(() => repositories.id, { onDelete: "cascade" }),
1213 ref: text("ref").notNull().default("refs/heads/gh-pages"),
1214 commitSha: text("commit_sha").notNull(),
1215 status: text("status").notNull().default("success"), // "success" | "failed"
1216 triggeredBy: uuid("triggered_by").references(() => users.id, {
1217 onDelete: "set null",
1218 }),
1219 createdAt: timestamp("created_at").defaultNow().notNull(),
1220 },
1221 (table) => [
1222 index("pages_deployments_repo").on(table.repositoryId),
1223 index("pages_deployments_created").on(table.createdAt),
1224 ]
1225);
1226
1227export const pagesSettings = pgTable("pages_settings", {
1228 repositoryId: uuid("repository_id")
1229 .primaryKey()
1230 .references(() => repositories.id, { onDelete: "cascade" }),
1231 enabled: boolean("enabled").default(true).notNull(),
1232 sourceBranch: text("source_branch").notNull().default("gh-pages"),
1233 sourceDir: text("source_dir").notNull().default("/"), // e.g. "/" or "/docs"
1234 customDomain: text("custom_domain"),
1235 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1236});
1237
1238export type PagesDeployment = typeof pagesDeployments.$inferSelect;
1239export type PagesSettings = typeof pagesSettings.$inferSelect;
1240
1241// ---------------------------------------------------------------------------
1242// Block C4 — Environments with protected approvals
1243// ---------------------------------------------------------------------------
1244
1245export const environments = pgTable(
1246 "environments",
1247 {
1248 id: uuid("id").primaryKey().defaultRandom(),
1249 repositoryId: uuid("repository_id")
1250 .notNull()
1251 .references(() => repositories.id, { onDelete: "cascade" }),
1252 name: text("name").notNull(), // "production" | "staging" | "preview"
1253 requireApproval: boolean("require_approval").default(false).notNull(),
1254 // JSON array of user IDs that can approve deploys.
1255 reviewers: text("reviewers").notNull().default("[]"),
1256 waitTimerMinutes: integer("wait_timer_minutes").default(0).notNull(),
1257 allowedBranches: text("allowed_branches").notNull().default("[]"), // JSON glob patterns
1258 createdAt: timestamp("created_at").defaultNow().notNull(),
1259 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1260 },
1261 (table) => [
1262 uniqueIndex("environments_repo_name").on(table.repositoryId, table.name),
1263 ]
1264);
1265
1266export const deploymentApprovals = pgTable(
1267 "deployment_approvals",
1268 {
1269 id: uuid("id").primaryKey().defaultRandom(),
1270 deploymentId: uuid("deployment_id")
1271 .notNull()
1272 .references(() => deployments.id, { onDelete: "cascade" }),
1273 userId: uuid("user_id")
1274 .notNull()
1275 .references(() => users.id, { onDelete: "cascade" }),
1276 decision: text("decision").notNull(), // "approved" | "rejected"
1277 comment: text("comment"),
1278 createdAt: timestamp("created_at").defaultNow().notNull(),
1279 },
1280 (table) => [
1281 index("deployment_approvals_deployment").on(table.deploymentId),
1282 ]
1283);
1284
1285export type Environment = typeof environments.$inferSelect;
1286export type DeploymentApproval = typeof deploymentApprovals.$inferSelect;
3cbe3d6Claude1287
1288// ---------------------------------------------------------------------------
1289// Block D — AI-native differentiation (migration 0012)
1290// ---------------------------------------------------------------------------
1291
1292// D6 — cached "explain this codebase" markdown keyed on commit sha.
1293export const codebaseExplanations = pgTable(
1294 "codebase_explanations",
1295 {
1296 id: uuid("id").primaryKey().defaultRandom(),
1297 repositoryId: uuid("repository_id")
1298 .notNull()
1299 .references(() => repositories.id, { onDelete: "cascade" }),
1300 commitSha: text("commit_sha").notNull(),
1301 summary: text("summary").notNull(),
1302 markdown: text("markdown").notNull(),
1303 model: text("model").notNull(),
1304 generatedAt: timestamp("generated_at").defaultNow().notNull(),
1305 },
1306 (table) => [
1307 uniqueIndex("codebase_explanations_repo_sha").on(
1308 table.repositoryId,
1309 table.commitSha
1310 ),
1311 ]
1312);
1313
1314// D2 — AI dependency bumper run history.
1315export const depUpdateRuns = pgTable(
1316 "dep_update_runs",
1317 {
1318 id: uuid("id").primaryKey().defaultRandom(),
1319 repositoryId: uuid("repository_id")
1320 .notNull()
1321 .references(() => repositories.id, { onDelete: "cascade" }),
1322 status: text("status").notNull().default("pending"), // pending|running|success|failed|no_updates
1323 ecosystem: text("ecosystem").notNull(), // npm|bun
1324 manifestPath: text("manifest_path").notNull(),
1325 attemptedBumps: text("attempted_bumps").notNull().default("[]"), // JSON
1326 appliedBumps: text("applied_bumps").notNull().default("[]"), // JSON
1327 branchName: text("branch_name"),
1328 prNumber: integer("pr_number"),
1329 errorMessage: text("error_message"),
1330 triggeredBy: uuid("triggered_by").references(() => users.id, {
1331 onDelete: "set null",
1332 }),
1333 createdAt: timestamp("created_at").defaultNow().notNull(),
1334 completedAt: timestamp("completed_at"),
1335 },
1336 (table) => [
1337 index("dep_update_runs_repo").on(table.repositoryId),
1338 index("dep_update_runs_created").on(table.createdAt),
1339 ]
1340);
1341
1342// D1 — code chunks for semantic search. Embedding stored as JSON-encoded
1343// number array in text to avoid requiring pgvector; cosine similarity is
1344// computed in JS. Upgrade path: ALTER COLUMN embedding TYPE vector(1024).
1345export const codeChunks = pgTable(
1346 "code_chunks",
1347 {
1348 id: uuid("id").primaryKey().defaultRandom(),
1349 repositoryId: uuid("repository_id")
1350 .notNull()
1351 .references(() => repositories.id, { onDelete: "cascade" }),
1352 commitSha: text("commit_sha").notNull(),
1353 path: text("path").notNull(),
1354 startLine: integer("start_line").notNull(),
1355 endLine: integer("end_line").notNull(),
1356 content: text("content").notNull(),
1357 embedding: text("embedding"), // JSON number[]
1358 embeddingModel: text("embedding_model"),
1359 createdAt: timestamp("created_at").defaultNow().notNull(),
1360 },
1361 (table) => [
1362 index("code_chunks_repo").on(table.repositoryId),
1363 index("code_chunks_repo_path").on(table.repositoryId, table.path),
1364 ]
1365);
1366
1367export type CodebaseExplanation = typeof codebaseExplanations.$inferSelect;
1368export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
1e162a8Claude1369
1370// ---------------------------------------------------------------------------
1371// Block E2 — Discussions (migration 0013)
1372// ---------------------------------------------------------------------------
1373
1374/**
1375 * Discussions — forum-style threaded conversations attached to a repo.
1376 * Similar to GitHub Discussions: categorised + pinnable + answerable.
1377 */
1378export const discussions = pgTable(
1379 "discussions",
1380 {
1381 id: uuid("id").primaryKey().defaultRandom(),
1382 number: serial("number"),
1383 repositoryId: uuid("repository_id")
1384 .notNull()
1385 .references(() => repositories.id, { onDelete: "cascade" }),
1386 authorId: uuid("author_id")
1387 .notNull()
1388 .references(() => users.id),
1389 // one of: "general" | "q-and-a" | "ideas" | "announcements" | "show-and-tell"
1390 category: text("category").notNull().default("general"),
1391 title: text("title").notNull(),
1392 body: text("body"),
1393 state: text("state").notNull().default("open"), // open, closed
1394 locked: boolean("locked").notNull().default(false),
1395 answerCommentId: uuid("answer_comment_id"),
1396 pinned: boolean("pinned").notNull().default(false),
1397 createdAt: timestamp("created_at").defaultNow().notNull(),
1398 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1399 },
1400 (table) => [
1401 index("discussions_repo").on(table.repositoryId),
1402 uniqueIndex("discussions_repo_number").on(
1403 table.repositoryId,
1404 table.number
1405 ),
1406 ]
1407);
1408
1409export const discussionComments = pgTable(
1410 "discussion_comments",
1411 {
1412 id: uuid("id").primaryKey().defaultRandom(),
1413 discussionId: uuid("discussion_id")
1414 .notNull()
1415 .references(() => discussions.id, { onDelete: "cascade" }),
1416 parentCommentId: uuid("parent_comment_id"),
1417 authorId: uuid("author_id")
1418 .notNull()
1419 .references(() => users.id),
1420 body: text("body").notNull(),
1421 isAnswer: boolean("is_answer").notNull().default(false),
1422 createdAt: timestamp("created_at").defaultNow().notNull(),
1423 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1424 },
1425 (table) => [
1426 index("discussion_comments_discussion").on(table.discussionId),
1427 ]
1428);
1429
1430export type Discussion = typeof discussions.$inferSelect;
1431export type DiscussionComment = typeof discussionComments.$inferSelect;
3cbe3d6Claude1432export type CodeChunk = typeof codeChunks.$inferSelect;
1e162a8Claude1433
1434// ---------------------------------------------------------------------------
1435// Block E4 — Gists (migration 0014)
1436// ---------------------------------------------------------------------------
1437//
1438// User-owned small snippets/files that behave like tiny repos. DB-backed
1439// for v1 (no bare git repo): each gist owns a collection of gist_files and
1440// every edit appends a gist_revisions row with a JSON snapshot.
1441
1442export const gists = pgTable(
1443 "gists",
1444 {
1445 id: uuid("id").primaryKey().defaultRandom(),
1446 ownerId: uuid("owner_id")
1447 .notNull()
1448 .references(() => users.id, { onDelete: "cascade" }),
1449 // 8-char hex slug used in pretty URLs (e.g. /gists/a1b2c3d4).
1450 slug: text("slug").notNull().unique(),
1451 title: text("title").notNull().default(""),
1452 description: text("description").notNull().default(""),
1453 isPublic: boolean("is_public").default(true).notNull(),
1454 createdAt: timestamp("created_at").defaultNow().notNull(),
1455 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1456 },
1457 (table) => [index("gists_owner").on(table.ownerId)]
1458);
1459
1460export const gistFiles = pgTable(
1461 "gist_files",
1462 {
1463 id: uuid("id").primaryKey().defaultRandom(),
1464 gistId: uuid("gist_id")
1465 .notNull()
1466 .references(() => gists.id, { onDelete: "cascade" }),
1467 filename: text("filename").notNull(),
1468 // Optional explicit language override; falls back to filename detection.
1469 language: text("language"),
1470 content: text("content").notNull().default(""),
1471 sizeBytes: integer("size_bytes").default(0).notNull(),
1472 },
1473 (table) => [
1474 index("gist_files_gist").on(table.gistId),
1475 uniqueIndex("gist_files_gist_filename").on(table.gistId, table.filename),
1476 ]
1477);
1478
1479export const gistRevisions = pgTable(
1480 "gist_revisions",
1481 {
1482 id: uuid("id").primaryKey().defaultRandom(),
1483 gistId: uuid("gist_id")
1484 .notNull()
1485 .references(() => gists.id, { onDelete: "cascade" }),
1486 revision: integer("revision").notNull(),
1487 // JSON-encoded {filename: content} map capturing the full snapshot at
1488 // this revision. Stored as text to avoid requiring jsonb.
1489 snapshot: text("snapshot").notNull().default("{}"),
1490 authorId: uuid("author_id")
1491 .notNull()
1492 .references(() => users.id, { onDelete: "cascade" }),
1493 message: text("message"),
1494 createdAt: timestamp("created_at").defaultNow().notNull(),
1495 },
1496 (table) => [
1497 index("gist_revisions_gist_rev").on(table.gistId, table.revision),
1498 ]
1499);
1500
1501export const gistStars = pgTable(
1502 "gist_stars",
1503 {
1504 id: uuid("id").primaryKey().defaultRandom(),
1505 gistId: uuid("gist_id")
1506 .notNull()
1507 .references(() => gists.id, { onDelete: "cascade" }),
1508 userId: uuid("user_id")
1509 .notNull()
1510 .references(() => users.id, { onDelete: "cascade" }),
1511 createdAt: timestamp("created_at").defaultNow().notNull(),
1512 },
1513 (table) => [uniqueIndex("gist_stars_gist_user").on(table.gistId, table.userId)]
1514);
1515
1516export type Gist = typeof gists.$inferSelect;
1517export type GistFile = typeof gistFiles.$inferSelect;
1518export type GistRevision = typeof gistRevisions.$inferSelect;
1519export type GistStar = typeof gistStars.$inferSelect;
1520
1521// ---------------------------------------------------------------------------
1522// Block E1 — Projects / kanban (migration 0015)
1523// ---------------------------------------------------------------------------
1524
1525export const projects = pgTable(
1526 "projects",
1527 {
1528 id: uuid("id").primaryKey().defaultRandom(),
1529 number: serial("number"),
1530 repositoryId: uuid("repository_id")
1531 .notNull()
1532 .references(() => repositories.id, { onDelete: "cascade" }),
1533 ownerId: uuid("owner_id")
1534 .notNull()
1535 .references(() => users.id),
1536 title: text("title").notNull(),
1537 description: text("description").notNull().default(""),
1538 state: text("state").notNull().default("open"), // open | closed
1539 createdAt: timestamp("created_at").defaultNow().notNull(),
1540 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1541 },
1542 (table) => [
1543 index("projects_repo").on(table.repositoryId),
1544 uniqueIndex("projects_repo_number").on(table.repositoryId, table.number),
1545 ]
1546);
1547
1548export const projectColumns = pgTable(
1549 "project_columns",
1550 {
1551 id: uuid("id").primaryKey().defaultRandom(),
1552 projectId: uuid("project_id")
1553 .notNull()
1554 .references(() => projects.id, { onDelete: "cascade" }),
1555 name: text("name").notNull(),
1556 position: integer("position").notNull().default(0),
1557 createdAt: timestamp("created_at").defaultNow().notNull(),
1558 },
1559 (table) => [index("project_columns_project").on(table.projectId)]
1560);
1561
1562export const projectItems = pgTable(
1563 "project_items",
1564 {
1565 id: uuid("id").primaryKey().defaultRandom(),
1566 projectId: uuid("project_id")
1567 .notNull()
1568 .references(() => projects.id, { onDelete: "cascade" }),
1569 columnId: uuid("column_id")
1570 .notNull()
1571 .references(() => projectColumns.id, { onDelete: "cascade" }),
1572 position: integer("position").notNull().default(0),
1573 // "note" | "issue" | "pr" — application-level FK on itemId by type
1574 itemType: text("item_type").notNull().default("note"),
1575 itemId: uuid("item_id"),
1576 title: text("title").notNull().default(""),
1577 note: text("note").notNull().default(""),
1578 createdAt: timestamp("created_at").defaultNow().notNull(),
1579 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1580 },
1581 (table) => [
1582 index("project_items_project").on(table.projectId),
1583 index("project_items_column").on(table.columnId, table.position),
1584 ]
1585);
1586
1587export type Project = typeof projects.$inferSelect;
1588export type ProjectColumn = typeof projectColumns.$inferSelect;
1589export type ProjectItem = typeof projectItems.$inferSelect;
1590
1591// ---------------------------------------------------------------------------
1592// Block E3 — Wikis (migration 0016)
1593// ---------------------------------------------------------------------------
1594// DB-backed for v1; git-backed mirror is a future upgrade.
1595
1596export const wikiPages = pgTable(
1597 "wiki_pages",
1598 {
1599 id: uuid("id").primaryKey().defaultRandom(),
1600 repositoryId: uuid("repository_id")
1601 .notNull()
1602 .references(() => repositories.id, { onDelete: "cascade" }),
1603 slug: text("slug").notNull(),
1604 title: text("title").notNull(),
1605 body: text("body").notNull().default(""),
1606 revision: integer("revision").notNull().default(1),
1607 createdAt: timestamp("created_at").defaultNow().notNull(),
1608 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1609 updatedBy: uuid("updated_by").references(() => users.id),
1610 },
1611 (table) => [
1612 index("wiki_pages_repo").on(table.repositoryId),
1613 uniqueIndex("wiki_pages_repo_slug").on(table.repositoryId, table.slug),
1614 ]
1615);
1616
1617export const wikiRevisions = pgTable(
1618 "wiki_revisions",
1619 {
1620 id: uuid("id").primaryKey().defaultRandom(),
1621 pageId: uuid("page_id")
1622 .notNull()
1623 .references(() => wikiPages.id, { onDelete: "cascade" }),
1624 revision: integer("revision").notNull(),
1625 title: text("title").notNull(),
1626 body: text("body").notNull().default(""),
1627 message: text("message"),
1628 authorId: uuid("author_id")
1629 .notNull()
1630 .references(() => users.id),
1631 createdAt: timestamp("created_at").defaultNow().notNull(),
1632 },
1633 (table) => [index("wiki_revisions_page").on(table.pageId, table.revision)]
1634);
1635
1636export type WikiPage = typeof wikiPages.$inferSelect;
1637export type WikiRevision = typeof wikiRevisions.$inferSelect;
a79a9edClaude1638
1639// ---------------------------------------------------------------------------
1640// Block E5 — Merge queues (migration 0017)
1641// ---------------------------------------------------------------------------
1642
1643export const mergeQueueEntries = pgTable(
1644 "merge_queue_entries",
1645 {
1646 id: uuid("id").primaryKey().defaultRandom(),
1647 repositoryId: uuid("repository_id")
1648 .notNull()
1649 .references(() => repositories.id, { onDelete: "cascade" }),
1650 pullRequestId: uuid("pull_request_id")
1651 .notNull()
1652 .references(() => pullRequests.id, { onDelete: "cascade" }),
1653 baseBranch: text("base_branch").notNull(),
1654 // queued | running | merged | failed | dequeued
1655 state: text("state").notNull().default("queued"),
1656 position: integer("position").notNull().default(0),
1657 enqueuedBy: uuid("enqueued_by").references(() => users.id),
1658 enqueuedAt: timestamp("enqueued_at").defaultNow().notNull(),
1659 startedAt: timestamp("started_at"),
1660 finishedAt: timestamp("finished_at"),
1661 errorMessage: text("error_message"),
1662 },
1663 (table) => [
1664 index("merge_queue_repo_branch").on(
1665 table.repositoryId,
1666 table.baseBranch,
1667 table.state
1668 ),
1669 ]
1670);
1671
1672export type MergeQueueEntry = typeof mergeQueueEntries.$inferSelect;
1673
1674// ---------------------------------------------------------------------------
1675// Block E6 — Required status checks matrix (migration 0018)
1676// ---------------------------------------------------------------------------
1677
1678export const branchRequiredChecks = pgTable(
1679 "branch_required_checks",
1680 {
1681 id: uuid("id").primaryKey().defaultRandom(),
1682 branchProtectionId: uuid("branch_protection_id")
1683 .notNull()
1684 .references(() => branchProtection.id, { onDelete: "cascade" }),
1685 checkName: text("check_name").notNull(),
1686 createdAt: timestamp("created_at").defaultNow().notNull(),
1687 },
1688 (table) => [
1689 index("branch_required_checks_rule").on(table.branchProtectionId),
1690 uniqueIndex("branch_required_checks_unique").on(
1691 table.branchProtectionId,
1692 table.checkName
1693 ),
1694 ]
1695);
1696
1697export type BranchRequiredCheck = typeof branchRequiredChecks.$inferSelect;
1698
1699// ---------------------------------------------------------------------------
1700// Block E7 — Protected tags (migration 0019)
1701// ---------------------------------------------------------------------------
1702
1703export const protectedTags = pgTable(
1704 "protected_tags",
1705 {
1706 id: uuid("id").primaryKey().defaultRandom(),
1707 repositoryId: uuid("repository_id")
1708 .notNull()
1709 .references(() => repositories.id, { onDelete: "cascade" }),
1710 pattern: text("pattern").notNull(),
1711 createdAt: timestamp("created_at").defaultNow().notNull(),
1712 createdBy: uuid("created_by").references(() => users.id),
1713 },
1714 (table) => [
1715 index("protected_tags_repo").on(table.repositoryId),
1716 uniqueIndex("protected_tags_repo_pattern").on(
1717 table.repositoryId,
1718 table.pattern
1719 ),
1720 ]
1721);
1722
1723export type ProtectedTag = typeof protectedTags.$inferSelect;
8f50ed0Claude1724
1725// ---------------------------------------------------------------------------
1726// Block F — Observability + admin (migration 0020)
1727// ---------------------------------------------------------------------------
1728
1729// F1 — Traffic analytics per repo
1730export const repoTrafficEvents = pgTable(
1731 "repo_traffic_events",
1732 {
1733 id: uuid("id").primaryKey().defaultRandom(),
1734 repositoryId: uuid("repository_id")
1735 .notNull()
1736 .references(() => repositories.id, { onDelete: "cascade" }),
1737 kind: text("kind").notNull(), // view | clone | api | ui
1738 path: text("path"),
1739 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
1740 ipHash: text("ip_hash"),
1741 userAgent: text("user_agent"),
1742 referer: text("referer"),
1743 createdAt: timestamp("created_at").defaultNow().notNull(),
1744 },
1745 (table) => [
1746 index("repo_traffic_events_repo_time").on(
1747 table.repositoryId,
1748 table.createdAt
1749 ),
1750 index("repo_traffic_events_kind").on(
1751 table.repositoryId,
1752 table.kind,
1753 table.createdAt
1754 ),
1755 ]
1756);
1757
1758export type RepoTrafficEvent = typeof repoTrafficEvents.$inferSelect;
1759
1760// F3 — Admin panel (site admins + toggleable flags)
1761export const systemFlags = pgTable("system_flags", {
1762 key: text("key").primaryKey(),
1763 value: text("value").notNull().default(""),
1764 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1765 updatedBy: uuid("updated_by").references(() => users.id),
1766});
1767
1768export type SystemFlag = typeof systemFlags.$inferSelect;
1769
1770export const siteAdmins = pgTable("site_admins", {
1771 userId: uuid("user_id")
1772 .primaryKey()
1773 .references(() => users.id, { onDelete: "cascade" }),
1774 grantedAt: timestamp("granted_at").defaultNow().notNull(),
1775 grantedBy: uuid("granted_by").references(() => users.id),
1776});
1777
1778export type SiteAdmin = typeof siteAdmins.$inferSelect;
1779
1780// F4 — Billing + quotas
1781export const billingPlans = pgTable("billing_plans", {
1782 id: uuid("id").primaryKey().defaultRandom(),
1783 slug: text("slug").notNull().unique(),
1784 name: text("name").notNull(),
1785 priceCents: integer("price_cents").notNull().default(0),
1786 repoLimit: integer("repo_limit").notNull().default(10),
1787 storageMbLimit: integer("storage_mb_limit").notNull().default(1024),
1788 aiTokensMonthly: integer("ai_tokens_monthly").notNull().default(100000),
1789 bandwidthGbMonthly: integer("bandwidth_gb_monthly").notNull().default(10),
1790 privateRepos: boolean("private_repos").notNull().default(false),
1791 createdAt: timestamp("created_at").defaultNow().notNull(),
1792});
1793
1794export type BillingPlan = typeof billingPlans.$inferSelect;
1795
1796export const userQuotas = pgTable("user_quotas", {
1797 userId: uuid("user_id")
1798 .primaryKey()
1799 .references(() => users.id, { onDelete: "cascade" }),
1800 planSlug: text("plan_slug").notNull().default("free"),
1801 storageMbUsed: integer("storage_mb_used").notNull().default(0),
1802 aiTokensUsedThisMonth: integer("ai_tokens_used_this_month")
1803 .notNull()
1804 .default(0),
1805 bandwidthGbUsedThisMonth: integer("bandwidth_gb_used_this_month")
1806 .notNull()
1807 .default(0),
1808 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
1809 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1810});
1811
1812export type UserQuota = typeof userQuotas.$inferSelect;
06139e6Claude1813
1814// Block H — App marketplace + bot identities (GitHub Apps equivalent)
1815
1816export const apps = pgTable(
1817 "apps",
1818 {
1819 id: uuid("id").primaryKey().defaultRandom(),
1820 slug: text("slug").notNull().unique(),
1821 name: text("name").notNull(),
1822 description: text("description").notNull().default(""),
1823 iconUrl: text("icon_url"),
1824 homepageUrl: text("homepage_url"),
1825 webhookUrl: text("webhook_url"),
1826 webhookSecret: text("webhook_secret"),
1827 creatorId: uuid("creator_id")
1828 .notNull()
1829 .references(() => users.id, { onDelete: "cascade" }),
1830 permissions: text("permissions").notNull().default("[]"), // JSON array
1831 defaultEvents: text("default_events").notNull().default("[]"), // JSON array
1832 isPublic: boolean("is_public").notNull().default(true),
1833 createdAt: timestamp("created_at").defaultNow().notNull(),
1834 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1835 },
1836 (table) => [index("apps_public_slug").on(table.isPublic, table.slug)]
1837);
1838
1839export type App = typeof apps.$inferSelect;
1840
1841export const appInstallations = pgTable(
1842 "app_installations",
1843 {
1844 id: uuid("id").primaryKey().defaultRandom(),
1845 appId: uuid("app_id")
1846 .notNull()
1847 .references(() => apps.id, { onDelete: "cascade" }),
1848 installedBy: uuid("installed_by")
1849 .notNull()
1850 .references(() => users.id, { onDelete: "cascade" }),
1851 targetType: text("target_type").notNull(), // user | org | repository
1852 targetId: uuid("target_id").notNull(),
1853 grantedPermissions: text("granted_permissions").notNull().default("[]"),
1854 suspendedAt: timestamp("suspended_at"),
1855 createdAt: timestamp("created_at").defaultNow().notNull(),
1856 uninstalledAt: timestamp("uninstalled_at"),
1857 },
1858 (table) => [
1859 index("app_installations_app").on(table.appId),
1860 index("app_installations_target").on(table.targetType, table.targetId),
1861 ]
1862);
1863
1864export type AppInstallation = typeof appInstallations.$inferSelect;
1865
1866export const appBots = pgTable("app_bots", {
1867 id: uuid("id").primaryKey().defaultRandom(),
1868 appId: uuid("app_id")
1869 .notNull()
1870 .unique()
1871 .references(() => apps.id, { onDelete: "cascade" }),
1872 username: text("username").notNull().unique(),
1873 displayName: text("display_name").notNull(),
1874 avatarUrl: text("avatar_url"),
1875 createdAt: timestamp("created_at").defaultNow().notNull(),
1876});
1877
1878export type AppBot = typeof appBots.$inferSelect;
1879
1880export const appInstallTokens = pgTable(
1881 "app_install_tokens",
1882 {
1883 id: uuid("id").primaryKey().defaultRandom(),
1884 installationId: uuid("installation_id")
1885 .notNull()
1886 .references(() => appInstallations.id, { onDelete: "cascade" }),
1887 tokenHash: text("token_hash").notNull().unique(),
1888 expiresAt: timestamp("expires_at").notNull(),
1889 createdAt: timestamp("created_at").defaultNow().notNull(),
1890 revokedAt: timestamp("revoked_at"),
1891 },
1892 (table) => [index("app_install_tokens_hash").on(table.tokenHash)]
1893);
1894
1895export type AppInstallToken = typeof appInstallTokens.$inferSelect;
1896
1897export const appEvents = pgTable(
1898 "app_events",
1899 {
1900 id: uuid("id").primaryKey().defaultRandom(),
1901 appId: uuid("app_id")
1902 .notNull()
1903 .references(() => apps.id, { onDelete: "cascade" }),
1904 installationId: uuid("installation_id"),
1905 kind: text("kind").notNull(), // installed | uninstalled | delivery_ok | delivery_fail
1906 payload: text("payload"),
1907 responseStatus: integer("response_status"),
1908 createdAt: timestamp("created_at").defaultNow().notNull(),
1909 },
1910 (table) => [index("app_events_app_time").on(table.appId, table.createdAt)]
1911);
1912
1913export type AppEvent = typeof appEvents.$inferSelect;
71cd5ecClaude1914
1915// ---------- Block I3 — Repository transfer history ----------
1916
1917export const repoTransfers = pgTable(
1918 "repo_transfers",
1919 {
1920 id: uuid("id").primaryKey().defaultRandom(),
1921 repositoryId: uuid("repository_id")
1922 .notNull()
1923 .references(() => repositories.id, { onDelete: "cascade" }),
1924 fromOwnerId: uuid("from_owner_id").notNull(),
1925 fromOrgId: uuid("from_org_id"),
1926 toOwnerId: uuid("to_owner_id").notNull(),
1927 toOrgId: uuid("to_org_id"),
1928 initiatedBy: uuid("initiated_by")
1929 .notNull()
1930 .references(() => users.id, { onDelete: "cascade" }),
1931 createdAt: timestamp("created_at").defaultNow().notNull(),
1932 },
1933 (table) => [
1934 index("repo_transfers_repo").on(table.repositoryId, table.createdAt),
1935 ]
1936);
1937
1938export type RepoTransfer = typeof repoTransfers.$inferSelect;
08420cdClaude1939
1940// ---------- Block I6 — Sponsors ----------
1941
1942export const sponsorshipTiers = pgTable(
1943 "sponsorship_tiers",
1944 {
1945 id: uuid("id").primaryKey().defaultRandom(),
1946 maintainerId: uuid("maintainer_id")
1947 .notNull()
1948 .references(() => users.id, { onDelete: "cascade" }),
1949 name: text("name").notNull(),
1950 description: text("description").default("").notNull(),
1951 monthlyCents: integer("monthly_cents").notNull(),
1952 oneTimeAllowed: boolean("one_time_allowed").default(true).notNull(),
1953 isActive: boolean("is_active").default(true).notNull(),
1954 createdAt: timestamp("created_at").defaultNow().notNull(),
1955 },
1956 (table) => [
1957 index("sponsor_tiers_maintainer").on(table.maintainerId, table.isActive),
1958 ]
1959);
1960
1961export type SponsorshipTier = typeof sponsorshipTiers.$inferSelect;
1962
1963export const sponsorships = pgTable(
1964 "sponsorships",
1965 {
1966 id: uuid("id").primaryKey().defaultRandom(),
1967 sponsorId: uuid("sponsor_id")
1968 .notNull()
1969 .references(() => users.id, { onDelete: "cascade" }),
1970 maintainerId: uuid("maintainer_id")
1971 .notNull()
1972 .references(() => users.id, { onDelete: "cascade" }),
1973 tierId: uuid("tier_id"),
1974 amountCents: integer("amount_cents").notNull(),
1975 kind: text("kind").notNull(), // one_time | monthly
1976 note: text("note"),
1977 isPublic: boolean("is_public").default(true).notNull(),
1978 externalRef: text("external_ref"),
1979 cancelledAt: timestamp("cancelled_at"),
1980 createdAt: timestamp("created_at").defaultNow().notNull(),
1981 },
1982 (table) => [
1983 index("sponsorships_maintainer").on(
1984 table.maintainerId,
1985 table.createdAt
1986 ),
1987 index("sponsorships_sponsor").on(table.sponsorId, table.createdAt),
1988 ]
1989);
1990
1991export type Sponsorship = typeof sponsorships.$inferSelect;
4c8f666Claude1992
1993// Block I8 — Code symbol index for xref navigation.
1994export const codeSymbols = pgTable(
1995 "code_symbols",
1996 {
1997 id: uuid("id").primaryKey().defaultRandom(),
1998 repositoryId: uuid("repository_id")
1999 .notNull()
2000 .references(() => repositories.id, { onDelete: "cascade" }),
2001 commitSha: text("commit_sha").notNull(),
2002 name: text("name").notNull(),
2003 kind: text("kind").notNull(), // function | class | interface | type | const | variable
2004 path: text("path").notNull(),
2005 line: integer("line").notNull(),
2006 signature: text("signature"),
2007 createdAt: timestamp("created_at").defaultNow().notNull(),
2008 },
2009 (table) => [
2010 index("code_symbols_repo_name_idx").on(table.repositoryId, table.name),
2011 index("code_symbols_repo_path_idx").on(table.repositoryId, table.path),
2012 ]
2013);
2014
2015export type CodeSymbol = typeof codeSymbols.$inferSelect;
4a0dea1Claude2016
2017// Block I9 — Repository mirroring. One row per mirrored repo + an
2018// append-only log of sync attempts.
2019export const repoMirrors = pgTable("repo_mirrors", {
2020 id: uuid("id").primaryKey().defaultRandom(),
2021 repositoryId: uuid("repository_id")
2022 .notNull()
2023 .unique()
2024 .references(() => repositories.id, { onDelete: "cascade" }),
2025 upstreamUrl: text("upstream_url").notNull(),
2026 intervalMinutes: integer("interval_minutes").default(1440).notNull(),
2027 lastSyncedAt: timestamp("last_synced_at"),
2028 lastStatus: text("last_status"), // "ok" | "error"
2029 lastError: text("last_error"),
2030 isEnabled: boolean("is_enabled").default(true).notNull(),
2031 createdAt: timestamp("created_at").defaultNow().notNull(),
2032 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2033});
2034
2035export type RepoMirror = typeof repoMirrors.$inferSelect;
2036
2037export const repoMirrorRuns = pgTable(
2038 "repo_mirror_runs",
2039 {
2040 id: uuid("id").primaryKey().defaultRandom(),
2041 mirrorId: uuid("mirror_id")
2042 .notNull()
2043 .references(() => repoMirrors.id, { onDelete: "cascade" }),
2044 startedAt: timestamp("started_at").defaultNow().notNull(),
2045 finishedAt: timestamp("finished_at"),
2046 status: text("status").default("running").notNull(),
2047 message: text("message"),
2048 exitCode: integer("exit_code"),
2049 },
2050 (table) => [
2051 index("repo_mirror_runs_mirror_id_idx").on(table.mirrorId, table.startedAt),
2052 ]
2053);
2054
2055export type RepoMirrorRun = typeof repoMirrorRuns.$inferSelect;
edf7c36Claude2056
2057// ----------------------------------------------------------------------------
2058// Block I10 — Enterprise SSO (OIDC)
2059// ----------------------------------------------------------------------------
2060
2061/** Site-wide SSO provider. Singleton row with id = 'default'. */
2062export const ssoConfig = pgTable("sso_config", {
2063 id: text("id").primaryKey(),
2064 enabled: boolean("enabled").default(false).notNull(),
2065 providerName: text("provider_name").default("SSO").notNull(),
2066 issuer: text("issuer"),
2067 authorizationEndpoint: text("authorization_endpoint"),
2068 tokenEndpoint: text("token_endpoint"),
2069 userinfoEndpoint: text("userinfo_endpoint"),
2070 clientId: text("client_id"),
2071 clientSecret: text("client_secret"),
2072 scopes: text("scopes").default("openid profile email").notNull(),
2073 allowedEmailDomains: text("allowed_email_domains"),
2074 autoCreateUsers: boolean("auto_create_users").default(true).notNull(),
2075 createdAt: timestamp("created_at").defaultNow().notNull(),
2076 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2077});
2078
2079export type SsoConfig = typeof ssoConfig.$inferSelect;
2080
2081/** Maps a local user to an IdP `sub` claim. */
2082export const ssoUserLinks = pgTable(
2083 "sso_user_links",
2084 {
2085 id: uuid("id").primaryKey().defaultRandom(),
2086 userId: uuid("user_id")
2087 .notNull()
2088 .references(() => users.id, { onDelete: "cascade" }),
2089 subject: text("subject").notNull().unique(),
2090 emailAtLink: text("email_at_link").notNull(),
2091 linkedAt: timestamp("linked_at").defaultNow().notNull(),
2092 },
2093 (table) => [index("sso_user_links_user_id_idx").on(table.userId)]
2094);
2095
2096export type SsoUserLink = typeof ssoUserLinks.$inferSelect;
8098672Claude2097
2098// ----------------------------------------------------------------------------
2099// Block J1 — Dependency graph
2100// ----------------------------------------------------------------------------
2101
2102/**
2103 * Last known set of dependencies parsed from manifest files. Each reindex
2104 * replaces the prior rows for that repo. One row per (ecosystem, name,
2105 * manifest_path) — same name in multiple manifests is kept.
2106 */
2107export const repoDependencies = pgTable(
2108 "repo_dependencies",
2109 {
2110 id: uuid("id").primaryKey().defaultRandom(),
2111 repositoryId: uuid("repository_id")
2112 .notNull()
2113 .references(() => repositories.id, { onDelete: "cascade" }),
2114 ecosystem: text("ecosystem").notNull(),
2115 name: text("name").notNull(),
2116 versionSpec: text("version_spec"),
2117 manifestPath: text("manifest_path").notNull(),
2118 isDev: boolean("is_dev").default(false).notNull(),
2119 commitSha: text("commit_sha").notNull(),
2120 indexedAt: timestamp("indexed_at").defaultNow().notNull(),
2121 },
2122 (table) => [
2123 index("repo_dependencies_repo_id_idx").on(
2124 table.repositoryId,
2125 table.ecosystem
2126 ),
2127 index("repo_dependencies_name_idx").on(table.name),
2128 ]
2129);
2130
2131export type RepoDependency = typeof repoDependencies.$inferSelect;
f60ccdeClaude2132
2133// ----------------------------------------------------------------------------
2134// Block J2 — Security advisories + alerts
2135// ----------------------------------------------------------------------------
2136
2137/** CVE-style package advisories. Populated via seed + admin import. */
2138export const securityAdvisories = pgTable(
2139 "security_advisories",
2140 {
2141 id: uuid("id").primaryKey().defaultRandom(),
2142 ghsaId: text("ghsa_id").unique(),
2143 cveId: text("cve_id"),
2144 summary: text("summary").notNull(),
2145 severity: text("severity").default("moderate").notNull(),
2146 ecosystem: text("ecosystem").notNull(),
2147 packageName: text("package_name").notNull(),
2148 affectedRange: text("affected_range").notNull(),
2149 fixedVersion: text("fixed_version"),
2150 referenceUrl: text("reference_url"),
2151 publishedAt: timestamp("published_at").defaultNow().notNull(),
2152 },
2153 (table) => [
2154 index("security_advisories_pkg_idx").on(
2155 table.ecosystem,
2156 table.packageName
2157 ),
2158 ]
2159);
2160
2161export type SecurityAdvisory = typeof securityAdvisories.$inferSelect;
2162
2163/** Per-repo match state. One row per (repo, advisory, manifest_path). */
2164export const repoAdvisoryAlerts = pgTable(
2165 "repo_advisory_alerts",
2166 {
2167 id: uuid("id").primaryKey().defaultRandom(),
2168 repositoryId: uuid("repository_id")
2169 .notNull()
2170 .references(() => repositories.id, { onDelete: "cascade" }),
2171 advisoryId: uuid("advisory_id")
2172 .notNull()
2173 .references(() => securityAdvisories.id, { onDelete: "cascade" }),
2174 dependencyName: text("dependency_name").notNull(),
2175 dependencyVersion: text("dependency_version"),
2176 manifestPath: text("manifest_path").notNull(),
2177 status: text("status").default("open").notNull(),
2178 dismissedReason: text("dismissed_reason"),
2179 createdAt: timestamp("created_at").defaultNow().notNull(),
2180 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2181 },
2182 (table) => [
2183 index("repo_advisory_alerts_status_idx").on(
2184 table.repositoryId,
2185 table.status
2186 ),
2187 ]
2188);
2189
2190export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
3951454Claude2191
2192// ----------------------------------------------------------------------------
2193// Block J3 — Commit signature verification (GPG + SSH)
2194// ----------------------------------------------------------------------------
2195
2196/** Per-user GPG/SSH public keys for commit signing. */
2197export const signingKeys = pgTable(
2198 "signing_keys",
2199 {
2200 id: uuid("id").primaryKey().defaultRandom(),
2201 userId: uuid("user_id")
2202 .notNull()
2203 .references(() => users.id, { onDelete: "cascade" }),
2204 keyType: text("key_type").notNull(), // 'gpg' | 'ssh'
2205 title: text("title").notNull(),
2206 fingerprint: text("fingerprint").notNull(),
2207 publicKey: text("public_key").notNull(),
2208 email: text("email"),
2209 expiresAt: timestamp("expires_at"),
2210 lastUsedAt: timestamp("last_used_at"),
2211 createdAt: timestamp("created_at").defaultNow().notNull(),
2212 },
2213 (table) => [
2214 uniqueIndex("signing_keys_fp_unique").on(table.keyType, table.fingerprint),
2215 index("signing_keys_user_idx").on(table.userId),
2216 ]
2217);
2218
2219export type SigningKey = typeof signingKeys.$inferSelect;
2220
2221/**
2222 * Cached verification result for a (repo, commit) pair. Repopulated on demand;
2223 * rows are invalidated implicitly by CASCADE when either side is removed.
2224 */
2225export const commitVerifications = pgTable(
2226 "commit_verifications",
2227 {
2228 id: uuid("id").primaryKey().defaultRandom(),
2229 repositoryId: uuid("repository_id")
2230 .notNull()
2231 .references(() => repositories.id, { onDelete: "cascade" }),
2232 commitSha: text("commit_sha").notNull(),
2233 verified: boolean("verified").default(false).notNull(),
2234 reason: text("reason").notNull(),
2235 signatureType: text("signature_type"),
2236 signerKeyId: uuid("signer_key_id").references(() => signingKeys.id, {
2237 onDelete: "set null",
2238 }),
2239 signerUserId: uuid("signer_user_id").references(() => users.id, {
2240 onDelete: "set null",
2241 }),
2242 signerFingerprint: text("signer_fingerprint"),
2243 verifiedAt: timestamp("verified_at").defaultNow().notNull(),
2244 },
2245 (table) => [
2246 uniqueIndex("commit_verifications_sha_unique").on(
2247 table.repositoryId,
2248 table.commitSha
2249 ),
2250 ]
2251);
2252
2253export type CommitVerification = typeof commitVerifications.$inferSelect;
7aa8b99Claude2254
2255// ----------------------------------------------------------------------------
2256// Block J4 — User following
2257// ----------------------------------------------------------------------------
2258
2259/**
2260 * Directed user→user follow edges. Primary key is the composite
2261 * (follower_id, following_id) at the SQL level; drizzle sees it as a
2262 * regular table with a unique index plus a secondary index on the
2263 * reverse-lookup column.
2264 */
2265export const userFollows = pgTable(
2266 "user_follows",
2267 {
2268 followerId: uuid("follower_id")
2269 .notNull()
2270 .references(() => users.id, { onDelete: "cascade" }),
2271 followingId: uuid("following_id")
2272 .notNull()
2273 .references(() => users.id, { onDelete: "cascade" }),
2274 createdAt: timestamp("created_at").defaultNow().notNull(),
2275 },
2276 (table) => [
2277 uniqueIndex("user_follows_pair_unique").on(
2278 table.followerId,
2279 table.followingId
2280 ),
2281 index("user_follows_following_idx").on(table.followingId),
2282 ]
2283);
2284
2285export type UserFollow = typeof userFollows.$inferSelect;
9ff7128Claude2286
2287// ----------------------------------------------------------------------------
2288// Block J6 — Repository rulesets
2289// ----------------------------------------------------------------------------
2290
2291/**
2292 * A ruleset groups N rules under a named policy at enforcement level active /
2293 * evaluate / disabled. Unique per (repo, name) so a repo can carry multiple
2294 * overlapping rulesets (e.g. "release branches" vs "everywhere").
2295 */
2296export const repoRulesets = pgTable(
2297 "repo_rulesets",
2298 {
2299 id: uuid("id").primaryKey().defaultRandom(),
2300 repositoryId: uuid("repository_id")
2301 .notNull()
2302 .references(() => repositories.id, { onDelete: "cascade" }),
2303 name: text("name").notNull(),
2304 enforcement: text("enforcement").default("active").notNull(),
2305 createdBy: uuid("created_by").references(() => users.id, {
2306 onDelete: "set null",
2307 }),
2308 createdAt: timestamp("created_at").defaultNow().notNull(),
2309 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2310 },
2311 (table) => [
2312 index("repo_rulesets_repo_idx").on(table.repositoryId),
2313 uniqueIndex("repo_rulesets_repo_name_unique").on(
2314 table.repositoryId,
2315 table.name
2316 ),
2317 ]
2318);
2319
2320export type RepoRuleset = typeof repoRulesets.$inferSelect;
2321
2322/** Individual rule — type tag plus JSON params. */
2323export const rulesetRules = pgTable(
2324 "ruleset_rules",
2325 {
2326 id: uuid("id").primaryKey().defaultRandom(),
2327 rulesetId: uuid("ruleset_id")
2328 .notNull()
2329 .references(() => repoRulesets.id, { onDelete: "cascade" }),
2330 ruleType: text("rule_type").notNull(),
2331 params: text("params").default("{}").notNull(),
2332 createdAt: timestamp("created_at").defaultNow().notNull(),
2333 },
2334 (table) => [index("ruleset_rules_set_idx").on(table.rulesetId)]
2335);
2336
2337export type RulesetRule = typeof rulesetRules.$inferSelect;
0cdfd89Claude2338
2339// ---------------------------------------------------------------------------
2340// Block J8 — Commit statuses.
2341// ---------------------------------------------------------------------------
2342
2343/**
2344 * External CI / automation posts per-commit (sha, context) statuses. Upsert
2345 * semantics keyed on (repository, commit_sha, context). State vocabulary:
2346 * pending | success | failure | error. Combined rollup logic lives in
2347 * `src/lib/commit-statuses.ts`.
2348 */
2349export const commitStatuses = pgTable(
2350 "commit_statuses",
2351 {
2352 id: uuid("id").primaryKey().defaultRandom(),
2353 repositoryId: uuid("repository_id")
2354 .notNull()
2355 .references(() => repositories.id, { onDelete: "cascade" }),
2356 commitSha: text("commit_sha").notNull(),
2357 state: text("state").notNull(),
2358 context: text("context").default("default").notNull(),
2359 description: text("description"),
2360 targetUrl: text("target_url"),
2361 creatorId: uuid("creator_id").references(() => users.id, {
2362 onDelete: "set null",
2363 }),
2364 createdAt: timestamp("created_at").defaultNow().notNull(),
2365 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2366 },
2367 (table) => [
2368 uniqueIndex("commit_statuses_repo_sha_context_unique").on(
2369 table.repositoryId,
2370 table.commitSha,
2371 table.context
2372 ),
2373 index("commit_statuses_repo_sha_idx").on(
2374 table.repositoryId,
2375 table.commitSha
2376 ),
2377 ]
2378);
2379
2380export type CommitStatus = typeof commitStatuses.$inferSelect;
23d1a81Claude2381
2382// ---------------------------------------------------------------------------
2383// Collaborators — per-repo role grants (read / write / admin).
2384// ---------------------------------------------------------------------------
2385
2386/**
2387 * A user granted access to a repository beyond ownership. Roles are
2388 * hierarchical — 'admin' implies write, write implies read. `invitedBy` tracks
2389 * who added them (nullable once that user is deleted); `acceptedAt` is null
2390 * until the invitee explicitly accepts. Unique per (repo, user) so a given
2391 * user has at most one role per repo.
2392 */
2393export const repoCollaborators = pgTable(
2394 "repo_collaborators",
2395 {
2396 id: uuid("id").primaryKey().defaultRandom(),
2397 repositoryId: uuid("repository_id")
2398 .notNull()
2399 .references(() => repositories.id, { onDelete: "cascade" }),
2400 userId: uuid("user_id")
2401 .notNull()
2402 .references(() => users.id, { onDelete: "cascade" }),
2403 role: text("role", { enum: ["read", "write", "admin"] })
2404 .notNull()
2405 .default("read"),
2406 invitedBy: uuid("invited_by").references(() => users.id, {
2407 onDelete: "set null",
2408 }),
2409 invitedAt: timestamp("invited_at").defaultNow().notNull(),
2410 acceptedAt: timestamp("accepted_at"),
febd4f0Claude2411 // sha256(plaintext) of the outstanding invite token. Set when the owner
2412 // sends an invite, cleared when the invitee accepts. NULL on older rows
2413 // that were auto-accepted before the email flow existed.
2414 inviteTokenHash: text("invite_token_hash"),
23d1a81Claude2415 },
2416 (table) => [
2417 uniqueIndex("repo_collaborators_repo_user_uq").on(
2418 table.repositoryId,
2419 table.userId
2420 ),
2421 index("repo_collaborators_repo_idx").on(table.repositoryId),
2422 index("repo_collaborators_user_idx").on(table.userId),
2423 ]
2424);
2425
2426export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
abfa9adClaude2427
2428// ---------------------------------------------------------------------------
2429// Workflow engine v2 — Sprint 1 additions (drizzle/0037_workflow_engine_v2.sql)
2430//
2431// Strictly additive to Block C1. The original workflow tables defined above
2432// (workflows / workflow_runs / workflow_jobs / workflow_artifacts) are locked
2433// and must not be altered in-place; the four tables below extend the runner
2434// with secrets, workflow_dispatch inputs, a content-addressable cache, and a
2435// warm-runner worker pool.
2436// ---------------------------------------------------------------------------
2437
2438/**
2439 * Per-repo encrypted secrets exposed to workflow jobs as env vars.
2440 *
2441 * `encrypted_value` is base64 of `iv || authTag || ciphertext` produced by
2442 * AES-256-GCM — the crypto lives in `src/lib/workflow-crypto.ts` (Agent 2);
2443 * the DB only stores opaque ciphertext. `name` is validated against
2444 * `[A-Z_][A-Z0-9_]*` at the write-site, not the DB. Unique per (repo, name).
2445 */
2446export const workflowSecrets = pgTable(
2447 "workflow_secrets",
2448 {
2449 id: uuid("id").primaryKey().defaultRandom(),
2450 repositoryId: uuid("repository_id")
2451 .notNull()
2452 .references(() => repositories.id, { onDelete: "cascade" }),
2453 name: text("name").notNull(),
2454 encryptedValue: text("encrypted_value").notNull(),
2455 createdBy: uuid("created_by").references(() => users.id, {
2456 onDelete: "set null",
2457 }),
2458 createdAt: timestamp("created_at").defaultNow().notNull(),
2459 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2460 },
2461 (table) => [
2462 uniqueIndex("workflow_secrets_repo_name_uq").on(
2463 table.repositoryId,
2464 table.name
2465 ),
2466 index("workflow_secrets_repo_idx").on(table.repositoryId),
2467 ]
2468);
2469
2470export type WorkflowSecret = typeof workflowSecrets.$inferSelect;
2471export type NewWorkflowSecret = typeof workflowSecrets.$inferInsert;
2472
2473/**
2474 * Parameter schema for the `workflow_dispatch` trigger. One row per input
2475 * declared in the workflow YAML. `type` is constrained to the four values
2476 * GitHub Actions supports; `options` is only meaningful for type='choice'
2477 * (a JSON array of allowed strings). `default_value` and `description` are
2478 * optional. Unique per (workflow, name) so two inputs on the same workflow
2479 * can't share an identifier.
2480 */
2481export const workflowDispatchInputs = pgTable(
2482 "workflow_dispatch_inputs",
2483 {
2484 id: uuid("id").primaryKey().defaultRandom(),
2485 workflowId: uuid("workflow_id")
2486 .notNull()
2487 .references(() => workflows.id, { onDelete: "cascade" }),
2488 name: text("name").notNull(),
2489 type: text("type", {
2490 enum: ["string", "boolean", "choice", "number"],
2491 }).notNull(),
2492 required: boolean("required").default(false).notNull(),
2493 defaultValue: text("default_value"),
2494 // JSON array of strings; only populated when type = 'choice'.
2495 options: jsonb("options"),
2496 description: text("description"),
2497 },
2498 (table) => [
2499 uniqueIndex("workflow_dispatch_inputs_wf_name_uq").on(
2500 table.workflowId,
2501 table.name
2502 ),
2503 ]
2504);
2505
2506export type WorkflowDispatchInput =
2507 typeof workflowDispatchInputs.$inferSelect;
2508export type NewWorkflowDispatchInput =
2509 typeof workflowDispatchInputs.$inferInsert;
2510
2511/**
2512 * Content-addressable cache backing the `gluecron/cache@v1` action. Rows are
2513 * keyed by user-chosen `cache_key` within a `scope` — 'repo' for repo-wide
2514 * entries, 'branch' or 'tag' when isolation is desired (with `scope_ref`
2515 * holding the branch/tag name). `content_hash` is the sha256 of `content`
2516 * so jobs can short-circuit redundant writes. `content` is real bytea; the
2517 * 100MB payload cap is enforced at the write-site, not by the DB. LRU
2518 * eviction reads (`repository_id`, `last_accessed_at`).
2519 */
2520export const workflowRunCache = pgTable(
2521 "workflow_run_cache",
2522 {
2523 id: uuid("id").primaryKey().defaultRandom(),
2524 repositoryId: uuid("repository_id")
2525 .notNull()
2526 .references(() => repositories.id, { onDelete: "cascade" }),
2527 cacheKey: text("cache_key").notNull(),
2528 // 'repo' | 'branch' | 'tag' — constraint enforced at the write-site.
2529 scope: text("scope").default("repo").notNull(),
2530 scopeRef: text("scope_ref"),
2531 contentHash: text("content_hash").notNull(),
2532 content: bytea("content").notNull(),
2533 sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
2534 createdAt: timestamp("created_at").defaultNow().notNull(),
2535 lastAccessedAt: timestamp("last_accessed_at").defaultNow().notNull(),
2536 },
2537 (table) => [
2538 uniqueIndex("workflow_run_cache_repo_key_scope_uq").on(
2539 table.repositoryId,
2540 table.cacheKey,
2541 table.scope,
2542 table.scopeRef
2543 ),
2544 index("workflow_run_cache_repo_lru_idx").on(
2545 table.repositoryId,
2546 table.lastAccessedAt
2547 ),
2548 ]
2549);
2550
2551export type WorkflowRunCache = typeof workflowRunCache.$inferSelect;
2552export type NewWorkflowRunCache = typeof workflowRunCache.$inferInsert;
2553
2554/**
2555 * Warm-runner worker registry. The scheduler reads idle rows to dispatch
2556 * queued jobs without paying cold-start cost; workers heartbeat here so
2557 * stale entries (>N seconds since `last_heartbeat_at`) can be reaped.
2558 * `worker_id` is a stable string (hostname:pid or similar) and is globally
2559 * unique so a restarted worker naturally replaces its predecessor.
2560 * `current_run_id` is set when status='busy' and cleared on completion.
2561 */
2562export const workflowRunnerPool = pgTable(
2563 "workflow_runner_pool",
2564 {
2565 id: uuid("id").primaryKey().defaultRandom(),
2566 workerId: text("worker_id").notNull().unique(),
2567 status: text("status", {
2568 enum: ["idle", "busy", "draining", "dead"],
2569 }).notNull(),
2570 currentRunId: uuid("current_run_id").references(() => workflowRuns.id, {
2571 onDelete: "set null",
2572 }),
2573 warmedAt: timestamp("warmed_at").defaultNow().notNull(),
2574 lastHeartbeatAt: timestamp("last_heartbeat_at").defaultNow().notNull(),
2575 capacity: integer("capacity").default(1).notNull(),
2576 },
2577 (table) => [index("workflow_runner_pool_status_idx").on(table.status)]
2578);
2579
2580export type WorkflowRunnerPoolEntry =
2581 typeof workflowRunnerPool.$inferSelect;
2582export type NewWorkflowRunnerPoolEntry =
2583 typeof workflowRunnerPool.$inferInsert;