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