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

schema.ts

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

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