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