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

schema.ts

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

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