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