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

schema.ts

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

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