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

schema.ts

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

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