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.tsBlame3582 lines · 3 contributors
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
a686079Claude26// pgvector `vector(N)` — drizzle-orm has no first-class pgvector column.
27// We map it to `number[]` and serialise/deserialise via Postgres's
28// canonical text format `[0.1,0.2,...]`. The `default: false` flag tells
29// drizzle this column has no default value.
30//
31// Migration 0057 creates the column with `vector(1024)`. If the migration
32// degraded (pgvector unavailable on the host), the column is missing and
33// every read/write throws; src/lib/semantic-index.ts catches at the
34// boundary and falls back to empty-result behaviour.
35const vector = customType<{ data: number[]; driverData: string; default: false }>({
36 dataType(config: unknown) {
37 const cfg = config as { dimensions?: number } | undefined;
38 return cfg?.dimensions ? `vector(${cfg.dimensions})` : "vector";
39 },
40 toDriver(value: number[]): string {
41 return "[" + value.join(",") + "]";
42 },
43 fromDriver(value: string | number[]): number[] {
44 if (Array.isArray(value)) return value as number[];
45 // Canonical format: "[0.1,0.2,0.3]"
46 const trimmed = String(value).trim();
47 if (!trimmed) return [];
48 const inner = trimmed.startsWith("[") && trimmed.endsWith("]")
49 ? trimmed.slice(1, -1)
50 : trimmed;
51 if (!inner) return [];
52 return inner.split(",").map((s) => parseFloat(s));
53 },
54});
55
fc1817aClaude56export const users = pgTable("users", {
57 id: uuid("id").primaryKey().defaultRandom(),
58 username: text("username").notNull().unique(),
59 email: text("email").notNull().unique(),
60 displayName: text("display_name"),
61 passwordHash: text("password_hash").notNull(),
62 avatarUrl: text("avatar_url"),
63 bio: text("bio"),
24cf2caClaude64 // Email notification preferences (Block A8). Default on; opt-out via /settings.
65 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
66 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
67 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
08420cdClaude68 // Block I7 — weekly digest opt-in.
69 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
cb5a796Claude70 // Drizzle/0066 — Pending comment moderation requests. Defaults ON so
71 // a new repo owner is alerted as soon as the first comment lands in
72 // the queue. The in-app notification is always written regardless;
73 // this controls fan-out (currently the audit/notification surface,
74 // future email).
75 notifyEmailOnPendingComment: boolean("notify_email_on_pending_comment")
76 .default(true)
77 .notNull(),
08420cdClaude78 lastDigestSentAt: timestamp("last_digest_sent_at"),
46d6165Claude79 // Block L1 — Sleep Mode. When enabled, the autopilot sleep-mode-digest
80 // task delivers a daily "what Claude shipped overnight" report at the
81 // user-configured UTC hour (0-23, default 9). Reuses lastDigestSentAt
82 // as the 23h cooldown anchor — the cooldown is shared with the weekly
83 // digest, so a user cannot receive both on the same day.
84 sleepModeEnabled: boolean("sleep_mode_enabled").default(false).notNull(),
85 sleepModeDigestHourUtc: integer("sleep_mode_digest_hour_utc").default(9).notNull(),
534f04aClaude86 // Block M2 — Web Push per-event preferences. Default on; opt-out via /settings.
87 notifyPushOnMention: boolean("notify_push_on_mention").default(true).notNull(),
88 notifyPushOnAssign: boolean("notify_push_on_assign").default(true).notNull(),
89 notifyPushOnReviewRequest: boolean("notify_push_on_review_request")
90 .default(true)
91 .notNull(),
92 notifyPushOnDeployFailed: boolean("notify_push_on_deploy_failed")
93 .default(true)
94 .notNull(),
a4f3e24Claude95 isAdmin: boolean("is_admin").default(false).notNull(),
c63b860Claude96 // Block P2 — set when the user clicks the verification link. Soft-gate
97 // only: registration succeeds regardless; /dashboard surfaces a banner
98 // until this is non-null.
99 emailVerifiedAt: timestamp("email_verified_at"),
100 // Block P5 — Account deletion with 30-day grace period.
101 // See drizzle/0049_account_deletion.sql.
102 deletedAt: timestamp("deleted_at"),
103 deletionScheduledFor: timestamp("deletion_scheduled_for"),
104 // Block P3 — Terms of Service / Privacy Policy acceptance audit trail.
105 // Set on /register when the user ticks the accept_terms checkbox.
106 // termsVersion bumps when Terms change; UI prompts re-acceptance later.
107 termsAcceptedAt: timestamp("terms_accepted_at"),
108 termsVersion: text("terms_version"),
cd4f63bTest User109 // Block Q3 — Anonymous playground accounts. When `isPlayground=true`, the
110 // account was minted by /play with a synthetic email + random password
111 // and is hard-deleted by the autopilot `playground-purge` task once
112 // `playgroundExpiresAt` passes. Real accounts always carry false/null.
113 // See drizzle/0052_playground_accounts.sql.
114 isPlayground: boolean("is_playground").default(false).notNull(),
115 playgroundExpiresAt: timestamp("playground_expires_at"),
fc1817aClaude116 createdAt: timestamp("created_at").defaultNow().notNull(),
117 updatedAt: timestamp("updated_at").defaultNow().notNull(),
118});
119
06d5ffeClaude120export const sessions = pgTable("sessions", {
121 id: uuid("id").primaryKey().defaultRandom(),
122 userId: uuid("user_id")
123 .notNull()
124 .references(() => users.id, { onDelete: "cascade" }),
125 token: text("token").notNull().unique(),
126 expiresAt: timestamp("expires_at").notNull(),
7298a17Claude127 // B4: true when the user has entered their password but not yet their TOTP
128 // code. softAuth/requireAuth treat such sessions as anonymous; only
129 // /login/2fa can consume them. Flips to false on successful 2FA.
130 requires2fa: boolean("requires_2fa").default(false).notNull(),
06d5ffeClaude131 createdAt: timestamp("created_at").defaultNow().notNull(),
132});
133
45e31d0Claude134// @ts-ignore — self-referential FK on forkedFromId causes circular inference
fc1817aClaude135export const repositories = pgTable(
136 "repositories",
137 {
138 id: uuid("id").primaryKey().defaultRandom(),
139 name: text("name").notNull(),
7437605Claude140 // ownerId = creator / user-owner. Always set (for attribution + user
141 // namespace uniqueness). For org-owned repos, also represents "created by".
fc1817aClaude142 ownerId: uuid("owner_id")
143 .notNull()
144 .references(() => users.id),
7437605Claude145 // Block B2: nullable org owner. When set, the repo lives in the org
146 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
147 orgId: uuid("org_id"),
fc1817aClaude148 description: text("description"),
149 isPrivate: boolean("is_private").default(false).notNull(),
3ef4c9dClaude150 isArchived: boolean("is_archived").default(false).notNull(),
71cd5ecClaude151 isTemplate: boolean("is_template").default(false).notNull(),
fc1817aClaude152 defaultBranch: text("default_branch").default("main").notNull(),
153 diskPath: text("disk_path").notNull(),
45e31d0Claude154 forkedFromId: uuid("forked_from_id"),
fc1817aClaude155 createdAt: timestamp("created_at").defaultNow().notNull(),
156 updatedAt: timestamp("updated_at").defaultNow().notNull(),
157 pushedAt: timestamp("pushed_at"),
158 starCount: integer("star_count").default(0).notNull(),
159 forkCount: integer("fork_count").default(0).notNull(),
79136bbClaude160 issueCount: integer("issue_count").default(0).notNull(),
534f04aClaude161 // Block M5: autopilot stale-sweep opt-out flags. Default true — the
162 // 2-stage close (poke at 7d/30d, close at 14d/60d after poke) runs on
163 // every repo unless an owner disables it via repo-settings.
164 autoCloseStalePrs: boolean("auto_close_stale_prs").default(true).notNull(),
165 autoCloseStaleIssues: boolean("auto_close_stale_issues")
166 .default(true)
167 .notNull(),
4bbacbeClaude168 // Migration 0062 — opt-out flag for per-branch preview URLs. Default on;
169 // owners can disable via repo-settings. When false, post-receive skips
170 // the enqueuePreviewBuild call entirely.
171 previewBuildsEnabled: boolean("preview_builds_enabled")
172 .default(true)
173 .notNull(),
0c3eee5Claude174 // Migration 0065 — opt-in flag for the AI test generator autopilot task.
175 // Default false (off) because writing tests against unreviewed code can
176 // produce noise; owners must explicitly enable it via repo-settings.
177 autoGenerateTests: boolean("auto_generate_tests")
178 .default(false)
179 .notNull(),
79ed944Claude180 // Migration 0067 — opt-in flag for auto-provisioning a runnable PR
181 // sandbox on every PR open. Default false (off) because each sandbox
182 // burns compute; owners must explicitly enable it via repo-settings.
183 autoPrSandbox: boolean("auto_pr_sandbox").default(false).notNull(),
fc1817aClaude184 },
7437605Claude185 (table) => [
186 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
187 // Matches the partial index in migration 0004.
188 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
189 index("repos_org").on(table.orgId),
190 ]
fc1817aClaude191);
192
3ef4c9dClaude193/**
194 * Per-repository gate + auto-repair configuration.
195 * Every new repo is created with all gates ENABLED by default —
196 * the "full green ecosystem" default. Owners can manually opt-out per setting.
197 */
198export const repoSettings = pgTable("repo_settings", {
199 id: uuid("id").primaryKey().defaultRandom(),
200 repositoryId: uuid("repository_id")
201 .notNull()
202 .unique()
203 .references(() => repositories.id, { onDelete: "cascade" }),
204 // Gates
205 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
206 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
207 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
208 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
209 dependencyScanEnabled: boolean("dependency_scan_enabled").default(true).notNull(),
210 lintEnabled: boolean("lint_enabled").default(true).notNull(),
211 typeCheckEnabled: boolean("type_check_enabled").default(true).notNull(),
212 testEnabled: boolean("test_enabled").default(true).notNull(),
213 // Auto-repair
214 autoFixEnabled: boolean("auto_fix_enabled").default(true).notNull(),
215 autoMergeResolveEnabled: boolean("auto_merge_resolve_enabled").default(true).notNull(),
216 autoFormatEnabled: boolean("auto_format_enabled").default(true).notNull(),
217 // AI features
218 aiCommitMessagesEnabled: boolean("ai_commit_messages_enabled").default(true).notNull(),
219 aiPrSummaryEnabled: boolean("ai_pr_summary_enabled").default(true).notNull(),
220 aiChangelogEnabled: boolean("ai_changelog_enabled").default(true).notNull(),
221 // Deploy
222 autoDeployEnabled: boolean("auto_deploy_enabled").default(true).notNull(),
223 deployRequireAllGreen: boolean("deploy_require_all_green").default(true).notNull(),
224 createdAt: timestamp("created_at").defaultNow().notNull(),
225 updatedAt: timestamp("updated_at").defaultNow().notNull(),
226});
227
228/**
229 * Branch protection rules — enforced on push and merge.
230 * Every repo's default branch gets a protection rule on creation.
231 */
232export const branchProtection = pgTable(
233 "branch_protection",
234 {
235 id: uuid("id").primaryKey().defaultRandom(),
236 repositoryId: uuid("repository_id")
237 .notNull()
238 .references(() => repositories.id, { onDelete: "cascade" }),
239 pattern: text("pattern").notNull(), // branch name or glob (e.g. "main", "release/*")
240 requirePullRequest: boolean("require_pull_request").default(true).notNull(),
241 requireGreenGates: boolean("require_green_gates").default(true).notNull(),
242 requireAiApproval: boolean("require_ai_approval").default(true).notNull(),
243 requireHumanReview: boolean("require_human_review").default(false).notNull(),
244 requiredApprovals: integer("required_approvals").default(0).notNull(),
245 allowForcePush: boolean("allow_force_push").default(false).notNull(),
246 allowDeletion: boolean("allow_deletion").default(false).notNull(),
247 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
4626e61Claude248 // K2 — Auto-merge opt-in. When true, the autopilot ticker may auto-merge
249 // a PR whose base branch matches this rule, provided every gate the
250 // manual-merge path enforces is green. Default-deny.
251 enableAutoMerge: boolean("enable_auto_merge").default(false).notNull(),
3ef4c9dClaude252 createdAt: timestamp("created_at").defaultNow().notNull(),
253 updatedAt: timestamp("updated_at").defaultNow().notNull(),
254 },
255 (table) => [
256 uniqueIndex("branch_protection_repo_pattern").on(
257 table.repositoryId,
258 table.pattern
259 ),
260 ]
261);
262
263/**
264 * Gate run history. Every push + every PR creates gate_runs entries —
265 * one per configured gate. Serves as the source of truth for "is this green?".
266 */
267export const gateRuns = pgTable(
268 "gate_runs",
269 {
270 id: uuid("id").primaryKey().defaultRandom(),
271 repositoryId: uuid("repository_id")
272 .notNull()
273 .references(() => repositories.id, { onDelete: "cascade" }),
274 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
275 onDelete: "cascade",
276 }),
277 commitSha: text("commit_sha").notNull(),
278 ref: text("ref").notNull(),
279 gateName: text("gate_name").notNull(), // e.g. "GateTest", "AI Review", "Secret Scan", "Type Check"
280 status: text("status").notNull(), // pending, running, passed, failed, skipped, repaired
281 summary: text("summary"),
282 details: text("details"), // JSON: per-check output, affected files, etc
283 repairAttempted: boolean("repair_attempted").default(false).notNull(),
284 repairSucceeded: boolean("repair_succeeded").default(false).notNull(),
285 repairCommitSha: text("repair_commit_sha"),
286 durationMs: integer("duration_ms"),
287 createdAt: timestamp("created_at").defaultNow().notNull(),
288 completedAt: timestamp("completed_at"),
289 },
290 (table) => [
291 index("gate_runs_repo_sha").on(table.repositoryId, table.commitSha),
292 index("gate_runs_pr").on(table.pullRequestId),
293 index("gate_runs_created").on(table.createdAt),
294 ]
295);
296
297/**
298 * In-app notifications. Powered by the activity feed + explicit mentions.
299 */
300export const notifications = pgTable(
301 "notifications",
302 {
303 id: uuid("id").primaryKey().defaultRandom(),
304 userId: uuid("user_id")
305 .notNull()
306 .references(() => users.id, { onDelete: "cascade" }),
307 repositoryId: uuid("repository_id").references(() => repositories.id, {
308 onDelete: "cascade",
309 }),
310 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
311 title: text("title").notNull(),
312 body: text("body"),
313 url: text("url"), // link to the relevant page
314 readAt: timestamp("read_at"),
315 createdAt: timestamp("created_at").defaultNow().notNull(),
316 },
317 (table) => [
318 index("notifications_user_unread").on(table.userId, table.readAt),
319 index("notifications_user_created").on(table.userId, table.createdAt),
320 ]
321);
322
323/**
324 * Releases — named snapshots of a repo at a tag/commit.
325 * AI-generated changelogs bundled in notes field.
326 */
327export const releases = pgTable(
328 "releases",
329 {
330 id: uuid("id").primaryKey().defaultRandom(),
331 repositoryId: uuid("repository_id")
332 .notNull()
333 .references(() => repositories.id, { onDelete: "cascade" }),
334 authorId: uuid("author_id")
335 .notNull()
336 .references(() => users.id),
337 tag: text("tag").notNull(),
338 name: text("name").notNull(),
339 body: text("body"), // AI-generated release notes + changelog
340 targetCommit: text("target_commit").notNull(),
341 isDraft: boolean("is_draft").default(false).notNull(),
342 isPrerelease: boolean("is_prerelease").default(false).notNull(),
343 createdAt: timestamp("created_at").defaultNow().notNull(),
344 publishedAt: timestamp("published_at"),
345 },
346 (table) => [
347 uniqueIndex("releases_repo_tag").on(table.repositoryId, table.tag),
348 ]
349);
350
351/**
352 * Milestones — group issues + PRs toward a shared goal.
353 */
354export const milestones = pgTable(
355 "milestones",
356 {
357 id: uuid("id").primaryKey().defaultRandom(),
358 repositoryId: uuid("repository_id")
359 .notNull()
360 .references(() => repositories.id, { onDelete: "cascade" }),
361 title: text("title").notNull(),
362 description: text("description"),
363 state: text("state").notNull().default("open"), // open, closed
364 dueDate: timestamp("due_date"),
365 createdAt: timestamp("created_at").defaultNow().notNull(),
366 closedAt: timestamp("closed_at"),
367 },
368 (table) => [index("milestones_repo_state").on(table.repositoryId, table.state)]
369);
370
371/**
372 * Reactions on issues, PRs, and comments. Universal target pointer.
373 */
374export const reactions = pgTable(
375 "reactions",
376 {
377 id: uuid("id").primaryKey().defaultRandom(),
378 userId: uuid("user_id")
379 .notNull()
380 .references(() => users.id, { onDelete: "cascade" }),
381 targetType: text("target_type").notNull(), // issue, pr, issue_comment, pr_comment
382 targetId: uuid("target_id").notNull(),
383 emoji: text("emoji").notNull(), // thumbs_up, thumbs_down, rocket, heart, eyes, laugh, hooray, confused
384 createdAt: timestamp("created_at").defaultNow().notNull(),
385 },
386 (table) => [
387 uniqueIndex("reactions_unique").on(
388 table.userId,
389 table.targetType,
390 table.targetId,
391 table.emoji
392 ),
393 index("reactions_target").on(table.targetType, table.targetId),
394 ]
395);
396
397/**
398 * PR reviews (formal approve/request-changes).
399 * Separate from inline comments in pr_comments.
400 */
401export const prReviews = pgTable(
402 "pr_reviews",
403 {
404 id: uuid("id").primaryKey().defaultRandom(),
405 pullRequestId: uuid("pull_request_id")
406 .notNull()
407 .references(() => pullRequests.id, { onDelete: "cascade" }),
408 reviewerId: uuid("reviewer_id")
409 .notNull()
410 .references(() => users.id),
411 state: text("state").notNull(), // approved, changes_requested, commented
412 body: text("body"),
413 isAi: boolean("is_ai").default(false).notNull(),
414 createdAt: timestamp("created_at").defaultNow().notNull(),
415 },
416 (table) => [index("pr_reviews_pr").on(table.pullRequestId)]
417);
418
419/**
420 * Code owners — who owns which paths (auto-request review on PR).
421 * Parsed from a CODEOWNERS file at the root of the default branch.
422 */
423export const codeOwners = pgTable(
424 "code_owners",
425 {
426 id: uuid("id").primaryKey().defaultRandom(),
427 repositoryId: uuid("repository_id")
428 .notNull()
429 .references(() => repositories.id, { onDelete: "cascade" }),
430 pathPattern: text("path_pattern").notNull(),
431 ownerUsernames: text("owner_usernames").notNull(), // comma-separated
432 createdAt: timestamp("created_at").defaultNow().notNull(),
433 },
434 (table) => [index("code_owners_repo").on(table.repositoryId)]
435);
436
437/**
438 * Per-repo AI chat sessions — conversational repo assistant.
439 */
440export const aiChats = pgTable(
441 "ai_chats",
442 {
443 id: uuid("id").primaryKey().defaultRandom(),
444 userId: uuid("user_id")
445 .notNull()
446 .references(() => users.id, { onDelete: "cascade" }),
447 repositoryId: uuid("repository_id").references(() => repositories.id, {
448 onDelete: "cascade",
449 }),
450 title: text("title"),
451 messages: text("messages").notNull().default("[]"), // JSON array of {role, content}
452 createdAt: timestamp("created_at").defaultNow().notNull(),
453 updatedAt: timestamp("updated_at").defaultNow().notNull(),
454 },
455 (table) => [
456 index("ai_chats_user").on(table.userId),
457 index("ai_chats_repo").on(table.repositoryId),
458 ]
459);
460
461/**
462 * Audit log — every sensitive action. Who did what, when, from where.
463 */
464export const auditLog = pgTable(
465 "audit_log",
466 {
467 id: uuid("id").primaryKey().defaultRandom(),
468 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
469 repositoryId: uuid("repository_id").references(() => repositories.id, {
470 onDelete: "set null",
471 }),
472 action: text("action").notNull(), // repo.create, repo.delete, repo.transfer, token.create, token.revoke, merge, force_push, branch_protection.update, deploy, ...
473 targetType: text("target_type"),
474 targetId: text("target_id"),
475 ip: text("ip"),
476 userAgent: text("user_agent"),
477 metadata: text("metadata"), // JSON for extra context
478 createdAt: timestamp("created_at").defaultNow().notNull(),
479 },
480 (table) => [
481 index("audit_log_user").on(table.userId),
482 index("audit_log_repo").on(table.repositoryId),
483 index("audit_log_created").on(table.createdAt),
484 ]
485);
486
487/**
488 * Deployments — tracks every deploy to downstream systems (Crontech, etc).
489 * Each deploy is gated on ALL green gates passing.
490 */
491export const deployments = pgTable(
492 "deployments",
493 {
494 id: uuid("id").primaryKey().defaultRandom(),
495 repositoryId: uuid("repository_id")
496 .notNull()
497 .references(() => repositories.id, { onDelete: "cascade" }),
498 environment: text("environment").notNull().default("production"),
499 commitSha: text("commit_sha").notNull(),
500 ref: text("ref").notNull(),
a76d984Claude501 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
3ef4c9dClaude502 blockedReason: text("blocked_reason"),
503 target: text("target"), // e.g. "crontech", "fly.io"
504 triggeredBy: uuid("triggered_by").references(() => users.id),
a76d984Claude505 /**
506 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
507 * NULL = no wait; non-null = autopilot flips status from "waiting_timer"
508 * to "pending" once `now() >= ready_after`.
509 */
510 readyAfter: timestamp("ready_after"),
3ef4c9dClaude511 createdAt: timestamp("created_at").defaultNow().notNull(),
512 completedAt: timestamp("completed_at"),
513 },
514 (table) => [
515 index("deployments_repo").on(table.repositoryId),
516 index("deployments_created").on(table.createdAt),
517 ]
518);
519
520/**
521 * Rate-limit buckets — in-memory or persisted counter per IP / token / route.
522 */
523export const rateLimitBuckets = pgTable(
524 "rate_limit_buckets",
525 {
526 id: uuid("id").primaryKey().defaultRandom(),
527 bucketKey: text("bucket_key").notNull().unique(), // "ip:1.2.3.4:api" or "token:abc:api"
528 count: integer("count").default(0).notNull(),
529 windowStart: timestamp("window_start").defaultNow().notNull(),
530 expiresAt: timestamp("expires_at").notNull(),
531 },
532 (table) => [index("rate_limit_expires").on(table.expiresAt)]
fc1817aClaude533);
534
06d5ffeClaude535export const stars = pgTable(
536 "stars",
537 {
538 id: uuid("id").primaryKey().defaultRandom(),
539 userId: uuid("user_id")
540 .notNull()
541 .references(() => users.id, { onDelete: "cascade" }),
542 repositoryId: uuid("repository_id")
543 .notNull()
544 .references(() => repositories.id, { onDelete: "cascade" }),
545 createdAt: timestamp("created_at").defaultNow().notNull(),
546 },
79136bbClaude547 (table) => [
548 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
549 ]
550);
551
552export const issues = pgTable(
553 "issues",
554 {
555 id: uuid("id").primaryKey().defaultRandom(),
556 number: serial("number"),
557 repositoryId: uuid("repository_id")
558 .notNull()
559 .references(() => repositories.id, { onDelete: "cascade" }),
560 authorId: uuid("author_id")
561 .notNull()
562 .references(() => users.id),
563 title: text("title").notNull(),
564 body: text("body"),
565 state: text("state").notNull().default("open"), // open, closed
566 createdAt: timestamp("created_at").defaultNow().notNull(),
567 updatedAt: timestamp("updated_at").defaultNow().notNull(),
568 closedAt: timestamp("closed_at"),
569 },
570 (table) => [
571 index("issues_repo_state").on(table.repositoryId, table.state),
572 index("issues_repo_number").on(table.repositoryId, table.number),
573 ]
574);
575
576export const issueComments = pgTable(
577 "issue_comments",
578 {
579 id: uuid("id").primaryKey().defaultRandom(),
580 issueId: uuid("issue_id")
581 .notNull()
582 .references(() => issues.id, { onDelete: "cascade" }),
583 authorId: uuid("author_id")
584 .notNull()
585 .references(() => users.id),
586 body: text("body").notNull(),
cb5a796Claude587 // Comment moderation (drizzle/0066). 'approved' | 'pending' | 'rejected'
588 // | 'spam'. Default 'approved' keeps every legacy + collaborator path
589 // unchanged; the gate in `lib/comment-moderation.ts` only routes new
590 // non-collaborator comments into 'pending'.
591 moderationStatus: text("moderation_status").notNull().default("approved"),
592 moderatedAt: timestamp("moderated_at", { withTimezone: true }),
593 moderatedByUserId: uuid("moderated_by_user_id").references(() => users.id, {
594 onDelete: "set null",
595 }),
79136bbClaude596 createdAt: timestamp("created_at").defaultNow().notNull(),
597 updatedAt: timestamp("updated_at").defaultNow().notNull(),
598 },
599 (table) => [index("comments_issue").on(table.issueId)]
600);
601
602export const labels = pgTable(
603 "labels",
604 {
605 id: uuid("id").primaryKey().defaultRandom(),
606 repositoryId: uuid("repository_id")
607 .notNull()
608 .references(() => repositories.id, { onDelete: "cascade" }),
609 name: text("name").notNull(),
610 color: text("color").notNull().default("#8b949e"),
611 description: text("description"),
612 },
613 (table) => [
614 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
615 ]
616);
617
618export const issueLabels = pgTable(
619 "issue_labels",
620 {
621 id: uuid("id").primaryKey().defaultRandom(),
622 issueId: uuid("issue_id")
623 .notNull()
624 .references(() => issues.id, { onDelete: "cascade" }),
625 labelId: uuid("label_id")
626 .notNull()
627 .references(() => labels.id, { onDelete: "cascade" }),
628 },
629 (table) => [
630 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
631 ]
06d5ffeClaude632);
633
0074234Claude634export const pullRequests = pgTable(
635 "pull_requests",
636 {
637 id: uuid("id").primaryKey().defaultRandom(),
638 number: serial("number"),
639 repositoryId: uuid("repository_id")
640 .notNull()
641 .references(() => repositories.id, { onDelete: "cascade" }),
642 authorId: uuid("author_id")
643 .notNull()
644 .references(() => users.id),
645 title: text("title").notNull(),
646 body: text("body"),
647 state: text("state").notNull().default("open"), // open, closed, merged
648 baseBranch: text("base_branch").notNull(),
649 headBranch: text("head_branch").notNull(),
3ef4c9dClaude650 isDraft: boolean("is_draft").default(false).notNull(),
651 mergeStrategy: text("merge_strategy").default("merge").notNull(), // merge, squash, rebase
652 milestoneId: uuid("milestone_id"),
0074234Claude653 mergedAt: timestamp("merged_at"),
654 mergedBy: uuid("merged_by").references(() => users.id),
655 createdAt: timestamp("created_at").defaultNow().notNull(),
656 updatedAt: timestamp("updated_at").defaultNow().notNull(),
657 closedAt: timestamp("closed_at"),
658 },
659 (table) => [
660 index("prs_repo_state").on(table.repositoryId, table.state),
661 index("prs_repo_number").on(table.repositoryId, table.number),
662 ]
663);
664
665export const prComments = pgTable(
666 "pr_comments",
667 {
668 id: uuid("id").primaryKey().defaultRandom(),
669 pullRequestId: uuid("pull_request_id")
670 .notNull()
671 .references(() => pullRequests.id, { onDelete: "cascade" }),
672 authorId: uuid("author_id")
673 .notNull()
674 .references(() => users.id),
675 body: text("body").notNull(),
676 isAiReview: boolean("is_ai_review").default(false).notNull(),
677 filePath: text("file_path"),
678 lineNumber: integer("line_number"),
cb5a796Claude679 // Comment moderation (drizzle/0066). See `issueComments.moderationStatus`.
680 moderationStatus: text("moderation_status").notNull().default("approved"),
681 moderatedAt: timestamp("moderated_at", { withTimezone: true }),
682 moderatedByUserId: uuid("moderated_by_user_id").references(() => users.id, {
683 onDelete: "set null",
684 }),
0074234Claude685 createdAt: timestamp("created_at").defaultNow().notNull(),
686 updatedAt: timestamp("updated_at").defaultNow().notNull(),
687 },
688 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
689);
690
cb5a796Claude691/**
692 * Comment moderation (drizzle/0066) — per-repo allow/deny list for
693 * commenters. A 'trusted' row makes `shouldRequireApproval` short-circuit
694 * to false; a 'banned' row makes it short-circuit to "auto-reject without
695 * notifying the owner" so a single spam decision sticks.
696 *
697 * Indexed by (repository_id, commenter_user_id) for the per-comment gate
698 * lookup and by (repository_id, status) for the owner's trust-list page.
699 */
700export const repoCommenterTrust = pgTable(
701 "repo_commenter_trust",
702 {
703 id: uuid("id").primaryKey().defaultRandom(),
704 repositoryId: uuid("repository_id")
705 .notNull()
706 .references(() => repositories.id, { onDelete: "cascade" }),
707 commenterUserId: uuid("commenter_user_id")
708 .notNull()
709 .references(() => users.id, { onDelete: "cascade" }),
710 status: text("status").notNull(), // 'trusted' | 'banned'
711 grantedByUserId: uuid("granted_by_user_id").references(() => users.id, {
712 onDelete: "set null",
713 }),
714 grantedAt: timestamp("granted_at", { withTimezone: true })
715 .defaultNow()
716 .notNull(),
717 },
718 (table) => [
719 uniqueIndex("repo_commenter_trust_unique").on(
720 table.repositoryId,
721 table.commenterUserId
722 ),
723 index("repo_commenter_trust_repo_status").on(
724 table.repositoryId,
725 table.status
726 ),
727 ]
728);
729
0074234Claude730export const activityFeed = pgTable(
731 "activity_feed",
732 {
733 id: uuid("id").primaryKey().defaultRandom(),
734 repositoryId: uuid("repository_id")
735 .notNull()
736 .references(() => repositories.id, { onDelete: "cascade" }),
737 userId: uuid("user_id").references(() => users.id),
738 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
739 targetType: text("target_type"), // issue, pr, commit
740 targetId: text("target_id"),
741 metadata: text("metadata"), // JSON string for extra data
742 createdAt: timestamp("created_at").defaultNow().notNull(),
743 },
744 (table) => [
745 index("activity_repo").on(table.repositoryId),
746 index("activity_user").on(table.userId),
747 ]
748);
749
c81ab7aClaude750export const webhooks = pgTable(
751 "webhooks",
752 {
753 id: uuid("id").primaryKey().defaultRandom(),
754 repositoryId: uuid("repository_id")
755 .notNull()
756 .references(() => repositories.id, { onDelete: "cascade" }),
757 url: text("url").notNull(),
758 secret: text("secret"),
759 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
760 isActive: boolean("is_active").default(true).notNull(),
761 lastDeliveredAt: timestamp("last_delivered_at"),
762 lastStatus: integer("last_status"),
763 createdAt: timestamp("created_at").defaultNow().notNull(),
764 },
765 (table) => [index("webhooks_repo").on(table.repositoryId)]
766);
767
8405c43Claude768// Reliable webhook delivery (migration 0056). One row per (hook, event)
769// emission. Worker in src/lib/webhook-delivery.ts polls pending rows whose
770// next_attempt_at <= now() and retries with exponential backoff.
771export const webhookDeliveries = pgTable(
772 "webhook_deliveries",
773 {
774 id: uuid("id").primaryKey().defaultRandom(),
775 webhookId: uuid("webhook_id")
776 .notNull()
777 .references(() => webhooks.id, { onDelete: "cascade" }),
778 event: text("event").notNull(),
779 payload: text("payload").notNull(),
780 signature: text("signature").notNull(),
781 attemptCount: integer("attempt_count").default(0).notNull(),
782 nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
783 status: text("status").default("pending").notNull(), // pending | succeeded | failed | dead
784 lastStatusCode: integer("last_status_code"),
785 lastError: text("last_error"),
786 lastAttemptedAt: timestamp("last_attempted_at", { withTimezone: true }),
787 succeededAt: timestamp("succeeded_at", { withTimezone: true }),
788 createdAt: timestamp("created_at", { withTimezone: true })
789 .defaultNow()
790 .notNull(),
791 },
792 (table) => [
793 index("idx_webhook_deliveries_next_attempt").on(table.nextAttemptAt),
794 index("idx_webhook_deliveries_webhook_id").on(
795 table.webhookId,
796 table.createdAt
797 ),
798 ]
799);
800
c81ab7aClaude801export const apiTokens = pgTable("api_tokens", {
802 id: uuid("id").primaryKey().defaultRandom(),
803 userId: uuid("user_id")
804 .notNull()
805 .references(() => users.id, { onDelete: "cascade" }),
806 name: text("name").notNull(),
807 tokenHash: text("token_hash").notNull(),
808 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
809 scopes: text("scopes").notNull().default("repo"), // comma-separated
810 lastUsedAt: timestamp("last_used_at"),
811 expiresAt: timestamp("expires_at"),
812 createdAt: timestamp("created_at").defaultNow().notNull(),
813});
814
815export const repoTopics = pgTable(
816 "repo_topics",
817 {
818 id: uuid("id").primaryKey().defaultRandom(),
819 repositoryId: uuid("repository_id")
820 .notNull()
821 .references(() => repositories.id, { onDelete: "cascade" }),
822 topic: text("topic").notNull(),
823 },
824 (table) => [
825 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
826 index("topics_name").on(table.topic),
827 ]
828);
829
fc1817aClaude830export const sshKeys = pgTable("ssh_keys", {
831 id: uuid("id").primaryKey().defaultRandom(),
832 userId: uuid("user_id")
833 .notNull()
06d5ffeClaude834 .references(() => users.id, { onDelete: "cascade" }),
fc1817aClaude835 title: text("title").notNull(),
836 fingerprint: text("fingerprint").notNull(),
837 publicKey: text("public_key").notNull(),
838 lastUsedAt: timestamp("last_used_at"),
839 createdAt: timestamp("created_at").defaultNow().notNull(),
840});
841
534f04aClaude842// Block M2 — Web Push subscriptions. One row per (user, endpoint).
843// Endpoint is the browser-issued push URL; p256dh + auth are the W3C
844// payload-encryption keys (base64url). Stale endpoints (HTTP 410) are
845// deleted lazily on next `sendPushToUser` pass.
846export const pushSubscriptions = pgTable(
847 "push_subscriptions",
848 {
849 id: uuid("id").primaryKey().defaultRandom(),
850 userId: uuid("user_id")
851 .notNull()
852 .references(() => users.id, { onDelete: "cascade" }),
853 endpoint: text("endpoint").notNull(),
854 p256dh: text("p256dh").notNull(),
855 auth: text("auth").notNull(),
856 userAgent: text("user_agent"),
857 createdAt: timestamp("created_at").defaultNow().notNull(),
858 lastUsedAt: timestamp("last_used_at"),
859 },
860 (table) => [
861 uniqueIndex("push_subscriptions_user_endpoint").on(
862 table.userId,
863 table.endpoint
864 ),
865 index("idx_push_subscriptions_user").on(table.userId),
866 ]
867);
868
fc1817aClaude869export type User = typeof users.$inferSelect;
870export type NewUser = typeof users.$inferInsert;
871export type Repository = typeof repositories.$inferSelect;
872export type NewRepository = typeof repositories.$inferInsert;
06d5ffeClaude873export type Session = typeof sessions.$inferSelect;
874export type Star = typeof stars.$inferSelect;
875export type SshKey = typeof sshKeys.$inferSelect;
534f04aClaude876export type PushSubscription = typeof pushSubscriptions.$inferSelect;
877export type NewPushSubscription = typeof pushSubscriptions.$inferInsert;
79136bbClaude878export type Issue = typeof issues.$inferSelect;
879export type IssueComment = typeof issueComments.$inferSelect;
880export type Label = typeof labels.$inferSelect;
0074234Claude881export type PullRequest = typeof pullRequests.$inferSelect;
882export type PrComment = typeof prComments.$inferSelect;
cb5a796Claude883export type RepoCommenterTrust = typeof repoCommenterTrust.$inferSelect;
884export type NewRepoCommenterTrust = typeof repoCommenterTrust.$inferInsert;
0074234Claude885export type ActivityEntry = typeof activityFeed.$inferSelect;
c81ab7aClaude886export type Webhook = typeof webhooks.$inferSelect;
887export type ApiToken = typeof apiTokens.$inferSelect;
888export type RepoTopic = typeof repoTopics.$inferSelect;
3ef4c9dClaude889export type RepoSettings = typeof repoSettings.$inferSelect;
890export type BranchProtection = typeof branchProtection.$inferSelect;
891export type GateRun = typeof gateRuns.$inferSelect;
892export type Notification = typeof notifications.$inferSelect;
893export type Release = typeof releases.$inferSelect;
894export type Milestone = typeof milestones.$inferSelect;
895export type Reaction = typeof reactions.$inferSelect;
896export type PrReview = typeof prReviews.$inferSelect;
897export type CodeOwner = typeof codeOwners.$inferSelect;
898export type AiChat = typeof aiChats.$inferSelect;
899export type AuditLogEntry = typeof auditLog.$inferSelect;
900export type Deployment = typeof deployments.$inferSelect;
24cf2caClaude901
902/**
903 * Saved replies — per-user canned responses, insertable into any
904 * issue / PR comment textarea. Shortcut name must be unique per user.
905 */
906export const savedReplies = pgTable(
907 "saved_replies",
908 {
909 id: uuid("id").primaryKey().defaultRandom(),
910 userId: uuid("user_id")
911 .notNull()
912 .references(() => users.id, { onDelete: "cascade" }),
913 shortcut: text("shortcut").notNull(),
914 body: text("body").notNull(),
915 createdAt: timestamp("created_at").defaultNow().notNull(),
916 updatedAt: timestamp("updated_at").defaultNow().notNull(),
917 },
918 (table) => [
919 uniqueIndex("saved_replies_user_shortcut").on(table.userId, table.shortcut),
920 ]
921);
922
923export type SavedReply = typeof savedReplies.$inferSelect;
5cc5d95Claude924
925/**
926 * Organizations (Block B1) — multi-user namespaces. Distinct from `users`.
927 * An org has members (with org-level roles) and may contain teams.
928 * Repos can be owned by an org via `repositories.orgId` (added in Block B2).
929 *
930 * Slug is globally unique against itself; collision with a username is
931 * checked at create time in the route handler (no DB-level cross-table
932 * uniqueness in Postgres).
933 */
934export const organizations = pgTable("organizations", {
935 id: uuid("id").primaryKey().defaultRandom(),
936 slug: text("slug").notNull().unique(),
937 name: text("name").notNull(),
938 description: text("description"),
939 avatarUrl: text("avatar_url"),
940 billingEmail: text("billing_email"),
941 createdById: uuid("created_by_id")
942 .notNull()
943 .references(() => users.id, { onDelete: "restrict" }),
944 createdAt: timestamp("created_at").defaultNow().notNull(),
945 updatedAt: timestamp("updated_at").defaultNow().notNull(),
946});
947
948/**
949 * Org membership. Roles: owner (full control, billing), admin (manage
950 * members + teams + repos), member (default; can be added to teams).
951 */
952export const orgMembers = pgTable(
953 "org_members",
954 {
955 id: uuid("id").primaryKey().defaultRandom(),
956 orgId: uuid("org_id")
957 .notNull()
958 .references(() => organizations.id, { onDelete: "cascade" }),
959 userId: uuid("user_id")
960 .notNull()
961 .references(() => users.id, { onDelete: "cascade" }),
962 role: text("role").notNull().default("member"), // owner | admin | member
963 createdAt: timestamp("created_at").defaultNow().notNull(),
964 },
965 (table) => [
966 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
967 index("org_members_user").on(table.userId),
968 ]
969);
970
971/**
972 * Teams within an org. Slug is unique within an org.
973 * `parentTeamId` allows nesting (GitHub-style child teams). Optional.
974 */
975export const teams = pgTable(
976 "teams",
977 {
978 id: uuid("id").primaryKey().defaultRandom(),
979 orgId: uuid("org_id")
980 .notNull()
981 .references(() => organizations.id, { onDelete: "cascade" }),
982 slug: text("slug").notNull(),
983 name: text("name").notNull(),
984 description: text("description"),
985 parentTeamId: uuid("parent_team_id"),
986 createdAt: timestamp("created_at").defaultNow().notNull(),
987 updatedAt: timestamp("updated_at").defaultNow().notNull(),
988 },
989 (table) => [uniqueIndex("teams_org_slug").on(table.orgId, table.slug)]
990);
991
992/**
993 * Team membership. Roles: maintainer (can edit team), member (default).
994 * A user can belong to many teams; team membership requires org membership
995 * but that invariant is enforced at the route layer, not the DB layer.
996 */
997export const teamMembers = pgTable(
998 "team_members",
999 {
1000 id: uuid("id").primaryKey().defaultRandom(),
1001 teamId: uuid("team_id")
1002 .notNull()
1003 .references(() => teams.id, { onDelete: "cascade" }),
1004 userId: uuid("user_id")
1005 .notNull()
1006 .references(() => users.id, { onDelete: "cascade" }),
1007 role: text("role").notNull().default("member"), // maintainer | member
1008 createdAt: timestamp("created_at").defaultNow().notNull(),
1009 },
1010 (table) => [
1011 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
1012 index("team_members_user").on(table.userId),
1013 ]
1014);
1015
1016export type Organization = typeof organizations.$inferSelect;
1017export type OrgMember = typeof orgMembers.$inferSelect;
1018export type Team = typeof teams.$inferSelect;
1019export type TeamMember = typeof teamMembers.$inferSelect;
1020export type OrgRole = "owner" | "admin" | "member";
1021export type TeamRole = "maintainer" | "member";
7298a17Claude1022
1023/**
1024 * 2FA / TOTP (Block B4).
1025 *
1026 * Secret is stored in plain Base32 for now — the DB has row-level-secure
1027 * access and the app boundary is the only code that reads it. A follow-up
1028 * (B4.1) will wrap it with AES-GCM at rest once we standardise the KEK.
1029 *
1030 * `enabledAt` is set only after the user has successfully entered their
1031 * first code (confirming the authenticator was set up correctly). Rows with
1032 * `enabledAt = NULL` represent pending enrolment.
1033 */
1034export const userTotp = pgTable("user_totp", {
1035 userId: uuid("user_id")
1036 .primaryKey()
1037 .references(() => users.id, { onDelete: "cascade" }),
1038 secret: text("secret").notNull(),
1039 enabledAt: timestamp("enabled_at"),
1040 lastUsedAt: timestamp("last_used_at"),
1041 createdAt: timestamp("created_at").defaultNow().notNull(),
1042});
1043
1044/**
1045 * Recovery codes — single-use fallback when the authenticator is lost.
1046 * Stored as SHA-256 hashes; used rows are marked with `usedAt` rather than
1047 * deleted so the audit log keeps the full history.
1048 */
1049export const userRecoveryCodes = pgTable(
1050 "user_recovery_codes",
1051 {
1052 id: uuid("id").primaryKey().defaultRandom(),
1053 userId: uuid("user_id")
1054 .notNull()
1055 .references(() => users.id, { onDelete: "cascade" }),
1056 codeHash: text("code_hash").notNull(),
1057 usedAt: timestamp("used_at"),
1058 createdAt: timestamp("created_at").defaultNow().notNull(),
1059 },
1060 (table) => [
1061 index("recovery_codes_user").on(table.userId),
1062 uniqueIndex("recovery_codes_user_hash").on(table.userId, table.codeHash),
1063 ]
1064);
1065
1066export type UserTotp = typeof userTotp.$inferSelect;
1067export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
2df1f8cClaude1068
1069/**
1070 * WebAuthn passkeys (Block B5).
1071 *
1072 * Each row is one registered authenticator. The `credentialId` is the
1073 * globally-unique identifier the browser returns; `publicKey` is the
1074 * COSE-encoded public key we use to verify signatures. `counter` tracks
1075 * the authenticator's signature counter for replay-protection.
1076 *
1077 * `transports` is a JSON array (stored as text) of the
1078 * AuthenticatorTransport values ("usb" | "nfc" | "ble" | "internal" | "hybrid").
1079 */
1080export const userPasskeys = pgTable(
1081 "user_passkeys",
1082 {
1083 id: uuid("id").primaryKey().defaultRandom(),
1084 userId: uuid("user_id")
1085 .notNull()
1086 .references(() => users.id, { onDelete: "cascade" }),
1087 credentialId: text("credential_id").notNull().unique(),
1088 publicKey: text("public_key").notNull(), // base64url of COSE key
1089 counter: integer("counter").default(0).notNull(),
1090 transports: text("transports"), // JSON array string
1091 name: text("name").notNull().default("Passkey"),
1092 lastUsedAt: timestamp("last_used_at"),
1093 createdAt: timestamp("created_at").defaultNow().notNull(),
1094 },
1095 (table) => [index("passkeys_user").on(table.userId)]
1096);
1097
1098/**
1099 * Short-lived WebAuthn challenges. A row is written when we issue options
1100 * (registration or authentication) and deleted after the verify step or when
1101 * it expires (5 min). Keeping them in the DB lets us verify without sticky
1102 * sessions or client-side state.
1103 */
1104export const webauthnChallenges = pgTable(
1105 "webauthn_challenges",
1106 {
1107 id: uuid("id").primaryKey().defaultRandom(),
1108 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
1109 // For passwordless login we don't know the user yet, so userId is nullable
1110 // and we bind the challenge to a short-lived cookie token instead.
1111 sessionKey: text("session_key").notNull().unique(),
1112 challenge: text("challenge").notNull(),
1113 kind: text("kind").notNull(), // "register" | "authenticate"
1114 expiresAt: timestamp("expires_at").notNull(),
1115 createdAt: timestamp("created_at").defaultNow().notNull(),
1116 },
1117 (table) => [index("webauthn_challenges_expires").on(table.expiresAt)]
1118);
1119
1120export type UserPasskey = typeof userPasskeys.$inferSelect;
1121export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
bfdb5e7Claude1122
1123/**
1124 * OAuth 2.0 provider (Block B6).
1125 *
1126 * `oauthApps` is a third-party app registered by a developer. Each app has
1127 * a public `client_id`, a hashed `client_secret`, and one or more allowed
1128 * `redirect_uris` (newline-separated). The plaintext secret is shown to the
1129 * developer exactly once at creation and cannot be recovered; they can
1130 * rotate it instead.
1131 *
1132 * `oauthAuthorizations` is a short-lived authorization code issued after
1133 * the user consents at /oauth/authorize. Single-use: `usedAt` is set on
1134 * redemption so a replay after-the-fact fails.
1135 *
1136 * `oauthAccessTokens` is a long-lived bearer token plus an optional
1137 * refresh token. Both are stored as SHA-256 hashes; the plaintext values
1138 * are only returned to the client once in the /oauth/token response.
1139 */
1140export const oauthApps = pgTable(
1141 "oauth_apps",
1142 {
1143 id: uuid("id").primaryKey().defaultRandom(),
1144 ownerId: uuid("owner_id")
1145 .notNull()
1146 .references(() => users.id, { onDelete: "cascade" }),
1147 name: text("name").notNull(),
1148 clientId: text("client_id").notNull().unique(),
1149 clientSecretHash: text("client_secret_hash").notNull(),
1150 clientSecretPrefix: text("client_secret_prefix").notNull(), // first 8 chars for display
1151 /** Newline-separated list of allowed redirect URIs. */
1152 redirectUris: text("redirect_uris").notNull(),
1153 homepageUrl: text("homepage_url"),
1154 description: text("description"),
1155 /**
1156 * If `true`, the app must present its client_secret at /oauth/token.
1157 * Public SPA/mobile apps should set this to `false` and use PKCE.
1158 */
1159 confidential: boolean("confidential").default(true).notNull(),
1160 revokedAt: timestamp("revoked_at"),
1161 createdAt: timestamp("created_at").defaultNow().notNull(),
1162 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1163 },
1164 (table) => [index("oauth_apps_owner").on(table.ownerId)]
1165);
1166
1167export const oauthAuthorizations = pgTable(
1168 "oauth_authorizations",
1169 {
1170 id: uuid("id").primaryKey().defaultRandom(),
1171 appId: uuid("app_id")
1172 .notNull()
1173 .references(() => oauthApps.id, { onDelete: "cascade" }),
1174 userId: uuid("user_id")
1175 .notNull()
1176 .references(() => users.id, { onDelete: "cascade" }),
1177 codeHash: text("code_hash").notNull().unique(),
1178 redirectUri: text("redirect_uri").notNull(),
1179 scopes: text("scopes").notNull().default(""),
1180 codeChallenge: text("code_challenge"),
1181 codeChallengeMethod: text("code_challenge_method"), // "S256" | "plain"
1182 expiresAt: timestamp("expires_at").notNull(),
1183 usedAt: timestamp("used_at"),
1184 createdAt: timestamp("created_at").defaultNow().notNull(),
1185 },
1186 (table) => [index("oauth_authorizations_expires").on(table.expiresAt)]
1187);
1188
1189export const oauthAccessTokens = pgTable(
1190 "oauth_access_tokens",
1191 {
1192 id: uuid("id").primaryKey().defaultRandom(),
1193 appId: uuid("app_id")
1194 .notNull()
1195 .references(() => oauthApps.id, { onDelete: "cascade" }),
1196 userId: uuid("user_id")
1197 .notNull()
1198 .references(() => users.id, { onDelete: "cascade" }),
1199 accessTokenHash: text("access_token_hash").notNull().unique(),
1200 refreshTokenHash: text("refresh_token_hash").unique(),
1201 scopes: text("scopes").notNull().default(""),
1202 expiresAt: timestamp("expires_at").notNull(),
1203 refreshExpiresAt: timestamp("refresh_expires_at"),
1204 revokedAt: timestamp("revoked_at"),
1205 lastUsedAt: timestamp("last_used_at"),
1206 createdAt: timestamp("created_at").defaultNow().notNull(),
1207 },
1208 (table) => [
1209 index("oauth_access_tokens_user").on(table.userId),
1210 index("oauth_access_tokens_app").on(table.appId),
1211 index("oauth_access_tokens_expires").on(table.expiresAt),
1212 ]
1213);
1214
1215export type OauthApp = typeof oauthApps.$inferSelect;
1216export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
1217export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
eafe8c6Claude1218
1219/**
1220 * Actions-equivalent workflow runner (Block C1).
1221 *
1222 * `workflows` rows are the YAML files discovered at `.gluecron/workflows/*.yml`
1223 * on the repo's default branch. `parsed` is the normalised JSON form used by
1224 * the runner so we don't re-parse on every trigger.
1225 *
1226 * `workflow_runs` is one execution: one row per trigger event. Status
1227 * progression: queued → running → success|failure|cancelled. `conclusion`
1228 * stays null until `status` is terminal.
1229 *
1230 * `workflow_jobs` is a single job within a run — each has its own steps
1231 * array and concatenated logs. We keep logs inline for v1 (no streaming)
1232 * to avoid a fifth table; they're truncated at the runner.
1233 *
1234 * `workflow_artifacts` persist files a job uploaded. `content` is a bytea;
1235 * we'll move this to object storage once we hit size limits.
1236 */
1237export const workflows = pgTable(
1238 "workflows",
1239 {
1240 id: uuid("id").primaryKey().defaultRandom(),
1241 repositoryId: uuid("repository_id")
1242 .notNull()
1243 .references(() => repositories.id, { onDelete: "cascade" }),
1244 name: text("name").notNull(),
1245 path: text("path").notNull(), // e.g. ".gluecron/workflows/ci.yml"
1246 yaml: text("yaml").notNull(),
1247 parsed: text("parsed").notNull(), // JSON string
1248 onEvents: text("on_events").notNull().default("[]"), // JSON array of event names
1249 disabled: boolean("disabled").default(false).notNull(),
1250 createdAt: timestamp("created_at").defaultNow().notNull(),
1251 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1252 },
1253 (table) => [
1254 index("workflows_repo").on(table.repositoryId),
1255 uniqueIndex("workflows_repo_path").on(table.repositoryId, table.path),
1256 ]
1257);
1258
1259export const workflowRuns = pgTable(
1260 "workflow_runs",
1261 {
1262 id: uuid("id").primaryKey().defaultRandom(),
1263 workflowId: uuid("workflow_id")
1264 .notNull()
1265 .references(() => workflows.id, { onDelete: "cascade" }),
1266 repositoryId: uuid("repository_id")
1267 .notNull()
1268 .references(() => repositories.id, { onDelete: "cascade" }),
1269 runNumber: integer("run_number").notNull(),
1270 event: text("event").notNull(), // "push" | "pull_request" | "manual" | ...
1271 ref: text("ref"),
1272 commitSha: text("commit_sha"),
1273 triggeredBy: uuid("triggered_by").references(() => users.id, {
1274 onDelete: "set null",
1275 }),
1276 status: text("status").notNull().default("queued"), // queued|running|success|failure|cancelled
1277 conclusion: text("conclusion"),
1278 queuedAt: timestamp("queued_at").defaultNow().notNull(),
1279 startedAt: timestamp("started_at"),
1280 finishedAt: timestamp("finished_at"),
1281 createdAt: timestamp("created_at").defaultNow().notNull(),
1282 },
1283 (table) => [
1284 index("workflow_runs_repo").on(table.repositoryId),
1285 index("workflow_runs_status").on(table.status),
1286 index("workflow_runs_workflow").on(table.workflowId),
1287 ]
1288);
1289
1290export const workflowJobs = pgTable(
1291 "workflow_jobs",
1292 {
1293 id: uuid("id").primaryKey().defaultRandom(),
1294 runId: uuid("run_id")
1295 .notNull()
1296 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1297 name: text("name").notNull(),
1298 jobOrder: integer("job_order").default(0).notNull(),
1299 runsOn: text("runs_on").notNull().default("default"),
1300 status: text("status").notNull().default("queued"),
1301 conclusion: text("conclusion"),
1302 exitCode: integer("exit_code"),
1303 steps: text("steps").notNull().default("[]"), // JSON array of step results
1304 logs: text("logs").notNull().default(""),
1305 startedAt: timestamp("started_at"),
1306 finishedAt: timestamp("finished_at"),
1307 createdAt: timestamp("created_at").defaultNow().notNull(),
1308 },
1309 (table) => [index("workflow_jobs_run").on(table.runId)]
1310);
1311
1312export const workflowArtifacts = pgTable(
1313 "workflow_artifacts",
1314 {
1315 id: uuid("id").primaryKey().defaultRandom(),
1316 runId: uuid("run_id")
1317 .notNull()
1318 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1319 jobId: uuid("job_id").references(() => workflowJobs.id, {
1320 onDelete: "set null",
1321 }),
1322 name: text("name").notNull(),
1323 sizeBytes: integer("size_bytes").default(0).notNull(),
1324 contentType: text("content_type")
1325 .default("application/octet-stream")
1326 .notNull(),
1327 // bytea — drizzle doesn't have a built-in bytea type at the level we use
1328 // elsewhere; store as text (base64) for v1. Migration uses real bytea so
1329 // we can swap the column type later.
1330 content: text("content"),
1331 createdAt: timestamp("created_at").defaultNow().notNull(),
1332 },
1333 (table) => [index("workflow_artifacts_run").on(table.runId)]
1334);
1335
1336export type Workflow = typeof workflows.$inferSelect;
1337export type WorkflowRun = typeof workflowRuns.$inferSelect;
1338export type WorkflowJob = typeof workflowJobs.$inferSelect;
1339export type WorkflowArtifact = typeof workflowArtifacts.$inferSelect;
e2da5c6Claude1340
1341// ---------------------------------------------------------------------------
1342// Block C2 — Package registry (npm-compatible)
1343// ---------------------------------------------------------------------------
1344
1345export const packages = pgTable(
1346 "packages",
1347 {
1348 id: uuid("id").primaryKey().defaultRandom(),
1349 repositoryId: uuid("repository_id")
1350 .notNull()
1351 .references(() => repositories.id, { onDelete: "cascade" }),
1352 ecosystem: text("ecosystem").notNull().default("npm"), // "npm" | "container"
1353 scope: text("scope"), // "@acme" for npm; null for unscoped
1354 name: text("name").notNull(), // "my-lib" (without scope)
1355 description: text("description"),
1356 readme: text("readme"),
1357 homepage: text("homepage"),
1358 license: text("license"),
1359 visibility: text("visibility").notNull().default("public"), // "public" | "private"
1360 createdAt: timestamp("created_at").defaultNow().notNull(),
1361 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1362 },
1363 (table) => [
1364 index("packages_repo").on(table.repositoryId),
1365 uniqueIndex("packages_eco_scope_name").on(
1366 table.ecosystem,
1367 table.scope,
1368 table.name
1369 ),
1370 ]
1371);
1372
1373export const packageVersions = pgTable(
1374 "package_versions",
1375 {
1376 id: uuid("id").primaryKey().defaultRandom(),
1377 packageId: uuid("package_id")
1378 .notNull()
1379 .references(() => packages.id, { onDelete: "cascade" }),
1380 version: text("version").notNull(), // "1.2.3"
1381 shasum: text("shasum").notNull(), // sha1 (for npm compat) hex
1382 integrity: text("integrity"), // "sha512-..." base64
1383 sizeBytes: integer("size_bytes").default(0).notNull(),
1384 metadata: text("metadata").notNull().default("{}"), // package.json JSON
1385 tarball: text("tarball"), // base64-encoded; bytea in migration
1386 publishedBy: uuid("published_by").references(() => users.id, {
1387 onDelete: "set null",
1388 }),
1389 yanked: boolean("yanked").default(false).notNull(),
1390 yankedReason: text("yanked_reason"),
1391 publishedAt: timestamp("published_at").defaultNow().notNull(),
1392 },
1393 (table) => [
1394 index("package_versions_pkg").on(table.packageId),
1395 uniqueIndex("package_versions_pkg_version").on(table.packageId, table.version),
1396 ]
1397);
1398
1399export const packageTags = pgTable(
1400 "package_tags",
1401 {
1402 id: uuid("id").primaryKey().defaultRandom(),
1403 packageId: uuid("package_id")
1404 .notNull()
1405 .references(() => packages.id, { onDelete: "cascade" }),
1406 tag: text("tag").notNull(), // "latest" | "beta" | ...
1407 versionId: uuid("version_id")
1408 .notNull()
1409 .references(() => packageVersions.id, { onDelete: "cascade" }),
1410 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1411 },
1412 (table) => [
1413 uniqueIndex("package_tags_pkg_tag").on(table.packageId, table.tag),
1414 ]
1415);
1416
1417export type Package = typeof packages.$inferSelect;
1418export type PackageVersion = typeof packageVersions.$inferSelect;
1419export type PackageTag = typeof packageTags.$inferSelect;
1420
1421// ---------------------------------------------------------------------------
1422// Block C3 — Pages / static hosting
1423// ---------------------------------------------------------------------------
1424
1425export const pagesDeployments = pgTable(
1426 "pages_deployments",
1427 {
1428 id: uuid("id").primaryKey().defaultRandom(),
1429 repositoryId: uuid("repository_id")
1430 .notNull()
1431 .references(() => repositories.id, { onDelete: "cascade" }),
1432 ref: text("ref").notNull().default("refs/heads/gh-pages"),
1433 commitSha: text("commit_sha").notNull(),
1434 status: text("status").notNull().default("success"), // "success" | "failed"
1435 triggeredBy: uuid("triggered_by").references(() => users.id, {
1436 onDelete: "set null",
1437 }),
1438 createdAt: timestamp("created_at").defaultNow().notNull(),
1439 },
1440 (table) => [
1441 index("pages_deployments_repo").on(table.repositoryId),
1442 index("pages_deployments_created").on(table.createdAt),
1443 ]
1444);
1445
1446export const pagesSettings = pgTable("pages_settings", {
1447 repositoryId: uuid("repository_id")
1448 .primaryKey()
1449 .references(() => repositories.id, { onDelete: "cascade" }),
1450 enabled: boolean("enabled").default(true).notNull(),
1451 sourceBranch: text("source_branch").notNull().default("gh-pages"),
1452 sourceDir: text("source_dir").notNull().default("/"), // e.g. "/" or "/docs"
1453 customDomain: text("custom_domain"),
1454 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1455});
1456
1457export type PagesDeployment = typeof pagesDeployments.$inferSelect;
1458export type PagesSettings = typeof pagesSettings.$inferSelect;
1459
1460// ---------------------------------------------------------------------------
1461// Block C4 — Environments with protected approvals
1462// ---------------------------------------------------------------------------
1463
1464export const environments = pgTable(
1465 "environments",
1466 {
1467 id: uuid("id").primaryKey().defaultRandom(),
1468 repositoryId: uuid("repository_id")
1469 .notNull()
1470 .references(() => repositories.id, { onDelete: "cascade" }),
1471 name: text("name").notNull(), // "production" | "staging" | "preview"
1472 requireApproval: boolean("require_approval").default(false).notNull(),
1473 // JSON array of user IDs that can approve deploys.
1474 reviewers: text("reviewers").notNull().default("[]"),
1475 waitTimerMinutes: integer("wait_timer_minutes").default(0).notNull(),
1476 allowedBranches: text("allowed_branches").notNull().default("[]"), // JSON glob patterns
1477 createdAt: timestamp("created_at").defaultNow().notNull(),
1478 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1479 },
1480 (table) => [
1481 uniqueIndex("environments_repo_name").on(table.repositoryId, table.name),
1482 ]
1483);
1484
1485export const deploymentApprovals = pgTable(
1486 "deployment_approvals",
1487 {
1488 id: uuid("id").primaryKey().defaultRandom(),
1489 deploymentId: uuid("deployment_id")
1490 .notNull()
1491 .references(() => deployments.id, { onDelete: "cascade" }),
1492 userId: uuid("user_id")
1493 .notNull()
1494 .references(() => users.id, { onDelete: "cascade" }),
1495 decision: text("decision").notNull(), // "approved" | "rejected"
1496 comment: text("comment"),
1497 createdAt: timestamp("created_at").defaultNow().notNull(),
1498 },
1499 (table) => [
1500 index("deployment_approvals_deployment").on(table.deploymentId),
1501 ]
1502);
1503
1504export type Environment = typeof environments.$inferSelect;
1505export type DeploymentApproval = typeof deploymentApprovals.$inferSelect;
3cbe3d6Claude1506
1507// ---------------------------------------------------------------------------
1508// Block D — AI-native differentiation (migration 0012)
1509// ---------------------------------------------------------------------------
1510
1511// D6 — cached "explain this codebase" markdown keyed on commit sha.
1512export const codebaseExplanations = pgTable(
1513 "codebase_explanations",
1514 {
1515 id: uuid("id").primaryKey().defaultRandom(),
1516 repositoryId: uuid("repository_id")
1517 .notNull()
1518 .references(() => repositories.id, { onDelete: "cascade" }),
1519 commitSha: text("commit_sha").notNull(),
1520 summary: text("summary").notNull(),
1521 markdown: text("markdown").notNull(),
1522 model: text("model").notNull(),
1523 generatedAt: timestamp("generated_at").defaultNow().notNull(),
1524 },
1525 (table) => [
1526 uniqueIndex("codebase_explanations_repo_sha").on(
1527 table.repositoryId,
1528 table.commitSha
1529 ),
1530 ]
1531);
1532
1533// D2 — AI dependency bumper run history.
1534export const depUpdateRuns = pgTable(
1535 "dep_update_runs",
1536 {
1537 id: uuid("id").primaryKey().defaultRandom(),
1538 repositoryId: uuid("repository_id")
1539 .notNull()
1540 .references(() => repositories.id, { onDelete: "cascade" }),
1541 status: text("status").notNull().default("pending"), // pending|running|success|failed|no_updates
1542 ecosystem: text("ecosystem").notNull(), // npm|bun
1543 manifestPath: text("manifest_path").notNull(),
1544 attemptedBumps: text("attempted_bumps").notNull().default("[]"), // JSON
1545 appliedBumps: text("applied_bumps").notNull().default("[]"), // JSON
1546 branchName: text("branch_name"),
1547 prNumber: integer("pr_number"),
1548 errorMessage: text("error_message"),
1549 triggeredBy: uuid("triggered_by").references(() => users.id, {
1550 onDelete: "set null",
1551 }),
1552 createdAt: timestamp("created_at").defaultNow().notNull(),
1553 completedAt: timestamp("completed_at"),
1554 },
1555 (table) => [
1556 index("dep_update_runs_repo").on(table.repositoryId),
1557 index("dep_update_runs_created").on(table.createdAt),
1558 ]
1559);
1560
1561// D1 — code chunks for semantic search. Embedding stored as JSON-encoded
1562// number array in text to avoid requiring pgvector; cosine similarity is
1563// computed in JS. Upgrade path: ALTER COLUMN embedding TYPE vector(1024).
1564export const codeChunks = pgTable(
1565 "code_chunks",
1566 {
1567 id: uuid("id").primaryKey().defaultRandom(),
1568 repositoryId: uuid("repository_id")
1569 .notNull()
1570 .references(() => repositories.id, { onDelete: "cascade" }),
1571 commitSha: text("commit_sha").notNull(),
1572 path: text("path").notNull(),
1573 startLine: integer("start_line").notNull(),
1574 endLine: integer("end_line").notNull(),
1575 content: text("content").notNull(),
1576 embedding: text("embedding"), // JSON number[]
1577 embeddingModel: text("embedding_model"),
1578 createdAt: timestamp("created_at").defaultNow().notNull(),
1579 },
1580 (table) => [
1581 index("code_chunks_repo").on(table.repositoryId),
1582 index("code_chunks_repo_path").on(table.repositoryId, table.path),
1583 ]
1584);
1585
1586export type CodebaseExplanation = typeof codebaseExplanations.$inferSelect;
1587export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
1e162a8Claude1588
1589// ---------------------------------------------------------------------------
1590// Block E2 — Discussions (migration 0013)
1591// ---------------------------------------------------------------------------
1592
1593/**
1594 * Discussions — forum-style threaded conversations attached to a repo.
1595 * Similar to GitHub Discussions: categorised + pinnable + answerable.
1596 */
1597export const discussions = pgTable(
1598 "discussions",
1599 {
1600 id: uuid("id").primaryKey().defaultRandom(),
1601 number: serial("number"),
1602 repositoryId: uuid("repository_id")
1603 .notNull()
1604 .references(() => repositories.id, { onDelete: "cascade" }),
1605 authorId: uuid("author_id")
1606 .notNull()
1607 .references(() => users.id),
1608 // one of: "general" | "q-and-a" | "ideas" | "announcements" | "show-and-tell"
1609 category: text("category").notNull().default("general"),
1610 title: text("title").notNull(),
1611 body: text("body"),
1612 state: text("state").notNull().default("open"), // open, closed
1613 locked: boolean("locked").notNull().default(false),
1614 answerCommentId: uuid("answer_comment_id"),
1615 pinned: boolean("pinned").notNull().default(false),
1616 createdAt: timestamp("created_at").defaultNow().notNull(),
1617 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1618 },
1619 (table) => [
1620 index("discussions_repo").on(table.repositoryId),
1621 uniqueIndex("discussions_repo_number").on(
1622 table.repositoryId,
1623 table.number
1624 ),
1625 ]
1626);
1627
1628export const discussionComments = pgTable(
1629 "discussion_comments",
1630 {
1631 id: uuid("id").primaryKey().defaultRandom(),
1632 discussionId: uuid("discussion_id")
1633 .notNull()
1634 .references(() => discussions.id, { onDelete: "cascade" }),
1635 parentCommentId: uuid("parent_comment_id"),
1636 authorId: uuid("author_id")
1637 .notNull()
1638 .references(() => users.id),
1639 body: text("body").notNull(),
1640 isAnswer: boolean("is_answer").notNull().default(false),
1641 createdAt: timestamp("created_at").defaultNow().notNull(),
1642 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1643 },
1644 (table) => [
1645 index("discussion_comments_discussion").on(table.discussionId),
1646 ]
1647);
1648
1649export type Discussion = typeof discussions.$inferSelect;
1650export type DiscussionComment = typeof discussionComments.$inferSelect;
3cbe3d6Claude1651export type CodeChunk = typeof codeChunks.$inferSelect;
1e162a8Claude1652
1653// ---------------------------------------------------------------------------
1654// Block E4 — Gists (migration 0014)
1655// ---------------------------------------------------------------------------
1656//
1657// User-owned small snippets/files that behave like tiny repos. DB-backed
1658// for v1 (no bare git repo): each gist owns a collection of gist_files and
1659// every edit appends a gist_revisions row with a JSON snapshot.
1660
1661export const gists = pgTable(
1662 "gists",
1663 {
1664 id: uuid("id").primaryKey().defaultRandom(),
1665 ownerId: uuid("owner_id")
1666 .notNull()
1667 .references(() => users.id, { onDelete: "cascade" }),
1668 // 8-char hex slug used in pretty URLs (e.g. /gists/a1b2c3d4).
1669 slug: text("slug").notNull().unique(),
1670 title: text("title").notNull().default(""),
1671 description: text("description").notNull().default(""),
1672 isPublic: boolean("is_public").default(true).notNull(),
1673 createdAt: timestamp("created_at").defaultNow().notNull(),
1674 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1675 },
1676 (table) => [index("gists_owner").on(table.ownerId)]
1677);
1678
1679export const gistFiles = pgTable(
1680 "gist_files",
1681 {
1682 id: uuid("id").primaryKey().defaultRandom(),
1683 gistId: uuid("gist_id")
1684 .notNull()
1685 .references(() => gists.id, { onDelete: "cascade" }),
1686 filename: text("filename").notNull(),
1687 // Optional explicit language override; falls back to filename detection.
1688 language: text("language"),
1689 content: text("content").notNull().default(""),
1690 sizeBytes: integer("size_bytes").default(0).notNull(),
1691 },
1692 (table) => [
1693 index("gist_files_gist").on(table.gistId),
1694 uniqueIndex("gist_files_gist_filename").on(table.gistId, table.filename),
1695 ]
1696);
1697
1698export const gistRevisions = pgTable(
1699 "gist_revisions",
1700 {
1701 id: uuid("id").primaryKey().defaultRandom(),
1702 gistId: uuid("gist_id")
1703 .notNull()
1704 .references(() => gists.id, { onDelete: "cascade" }),
1705 revision: integer("revision").notNull(),
1706 // JSON-encoded {filename: content} map capturing the full snapshot at
1707 // this revision. Stored as text to avoid requiring jsonb.
1708 snapshot: text("snapshot").notNull().default("{}"),
1709 authorId: uuid("author_id")
1710 .notNull()
1711 .references(() => users.id, { onDelete: "cascade" }),
1712 message: text("message"),
1713 createdAt: timestamp("created_at").defaultNow().notNull(),
1714 },
1715 (table) => [
1716 index("gist_revisions_gist_rev").on(table.gistId, table.revision),
1717 ]
1718);
1719
1720export const gistStars = pgTable(
1721 "gist_stars",
1722 {
1723 id: uuid("id").primaryKey().defaultRandom(),
1724 gistId: uuid("gist_id")
1725 .notNull()
1726 .references(() => gists.id, { onDelete: "cascade" }),
1727 userId: uuid("user_id")
1728 .notNull()
1729 .references(() => users.id, { onDelete: "cascade" }),
1730 createdAt: timestamp("created_at").defaultNow().notNull(),
1731 },
1732 (table) => [uniqueIndex("gist_stars_gist_user").on(table.gistId, table.userId)]
1733);
1734
1735export type Gist = typeof gists.$inferSelect;
1736export type GistFile = typeof gistFiles.$inferSelect;
1737export type GistRevision = typeof gistRevisions.$inferSelect;
1738export type GistStar = typeof gistStars.$inferSelect;
1739
1740// ---------------------------------------------------------------------------
1741// Block E1 — Projects / kanban (migration 0015)
1742// ---------------------------------------------------------------------------
1743
1744export const projects = pgTable(
1745 "projects",
1746 {
1747 id: uuid("id").primaryKey().defaultRandom(),
1748 number: serial("number"),
1749 repositoryId: uuid("repository_id")
1750 .notNull()
1751 .references(() => repositories.id, { onDelete: "cascade" }),
1752 ownerId: uuid("owner_id")
1753 .notNull()
1754 .references(() => users.id),
1755 title: text("title").notNull(),
1756 description: text("description").notNull().default(""),
1757 state: text("state").notNull().default("open"), // open | closed
1758 createdAt: timestamp("created_at").defaultNow().notNull(),
1759 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1760 },
1761 (table) => [
1762 index("projects_repo").on(table.repositoryId),
1763 uniqueIndex("projects_repo_number").on(table.repositoryId, table.number),
1764 ]
1765);
1766
1767export const projectColumns = pgTable(
1768 "project_columns",
1769 {
1770 id: uuid("id").primaryKey().defaultRandom(),
1771 projectId: uuid("project_id")
1772 .notNull()
1773 .references(() => projects.id, { onDelete: "cascade" }),
1774 name: text("name").notNull(),
1775 position: integer("position").notNull().default(0),
1776 createdAt: timestamp("created_at").defaultNow().notNull(),
1777 },
1778 (table) => [index("project_columns_project").on(table.projectId)]
1779);
1780
1781export const projectItems = pgTable(
1782 "project_items",
1783 {
1784 id: uuid("id").primaryKey().defaultRandom(),
1785 projectId: uuid("project_id")
1786 .notNull()
1787 .references(() => projects.id, { onDelete: "cascade" }),
1788 columnId: uuid("column_id")
1789 .notNull()
1790 .references(() => projectColumns.id, { onDelete: "cascade" }),
1791 position: integer("position").notNull().default(0),
1792 // "note" | "issue" | "pr" — application-level FK on itemId by type
1793 itemType: text("item_type").notNull().default("note"),
1794 itemId: uuid("item_id"),
1795 title: text("title").notNull().default(""),
1796 note: text("note").notNull().default(""),
1797 createdAt: timestamp("created_at").defaultNow().notNull(),
1798 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1799 },
1800 (table) => [
1801 index("project_items_project").on(table.projectId),
1802 index("project_items_column").on(table.columnId, table.position),
1803 ]
1804);
1805
1806export type Project = typeof projects.$inferSelect;
1807export type ProjectColumn = typeof projectColumns.$inferSelect;
1808export type ProjectItem = typeof projectItems.$inferSelect;
1809
1810// ---------------------------------------------------------------------------
1811// Block E3 — Wikis (migration 0016)
1812// ---------------------------------------------------------------------------
1813// DB-backed for v1; git-backed mirror is a future upgrade.
1814
1815export const wikiPages = pgTable(
1816 "wiki_pages",
1817 {
1818 id: uuid("id").primaryKey().defaultRandom(),
1819 repositoryId: uuid("repository_id")
1820 .notNull()
1821 .references(() => repositories.id, { onDelete: "cascade" }),
1822 slug: text("slug").notNull(),
1823 title: text("title").notNull(),
1824 body: text("body").notNull().default(""),
1825 revision: integer("revision").notNull().default(1),
1826 createdAt: timestamp("created_at").defaultNow().notNull(),
1827 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1828 updatedBy: uuid("updated_by").references(() => users.id),
1829 },
1830 (table) => [
1831 index("wiki_pages_repo").on(table.repositoryId),
1832 uniqueIndex("wiki_pages_repo_slug").on(table.repositoryId, table.slug),
1833 ]
1834);
1835
1836export const wikiRevisions = pgTable(
1837 "wiki_revisions",
1838 {
1839 id: uuid("id").primaryKey().defaultRandom(),
1840 pageId: uuid("page_id")
1841 .notNull()
1842 .references(() => wikiPages.id, { onDelete: "cascade" }),
1843 revision: integer("revision").notNull(),
1844 title: text("title").notNull(),
1845 body: text("body").notNull().default(""),
1846 message: text("message"),
1847 authorId: uuid("author_id")
1848 .notNull()
1849 .references(() => users.id),
1850 createdAt: timestamp("created_at").defaultNow().notNull(),
1851 },
1852 (table) => [index("wiki_revisions_page").on(table.pageId, table.revision)]
1853);
1854
1855export type WikiPage = typeof wikiPages.$inferSelect;
1856export type WikiRevision = typeof wikiRevisions.$inferSelect;
a79a9edClaude1857
1858// ---------------------------------------------------------------------------
1859// Block E5 — Merge queues (migration 0017)
1860// ---------------------------------------------------------------------------
1861
1862export const mergeQueueEntries = pgTable(
1863 "merge_queue_entries",
1864 {
1865 id: uuid("id").primaryKey().defaultRandom(),
1866 repositoryId: uuid("repository_id")
1867 .notNull()
1868 .references(() => repositories.id, { onDelete: "cascade" }),
1869 pullRequestId: uuid("pull_request_id")
1870 .notNull()
1871 .references(() => pullRequests.id, { onDelete: "cascade" }),
1872 baseBranch: text("base_branch").notNull(),
1873 // queued | running | merged | failed | dequeued
1874 state: text("state").notNull().default("queued"),
1875 position: integer("position").notNull().default(0),
1876 enqueuedBy: uuid("enqueued_by").references(() => users.id),
1877 enqueuedAt: timestamp("enqueued_at").defaultNow().notNull(),
1878 startedAt: timestamp("started_at"),
1879 finishedAt: timestamp("finished_at"),
1880 errorMessage: text("error_message"),
1881 },
1882 (table) => [
1883 index("merge_queue_repo_branch").on(
1884 table.repositoryId,
1885 table.baseBranch,
1886 table.state
1887 ),
1888 ]
1889);
1890
1891export type MergeQueueEntry = typeof mergeQueueEntries.$inferSelect;
1892
1893// ---------------------------------------------------------------------------
1894// Block E6 — Required status checks matrix (migration 0018)
1895// ---------------------------------------------------------------------------
1896
1897export const branchRequiredChecks = pgTable(
1898 "branch_required_checks",
1899 {
1900 id: uuid("id").primaryKey().defaultRandom(),
1901 branchProtectionId: uuid("branch_protection_id")
1902 .notNull()
1903 .references(() => branchProtection.id, { onDelete: "cascade" }),
1904 checkName: text("check_name").notNull(),
1905 createdAt: timestamp("created_at").defaultNow().notNull(),
1906 },
1907 (table) => [
1908 index("branch_required_checks_rule").on(table.branchProtectionId),
1909 uniqueIndex("branch_required_checks_unique").on(
1910 table.branchProtectionId,
1911 table.checkName
1912 ),
1913 ]
1914);
1915
1916export type BranchRequiredCheck = typeof branchRequiredChecks.$inferSelect;
1917
1918// ---------------------------------------------------------------------------
1919// Block E7 — Protected tags (migration 0019)
1920// ---------------------------------------------------------------------------
1921
1922export const protectedTags = pgTable(
1923 "protected_tags",
1924 {
1925 id: uuid("id").primaryKey().defaultRandom(),
1926 repositoryId: uuid("repository_id")
1927 .notNull()
1928 .references(() => repositories.id, { onDelete: "cascade" }),
1929 pattern: text("pattern").notNull(),
1930 createdAt: timestamp("created_at").defaultNow().notNull(),
1931 createdBy: uuid("created_by").references(() => users.id),
1932 },
1933 (table) => [
1934 index("protected_tags_repo").on(table.repositoryId),
1935 uniqueIndex("protected_tags_repo_pattern").on(
1936 table.repositoryId,
1937 table.pattern
1938 ),
1939 ]
1940);
1941
1942export type ProtectedTag = typeof protectedTags.$inferSelect;
8f50ed0Claude1943
1944// ---------------------------------------------------------------------------
1945// Block F — Observability + admin (migration 0020)
1946// ---------------------------------------------------------------------------
1947
1948// F1 — Traffic analytics per repo
1949export const repoTrafficEvents = pgTable(
1950 "repo_traffic_events",
1951 {
1952 id: uuid("id").primaryKey().defaultRandom(),
1953 repositoryId: uuid("repository_id")
1954 .notNull()
1955 .references(() => repositories.id, { onDelete: "cascade" }),
1956 kind: text("kind").notNull(), // view | clone | api | ui
1957 path: text("path"),
1958 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
1959 ipHash: text("ip_hash"),
1960 userAgent: text("user_agent"),
1961 referer: text("referer"),
1962 createdAt: timestamp("created_at").defaultNow().notNull(),
1963 },
1964 (table) => [
1965 index("repo_traffic_events_repo_time").on(
1966 table.repositoryId,
1967 table.createdAt
1968 ),
1969 index("repo_traffic_events_kind").on(
1970 table.repositoryId,
1971 table.kind,
1972 table.createdAt
1973 ),
1974 ]
1975);
1976
1977export type RepoTrafficEvent = typeof repoTrafficEvents.$inferSelect;
1978
1979// F3 — Admin panel (site admins + toggleable flags)
1980export const systemFlags = pgTable("system_flags", {
1981 key: text("key").primaryKey(),
1982 value: text("value").notNull().default(""),
1983 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1984 updatedBy: uuid("updated_by").references(() => users.id),
1985});
1986
1987export type SystemFlag = typeof systemFlags.$inferSelect;
1988
1989export const siteAdmins = pgTable("site_admins", {
1990 userId: uuid("user_id")
1991 .primaryKey()
1992 .references(() => users.id, { onDelete: "cascade" }),
1993 grantedAt: timestamp("granted_at").defaultNow().notNull(),
1994 grantedBy: uuid("granted_by").references(() => users.id),
1995});
1996
1997export type SiteAdmin = typeof siteAdmins.$inferSelect;
1998
509c376Claude1999// /admin/integrations — DB-stored platform integration secrets (migration 0055).
2000// Loaded into process.env at boot so the existing synchronous config getters
2001// keep working transparently. Never exposes DATABASE_URL / SELF_HOST_REPO /
2002// PORT / GIT_REPOS_PATH — those stay env-only.
2003export const systemConfig = pgTable("system_config", {
2004 key: text("key").primaryKey(),
2005 value: text("value").notNull().default(""),
2006 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2007 updatedByUserId: uuid("updated_by_user_id").references(() => users.id, {
2008 onDelete: "set null",
2009 }),
2010});
2011
2012export type SystemConfig = typeof systemConfig.$inferSelect;
2013
8f50ed0Claude2014// F4 — Billing + quotas
2015export const billingPlans = pgTable("billing_plans", {
2016 id: uuid("id").primaryKey().defaultRandom(),
2017 slug: text("slug").notNull().unique(),
2018 name: text("name").notNull(),
2019 priceCents: integer("price_cents").notNull().default(0),
2020 repoLimit: integer("repo_limit").notNull().default(10),
2021 storageMbLimit: integer("storage_mb_limit").notNull().default(1024),
2022 aiTokensMonthly: integer("ai_tokens_monthly").notNull().default(100000),
2023 bandwidthGbMonthly: integer("bandwidth_gb_monthly").notNull().default(10),
2024 privateRepos: boolean("private_repos").notNull().default(false),
2025 createdAt: timestamp("created_at").defaultNow().notNull(),
2026});
2027
2028export type BillingPlan = typeof billingPlans.$inferSelect;
2029
2030export const userQuotas = pgTable("user_quotas", {
2031 userId: uuid("user_id")
2032 .primaryKey()
2033 .references(() => users.id, { onDelete: "cascade" }),
2034 planSlug: text("plan_slug").notNull().default("free"),
2035 storageMbUsed: integer("storage_mb_used").notNull().default(0),
2036 aiTokensUsedThisMonth: integer("ai_tokens_used_this_month")
2037 .notNull()
2038 .default(0),
2039 bandwidthGbUsedThisMonth: integer("bandwidth_gb_used_this_month")
2040 .notNull()
2041 .default(0),
2042 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
2043 updatedAt: timestamp("updated_at").defaultNow().notNull(),
619109aClaude2044 // Stripe linkage (migration 0038). Null until the user first upgrades.
2045 stripeCustomerId: text("stripe_customer_id"),
2046 stripeSubscriptionId: text("stripe_subscription_id"),
2047 stripeSubscriptionStatus: text("stripe_subscription_status"),
2048 currentPeriodEnd: timestamp("current_period_end"),
8f50ed0Claude2049});
2050
2051export type UserQuota = typeof userQuotas.$inferSelect;
06139e6Claude2052
2053// Block H — App marketplace + bot identities (GitHub Apps equivalent)
2054
2055export const apps = pgTable(
2056 "apps",
2057 {
2058 id: uuid("id").primaryKey().defaultRandom(),
2059 slug: text("slug").notNull().unique(),
2060 name: text("name").notNull(),
2061 description: text("description").notNull().default(""),
2062 iconUrl: text("icon_url"),
2063 homepageUrl: text("homepage_url"),
2064 webhookUrl: text("webhook_url"),
2065 webhookSecret: text("webhook_secret"),
2066 creatorId: uuid("creator_id")
2067 .notNull()
2068 .references(() => users.id, { onDelete: "cascade" }),
2069 permissions: text("permissions").notNull().default("[]"), // JSON array
2070 defaultEvents: text("default_events").notNull().default("[]"), // JSON array
2071 isPublic: boolean("is_public").notNull().default(true),
2072 createdAt: timestamp("created_at").defaultNow().notNull(),
2073 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2074 },
2075 (table) => [index("apps_public_slug").on(table.isPublic, table.slug)]
2076);
2077
2078export type App = typeof apps.$inferSelect;
2079
2080export const appInstallations = pgTable(
2081 "app_installations",
2082 {
2083 id: uuid("id").primaryKey().defaultRandom(),
2084 appId: uuid("app_id")
2085 .notNull()
2086 .references(() => apps.id, { onDelete: "cascade" }),
2087 installedBy: uuid("installed_by")
2088 .notNull()
2089 .references(() => users.id, { onDelete: "cascade" }),
2090 targetType: text("target_type").notNull(), // user | org | repository
2091 targetId: uuid("target_id").notNull(),
2092 grantedPermissions: text("granted_permissions").notNull().default("[]"),
2093 suspendedAt: timestamp("suspended_at"),
2094 createdAt: timestamp("created_at").defaultNow().notNull(),
2095 uninstalledAt: timestamp("uninstalled_at"),
2096 },
2097 (table) => [
2098 index("app_installations_app").on(table.appId),
2099 index("app_installations_target").on(table.targetType, table.targetId),
2100 ]
2101);
2102
2103export type AppInstallation = typeof appInstallations.$inferSelect;
2104
2105export const appBots = pgTable("app_bots", {
2106 id: uuid("id").primaryKey().defaultRandom(),
2107 appId: uuid("app_id")
2108 .notNull()
2109 .unique()
2110 .references(() => apps.id, { onDelete: "cascade" }),
2111 username: text("username").notNull().unique(),
2112 displayName: text("display_name").notNull(),
2113 avatarUrl: text("avatar_url"),
2114 createdAt: timestamp("created_at").defaultNow().notNull(),
2115});
2116
2117export type AppBot = typeof appBots.$inferSelect;
2118
2119export const appInstallTokens = pgTable(
2120 "app_install_tokens",
2121 {
2122 id: uuid("id").primaryKey().defaultRandom(),
2123 installationId: uuid("installation_id")
2124 .notNull()
2125 .references(() => appInstallations.id, { onDelete: "cascade" }),
2126 tokenHash: text("token_hash").notNull().unique(),
2127 expiresAt: timestamp("expires_at").notNull(),
2128 createdAt: timestamp("created_at").defaultNow().notNull(),
2129 revokedAt: timestamp("revoked_at"),
2130 },
2131 (table) => [index("app_install_tokens_hash").on(table.tokenHash)]
2132);
2133
2134export type AppInstallToken = typeof appInstallTokens.$inferSelect;
2135
2136export const appEvents = pgTable(
2137 "app_events",
2138 {
2139 id: uuid("id").primaryKey().defaultRandom(),
2140 appId: uuid("app_id")
2141 .notNull()
2142 .references(() => apps.id, { onDelete: "cascade" }),
2143 installationId: uuid("installation_id"),
2144 kind: text("kind").notNull(), // installed | uninstalled | delivery_ok | delivery_fail
2145 payload: text("payload"),
2146 responseStatus: integer("response_status"),
2147 createdAt: timestamp("created_at").defaultNow().notNull(),
2148 },
2149 (table) => [index("app_events_app_time").on(table.appId, table.createdAt)]
2150);
2151
2152export type AppEvent = typeof appEvents.$inferSelect;
71cd5ecClaude2153
2154// ---------- Block I3 — Repository transfer history ----------
2155
2156export const repoTransfers = pgTable(
2157 "repo_transfers",
2158 {
2159 id: uuid("id").primaryKey().defaultRandom(),
2160 repositoryId: uuid("repository_id")
2161 .notNull()
2162 .references(() => repositories.id, { onDelete: "cascade" }),
2163 fromOwnerId: uuid("from_owner_id").notNull(),
2164 fromOrgId: uuid("from_org_id"),
2165 toOwnerId: uuid("to_owner_id").notNull(),
2166 toOrgId: uuid("to_org_id"),
2167 initiatedBy: uuid("initiated_by")
2168 .notNull()
2169 .references(() => users.id, { onDelete: "cascade" }),
2170 createdAt: timestamp("created_at").defaultNow().notNull(),
2171 },
2172 (table) => [
2173 index("repo_transfers_repo").on(table.repositoryId, table.createdAt),
2174 ]
2175);
2176
2177export type RepoTransfer = typeof repoTransfers.$inferSelect;
08420cdClaude2178
2179// ---------- Block I6 — Sponsors ----------
2180
2181export const sponsorshipTiers = pgTable(
2182 "sponsorship_tiers",
2183 {
2184 id: uuid("id").primaryKey().defaultRandom(),
2185 maintainerId: uuid("maintainer_id")
2186 .notNull()
2187 .references(() => users.id, { onDelete: "cascade" }),
2188 name: text("name").notNull(),
2189 description: text("description").default("").notNull(),
2190 monthlyCents: integer("monthly_cents").notNull(),
2191 oneTimeAllowed: boolean("one_time_allowed").default(true).notNull(),
2192 isActive: boolean("is_active").default(true).notNull(),
2193 createdAt: timestamp("created_at").defaultNow().notNull(),
2194 },
2195 (table) => [
2196 index("sponsor_tiers_maintainer").on(table.maintainerId, table.isActive),
2197 ]
2198);
2199
2200export type SponsorshipTier = typeof sponsorshipTiers.$inferSelect;
2201
2202export const sponsorships = pgTable(
2203 "sponsorships",
2204 {
2205 id: uuid("id").primaryKey().defaultRandom(),
2206 sponsorId: uuid("sponsor_id")
2207 .notNull()
2208 .references(() => users.id, { onDelete: "cascade" }),
2209 maintainerId: uuid("maintainer_id")
2210 .notNull()
2211 .references(() => users.id, { onDelete: "cascade" }),
2212 tierId: uuid("tier_id"),
2213 amountCents: integer("amount_cents").notNull(),
2214 kind: text("kind").notNull(), // one_time | monthly
2215 note: text("note"),
2216 isPublic: boolean("is_public").default(true).notNull(),
2217 externalRef: text("external_ref"),
2218 cancelledAt: timestamp("cancelled_at"),
2219 createdAt: timestamp("created_at").defaultNow().notNull(),
2220 },
2221 (table) => [
2222 index("sponsorships_maintainer").on(
2223 table.maintainerId,
2224 table.createdAt
2225 ),
2226 index("sponsorships_sponsor").on(table.sponsorId, table.createdAt),
2227 ]
2228);
2229
2230export type Sponsorship = typeof sponsorships.$inferSelect;
4c8f666Claude2231
2232// Block I8 — Code symbol index for xref navigation.
2233export const codeSymbols = pgTable(
2234 "code_symbols",
2235 {
2236 id: uuid("id").primaryKey().defaultRandom(),
2237 repositoryId: uuid("repository_id")
2238 .notNull()
2239 .references(() => repositories.id, { onDelete: "cascade" }),
2240 commitSha: text("commit_sha").notNull(),
2241 name: text("name").notNull(),
2242 kind: text("kind").notNull(), // function | class | interface | type | const | variable
2243 path: text("path").notNull(),
2244 line: integer("line").notNull(),
2245 signature: text("signature"),
2246 createdAt: timestamp("created_at").defaultNow().notNull(),
2247 },
2248 (table) => [
2249 index("code_symbols_repo_name_idx").on(table.repositoryId, table.name),
2250 index("code_symbols_repo_path_idx").on(table.repositoryId, table.path),
2251 ]
2252);
2253
2254export type CodeSymbol = typeof codeSymbols.$inferSelect;
4a0dea1Claude2255
2256// Block I9 — Repository mirroring. One row per mirrored repo + an
2257// append-only log of sync attempts.
2258export const repoMirrors = pgTable("repo_mirrors", {
2259 id: uuid("id").primaryKey().defaultRandom(),
2260 repositoryId: uuid("repository_id")
2261 .notNull()
2262 .unique()
2263 .references(() => repositories.id, { onDelete: "cascade" }),
2264 upstreamUrl: text("upstream_url").notNull(),
2265 intervalMinutes: integer("interval_minutes").default(1440).notNull(),
2266 lastSyncedAt: timestamp("last_synced_at"),
2267 lastStatus: text("last_status"), // "ok" | "error"
2268 lastError: text("last_error"),
2269 isEnabled: boolean("is_enabled").default(true).notNull(),
2270 createdAt: timestamp("created_at").defaultNow().notNull(),
2271 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2272});
2273
2274export type RepoMirror = typeof repoMirrors.$inferSelect;
2275
2276export const repoMirrorRuns = pgTable(
2277 "repo_mirror_runs",
2278 {
2279 id: uuid("id").primaryKey().defaultRandom(),
2280 mirrorId: uuid("mirror_id")
2281 .notNull()
2282 .references(() => repoMirrors.id, { onDelete: "cascade" }),
2283 startedAt: timestamp("started_at").defaultNow().notNull(),
2284 finishedAt: timestamp("finished_at"),
2285 status: text("status").default("running").notNull(),
2286 message: text("message"),
2287 exitCode: integer("exit_code"),
2288 },
2289 (table) => [
2290 index("repo_mirror_runs_mirror_id_idx").on(table.mirrorId, table.startedAt),
2291 ]
2292);
2293
2294export type RepoMirrorRun = typeof repoMirrorRuns.$inferSelect;
edf7c36Claude2295
2296// ----------------------------------------------------------------------------
2297// Block I10 — Enterprise SSO (OIDC)
2298// ----------------------------------------------------------------------------
2299
2300/** Site-wide SSO provider. Singleton row with id = 'default'. */
2301export const ssoConfig = pgTable("sso_config", {
2302 id: text("id").primaryKey(),
2303 enabled: boolean("enabled").default(false).notNull(),
2304 providerName: text("provider_name").default("SSO").notNull(),
2305 issuer: text("issuer"),
2306 authorizationEndpoint: text("authorization_endpoint"),
2307 tokenEndpoint: text("token_endpoint"),
2308 userinfoEndpoint: text("userinfo_endpoint"),
2309 clientId: text("client_id"),
2310 clientSecret: text("client_secret"),
2311 scopes: text("scopes").default("openid profile email").notNull(),
2312 allowedEmailDomains: text("allowed_email_domains"),
2313 autoCreateUsers: boolean("auto_create_users").default(true).notNull(),
2314 createdAt: timestamp("created_at").defaultNow().notNull(),
2315 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2316});
2317
2318export type SsoConfig = typeof ssoConfig.$inferSelect;
2319
2320/** Maps a local user to an IdP `sub` claim. */
2321export const ssoUserLinks = pgTable(
2322 "sso_user_links",
2323 {
2324 id: uuid("id").primaryKey().defaultRandom(),
2325 userId: uuid("user_id")
2326 .notNull()
2327 .references(() => users.id, { onDelete: "cascade" }),
2328 subject: text("subject").notNull().unique(),
2329 emailAtLink: text("email_at_link").notNull(),
2330 linkedAt: timestamp("linked_at").defaultNow().notNull(),
2331 },
2332 (table) => [index("sso_user_links_user_id_idx").on(table.userId)]
2333);
2334
2335export type SsoUserLink = typeof ssoUserLinks.$inferSelect;
8098672Claude2336
2337// ----------------------------------------------------------------------------
2338// Block J1 — Dependency graph
2339// ----------------------------------------------------------------------------
2340
2341/**
2342 * Last known set of dependencies parsed from manifest files. Each reindex
2343 * replaces the prior rows for that repo. One row per (ecosystem, name,
2344 * manifest_path) — same name in multiple manifests is kept.
2345 */
2346export const repoDependencies = pgTable(
2347 "repo_dependencies",
2348 {
2349 id: uuid("id").primaryKey().defaultRandom(),
2350 repositoryId: uuid("repository_id")
2351 .notNull()
2352 .references(() => repositories.id, { onDelete: "cascade" }),
2353 ecosystem: text("ecosystem").notNull(),
2354 name: text("name").notNull(),
2355 versionSpec: text("version_spec"),
2356 manifestPath: text("manifest_path").notNull(),
2357 isDev: boolean("is_dev").default(false).notNull(),
2358 commitSha: text("commit_sha").notNull(),
2359 indexedAt: timestamp("indexed_at").defaultNow().notNull(),
2360 },
2361 (table) => [
2362 index("repo_dependencies_repo_id_idx").on(
2363 table.repositoryId,
2364 table.ecosystem
2365 ),
2366 index("repo_dependencies_name_idx").on(table.name),
2367 ]
2368);
2369
2370export type RepoDependency = typeof repoDependencies.$inferSelect;
f60ccdeClaude2371
2372// ----------------------------------------------------------------------------
2373// Block J2 — Security advisories + alerts
2374// ----------------------------------------------------------------------------
2375
2376/** CVE-style package advisories. Populated via seed + admin import. */
2377export const securityAdvisories = pgTable(
2378 "security_advisories",
2379 {
2380 id: uuid("id").primaryKey().defaultRandom(),
2381 ghsaId: text("ghsa_id").unique(),
2382 cveId: text("cve_id"),
2383 summary: text("summary").notNull(),
2384 severity: text("severity").default("moderate").notNull(),
2385 ecosystem: text("ecosystem").notNull(),
2386 packageName: text("package_name").notNull(),
2387 affectedRange: text("affected_range").notNull(),
2388 fixedVersion: text("fixed_version"),
2389 referenceUrl: text("reference_url"),
2390 publishedAt: timestamp("published_at").defaultNow().notNull(),
2391 },
2392 (table) => [
2393 index("security_advisories_pkg_idx").on(
2394 table.ecosystem,
2395 table.packageName
2396 ),
2397 ]
2398);
2399
2400export type SecurityAdvisory = typeof securityAdvisories.$inferSelect;
2401
2402/** Per-repo match state. One row per (repo, advisory, manifest_path). */
2403export const repoAdvisoryAlerts = pgTable(
2404 "repo_advisory_alerts",
2405 {
2406 id: uuid("id").primaryKey().defaultRandom(),
2407 repositoryId: uuid("repository_id")
2408 .notNull()
2409 .references(() => repositories.id, { onDelete: "cascade" }),
2410 advisoryId: uuid("advisory_id")
2411 .notNull()
2412 .references(() => securityAdvisories.id, { onDelete: "cascade" }),
2413 dependencyName: text("dependency_name").notNull(),
2414 dependencyVersion: text("dependency_version"),
2415 manifestPath: text("manifest_path").notNull(),
2416 status: text("status").default("open").notNull(),
2417 dismissedReason: text("dismissed_reason"),
2418 createdAt: timestamp("created_at").defaultNow().notNull(),
2419 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2420 },
2421 (table) => [
2422 index("repo_advisory_alerts_status_idx").on(
2423 table.repositoryId,
2424 table.status
2425 ),
2426 ]
2427);
2428
2429export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
3951454Claude2430
2431// ----------------------------------------------------------------------------
2432// Block J3 — Commit signature verification (GPG + SSH)
2433// ----------------------------------------------------------------------------
2434
2435/** Per-user GPG/SSH public keys for commit signing. */
2436export const signingKeys = pgTable(
2437 "signing_keys",
2438 {
2439 id: uuid("id").primaryKey().defaultRandom(),
2440 userId: uuid("user_id")
2441 .notNull()
2442 .references(() => users.id, { onDelete: "cascade" }),
2443 keyType: text("key_type").notNull(), // 'gpg' | 'ssh'
2444 title: text("title").notNull(),
2445 fingerprint: text("fingerprint").notNull(),
2446 publicKey: text("public_key").notNull(),
2447 email: text("email"),
2448 expiresAt: timestamp("expires_at"),
2449 lastUsedAt: timestamp("last_used_at"),
2450 createdAt: timestamp("created_at").defaultNow().notNull(),
2451 },
2452 (table) => [
2453 uniqueIndex("signing_keys_fp_unique").on(table.keyType, table.fingerprint),
2454 index("signing_keys_user_idx").on(table.userId),
2455 ]
2456);
2457
2458export type SigningKey = typeof signingKeys.$inferSelect;
2459
2460/**
2461 * Cached verification result for a (repo, commit) pair. Repopulated on demand;
2462 * rows are invalidated implicitly by CASCADE when either side is removed.
2463 */
2464export const commitVerifications = pgTable(
2465 "commit_verifications",
2466 {
2467 id: uuid("id").primaryKey().defaultRandom(),
2468 repositoryId: uuid("repository_id")
2469 .notNull()
2470 .references(() => repositories.id, { onDelete: "cascade" }),
2471 commitSha: text("commit_sha").notNull(),
2472 verified: boolean("verified").default(false).notNull(),
2473 reason: text("reason").notNull(),
2474 signatureType: text("signature_type"),
2475 signerKeyId: uuid("signer_key_id").references(() => signingKeys.id, {
2476 onDelete: "set null",
2477 }),
2478 signerUserId: uuid("signer_user_id").references(() => users.id, {
2479 onDelete: "set null",
2480 }),
2481 signerFingerprint: text("signer_fingerprint"),
2482 verifiedAt: timestamp("verified_at").defaultNow().notNull(),
2483 },
2484 (table) => [
2485 uniqueIndex("commit_verifications_sha_unique").on(
2486 table.repositoryId,
2487 table.commitSha
2488 ),
2489 ]
2490);
2491
2492export type CommitVerification = typeof commitVerifications.$inferSelect;
7aa8b99Claude2493
2494// ----------------------------------------------------------------------------
2495// Block J4 — User following
2496// ----------------------------------------------------------------------------
2497
2498/**
2499 * Directed user→user follow edges. Primary key is the composite
2500 * (follower_id, following_id) at the SQL level; drizzle sees it as a
2501 * regular table with a unique index plus a secondary index on the
2502 * reverse-lookup column.
2503 */
2504export const userFollows = pgTable(
2505 "user_follows",
2506 {
2507 followerId: uuid("follower_id")
2508 .notNull()
2509 .references(() => users.id, { onDelete: "cascade" }),
2510 followingId: uuid("following_id")
2511 .notNull()
2512 .references(() => users.id, { onDelete: "cascade" }),
2513 createdAt: timestamp("created_at").defaultNow().notNull(),
2514 },
2515 (table) => [
2516 uniqueIndex("user_follows_pair_unique").on(
2517 table.followerId,
2518 table.followingId
2519 ),
2520 index("user_follows_following_idx").on(table.followingId),
2521 ]
2522);
2523
2524export type UserFollow = typeof userFollows.$inferSelect;
9ff7128Claude2525
2526// ----------------------------------------------------------------------------
2527// Block J6 — Repository rulesets
2528// ----------------------------------------------------------------------------
2529
2530/**
2531 * A ruleset groups N rules under a named policy at enforcement level active /
2532 * evaluate / disabled. Unique per (repo, name) so a repo can carry multiple
2533 * overlapping rulesets (e.g. "release branches" vs "everywhere").
2534 */
2535export const repoRulesets = pgTable(
2536 "repo_rulesets",
2537 {
2538 id: uuid("id").primaryKey().defaultRandom(),
2539 repositoryId: uuid("repository_id")
2540 .notNull()
2541 .references(() => repositories.id, { onDelete: "cascade" }),
2542 name: text("name").notNull(),
2543 enforcement: text("enforcement").default("active").notNull(),
2544 createdBy: uuid("created_by").references(() => users.id, {
2545 onDelete: "set null",
2546 }),
2547 createdAt: timestamp("created_at").defaultNow().notNull(),
2548 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2549 },
2550 (table) => [
2551 index("repo_rulesets_repo_idx").on(table.repositoryId),
2552 uniqueIndex("repo_rulesets_repo_name_unique").on(
2553 table.repositoryId,
2554 table.name
2555 ),
2556 ]
2557);
2558
2559export type RepoRuleset = typeof repoRulesets.$inferSelect;
2560
2561/** Individual rule — type tag plus JSON params. */
2562export const rulesetRules = pgTable(
2563 "ruleset_rules",
2564 {
2565 id: uuid("id").primaryKey().defaultRandom(),
2566 rulesetId: uuid("ruleset_id")
2567 .notNull()
2568 .references(() => repoRulesets.id, { onDelete: "cascade" }),
2569 ruleType: text("rule_type").notNull(),
2570 params: text("params").default("{}").notNull(),
2571 createdAt: timestamp("created_at").defaultNow().notNull(),
2572 },
2573 (table) => [index("ruleset_rules_set_idx").on(table.rulesetId)]
2574);
2575
2576export type RulesetRule = typeof rulesetRules.$inferSelect;
0cdfd89Claude2577
2578// ---------------------------------------------------------------------------
2579// Block J8 — Commit statuses.
2580// ---------------------------------------------------------------------------
2581
2582/**
2583 * External CI / automation posts per-commit (sha, context) statuses. Upsert
2584 * semantics keyed on (repository, commit_sha, context). State vocabulary:
2585 * pending | success | failure | error. Combined rollup logic lives in
2586 * `src/lib/commit-statuses.ts`.
2587 */
2588export const commitStatuses = pgTable(
2589 "commit_statuses",
2590 {
2591 id: uuid("id").primaryKey().defaultRandom(),
2592 repositoryId: uuid("repository_id")
2593 .notNull()
2594 .references(() => repositories.id, { onDelete: "cascade" }),
2595 commitSha: text("commit_sha").notNull(),
2596 state: text("state").notNull(),
2597 context: text("context").default("default").notNull(),
2598 description: text("description"),
2599 targetUrl: text("target_url"),
2600 creatorId: uuid("creator_id").references(() => users.id, {
2601 onDelete: "set null",
2602 }),
2603 createdAt: timestamp("created_at").defaultNow().notNull(),
2604 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2605 },
2606 (table) => [
2607 uniqueIndex("commit_statuses_repo_sha_context_unique").on(
2608 table.repositoryId,
2609 table.commitSha,
2610 table.context
2611 ),
2612 index("commit_statuses_repo_sha_idx").on(
2613 table.repositoryId,
2614 table.commitSha
2615 ),
2616 ]
2617);
2618
2619export type CommitStatus = typeof commitStatuses.$inferSelect;
23d1a81Claude2620
2621// ---------------------------------------------------------------------------
2622// Collaborators — per-repo role grants (read / write / admin).
2623// ---------------------------------------------------------------------------
2624
2625/**
2626 * A user granted access to a repository beyond ownership. Roles are
2627 * hierarchical — 'admin' implies write, write implies read. `invitedBy` tracks
2628 * who added them (nullable once that user is deleted); `acceptedAt` is null
2629 * until the invitee explicitly accepts. Unique per (repo, user) so a given
2630 * user has at most one role per repo.
2631 */
2632export const repoCollaborators = pgTable(
2633 "repo_collaborators",
2634 {
2635 id: uuid("id").primaryKey().defaultRandom(),
2636 repositoryId: uuid("repository_id")
2637 .notNull()
2638 .references(() => repositories.id, { onDelete: "cascade" }),
2639 userId: uuid("user_id")
2640 .notNull()
2641 .references(() => users.id, { onDelete: "cascade" }),
2642 role: text("role", { enum: ["read", "write", "admin"] })
2643 .notNull()
2644 .default("read"),
2645 invitedBy: uuid("invited_by").references(() => users.id, {
2646 onDelete: "set null",
2647 }),
2648 invitedAt: timestamp("invited_at").defaultNow().notNull(),
2649 acceptedAt: timestamp("accepted_at"),
febd4f0Claude2650 // sha256(plaintext) of the outstanding invite token. Set when the owner
2651 // sends an invite, cleared when the invitee accepts. NULL on older rows
2652 // that were auto-accepted before the email flow existed.
2653 inviteTokenHash: text("invite_token_hash"),
23d1a81Claude2654 },
2655 (table) => [
2656 uniqueIndex("repo_collaborators_repo_user_uq").on(
2657 table.repositoryId,
2658 table.userId
2659 ),
2660 index("repo_collaborators_repo_idx").on(table.repositoryId),
2661 index("repo_collaborators_user_idx").on(table.userId),
2662 ]
2663);
2664
2665export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
abfa9adClaude2666
2667// ---------------------------------------------------------------------------
2668// Workflow engine v2 — Sprint 1 additions (drizzle/0037_workflow_engine_v2.sql)
2669//
2670// Strictly additive to Block C1. The original workflow tables defined above
2671// (workflows / workflow_runs / workflow_jobs / workflow_artifacts) are locked
2672// and must not be altered in-place; the four tables below extend the runner
2673// with secrets, workflow_dispatch inputs, a content-addressable cache, and a
2674// warm-runner worker pool.
2675// ---------------------------------------------------------------------------
2676
2677/**
2678 * Per-repo encrypted secrets exposed to workflow jobs as env vars.
2679 *
2680 * `encrypted_value` is base64 of `iv || authTag || ciphertext` produced by
2681 * AES-256-GCM — the crypto lives in `src/lib/workflow-crypto.ts` (Agent 2);
2682 * the DB only stores opaque ciphertext. `name` is validated against
2683 * `[A-Z_][A-Z0-9_]*` at the write-site, not the DB. Unique per (repo, name).
2684 */
2685export const workflowSecrets = pgTable(
2686 "workflow_secrets",
2687 {
2688 id: uuid("id").primaryKey().defaultRandom(),
2689 repositoryId: uuid("repository_id")
2690 .notNull()
2691 .references(() => repositories.id, { onDelete: "cascade" }),
2692 name: text("name").notNull(),
2693 encryptedValue: text("encrypted_value").notNull(),
2694 createdBy: uuid("created_by").references(() => users.id, {
2695 onDelete: "set null",
2696 }),
2697 createdAt: timestamp("created_at").defaultNow().notNull(),
2698 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2699 },
2700 (table) => [
2701 uniqueIndex("workflow_secrets_repo_name_uq").on(
2702 table.repositoryId,
2703 table.name
2704 ),
2705 index("workflow_secrets_repo_idx").on(table.repositoryId),
2706 ]
2707);
2708
2709export type WorkflowSecret = typeof workflowSecrets.$inferSelect;
2710export type NewWorkflowSecret = typeof workflowSecrets.$inferInsert;
2711
2712/**
2713 * Parameter schema for the `workflow_dispatch` trigger. One row per input
2714 * declared in the workflow YAML. `type` is constrained to the four values
2715 * GitHub Actions supports; `options` is only meaningful for type='choice'
2716 * (a JSON array of allowed strings). `default_value` and `description` are
2717 * optional. Unique per (workflow, name) so two inputs on the same workflow
2718 * can't share an identifier.
2719 */
2720export const workflowDispatchInputs = pgTable(
2721 "workflow_dispatch_inputs",
2722 {
2723 id: uuid("id").primaryKey().defaultRandom(),
2724 workflowId: uuid("workflow_id")
2725 .notNull()
2726 .references(() => workflows.id, { onDelete: "cascade" }),
2727 name: text("name").notNull(),
2728 type: text("type", {
2729 enum: ["string", "boolean", "choice", "number"],
2730 }).notNull(),
2731 required: boolean("required").default(false).notNull(),
2732 defaultValue: text("default_value"),
2733 // JSON array of strings; only populated when type = 'choice'.
2734 options: jsonb("options"),
2735 description: text("description"),
2736 },
2737 (table) => [
2738 uniqueIndex("workflow_dispatch_inputs_wf_name_uq").on(
2739 table.workflowId,
2740 table.name
2741 ),
2742 ]
2743);
2744
2745export type WorkflowDispatchInput =
2746 typeof workflowDispatchInputs.$inferSelect;
2747export type NewWorkflowDispatchInput =
2748 typeof workflowDispatchInputs.$inferInsert;
2749
2750/**
2751 * Content-addressable cache backing the `gluecron/cache@v1` action. Rows are
2752 * keyed by user-chosen `cache_key` within a `scope` — 'repo' for repo-wide
2753 * entries, 'branch' or 'tag' when isolation is desired (with `scope_ref`
2754 * holding the branch/tag name). `content_hash` is the sha256 of `content`
2755 * so jobs can short-circuit redundant writes. `content` is real bytea; the
2756 * 100MB payload cap is enforced at the write-site, not by the DB. LRU
2757 * eviction reads (`repository_id`, `last_accessed_at`).
2758 */
2759export const workflowRunCache = pgTable(
2760 "workflow_run_cache",
2761 {
2762 id: uuid("id").primaryKey().defaultRandom(),
2763 repositoryId: uuid("repository_id")
2764 .notNull()
2765 .references(() => repositories.id, { onDelete: "cascade" }),
2766 cacheKey: text("cache_key").notNull(),
2767 // 'repo' | 'branch' | 'tag' — constraint enforced at the write-site.
2768 scope: text("scope").default("repo").notNull(),
2769 scopeRef: text("scope_ref"),
2770 contentHash: text("content_hash").notNull(),
2771 content: bytea("content").notNull(),
2772 sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
2773 createdAt: timestamp("created_at").defaultNow().notNull(),
2774 lastAccessedAt: timestamp("last_accessed_at").defaultNow().notNull(),
2775 },
2776 (table) => [
2777 uniqueIndex("workflow_run_cache_repo_key_scope_uq").on(
2778 table.repositoryId,
2779 table.cacheKey,
2780 table.scope,
2781 table.scopeRef
2782 ),
2783 index("workflow_run_cache_repo_lru_idx").on(
2784 table.repositoryId,
2785 table.lastAccessedAt
2786 ),
2787 ]
2788);
2789
2790export type WorkflowRunCache = typeof workflowRunCache.$inferSelect;
2791export type NewWorkflowRunCache = typeof workflowRunCache.$inferInsert;
2792
2793/**
2794 * Warm-runner worker registry. The scheduler reads idle rows to dispatch
2795 * queued jobs without paying cold-start cost; workers heartbeat here so
2796 * stale entries (>N seconds since `last_heartbeat_at`) can be reaped.
2797 * `worker_id` is a stable string (hostname:pid or similar) and is globally
2798 * unique so a restarted worker naturally replaces its predecessor.
2799 * `current_run_id` is set when status='busy' and cleared on completion.
2800 */
2801export const workflowRunnerPool = pgTable(
2802 "workflow_runner_pool",
2803 {
2804 id: uuid("id").primaryKey().defaultRandom(),
2805 workerId: text("worker_id").notNull().unique(),
2806 status: text("status", {
2807 enum: ["idle", "busy", "draining", "dead"],
2808 }).notNull(),
2809 currentRunId: uuid("current_run_id").references(() => workflowRuns.id, {
2810 onDelete: "set null",
2811 }),
2812 warmedAt: timestamp("warmed_at").defaultNow().notNull(),
2813 lastHeartbeatAt: timestamp("last_heartbeat_at").defaultNow().notNull(),
2814 capacity: integer("capacity").default(1).notNull(),
2815 },
2816 (table) => [index("workflow_runner_pool_status_idx").on(table.status)]
2817);
2818
2819export type WorkflowRunnerPoolEntry =
2820 typeof workflowRunnerPool.$inferSelect;
2821export type NewWorkflowRunnerPoolEntry =
2822 typeof workflowRunnerPool.$inferInsert;
e6bad81Claude2823
2824
2825/**
2826 * Repair Flywheel — every auto-repair attempt is recorded here so future
2827 * failures with the same signature can short-circuit straight to the cached
2828 * patch (Tier 0). After ~5000 entries the cache dominates and AI calls drop.
2829 * Migration: 0039_repair_flywheel.sql
2830 */
2831export const repairFlywheel = pgTable(
2832 "repair_flywheel",
2833 {
2834 id: uuid("id").primaryKey().defaultRandom(),
2835 repositoryId: uuid("repository_id").references(() => repositories.id, {
2836 onDelete: "cascade",
2837 }),
2838 // Fingerprint of the normalised failure text (variables/paths stripped).
2839 failureSignature: text("failure_signature").notNull(),
2840 // Original failure text (capped at write-site, ~4KB).
2841 failureText: text("failure_text").notNull(),
2842 // Mechanical classification or NULL if AI/human-driven.
2843 failureClassification: text("failure_classification"),
2844 // 'cached' | 'mechanical' | 'ai-sonnet' | 'human'
2845 repairTier: text("repair_tier").notNull(),
2846 patchSummary: text("patch_summary").notNull(),
2847 filesChanged: jsonb("files_changed").notNull().default([]),
2848 commitSha: text("commit_sha"),
2849 // 'pending' | 'success' | 'failed' | 'reverted'
2850 outcome: text("outcome").notNull().default("pending"),
2851 appliedAt: timestamp("applied_at").defaultNow().notNull(),
2852 outcomeAt: timestamp("outcome_at"),
2853 parentPatternId: uuid("parent_pattern_id"),
2854 cacheHitCount: integer("cache_hit_count").default(0).notNull(),
2855 isPublicPattern: boolean("is_public_pattern").default(false).notNull(),
2856 createdAt: timestamp("created_at").defaultNow().notNull(),
2857 },
2858 (table) => [
2859 index("repair_flywheel_signature_idx").on(table.failureSignature),
2860 index("repair_flywheel_repo_idx").on(table.repositoryId),
2861 index("repair_flywheel_outcome_idx").on(table.outcome),
2862 index("repair_flywheel_classification_idx").on(table.failureClassification),
2863 index("repair_flywheel_lookup_idx").on(
2864 table.repositoryId,
2865 table.failureSignature,
2866 table.outcome
2867 ),
2868 ]
2869);
2870
2871export type RepairFlywheelEntry = typeof repairFlywheel.$inferSelect;
2872export type NewRepairFlywheelEntry = typeof repairFlywheel.$inferInsert;
2873
534f04aClaude2874/**
2875 * Block M3 — AI pre-merge risk score cache.
2876 *
2877 * One row per (pull_request_id, commit_sha). The score is computed by a
2878 * transparent formula over `signals`; the LLM only writes `ai_summary`.
2879 * Pinning by head SHA means a new push invalidates the cache implicitly
2880 * (the new SHA won't have a row yet → cache miss → recompute).
2881 *
2882 * Migration: 0044_pr_risk_scores.sql
2883 */
2884export const prRiskScores = pgTable(
2885 "pr_risk_scores",
2886 {
2887 id: uuid("id").primaryKey().defaultRandom(),
2888 pullRequestId: uuid("pull_request_id")
2889 .notNull()
2890 .references(() => pullRequests.id, { onDelete: "cascade" }),
2891 commitSha: text("commit_sha").notNull(),
2892 // 0..10 integer score.
2893 score: integer("score").notNull(),
2894 // 'low' | 'medium' | 'high' | 'critical'
2895 band: text("band").notNull(),
2896 // Raw signal map — see PrRiskSignals in src/lib/pr-risk.ts.
2897 signals: jsonb("signals").notNull(),
2898 aiSummary: text("ai_summary"),
2899 generatedAt: timestamp("generated_at", { withTimezone: true })
2900 .defaultNow()
2901 .notNull(),
2902 },
2903 (table) => [
2904 uniqueIndex("pr_risk_scores_pr_sha_uq").on(
2905 table.pullRequestId,
2906 table.commitSha
2907 ),
2908 index("pr_risk_scores_pr_idx").on(table.pullRequestId),
2909 ]
2910);
2911
2912export type PrRiskScoreRow = typeof prRiskScores.$inferSelect;
2913export type NewPrRiskScoreRow = typeof prRiskScores.$inferInsert;
2914
c63b860Claude2915/**
2916 * Block P1 — Password reset tokens.
2917 *
2918 * One row per outstanding reset request. `tokenHash` is a SHA-256 of the
2919 * plaintext token mailed to the user; the plaintext never persists.
2920 * `usedAt` is flipped on consume so a replayed link cannot rotate the
2921 * password twice. `expiresAt` is enforced at consume time (1-hour TTL).
2922 *
2923 * Migration: 0047_password_reset_tokens.sql
2924 */
2925export const passwordResetTokens = pgTable(
2926 "password_reset_tokens",
2927 {
2928 id: uuid("id").primaryKey().defaultRandom(),
2929 userId: uuid("user_id")
2930 .notNull()
2931 .references(() => users.id, { onDelete: "cascade" }),
2932 tokenHash: text("token_hash").notNull().unique(),
2933 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2934 usedAt: timestamp("used_at", { withTimezone: true }),
2935 requestIp: text("request_ip"),
2936 createdAt: timestamp("created_at", { withTimezone: true })
2937 .defaultNow()
2938 .notNull(),
2939 },
2940 (table) => [
2941 index("idx_password_reset_tokens_user").on(table.userId),
2942 index("idx_password_reset_tokens_expires").on(table.expiresAt),
2943 ]
2944);
2945
2946export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
2947export type NewPasswordResetToken = typeof passwordResetTokens.$inferInsert;
2948
2949/**
2950 * Block P2 — email verification tokens.
2951 *
2952 * SHA-256 hashed at rest. `email` captured at issuance so a verification
2953 * link still resolves the right address even after a profile-email change.
2954 * Migration: drizzle/0048_email_verification.sql
2955 */
2956export const emailVerificationTokens = pgTable(
2957 "email_verification_tokens",
2958 {
2959 id: uuid("id").primaryKey().defaultRandom(),
2960 userId: uuid("user_id")
2961 .notNull()
2962 .references(() => users.id, { onDelete: "cascade" }),
2963 email: text("email").notNull(),
2964 tokenHash: text("token_hash").notNull().unique(),
2965 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2966 usedAt: timestamp("used_at", { withTimezone: true }),
2967 createdAt: timestamp("created_at", { withTimezone: true })
2968 .defaultNow()
2969 .notNull(),
2970 },
2971 (table) => [index("idx_email_verify_tokens_user").on(table.userId)]
2972);
2973
2974export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect;
2975export type NewEmailVerificationToken = typeof emailVerificationTokens.$inferInsert;
2976
cd4f63bTest User2977/**
2978 * Block Q2 — Magic-link sign-in tokens.
2979 *
2980 * Structurally identical to passwordResetTokens / emailVerificationTokens:
2981 * a short plaintext token is mailed to the user, only its sha256 hash is
2982 * persisted, the token is single-use (usedAt) and time-limited (expiresAt).
2983 *
2984 * Difference: `userId` is NULLABLE. When a user enters an email that does
2985 * NOT yet have an account, we still mint a row — consume will create the
2986 * account on click (autoCreate=true), using the click itself as proof the
2987 * recipient owns the address. See `src/lib/magic-link.ts`.
2988 *
2989 * Migration: drizzle/0051_magic_link_tokens.sql
2990 */
2991export const magicLinkTokens = pgTable(
2992 "magic_link_tokens",
2993 {
2994 id: uuid("id").primaryKey().defaultRandom(),
2995 email: text("email").notNull(),
2996 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
2997 tokenHash: text("token_hash").notNull().unique(),
2998 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2999 usedAt: timestamp("used_at", { withTimezone: true }),
3000 requestIp: text("request_ip"),
3001 createdAt: timestamp("created_at", { withTimezone: true })
3002 .defaultNow()
3003 .notNull(),
3004 },
3005 (table) => [
3006 index("idx_magic_link_tokens_email").on(table.email),
3007 index("idx_magic_link_tokens_user").on(table.userId),
3008 index("idx_magic_link_tokens_expires").on(table.expiresAt),
3009 ]
3010);
3011
3012export type MagicLinkToken = typeof magicLinkTokens.$inferSelect;
3013export type NewMagicLinkToken = typeof magicLinkTokens.$inferInsert;
3014
b1be050CC LABS App3015// ---------------------------------------------------------------------------
3016// BLOCK S4 — Synthetic monitor history.
3017//
3018// Every autopilot tick runs `runSyntheticChecks()` (see
3019// `src/lib/synthetic-monitor.ts`) and inserts one row per check here.
3020// The /admin/status page reads the most-recent row per check_name and
3021// renders the red/green dashboard. On a green->red transition we fire
3022// MONITOR_ALERT_WEBHOOK_URL so the owner sees the site is on fire.
3023// ---------------------------------------------------------------------------
3024export const syntheticChecks = pgTable(
3025 "synthetic_checks",
3026 {
3027 id: uuid("id").primaryKey().defaultRandom(),
3028 checkName: text("check_name").notNull(),
3029 status: text("status").notNull(), // "green" | "red" | "yellow"
3030 statusCode: integer("status_code"),
3031 durationMs: integer("duration_ms").notNull(),
3032 error: text("error"),
3033 checkedAt: timestamp("checked_at", { withTimezone: true })
3034 .defaultNow()
3035 .notNull(),
3036 },
3037 (table) => [
3038 index("idx_synthetic_checks_checked_at").on(table.checkedAt),
3039 index("idx_synthetic_checks_name_checked_at").on(
3040 table.checkName,
3041 table.checkedAt
3042 ),
3043 ]
3044);
3045
3046export type SyntheticCheckRow = typeof syntheticChecks.$inferSelect;
3047export type NewSyntheticCheckRow = typeof syntheticChecks.$inferInsert;
3048
a686079Claude3049// ---------------------------------------------------------------------------
3050// 0057 — Continuous semantic index (per-push embeddings).
e75eddcClaude3051// One row per (repository_id, file_path). `indexChangedFiles()` upserts on
3052// every push, `searchSemantic()` ranks by cosine distance via pgvector.
a686079Claude3053// ---------------------------------------------------------------------------
3054export const codeEmbeddings = pgTable(
3055 "code_embeddings",
3056 {
3057 id: uuid("id").primaryKey().defaultRandom(),
3058 repositoryId: uuid("repository_id")
3059 .notNull()
3060 .references(() => repositories.id, { onDelete: "cascade" }),
3061 filePath: text("file_path").notNull(),
3062 blobSha: text("blob_sha").notNull(),
3063 commitSha: text("commit_sha").notNull(),
3064 contentSnippet: text("content_snippet").notNull().default(""),
3065 embedding: vector("embedding", { dimensions: 1024 }),
3066 embeddingModel: text("embedding_model"),
3067 updatedAt: timestamp("updated_at", { withTimezone: true })
3068 .defaultNow()
3069 .notNull(),
3070 },
3071 (table) => [
3072 uniqueIndex("code_embeddings_repo_path_uniq").on(
3073 table.repositoryId,
3074 table.filePath
3075 ),
3076 index("code_embeddings_repo_idx").on(table.repositoryId),
3077 ]
3078);
3079
3080export type CodeEmbedding = typeof codeEmbeddings.$inferSelect;
3081export type NewCodeEmbedding = typeof codeEmbeddings.$inferInsert;
3082
e75eddcClaude3083// ---------------------------------------------------------------------------
3084// 0058 — Agent multiplayer v1: per-agent namespacing + leases + budget caps.
3085// See src/lib/agent-multiplayer.ts for helpers and 0058_agent_multiplayer.sql
3086// for the canonical column docs. UNIQUE partial index on (target_type,
3087// target_id) WHERE status='active' is enforced in SQL (drizzle exposes it
3088// as a regular index here).
3089// ---------------------------------------------------------------------------
3090export const agentSessions = pgTable(
3091 "agent_sessions",
3092 {
3093 id: uuid("id").primaryKey().defaultRandom(),
3094 name: text("name").notNull(),
3095 ownerUserId: uuid("owner_user_id")
3096 .notNull()
3097 .references(() => users.id, { onDelete: "cascade" }),
3098 repositoryId: uuid("repository_id").references(() => repositories.id, {
3099 onDelete: "cascade",
3100 }),
3101 tokenHash: text("token_hash").notNull().unique(),
3102 branchNamespace: text("branch_namespace").notNull(),
3103 budgetCentsPerDay: integer("budget_cents_per_day").default(500).notNull(),
3104 spentCentsToday: integer("spent_cents_today").default(0).notNull(),
3105 lastActiveAt: timestamp("last_active_at", { withTimezone: true }),
3106 createdAt: timestamp("created_at", { withTimezone: true })
3107 .defaultNow()
3108 .notNull(),
3109 },
3110 (table) => [
3111 uniqueIndex("agent_sessions_owner_name").on(table.ownerUserId, table.name),
3112 index("agent_sessions_owner").on(table.ownerUserId),
3113 index("agent_sessions_repo").on(table.repositoryId),
3114 ]
3115);
3116
3117export const agentLeases = pgTable(
3118 "agent_leases",
3119 {
3120 id: uuid("id").primaryKey().defaultRandom(),
3121 agentSessionId: uuid("agent_session_id")
3122 .notNull()
3123 .references(() => agentSessions.id, { onDelete: "cascade" }),
3124 targetType: text("target_type").notNull(),
3125 targetId: text("target_id").notNull(),
3126 acquiredAt: timestamp("acquired_at", { withTimezone: true })
3127 .defaultNow()
3128 .notNull(),
3129 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3130 status: text("status").default("active").notNull(),
3131 createdAt: timestamp("created_at", { withTimezone: true })
3132 .defaultNow()
3133 .notNull(),
3134 },
3135 (table) => [
3136 index("agent_leases_active_target").on(table.targetType, table.targetId),
3137 index("agent_leases_agent").on(table.agentSessionId, table.status),
3138 index("agent_leases_expires").on(table.expiresAt),
3139 ]
3140);
3141
3142export type AgentSession = typeof agentSessions.$inferSelect;
3143export type NewAgentSession = typeof agentSessions.$inferInsert;
3144export type AgentLease = typeof agentLeases.$inferSelect;
3145export type NewAgentLease = typeof agentLeases.$inferInsert;
3146
38d31d3Claude3147// ---------------------------------------------------------------------------
3c03977Claude3148// 0060 — AI repo rubber-duck chat. See src/lib/repo-chat.ts.
38d31d3Claude3149// ---------------------------------------------------------------------------
3150export const repoChats = pgTable(
3151 "repo_chats",
3152 {
3153 id: uuid("id").primaryKey().defaultRandom(),
3154 repositoryId: uuid("repository_id")
3155 .notNull()
3156 .references(() => repositories.id, { onDelete: "cascade" }),
3157 ownerUserId: uuid("owner_user_id")
3158 .notNull()
3159 .references(() => users.id, { onDelete: "cascade" }),
3160 title: text("title"),
3161 createdAt: timestamp("created_at", { withTimezone: true })
3162 .defaultNow()
3163 .notNull(),
3164 updatedAt: timestamp("updated_at", { withTimezone: true })
3165 .defaultNow()
3166 .notNull(),
3167 },
3168 (table) => [
3169 index("repo_chats_owner_updated").on(table.ownerUserId, table.updatedAt),
3170 index("repo_chats_repo").on(table.repositoryId),
3171 ]
3172);
3173
3174export const repoChatMessages = pgTable(
3175 "repo_chat_messages",
3176 {
3177 id: uuid("id").primaryKey().defaultRandom(),
3178 chatId: uuid("chat_id")
3179 .notNull()
3180 .references(() => repoChats.id, { onDelete: "cascade" }),
3181 role: text("role").notNull(),
3182 content: text("content").notNull(),
3183 citations: jsonb("citations").$type<Array<{ file_path: string; blob_sha: string }>>().notNull().default([]),
3184 tokenCost: integer("token_cost").notNull().default(0),
3185 createdAt: timestamp("created_at", { withTimezone: true })
3186 .defaultNow()
3187 .notNull(),
3188 },
3189 (table) => [
3190 index("repo_chat_messages_chat_created").on(table.chatId, table.createdAt),
3191 ]
3192);
3193
3194export type RepoChat = typeof repoChats.$inferSelect;
3195export type NewRepoChat = typeof repoChats.$inferInsert;
3196export type RepoChatMessage = typeof repoChatMessages.$inferSelect;
3197export type NewRepoChatMessage = typeof repoChatMessages.$inferInsert;
3198
3c03977Claude3199// ---------------------------------------------------------------------------
3200// 0061 — Live co-editing on PRs. See src/lib/pr-live.ts.
3201// ---------------------------------------------------------------------------
3202export const prLiveSessions = pgTable(
3203 "pr_live_sessions",
3204 {
3205 id: uuid("id").primaryKey().defaultRandom(),
3206 prId: uuid("pr_id")
3207 .notNull()
3208 .references(() => pullRequests.id, { onDelete: "cascade" }),
3209 userId: uuid("user_id").references(() => users.id, {
3210 onDelete: "cascade",
3211 }),
3212 agentSessionId: uuid("agent_session_id").references(
3213 () => agentSessions.id,
3214 { onDelete: "cascade" }
3215 ),
3216 cursorPosition: jsonb("cursor_position"),
3217 color: text("color").notNull(),
3218 joinedAt: timestamp("joined_at", { withTimezone: true })
3219 .defaultNow()
3220 .notNull(),
3221 lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
3222 .defaultNow()
3223 .notNull(),
3224 status: text("status").default("active").notNull(),
3225 },
3226 (table) => [
3227 index("pr_live_sessions_active_pr").on(table.prId, table.lastSeenAt),
3228 index("pr_live_sessions_status_seen").on(table.status, table.lastSeenAt),
3229 ]
3230);
3231
3232export type PrLiveSession = typeof prLiveSessions.$inferSelect;
3233export type NewPrLiveSession = typeof prLiveSessions.$inferInsert;
3234
4bbacbeClaude3235// ---------------------------------------------------------------------------
3236// ---------------------------------------------------------------------------
23d0abfClaude3237// Migration 0062 — per-branch preview URLs. See src/lib/branch-previews.ts.
3238// ---------------------------------------------------------------------------
4bbacbeClaude3239export const branchPreviews = pgTable(
3240 "branch_previews",
3241 {
3242 id: uuid("id").primaryKey().defaultRandom(),
3243 repositoryId: uuid("repository_id")
3244 .notNull()
3245 .references(() => repositories.id, { onDelete: "cascade" }),
3246 branchName: text("branch_name").notNull(),
3247 commitSha: text("commit_sha").notNull(),
3248 previewUrl: text("preview_url").notNull(),
3249 status: text("status").default("building").notNull(),
3250 buildStartedAt: timestamp("build_started_at", { withTimezone: true })
3251 .defaultNow()
3252 .notNull(),
3253 buildCompletedAt: timestamp("build_completed_at", { withTimezone: true }),
3254 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3255 errorMessage: text("error_message"),
3256 createdAt: timestamp("created_at", { withTimezone: true })
3257 .defaultNow()
3258 .notNull(),
3259 },
3260 (table) => [
3261 uniqueIndex("branch_previews_repo_branch").on(
3262 table.repositoryId,
3263 table.branchName
3264 ),
3265 index("branch_previews_repo_status").on(table.repositoryId, table.status),
3266 ]
3267);
3268
3269export type BranchPreview = typeof branchPreviews.$inferSelect;
3270export type NewBranchPreview = typeof branchPreviews.$inferInsert;
3271
23d0abfClaude3272// ---------------------------------------------------------------------------
3273// 0063 — Multi-repo refactor agent. See src/lib/multi-repo-refactor.ts.
3274// ---------------------------------------------------------------------------
3275export const multiRepoRefactors = pgTable(
3276 "multi_repo_refactors",
3277 {
3278 id: uuid("id").primaryKey().defaultRandom(),
3279 ownerUserId: uuid("owner_user_id")
3280 .notNull()
3281 .references(() => users.id, { onDelete: "cascade" }),
3282 title: text("title").notNull(),
3283 description: text("description").notNull(),
3284 status: text("status").default("planning").notNull(),
3285 createdAt: timestamp("created_at").defaultNow().notNull(),
3286 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3287 },
3288 (table) => [
3289 index("multi_repo_refactors_owner").on(table.ownerUserId, table.createdAt),
3290 index("multi_repo_refactors_status").on(table.status, table.updatedAt),
3291 ]
3292);
3293
3294export const multiRepoRefactorPrs = pgTable(
3295 "multi_repo_refactor_prs",
3296 {
3297 id: uuid("id").primaryKey().defaultRandom(),
3298 refactorId: uuid("refactor_id")
3299 .notNull()
3300 .references(() => multiRepoRefactors.id, { onDelete: "cascade" }),
3301 repositoryId: uuid("repository_id")
3302 .notNull()
3303 .references(() => repositories.id, { onDelete: "cascade" }),
3304 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
3305 onDelete: "set null",
3306 }),
3307 status: text("status").default("pending").notNull(),
3308 errorMessage: text("error_message"),
3309 createdAt: timestamp("created_at").defaultNow().notNull(),
3310 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3311 },
3312 (table) => [
3313 uniqueIndex("multi_repo_refactor_prs_unique").on(
3314 table.refactorId,
3315 table.repositoryId
3316 ),
3317 index("multi_repo_refactor_prs_refactor").on(
3318 table.refactorId,
3319 table.status
3320 ),
3321 index("multi_repo_refactor_prs_repo").on(table.repositoryId),
3322 ]
3323);
3324
3325export type MultiRepoRefactor = typeof multiRepoRefactors.$inferSelect;
3326export type NewMultiRepoRefactor = typeof multiRepoRefactors.$inferInsert;
3327export type MultiRepoRefactorPr = typeof multiRepoRefactorPrs.$inferSelect;
3328export type NewMultiRepoRefactorPr = typeof multiRepoRefactorPrs.$inferInsert;
3329
e3c90cdClaude3330// ─── Chat integrations (migration 0064) ────────────────────────────────────
3331// Slack / Discord / Teams bindings. One row per (owner, kind, team, channel).
3332// `webhook_url` is the outbound Incoming-Webhook URL for posting back into
3333// the channel; `signing_secret` is the per-install verification key (HMAC for
3334// Slack, Ed25519 public key for Discord). See drizzle/0064_chat_integrations.sql
3335// for the full schema rationale.
3336export const chatIntegrations = pgTable(
3337 "chat_integrations",
3338 {
3339 id: uuid("id").primaryKey().defaultRandom(),
3340 ownerUserId: uuid("owner_user_id")
3341 .notNull()
3342 .references(() => users.id, { onDelete: "cascade" }),
3343 kind: text("kind").notNull(), // 'slack' | 'discord' | 'teams'
3344 teamId: text("team_id"),
3345 channelId: text("channel_id"),
3346 webhookUrl: text("webhook_url"),
3347 signingSecret: text("signing_secret"),
3348 enabled: boolean("enabled").default(true).notNull(),
3349 createdAt: timestamp("created_at").defaultNow().notNull(),
3350 lastUsedAt: timestamp("last_used_at"),
3351 },
3352 (table) => [
3353 index("chat_integrations_owner").on(table.ownerUserId, table.kind),
3354 ]
3355);
3356
3357export type ChatIntegration = typeof chatIntegrations.$inferSelect;
3358export type NewChatIntegration = typeof chatIntegrations.$inferInsert;
3359
0c3eee5Claude3360// ---------------------------------------------------------------------------
3361// 0065 — Per-call AI cost tracking. See src/lib/ai-cost-tracker.ts and
3362// src/routes/billing-usage.tsx. Best-effort: insert failures must NEVER
3363// throw out of the Claude caller, so every call site wraps recordAiCost in
3364// try/catch. Cents are computed at insert time from a hardcoded pricing
3365// table so historical aggregates stay stable across price changes.
3366// ---------------------------------------------------------------------------
3367export const aiCostEvents = pgTable(
3368 "ai_cost_events",
3369 {
3370 id: uuid("id").primaryKey().defaultRandom(),
3371 occurredAt: timestamp("occurred_at", { withTimezone: true })
3372 .defaultNow()
3373 .notNull(),
3374 ownerUserId: uuid("owner_user_id").references(() => users.id, {
3375 onDelete: "set null",
3376 }),
3377 repositoryId: uuid("repository_id").references(() => repositories.id, {
3378 onDelete: "set null",
3379 }),
3380 agentSessionId: uuid("agent_session_id").references(
3381 () => agentSessions.id,
3382 { onDelete: "set null" }
3383 ),
3384 model: text("model").notNull(),
3385 inputTokens: integer("input_tokens").default(0).notNull(),
3386 outputTokens: integer("output_tokens").default(0).notNull(),
3387 centsEstimate: integer("cents_estimate").default(0).notNull(),
3388 category: text("category").default("other").notNull(),
3389 sourceId: text("source_id"),
3390 sourceKind: text("source_kind"),
3391 createdAt: timestamp("created_at", { withTimezone: true })
3392 .defaultNow()
3393 .notNull(),
3394 },
3395 (table) => [
3396 index("ai_cost_events_owner_time").on(table.ownerUserId, table.occurredAt),
3397 index("ai_cost_events_repo_time").on(table.repositoryId, table.occurredAt),
3398 index("ai_cost_events_agent_time").on(
3399 table.agentSessionId,
3400 table.occurredAt
3401 ),
3402 index("ai_cost_events_category_time").on(table.category, table.occurredAt),
3403 ]
3404);
3405
3406export type AiCostEvent = typeof aiCostEvents.$inferSelect;
3407export type NewAiCostEvent = typeof aiCostEvents.$inferInsert;
3408
3409export const aiBudgets = pgTable("ai_budgets", {
3410 userId: uuid("user_id")
3411 .primaryKey()
3412 .references(() => users.id, { onDelete: "cascade" }),
3413 monthlyCents: integer("monthly_cents").default(0).notNull(),
3414 updatedAt: timestamp("updated_at", { withTimezone: true })
3415 .defaultNow()
3416 .notNull(),
3417});
3418
3419export type AiBudget = typeof aiBudgets.$inferSelect;
3420export type NewAiBudget = typeof aiBudgets.$inferInsert;
79ed944Claude3421
3422// ---------------------------------------------------------------------------
3423// Migration 0067 — per-PR runnable sandboxes. See src/lib/pr-sandbox.ts.
3424//
3425// Each open PR can hold at most one sandbox row (UNIQUE on pr_id). The row
3426// owns the lifecycle (provisioning → ready / failed / destroyed) plus the
3427// resolved playground.yml the sandbox was provisioned from. Re-provisioning
3428// the same PR upserts onto the existing row so a force-push always points
3429// at the newest head.
3430// ---------------------------------------------------------------------------
3431export const prSandboxes = pgTable(
3432 "pr_sandboxes",
3433 {
3434 id: uuid("id").primaryKey().defaultRandom(),
3435 prId: uuid("pr_id")
3436 .notNull()
3437 .references(() => pullRequests.id, { onDelete: "cascade" }),
3438 status: text("status").default("provisioning").notNull(),
3439 sandboxUrl: text("sandbox_url").notNull(),
3440 containerId: text("container_id"),
3441 playgroundYml: text("playground_yml"),
3442 provisionedAt: timestamp("provisioned_at", { withTimezone: true })
3443 .defaultNow()
3444 .notNull(),
3445 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3446 destroyedAt: timestamp("destroyed_at", { withTimezone: true }),
3447 errorMessage: text("error_message"),
3448 createdAt: timestamp("created_at", { withTimezone: true })
3449 .defaultNow()
3450 .notNull(),
3451 },
3452 (table) => [
3453 uniqueIndex("pr_sandboxes_pr_id").on(table.prId),
3454 index("pr_sandboxes_status_expires").on(table.status, table.expiresAt),
3455 ]
3456);
3457
3458export type PrSandbox = typeof prSandboxes.$inferSelect;
3459export type NewPrSandbox = typeof prSandboxes.$inferInsert;
d199847Claude3460
3461// ---------------------------------------------------------------------------
3462// 0068 — AI-tracked documentation sections. See src/lib/ai-doc-updater.ts.
3463//
3464// Stores the currently claimed source-file hash for each
3465// `<!-- gluecron:doc-track src=... -->` region in a tracked markdown file.
3466// The post-receive hook re-hashes the referenced source after every push
3467// and proposes a PR (tagged `ai:doc-update`) when the prose drifts from
3468// the truth.
3469// ---------------------------------------------------------------------------
3470export const docTracking = pgTable(
3471 "doc_tracking",
3472 {
3473 id: uuid("id").primaryKey().defaultRandom(),
3474 repositoryId: uuid("repository_id")
3475 .notNull()
3476 .references(() => repositories.id, { onDelete: "cascade" }),
3477 docPath: text("doc_path").notNull(),
3478 sectionMarker: text("section_marker").notNull(),
3479 srcPath: text("src_path").notNull(),
3480 claimedHash: text("claimed_hash").notNull(),
3481 lastCheckedAt: timestamp("last_checked_at", { withTimezone: true })
3482 .defaultNow()
3483 .notNull(),
3484 lastPrId: uuid("last_pr_id").references(() => pullRequests.id, {
3485 onDelete: "set null",
3486 }),
3487 createdAt: timestamp("created_at", { withTimezone: true })
3488 .defaultNow()
3489 .notNull(),
3490 },
3491 (table) => [
3492 uniqueIndex("doc_tracking_repo_doc_marker").on(
3493 table.repositoryId,
3494 table.docPath,
3495 table.sectionMarker
3496 ),
3497 index("doc_tracking_repo").on(table.repositoryId),
3498 ]
3499);
3500
3501export type DocTracking = typeof docTracking.$inferSelect;
3502export type NewDocTracking = typeof docTracking.$inferInsert;
c6db5eeClaude3503
3504// ---------------------------------------------------------------------------
3505// 0069 — Hosted Claude tool-use loops. See src/lib/hosted-claude-loop.ts and
3506// src/routes/claude-deploy.tsx.
3507//
3508// Users paste a Claude tool-use loop, get a hosted endpoint + budget meter.
3509// Each loop is paired to an agent_sessions row so it inherits multiplayer
3510// namespacing and the daily budget mutex.
3511// ---------------------------------------------------------------------------
3512export const hostedClaudeLoops = pgTable(
3513 "hosted_claude_loops",
3514 {
3515 id: uuid("id").primaryKey().defaultRandom(),
3516 ownerUserId: uuid("owner_user_id")
3517 .notNull()
3518 .references(() => users.id, { onDelete: "cascade" }),
3519 name: text("name").notNull(),
3520 sourceCode: text("source_code").notNull(),
3521 endpointPath: text("endpoint_path").notNull().unique(),
3522 agentSessionId: uuid("agent_session_id").references(
3523 () => agentSessions.id,
3524 { onDelete: "set null" }
3525 ),
3526 status: text("status").default("paused").notNull(),
3527 isPublic: boolean("is_public").default(false).notNull(),
3528 monthlyBudgetCents: integer("monthly_budget_cents").default(500).notNull(),
3529 lastRunAt: timestamp("last_run_at", { withTimezone: true }),
3530 totalInvocations: integer("total_invocations").default(0).notNull(),
3531 totalCentsSpent: integer("total_cents_spent").default(0).notNull(),
3532 createdAt: timestamp("created_at", { withTimezone: true })
3533 .defaultNow()
3534 .notNull(),
3535 updatedAt: timestamp("updated_at", { withTimezone: true })
3536 .defaultNow()
3537 .notNull(),
3538 },
3539 (table) => [
3540 index("hosted_claude_loops_owner").on(table.ownerUserId, table.updatedAt),
3541 index("hosted_claude_loops_status").on(table.status),
3542 ]
3543);
3544
3545export const hostedClaudeLoopRuns = pgTable(
3546 "hosted_claude_loop_runs",
3547 {
3548 id: uuid("id").primaryKey().defaultRandom(),
3549 loopId: uuid("loop_id")
3550 .notNull()
3551 .references(() => hostedClaudeLoops.id, { onDelete: "cascade" }),
3552 inputPayload: jsonb("input_payload").default({}).notNull(),
3553 outputPayload: jsonb("output_payload"),
3554 stdout: text("stdout"),
3555 stderr: text("stderr"),
3556 startedAt: timestamp("started_at", { withTimezone: true })
3557 .defaultNow()
3558 .notNull(),
3559 finishedAt: timestamp("finished_at", { withTimezone: true }),
3560 status: text("status").default("running").notNull(),
3561 centsEstimate: integer("cents_estimate").default(0).notNull(),
3562 claudeInputTokens: integer("claude_input_tokens").default(0).notNull(),
3563 claudeOutputTokens: integer("claude_output_tokens").default(0).notNull(),
3564 exitCode: integer("exit_code"),
3565 errorMessage: text("error_message"),
3566 createdAt: timestamp("created_at", { withTimezone: true })
3567 .defaultNow()
3568 .notNull(),
3569 },
3570 (table) => [
3571 index("hosted_claude_loop_runs_loop_time").on(
3572 table.loopId,
3573 table.startedAt
3574 ),
3575 index("hosted_claude_loop_runs_status").on(table.status, table.startedAt),
3576 ]
3577);
3578
3579export type HostedClaudeLoop = typeof hostedClaudeLoops.$inferSelect;
3580export type NewHostedClaudeLoop = typeof hostedClaudeLoops.$inferInsert;
3581export type HostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferSelect;
3582export type NewHostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferInsert;