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