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

schema.ts

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

schema.tsBlame2680 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(),
1804});
1805
1806export type UserQuota = typeof userQuotas.$inferSelect;
06139e6Claude1807
1808// Block H — App marketplace + bot identities (GitHub Apps equivalent)
1809
1810export const apps = pgTable(
1811 "apps",
1812 {
1813 id: uuid("id").primaryKey().defaultRandom(),
1814 slug: text("slug").notNull().unique(),
1815 name: text("name").notNull(),
1816 description: text("description").notNull().default(""),
1817 iconUrl: text("icon_url"),
1818 homepageUrl: text("homepage_url"),
1819 webhookUrl: text("webhook_url"),
1820 webhookSecret: text("webhook_secret"),
1821 creatorId: uuid("creator_id")
1822 .notNull()
1823 .references(() => users.id, { onDelete: "cascade" }),
1824 permissions: text("permissions").notNull().default("[]"), // JSON array
1825 defaultEvents: text("default_events").notNull().default("[]"), // JSON array
1826 isPublic: boolean("is_public").notNull().default(true),
1827 createdAt: timestamp("created_at").defaultNow().notNull(),
1828 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1829 },
1830 (table) => [index("apps_public_slug").on(table.isPublic, table.slug)]
1831);
1832
1833export type App = typeof apps.$inferSelect;
1834
1835export const appInstallations = pgTable(
1836 "app_installations",
1837 {
1838 id: uuid("id").primaryKey().defaultRandom(),
1839 appId: uuid("app_id")
1840 .notNull()
1841 .references(() => apps.id, { onDelete: "cascade" }),
1842 installedBy: uuid("installed_by")
1843 .notNull()
1844 .references(() => users.id, { onDelete: "cascade" }),
1845 targetType: text("target_type").notNull(), // user | org | repository
1846 targetId: uuid("target_id").notNull(),
1847 grantedPermissions: text("granted_permissions").notNull().default("[]"),
1848 suspendedAt: timestamp("suspended_at"),
1849 createdAt: timestamp("created_at").defaultNow().notNull(),
1850 uninstalledAt: timestamp("uninstalled_at"),
1851 },
1852 (table) => [
1853 index("app_installations_app").on(table.appId),
1854 index("app_installations_target").on(table.targetType, table.targetId),
1855 ]
1856);
1857
1858export type AppInstallation = typeof appInstallations.$inferSelect;
1859
1860export const appBots = pgTable("app_bots", {
1861 id: uuid("id").primaryKey().defaultRandom(),
1862 appId: uuid("app_id")
1863 .notNull()
1864 .unique()
1865 .references(() => apps.id, { onDelete: "cascade" }),
1866 username: text("username").notNull().unique(),
1867 displayName: text("display_name").notNull(),
1868 avatarUrl: text("avatar_url"),
1869 createdAt: timestamp("created_at").defaultNow().notNull(),
1870});
1871
1872export type AppBot = typeof appBots.$inferSelect;
1873
1874export const appInstallTokens = pgTable(
1875 "app_install_tokens",
1876 {
1877 id: uuid("id").primaryKey().defaultRandom(),
1878 installationId: uuid("installation_id")
1879 .notNull()
1880 .references(() => appInstallations.id, { onDelete: "cascade" }),
1881 tokenHash: text("token_hash").notNull().unique(),
1882 expiresAt: timestamp("expires_at").notNull(),
1883 createdAt: timestamp("created_at").defaultNow().notNull(),
1884 revokedAt: timestamp("revoked_at"),
1885 },
1886 (table) => [index("app_install_tokens_hash").on(table.tokenHash)]
1887);
1888
1889export type AppInstallToken = typeof appInstallTokens.$inferSelect;
1890
1891export const appEvents = pgTable(
1892 "app_events",
1893 {
1894 id: uuid("id").primaryKey().defaultRandom(),
1895 appId: uuid("app_id")
1896 .notNull()
1897 .references(() => apps.id, { onDelete: "cascade" }),
1898 installationId: uuid("installation_id"),
1899 kind: text("kind").notNull(), // installed | uninstalled | delivery_ok | delivery_fail
1900 payload: text("payload"),
1901 responseStatus: integer("response_status"),
1902 createdAt: timestamp("created_at").defaultNow().notNull(),
1903 },
1904 (table) => [index("app_events_app_time").on(table.appId, table.createdAt)]
1905);
1906
1907export type AppEvent = typeof appEvents.$inferSelect;
71cd5ecClaude1908
1909// ---------- Block I3 — Repository transfer history ----------
1910
1911export const repoTransfers = pgTable(
1912 "repo_transfers",
1913 {
1914 id: uuid("id").primaryKey().defaultRandom(),
1915 repositoryId: uuid("repository_id")
1916 .notNull()
1917 .references(() => repositories.id, { onDelete: "cascade" }),
1918 fromOwnerId: uuid("from_owner_id").notNull(),
1919 fromOrgId: uuid("from_org_id"),
1920 toOwnerId: uuid("to_owner_id").notNull(),
1921 toOrgId: uuid("to_org_id"),
1922 initiatedBy: uuid("initiated_by")
1923 .notNull()
1924 .references(() => users.id, { onDelete: "cascade" }),
1925 createdAt: timestamp("created_at").defaultNow().notNull(),
1926 },
1927 (table) => [
1928 index("repo_transfers_repo").on(table.repositoryId, table.createdAt),
1929 ]
1930);
1931
1932export type RepoTransfer = typeof repoTransfers.$inferSelect;
08420cdClaude1933
1934// ---------- Block I6 — Sponsors ----------
1935
1936export const sponsorshipTiers = pgTable(
1937 "sponsorship_tiers",
1938 {
1939 id: uuid("id").primaryKey().defaultRandom(),
1940 maintainerId: uuid("maintainer_id")
1941 .notNull()
1942 .references(() => users.id, { onDelete: "cascade" }),
1943 name: text("name").notNull(),
1944 description: text("description").default("").notNull(),
1945 monthlyCents: integer("monthly_cents").notNull(),
1946 oneTimeAllowed: boolean("one_time_allowed").default(true).notNull(),
1947 isActive: boolean("is_active").default(true).notNull(),
1948 createdAt: timestamp("created_at").defaultNow().notNull(),
1949 },
1950 (table) => [
1951 index("sponsor_tiers_maintainer").on(table.maintainerId, table.isActive),
1952 ]
1953);
1954
1955export type SponsorshipTier = typeof sponsorshipTiers.$inferSelect;
1956
1957export const sponsorships = pgTable(
1958 "sponsorships",
1959 {
1960 id: uuid("id").primaryKey().defaultRandom(),
1961 sponsorId: uuid("sponsor_id")
1962 .notNull()
1963 .references(() => users.id, { onDelete: "cascade" }),
1964 maintainerId: uuid("maintainer_id")
1965 .notNull()
1966 .references(() => users.id, { onDelete: "cascade" }),
1967 tierId: uuid("tier_id"),
1968 amountCents: integer("amount_cents").notNull(),
1969 kind: text("kind").notNull(), // one_time | monthly
1970 note: text("note"),
1971 isPublic: boolean("is_public").default(true).notNull(),
1972 externalRef: text("external_ref"),
1973 cancelledAt: timestamp("cancelled_at"),
1974 createdAt: timestamp("created_at").defaultNow().notNull(),
1975 },
1976 (table) => [
1977 index("sponsorships_maintainer").on(
1978 table.maintainerId,
1979 table.createdAt
1980 ),
1981 index("sponsorships_sponsor").on(table.sponsorId, table.createdAt),
1982 ]
1983);
1984
1985export type Sponsorship = typeof sponsorships.$inferSelect;
4c8f666Claude1986
1987// Block I8 — Code symbol index for xref navigation.
1988export const codeSymbols = pgTable(
1989 "code_symbols",
1990 {
1991 id: uuid("id").primaryKey().defaultRandom(),
1992 repositoryId: uuid("repository_id")
1993 .notNull()
1994 .references(() => repositories.id, { onDelete: "cascade" }),
1995 commitSha: text("commit_sha").notNull(),
1996 name: text("name").notNull(),
1997 kind: text("kind").notNull(), // function | class | interface | type | const | variable
1998 path: text("path").notNull(),
1999 line: integer("line").notNull(),
2000 signature: text("signature"),
2001 createdAt: timestamp("created_at").defaultNow().notNull(),
2002 },
2003 (table) => [
2004 index("code_symbols_repo_name_idx").on(table.repositoryId, table.name),
2005 index("code_symbols_repo_path_idx").on(table.repositoryId, table.path),
2006 ]
2007);
2008
2009export type CodeSymbol = typeof codeSymbols.$inferSelect;
4a0dea1Claude2010
2011// Block I9 — Repository mirroring. One row per mirrored repo + an
2012// append-only log of sync attempts.
2013export const repoMirrors = pgTable("repo_mirrors", {
2014 id: uuid("id").primaryKey().defaultRandom(),
2015 repositoryId: uuid("repository_id")
2016 .notNull()
2017 .unique()
2018 .references(() => repositories.id, { onDelete: "cascade" }),
2019 upstreamUrl: text("upstream_url").notNull(),
2020 intervalMinutes: integer("interval_minutes").default(1440).notNull(),
2021 lastSyncedAt: timestamp("last_synced_at"),
2022 lastStatus: text("last_status"), // "ok" | "error"
2023 lastError: text("last_error"),
2024 isEnabled: boolean("is_enabled").default(true).notNull(),
2025 createdAt: timestamp("created_at").defaultNow().notNull(),
2026 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2027});
2028
2029export type RepoMirror = typeof repoMirrors.$inferSelect;
2030
2031export const repoMirrorRuns = pgTable(
2032 "repo_mirror_runs",
2033 {
2034 id: uuid("id").primaryKey().defaultRandom(),
2035 mirrorId: uuid("mirror_id")
2036 .notNull()
2037 .references(() => repoMirrors.id, { onDelete: "cascade" }),
2038 startedAt: timestamp("started_at").defaultNow().notNull(),
2039 finishedAt: timestamp("finished_at"),
2040 status: text("status").default("running").notNull(),
2041 message: text("message"),
2042 exitCode: integer("exit_code"),
2043 },
2044 (table) => [
2045 index("repo_mirror_runs_mirror_id_idx").on(table.mirrorId, table.startedAt),
2046 ]
2047);
2048
2049export type RepoMirrorRun = typeof repoMirrorRuns.$inferSelect;
edf7c36Claude2050
2051// ----------------------------------------------------------------------------
2052// Block I10 — Enterprise SSO (OIDC)
2053// ----------------------------------------------------------------------------
2054
2055/** Site-wide SSO provider. Singleton row with id = 'default'. */
2056export const ssoConfig = pgTable("sso_config", {
2057 id: text("id").primaryKey(),
2058 enabled: boolean("enabled").default(false).notNull(),
2059 providerName: text("provider_name").default("SSO").notNull(),
2060 issuer: text("issuer"),
2061 authorizationEndpoint: text("authorization_endpoint"),
2062 tokenEndpoint: text("token_endpoint"),
2063 userinfoEndpoint: text("userinfo_endpoint"),
2064 clientId: text("client_id"),
2065 clientSecret: text("client_secret"),
2066 scopes: text("scopes").default("openid profile email").notNull(),
2067 allowedEmailDomains: text("allowed_email_domains"),
2068 autoCreateUsers: boolean("auto_create_users").default(true).notNull(),
2069 createdAt: timestamp("created_at").defaultNow().notNull(),
2070 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2071});
2072
2073export type SsoConfig = typeof ssoConfig.$inferSelect;
2074
2075/** Maps a local user to an IdP `sub` claim. */
2076export const ssoUserLinks = pgTable(
2077 "sso_user_links",
2078 {
2079 id: uuid("id").primaryKey().defaultRandom(),
2080 userId: uuid("user_id")
2081 .notNull()
2082 .references(() => users.id, { onDelete: "cascade" }),
2083 subject: text("subject").notNull().unique(),
2084 emailAtLink: text("email_at_link").notNull(),
2085 linkedAt: timestamp("linked_at").defaultNow().notNull(),
2086 },
2087 (table) => [index("sso_user_links_user_id_idx").on(table.userId)]
2088);
2089
2090export type SsoUserLink = typeof ssoUserLinks.$inferSelect;
8098672Claude2091
2092// ----------------------------------------------------------------------------
2093// Block J1 — Dependency graph
2094// ----------------------------------------------------------------------------
2095
2096/**
2097 * Last known set of dependencies parsed from manifest files. Each reindex
2098 * replaces the prior rows for that repo. One row per (ecosystem, name,
2099 * manifest_path) — same name in multiple manifests is kept.
2100 */
2101export const repoDependencies = pgTable(
2102 "repo_dependencies",
2103 {
2104 id: uuid("id").primaryKey().defaultRandom(),
2105 repositoryId: uuid("repository_id")
2106 .notNull()
2107 .references(() => repositories.id, { onDelete: "cascade" }),
2108 ecosystem: text("ecosystem").notNull(),
2109 name: text("name").notNull(),
2110 versionSpec: text("version_spec"),
2111 manifestPath: text("manifest_path").notNull(),
2112 isDev: boolean("is_dev").default(false).notNull(),
2113 commitSha: text("commit_sha").notNull(),
2114 indexedAt: timestamp("indexed_at").defaultNow().notNull(),
2115 },
2116 (table) => [
2117 index("repo_dependencies_repo_id_idx").on(
2118 table.repositoryId,
2119 table.ecosystem
2120 ),
2121 index("repo_dependencies_name_idx").on(table.name),
2122 ]
2123);
2124
2125export type RepoDependency = typeof repoDependencies.$inferSelect;
f60ccdeClaude2126
2127// ----------------------------------------------------------------------------
2128// Block J2 — Security advisories + alerts
2129// ----------------------------------------------------------------------------
2130
2131/** CVE-style package advisories. Populated via seed + admin import. */
2132export const securityAdvisories = pgTable(
2133 "security_advisories",
2134 {
2135 id: uuid("id").primaryKey().defaultRandom(),
2136 ghsaId: text("ghsa_id").unique(),
2137 cveId: text("cve_id"),
2138 summary: text("summary").notNull(),
2139 severity: text("severity").default("moderate").notNull(),
2140 ecosystem: text("ecosystem").notNull(),
2141 packageName: text("package_name").notNull(),
2142 affectedRange: text("affected_range").notNull(),
2143 fixedVersion: text("fixed_version"),
2144 referenceUrl: text("reference_url"),
2145 publishedAt: timestamp("published_at").defaultNow().notNull(),
2146 },
2147 (table) => [
2148 index("security_advisories_pkg_idx").on(
2149 table.ecosystem,
2150 table.packageName
2151 ),
2152 ]
2153);
2154
2155export type SecurityAdvisory = typeof securityAdvisories.$inferSelect;
2156
2157/** Per-repo match state. One row per (repo, advisory, manifest_path). */
2158export const repoAdvisoryAlerts = pgTable(
2159 "repo_advisory_alerts",
2160 {
2161 id: uuid("id").primaryKey().defaultRandom(),
2162 repositoryId: uuid("repository_id")
2163 .notNull()
2164 .references(() => repositories.id, { onDelete: "cascade" }),
2165 advisoryId: uuid("advisory_id")
2166 .notNull()
2167 .references(() => securityAdvisories.id, { onDelete: "cascade" }),
2168 dependencyName: text("dependency_name").notNull(),
2169 dependencyVersion: text("dependency_version"),
2170 manifestPath: text("manifest_path").notNull(),
2171 status: text("status").default("open").notNull(),
2172 dismissedReason: text("dismissed_reason"),
2173 createdAt: timestamp("created_at").defaultNow().notNull(),
2174 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2175 },
2176 (table) => [
2177 index("repo_advisory_alerts_status_idx").on(
2178 table.repositoryId,
2179 table.status
2180 ),
2181 ]
2182);
2183
2184export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
3951454Claude2185
2186// ----------------------------------------------------------------------------
2187// Block J3 — Commit signature verification (GPG + SSH)
2188// ----------------------------------------------------------------------------
2189
2190/** Per-user GPG/SSH public keys for commit signing. */
2191export const signingKeys = pgTable(
2192 "signing_keys",
2193 {
2194 id: uuid("id").primaryKey().defaultRandom(),
2195 userId: uuid("user_id")
2196 .notNull()
2197 .references(() => users.id, { onDelete: "cascade" }),
2198 keyType: text("key_type").notNull(), // 'gpg' | 'ssh'
2199 title: text("title").notNull(),
2200 fingerprint: text("fingerprint").notNull(),
2201 publicKey: text("public_key").notNull(),
2202 email: text("email"),
2203 expiresAt: timestamp("expires_at"),
2204 lastUsedAt: timestamp("last_used_at"),
2205 createdAt: timestamp("created_at").defaultNow().notNull(),
2206 },
2207 (table) => [
2208 uniqueIndex("signing_keys_fp_unique").on(table.keyType, table.fingerprint),
2209 index("signing_keys_user_idx").on(table.userId),
2210 ]
2211);
2212
2213export type SigningKey = typeof signingKeys.$inferSelect;
2214
2215/**
2216 * Cached verification result for a (repo, commit) pair. Repopulated on demand;
2217 * rows are invalidated implicitly by CASCADE when either side is removed.
2218 */
2219export const commitVerifications = pgTable(
2220 "commit_verifications",
2221 {
2222 id: uuid("id").primaryKey().defaultRandom(),
2223 repositoryId: uuid("repository_id")
2224 .notNull()
2225 .references(() => repositories.id, { onDelete: "cascade" }),
2226 commitSha: text("commit_sha").notNull(),
2227 verified: boolean("verified").default(false).notNull(),
2228 reason: text("reason").notNull(),
2229 signatureType: text("signature_type"),
2230 signerKeyId: uuid("signer_key_id").references(() => signingKeys.id, {
2231 onDelete: "set null",
2232 }),
2233 signerUserId: uuid("signer_user_id").references(() => users.id, {
2234 onDelete: "set null",
2235 }),
2236 signerFingerprint: text("signer_fingerprint"),
2237 verifiedAt: timestamp("verified_at").defaultNow().notNull(),
2238 },
2239 (table) => [
2240 uniqueIndex("commit_verifications_sha_unique").on(
2241 table.repositoryId,
2242 table.commitSha
2243 ),
2244 ]
2245);
2246
2247export type CommitVerification = typeof commitVerifications.$inferSelect;
7aa8b99Claude2248
2249// ----------------------------------------------------------------------------
2250// Block J4 — User following
2251// ----------------------------------------------------------------------------
2252
2253/**
2254 * Directed user→user follow edges. Primary key is the composite
2255 * (follower_id, following_id) at the SQL level; drizzle sees it as a
2256 * regular table with a unique index plus a secondary index on the
2257 * reverse-lookup column.
2258 */
2259export const userFollows = pgTable(
2260 "user_follows",
2261 {
2262 followerId: uuid("follower_id")
2263 .notNull()
2264 .references(() => users.id, { onDelete: "cascade" }),
2265 followingId: uuid("following_id")
2266 .notNull()
2267 .references(() => users.id, { onDelete: "cascade" }),
2268 createdAt: timestamp("created_at").defaultNow().notNull(),
2269 },
2270 (table) => [
2271 uniqueIndex("user_follows_pair_unique").on(
2272 table.followerId,
2273 table.followingId
2274 ),
2275 index("user_follows_following_idx").on(table.followingId),
2276 ]
2277);
2278
2279export type UserFollow = typeof userFollows.$inferSelect;
9ff7128Claude2280
2281// ----------------------------------------------------------------------------
2282// Block J6 — Repository rulesets
2283// ----------------------------------------------------------------------------
2284
2285/**
2286 * A ruleset groups N rules under a named policy at enforcement level active /
2287 * evaluate / disabled. Unique per (repo, name) so a repo can carry multiple
2288 * overlapping rulesets (e.g. "release branches" vs "everywhere").
2289 */
2290export const repoRulesets = pgTable(
2291 "repo_rulesets",
2292 {
2293 id: uuid("id").primaryKey().defaultRandom(),
2294 repositoryId: uuid("repository_id")
2295 .notNull()
2296 .references(() => repositories.id, { onDelete: "cascade" }),
2297 name: text("name").notNull(),
2298 enforcement: text("enforcement").default("active").notNull(),
2299 createdBy: uuid("created_by").references(() => users.id, {
2300 onDelete: "set null",
2301 }),
2302 createdAt: timestamp("created_at").defaultNow().notNull(),
2303 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2304 },
2305 (table) => [
2306 index("repo_rulesets_repo_idx").on(table.repositoryId),
2307 uniqueIndex("repo_rulesets_repo_name_unique").on(
2308 table.repositoryId,
2309 table.name
2310 ),
2311 ]
2312);
2313
2314export type RepoRuleset = typeof repoRulesets.$inferSelect;
2315
2316/** Individual rule — type tag plus JSON params. */
2317export const rulesetRules = pgTable(
2318 "ruleset_rules",
2319 {
2320 id: uuid("id").primaryKey().defaultRandom(),
2321 rulesetId: uuid("ruleset_id")
2322 .notNull()
2323 .references(() => repoRulesets.id, { onDelete: "cascade" }),
2324 ruleType: text("rule_type").notNull(),
2325 params: text("params").default("{}").notNull(),
2326 createdAt: timestamp("created_at").defaultNow().notNull(),
2327 },
2328 (table) => [index("ruleset_rules_set_idx").on(table.rulesetId)]
2329);
2330
2331export type RulesetRule = typeof rulesetRules.$inferSelect;
0cdfd89Claude2332
2333// ---------------------------------------------------------------------------
2334// Block J8 — Commit statuses.
2335// ---------------------------------------------------------------------------
2336
2337/**
2338 * External CI / automation posts per-commit (sha, context) statuses. Upsert
2339 * semantics keyed on (repository, commit_sha, context). State vocabulary:
2340 * pending | success | failure | error. Combined rollup logic lives in
2341 * `src/lib/commit-statuses.ts`.
2342 */
2343export const commitStatuses = pgTable(
2344 "commit_statuses",
2345 {
2346 id: uuid("id").primaryKey().defaultRandom(),
2347 repositoryId: uuid("repository_id")
2348 .notNull()
2349 .references(() => repositories.id, { onDelete: "cascade" }),
2350 commitSha: text("commit_sha").notNull(),
2351 state: text("state").notNull(),
2352 context: text("context").default("default").notNull(),
2353 description: text("description"),
2354 targetUrl: text("target_url"),
2355 creatorId: uuid("creator_id").references(() => users.id, {
2356 onDelete: "set null",
2357 }),
2358 createdAt: timestamp("created_at").defaultNow().notNull(),
2359 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2360 },
2361 (table) => [
2362 uniqueIndex("commit_statuses_repo_sha_context_unique").on(
2363 table.repositoryId,
2364 table.commitSha,
2365 table.context
2366 ),
2367 index("commit_statuses_repo_sha_idx").on(
2368 table.repositoryId,
2369 table.commitSha
2370 ),
2371 ]
2372);
2373
2374export type CommitStatus = typeof commitStatuses.$inferSelect;
23d1a81Claude2375
2376// ---------------------------------------------------------------------------
2377// Collaborators — per-repo role grants (read / write / admin).
2378// ---------------------------------------------------------------------------
2379
2380/**
2381 * A user granted access to a repository beyond ownership. Roles are
2382 * hierarchical — 'admin' implies write, write implies read. `invitedBy` tracks
2383 * who added them (nullable once that user is deleted); `acceptedAt` is null
2384 * until the invitee explicitly accepts. Unique per (repo, user) so a given
2385 * user has at most one role per repo.
2386 */
2387export const repoCollaborators = pgTable(
2388 "repo_collaborators",
2389 {
2390 id: uuid("id").primaryKey().defaultRandom(),
2391 repositoryId: uuid("repository_id")
2392 .notNull()
2393 .references(() => repositories.id, { onDelete: "cascade" }),
2394 userId: uuid("user_id")
2395 .notNull()
2396 .references(() => users.id, { onDelete: "cascade" }),
2397 role: text("role", { enum: ["read", "write", "admin"] })
2398 .notNull()
2399 .default("read"),
2400 invitedBy: uuid("invited_by").references(() => users.id, {
2401 onDelete: "set null",
2402 }),
2403 invitedAt: timestamp("invited_at").defaultNow().notNull(),
2404 acceptedAt: timestamp("accepted_at"),
febd4f0Claude2405 // sha256(plaintext) of the outstanding invite token. Set when the owner
2406 // sends an invite, cleared when the invitee accepts. NULL on older rows
2407 // that were auto-accepted before the email flow existed.
2408 inviteTokenHash: text("invite_token_hash"),
23d1a81Claude2409 },
2410 (table) => [
2411 uniqueIndex("repo_collaborators_repo_user_uq").on(
2412 table.repositoryId,
2413 table.userId
2414 ),
2415 index("repo_collaborators_repo_idx").on(table.repositoryId),
2416 index("repo_collaborators_user_idx").on(table.userId),
2417 ]
2418);
2419
2420export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
abfa9adClaude2421
2422// ---------------------------------------------------------------------------
2423// Workflow engine v2 — Sprint 1 additions (drizzle/0037_workflow_engine_v2.sql)
2424//
2425// Strictly additive to Block C1. The original workflow tables defined above
2426// (workflows / workflow_runs / workflow_jobs / workflow_artifacts) are locked
2427// and must not be altered in-place; the four tables below extend the runner
2428// with secrets, workflow_dispatch inputs, a content-addressable cache, and a
2429// warm-runner worker pool.
2430// ---------------------------------------------------------------------------
2431
2432/**
2433 * Per-repo encrypted secrets exposed to workflow jobs as env vars.
2434 *
2435 * `encrypted_value` is base64 of `iv || authTag || ciphertext` produced by
2436 * AES-256-GCM — the crypto lives in `src/lib/workflow-crypto.ts` (Agent 2);
2437 * the DB only stores opaque ciphertext. `name` is validated against
2438 * `[A-Z_][A-Z0-9_]*` at the write-site, not the DB. Unique per (repo, name).
2439 */
2440export const workflowSecrets = pgTable(
2441 "workflow_secrets",
2442 {
2443 id: uuid("id").primaryKey().defaultRandom(),
2444 repositoryId: uuid("repository_id")
2445 .notNull()
2446 .references(() => repositories.id, { onDelete: "cascade" }),
2447 name: text("name").notNull(),
2448 encryptedValue: text("encrypted_value").notNull(),
2449 createdBy: uuid("created_by").references(() => users.id, {
2450 onDelete: "set null",
2451 }),
2452 createdAt: timestamp("created_at").defaultNow().notNull(),
2453 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2454 },
2455 (table) => [
2456 uniqueIndex("workflow_secrets_repo_name_uq").on(
2457 table.repositoryId,
2458 table.name
2459 ),
2460 index("workflow_secrets_repo_idx").on(table.repositoryId),
2461 ]
2462);
2463
2464export type WorkflowSecret = typeof workflowSecrets.$inferSelect;
2465export type NewWorkflowSecret = typeof workflowSecrets.$inferInsert;
2466
2467/**
2468 * Parameter schema for the `workflow_dispatch` trigger. One row per input
2469 * declared in the workflow YAML. `type` is constrained to the four values
2470 * GitHub Actions supports; `options` is only meaningful for type='choice'
2471 * (a JSON array of allowed strings). `default_value` and `description` are
2472 * optional. Unique per (workflow, name) so two inputs on the same workflow
2473 * can't share an identifier.
2474 */
2475export const workflowDispatchInputs = pgTable(
2476 "workflow_dispatch_inputs",
2477 {
2478 id: uuid("id").primaryKey().defaultRandom(),
2479 workflowId: uuid("workflow_id")
2480 .notNull()
2481 .references(() => workflows.id, { onDelete: "cascade" }),
2482 name: text("name").notNull(),
2483 type: text("type", {
2484 enum: ["string", "boolean", "choice", "number"],
2485 }).notNull(),
2486 required: boolean("required").default(false).notNull(),
2487 defaultValue: text("default_value"),
2488 // JSON array of strings; only populated when type = 'choice'.
2489 options: jsonb("options"),
2490 description: text("description"),
2491 },
2492 (table) => [
2493 uniqueIndex("workflow_dispatch_inputs_wf_name_uq").on(
2494 table.workflowId,
2495 table.name
2496 ),
2497 ]
2498);
2499
2500export type WorkflowDispatchInput =
2501 typeof workflowDispatchInputs.$inferSelect;
2502export type NewWorkflowDispatchInput =
2503 typeof workflowDispatchInputs.$inferInsert;
2504
2505/**
2506 * Content-addressable cache backing the `gluecron/cache@v1` action. Rows are
2507 * keyed by user-chosen `cache_key` within a `scope` — 'repo' for repo-wide
2508 * entries, 'branch' or 'tag' when isolation is desired (with `scope_ref`
2509 * holding the branch/tag name). `content_hash` is the sha256 of `content`
2510 * so jobs can short-circuit redundant writes. `content` is real bytea; the
2511 * 100MB payload cap is enforced at the write-site, not by the DB. LRU
2512 * eviction reads (`repository_id`, `last_accessed_at`).
2513 */
2514export const workflowRunCache = pgTable(
2515 "workflow_run_cache",
2516 {
2517 id: uuid("id").primaryKey().defaultRandom(),
2518 repositoryId: uuid("repository_id")
2519 .notNull()
2520 .references(() => repositories.id, { onDelete: "cascade" }),
2521 cacheKey: text("cache_key").notNull(),
2522 // 'repo' | 'branch' | 'tag' — constraint enforced at the write-site.
2523 scope: text("scope").default("repo").notNull(),
2524 scopeRef: text("scope_ref"),
2525 contentHash: text("content_hash").notNull(),
2526 content: bytea("content").notNull(),
2527 sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
2528 createdAt: timestamp("created_at").defaultNow().notNull(),
2529 lastAccessedAt: timestamp("last_accessed_at").defaultNow().notNull(),
2530 },
2531 (table) => [
2532 uniqueIndex("workflow_run_cache_repo_key_scope_uq").on(
2533 table.repositoryId,
2534 table.cacheKey,
2535 table.scope,
2536 table.scopeRef
2537 ),
2538 index("workflow_run_cache_repo_lru_idx").on(
2539 table.repositoryId,
2540 table.lastAccessedAt
2541 ),
2542 ]
2543);
2544
2545export type WorkflowRunCache = typeof workflowRunCache.$inferSelect;
2546export type NewWorkflowRunCache = typeof workflowRunCache.$inferInsert;
2547
2548/**
2549 * Warm-runner worker registry. The scheduler reads idle rows to dispatch
2550 * queued jobs without paying cold-start cost; workers heartbeat here so
2551 * stale entries (>N seconds since `last_heartbeat_at`) can be reaped.
2552 * `worker_id` is a stable string (hostname:pid or similar) and is globally
2553 * unique so a restarted worker naturally replaces its predecessor.
2554 * `current_run_id` is set when status='busy' and cleared on completion.
2555 */
2556export const workflowRunnerPool = pgTable(
2557 "workflow_runner_pool",
2558 {
2559 id: uuid("id").primaryKey().defaultRandom(),
2560 workerId: text("worker_id").notNull().unique(),
2561 status: text("status", {
2562 enum: ["idle", "busy", "draining", "dead"],
2563 }).notNull(),
2564 currentRunId: uuid("current_run_id").references(() => workflowRuns.id, {
2565 onDelete: "set null",
2566 }),
2567 warmedAt: timestamp("warmed_at").defaultNow().notNull(),
2568 lastHeartbeatAt: timestamp("last_heartbeat_at").defaultNow().notNull(),
2569 capacity: integer("capacity").default(1).notNull(),
2570 },
2571 (table) => [index("workflow_runner_pool_status_idx").on(table.status)]
2572);
2573
2574export type WorkflowRunnerPoolEntry =
2575 typeof workflowRunnerPool.$inferSelect;
2576export type NewWorkflowRunnerPoolEntry =
2577 typeof workflowRunnerPool.$inferInsert;
40e7738Claude2578
2579// ---------------------------------------------------------------------------
2580// AI flywheel + integrations registry (drizzle/0038_ai_flywheel_and_integrations.sql)
2581//
2582// Strictly additive. The flywheel records every AI invocation so we can render
2583// it live (/ai/live), surface per-repo "AI in action" panels, and feed future
2584// learning loops. Integrations are repo-scoped third-party connectors.
2585// ---------------------------------------------------------------------------
2586
2587export const aiActivity = pgTable(
2588 "ai_activity",
2589 {
2590 id: uuid("id").primaryKey().defaultRandom(),
2591 actionType: text("action_type").notNull(),
2592 model: text("model").notNull(),
2593 repositoryId: uuid("repository_id").references(() => repositories.id, {
2594 onDelete: "cascade",
2595 }),
2596 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
2597 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
2598 onDelete: "set null",
2599 }),
2600 issueId: uuid("issue_id").references(() => issues.id, {
2601 onDelete: "set null",
2602 }),
2603 commitSha: text("commit_sha"),
2604 summary: text("summary").notNull(),
2605 inputTokens: integer("input_tokens"),
2606 outputTokens: integer("output_tokens"),
2607 latencyMs: integer("latency_ms").notNull(),
2608 success: boolean("success").default(true).notNull(),
2609 error: text("error"),
2610 metadata: jsonb("metadata"),
2611 createdAt: timestamp("created_at").defaultNow().notNull(),
2612 },
2613 (table) => [
2614 index("ai_activity_created_idx").on(table.createdAt),
2615 index("ai_activity_repo_idx").on(table.repositoryId, table.createdAt),
2616 index("ai_activity_user_idx").on(table.userId, table.createdAt),
2617 index("ai_activity_action_idx").on(table.actionType, table.createdAt),
2618 ]
2619);
2620
2621export type AiActivity = typeof aiActivity.$inferSelect;
2622export type NewAiActivity = typeof aiActivity.$inferInsert;
2623
2624export const integrations = pgTable(
2625 "integrations",
2626 {
2627 id: uuid("id").primaryKey().defaultRandom(),
2628 repositoryId: uuid("repository_id")
2629 .notNull()
2630 .references(() => repositories.id, { onDelete: "cascade" }),
2631 kind: text("kind").notNull(),
2632 name: text("name").notNull(),
2633 enabled: boolean("enabled").default(true).notNull(),
2634 config: jsonb("config").default({}).notNull(),
2635 events: jsonb("events").default([]).notNull(),
2636 createdBy: uuid("created_by").references(() => users.id, {
2637 onDelete: "set null",
2638 }),
2639 createdAt: timestamp("created_at").defaultNow().notNull(),
2640 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2641 lastDeliveryAt: timestamp("last_delivery_at"),
2642 lastStatus: text("last_status"),
2643 },
2644 (table) => [
2645 index("integrations_repo_idx").on(table.repositoryId),
2646 index("integrations_kind_idx").on(table.kind),
2647 uniqueIndex("integrations_repo_name_unique").on(
2648 table.repositoryId,
2649 table.name
2650 ),
2651 ]
2652);
2653
2654export type Integration = typeof integrations.$inferSelect;
2655export type NewIntegration = typeof integrations.$inferInsert;
2656
2657export const integrationDeliveries = pgTable(
2658 "integration_deliveries",
2659 {
2660 id: uuid("id").primaryKey().defaultRandom(),
2661 integrationId: uuid("integration_id")
2662 .notNull()
2663 .references(() => integrations.id, { onDelete: "cascade" }),
2664 event: text("event").notNull(),
2665 status: text("status").notNull(),
2666 httpStatus: integer("http_status"),
2667 error: text("error"),
2668 durationMs: integer("duration_ms").default(0).notNull(),
2669 createdAt: timestamp("created_at").defaultNow().notNull(),
2670 },
2671 (table) => [
2672 index("integration_deliveries_integration_idx").on(
2673 table.integrationId,
2674 table.createdAt
2675 ),
2676 ]
2677);
2678
2679export type IntegrationDelivery = typeof integrationDeliveries.$inferSelect;
2680export type NewIntegrationDelivery = typeof integrationDeliveries.$inferInsert;