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