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