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.tsBlame2905 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,
13 customType,
fc1817aClaude14} from "drizzle-orm/pg-core";
15
abfa9adClaude16// Postgres `bytea` — drizzle-orm doesn't ship a first-class bytea column, so
17// we declare one via customType that maps to Buffer on both read and write.
18// Used by `workflow_run_cache.content` where we really do need raw bytes
19// (unlike `workflow_artifacts.content`, which stayed base64-text for v1).
20const bytea = customType<{ data: Buffer; default: false }>({
21 dataType() {
22 return "bytea";
23 },
24});
25
fc1817aClaude26export const users = pgTable("users", {
27 id: uuid("id").primaryKey().defaultRandom(),
28 username: text("username").notNull().unique(),
29 email: text("email").notNull().unique(),
30 displayName: text("display_name"),
31 passwordHash: text("password_hash").notNull(),
32 avatarUrl: text("avatar_url"),
33 bio: text("bio"),
24cf2caClaude34 // Email notification preferences (Block A8). Default on; opt-out via /settings.
35 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
36 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
37 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
08420cdClaude38 // Block I7 — weekly digest opt-in.
39 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
40 lastDigestSentAt: timestamp("last_digest_sent_at"),
46d6165Claude41 // Block L1 — Sleep Mode. When enabled, the autopilot sleep-mode-digest
42 // task delivers a daily "what Claude shipped overnight" report at the
43 // user-configured UTC hour (0-23, default 9). Reuses lastDigestSentAt
44 // as the 23h cooldown anchor — the cooldown is shared with the weekly
45 // digest, so a user cannot receive both on the same day.
46 sleepModeEnabled: boolean("sleep_mode_enabled").default(false).notNull(),
47 sleepModeDigestHourUtc: integer("sleep_mode_digest_hour_utc").default(9).notNull(),
534f04aClaude48 // Block M2 — Web Push per-event preferences. Default on; opt-out via /settings.
49 notifyPushOnMention: boolean("notify_push_on_mention").default(true).notNull(),
50 notifyPushOnAssign: boolean("notify_push_on_assign").default(true).notNull(),
51 notifyPushOnReviewRequest: boolean("notify_push_on_review_request")
52 .default(true)
53 .notNull(),
54 notifyPushOnDeployFailed: boolean("notify_push_on_deploy_failed")
55 .default(true)
56 .notNull(),
a4f3e24Claude57 isAdmin: boolean("is_admin").default(false).notNull(),
c63b860Claude58 // Block P2 — set when the user clicks the verification link. Soft-gate
59 // only: registration succeeds regardless; /dashboard surfaces a banner
60 // until this is non-null.
61 emailVerifiedAt: timestamp("email_verified_at"),
62 // Block P5 — Account deletion with 30-day grace period.
63 // See drizzle/0049_account_deletion.sql.
64 deletedAt: timestamp("deleted_at"),
65 deletionScheduledFor: timestamp("deletion_scheduled_for"),
66 // Block P3 — Terms of Service / Privacy Policy acceptance audit trail.
67 // Set on /register when the user ticks the accept_terms checkbox.
68 // termsVersion bumps when Terms change; UI prompts re-acceptance later.
69 termsAcceptedAt: timestamp("terms_accepted_at"),
70 termsVersion: text("terms_version"),
cd4f63bTest User71 // Block Q3 — Anonymous playground accounts. When `isPlayground=true`, the
72 // account was minted by /play with a synthetic email + random password
73 // and is hard-deleted by the autopilot `playground-purge` task once
74 // `playgroundExpiresAt` passes. Real accounts always carry false/null.
75 // See drizzle/0052_playground_accounts.sql.
76 isPlayground: boolean("is_playground").default(false).notNull(),
77 playgroundExpiresAt: timestamp("playground_expires_at"),
fc1817aClaude78 createdAt: timestamp("created_at").defaultNow().notNull(),
79 updatedAt: timestamp("updated_at").defaultNow().notNull(),
80});
81
06d5ffeClaude82export const sessions = pgTable("sessions", {
83 id: uuid("id").primaryKey().defaultRandom(),
84 userId: uuid("user_id")
85 .notNull()
86 .references(() => users.id, { onDelete: "cascade" }),
87 token: text("token").notNull().unique(),
88 expiresAt: timestamp("expires_at").notNull(),
7298a17Claude89 // B4: true when the user has entered their password but not yet their TOTP
90 // code. softAuth/requireAuth treat such sessions as anonymous; only
91 // /login/2fa can consume them. Flips to false on successful 2FA.
92 requires2fa: boolean("requires_2fa").default(false).notNull(),
06d5ffeClaude93 createdAt: timestamp("created_at").defaultNow().notNull(),
94});
95
45e31d0Claude96// @ts-ignore — self-referential FK on forkedFromId causes circular inference
fc1817aClaude97export const repositories = pgTable(
98 "repositories",
99 {
100 id: uuid("id").primaryKey().defaultRandom(),
101 name: text("name").notNull(),
7437605Claude102 // ownerId = creator / user-owner. Always set (for attribution + user
103 // namespace uniqueness). For org-owned repos, also represents "created by".
fc1817aClaude104 ownerId: uuid("owner_id")
105 .notNull()
106 .references(() => users.id),
7437605Claude107 // Block B2: nullable org owner. When set, the repo lives in the org
108 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
109 orgId: uuid("org_id"),
fc1817aClaude110 description: text("description"),
111 isPrivate: boolean("is_private").default(false).notNull(),
3ef4c9dClaude112 isArchived: boolean("is_archived").default(false).notNull(),
71cd5ecClaude113 isTemplate: boolean("is_template").default(false).notNull(),
fc1817aClaude114 defaultBranch: text("default_branch").default("main").notNull(),
115 diskPath: text("disk_path").notNull(),
45e31d0Claude116 forkedFromId: uuid("forked_from_id"),
fc1817aClaude117 createdAt: timestamp("created_at").defaultNow().notNull(),
118 updatedAt: timestamp("updated_at").defaultNow().notNull(),
119 pushedAt: timestamp("pushed_at"),
120 starCount: integer("star_count").default(0).notNull(),
121 forkCount: integer("fork_count").default(0).notNull(),
79136bbClaude122 issueCount: integer("issue_count").default(0).notNull(),
534f04aClaude123 // Block M5: autopilot stale-sweep opt-out flags. Default true — the
124 // 2-stage close (poke at 7d/30d, close at 14d/60d after poke) runs on
125 // every repo unless an owner disables it via repo-settings.
126 autoCloseStalePrs: boolean("auto_close_stale_prs").default(true).notNull(),
127 autoCloseStaleIssues: boolean("auto_close_stale_issues")
128 .default(true)
129 .notNull(),
fc1817aClaude130 },
7437605Claude131 (table) => [
132 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
133 // Matches the partial index in migration 0004.
134 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
135 index("repos_org").on(table.orgId),
136 ]
fc1817aClaude137);
138
3ef4c9dClaude139/**
140 * Per-repository gate + auto-repair configuration.
141 * Every new repo is created with all gates ENABLED by default —
142 * the "full green ecosystem" default. Owners can manually opt-out per setting.
143 */
144export const repoSettings = pgTable("repo_settings", {
145 id: uuid("id").primaryKey().defaultRandom(),
146 repositoryId: uuid("repository_id")
147 .notNull()
148 .unique()
149 .references(() => repositories.id, { onDelete: "cascade" }),
150 // Gates
151 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
152 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
153 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
154 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
155 dependencyScanEnabled: boolean("dependency_scan_enabled").default(true).notNull(),
156 lintEnabled: boolean("lint_enabled").default(true).notNull(),
157 typeCheckEnabled: boolean("type_check_enabled").default(true).notNull(),
158 testEnabled: boolean("test_enabled").default(true).notNull(),
159 // Auto-repair
160 autoFixEnabled: boolean("auto_fix_enabled").default(true).notNull(),
161 autoMergeResolveEnabled: boolean("auto_merge_resolve_enabled").default(true).notNull(),
162 autoFormatEnabled: boolean("auto_format_enabled").default(true).notNull(),
163 // AI features
164 aiCommitMessagesEnabled: boolean("ai_commit_messages_enabled").default(true).notNull(),
165 aiPrSummaryEnabled: boolean("ai_pr_summary_enabled").default(true).notNull(),
166 aiChangelogEnabled: boolean("ai_changelog_enabled").default(true).notNull(),
167 // Deploy
168 autoDeployEnabled: boolean("auto_deploy_enabled").default(true).notNull(),
169 deployRequireAllGreen: boolean("deploy_require_all_green").default(true).notNull(),
170 createdAt: timestamp("created_at").defaultNow().notNull(),
171 updatedAt: timestamp("updated_at").defaultNow().notNull(),
172});
173
174/**
175 * Branch protection rules — enforced on push and merge.
176 * Every repo's default branch gets a protection rule on creation.
177 */
178export const branchProtection = pgTable(
179 "branch_protection",
180 {
181 id: uuid("id").primaryKey().defaultRandom(),
182 repositoryId: uuid("repository_id")
183 .notNull()
184 .references(() => repositories.id, { onDelete: "cascade" }),
185 pattern: text("pattern").notNull(), // branch name or glob (e.g. "main", "release/*")
186 requirePullRequest: boolean("require_pull_request").default(true).notNull(),
187 requireGreenGates: boolean("require_green_gates").default(true).notNull(),
188 requireAiApproval: boolean("require_ai_approval").default(true).notNull(),
189 requireHumanReview: boolean("require_human_review").default(false).notNull(),
190 requiredApprovals: integer("required_approvals").default(0).notNull(),
191 allowForcePush: boolean("allow_force_push").default(false).notNull(),
192 allowDeletion: boolean("allow_deletion").default(false).notNull(),
193 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
4626e61Claude194 // K2 — Auto-merge opt-in. When true, the autopilot ticker may auto-merge
195 // a PR whose base branch matches this rule, provided every gate the
196 // manual-merge path enforces is green. Default-deny.
197 enableAutoMerge: boolean("enable_auto_merge").default(false).notNull(),
3ef4c9dClaude198 createdAt: timestamp("created_at").defaultNow().notNull(),
199 updatedAt: timestamp("updated_at").defaultNow().notNull(),
200 },
201 (table) => [
202 uniqueIndex("branch_protection_repo_pattern").on(
203 table.repositoryId,
204 table.pattern
205 ),
206 ]
207);
208
209/**
210 * Gate run history. Every push + every PR creates gate_runs entries —
211 * one per configured gate. Serves as the source of truth for "is this green?".
212 */
213export const gateRuns = pgTable(
214 "gate_runs",
215 {
216 id: uuid("id").primaryKey().defaultRandom(),
217 repositoryId: uuid("repository_id")
218 .notNull()
219 .references(() => repositories.id, { onDelete: "cascade" }),
220 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
221 onDelete: "cascade",
222 }),
223 commitSha: text("commit_sha").notNull(),
224 ref: text("ref").notNull(),
225 gateName: text("gate_name").notNull(), // e.g. "GateTest", "AI Review", "Secret Scan", "Type Check"
226 status: text("status").notNull(), // pending, running, passed, failed, skipped, repaired
227 summary: text("summary"),
228 details: text("details"), // JSON: per-check output, affected files, etc
229 repairAttempted: boolean("repair_attempted").default(false).notNull(),
230 repairSucceeded: boolean("repair_succeeded").default(false).notNull(),
231 repairCommitSha: text("repair_commit_sha"),
232 durationMs: integer("duration_ms"),
233 createdAt: timestamp("created_at").defaultNow().notNull(),
234 completedAt: timestamp("completed_at"),
235 },
236 (table) => [
237 index("gate_runs_repo_sha").on(table.repositoryId, table.commitSha),
238 index("gate_runs_pr").on(table.pullRequestId),
239 index("gate_runs_created").on(table.createdAt),
240 ]
241);
242
243/**
244 * In-app notifications. Powered by the activity feed + explicit mentions.
245 */
246export const notifications = pgTable(
247 "notifications",
248 {
249 id: uuid("id").primaryKey().defaultRandom(),
250 userId: uuid("user_id")
251 .notNull()
252 .references(() => users.id, { onDelete: "cascade" }),
253 repositoryId: uuid("repository_id").references(() => repositories.id, {
254 onDelete: "cascade",
255 }),
256 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
257 title: text("title").notNull(),
258 body: text("body"),
259 url: text("url"), // link to the relevant page
260 readAt: timestamp("read_at"),
261 createdAt: timestamp("created_at").defaultNow().notNull(),
262 },
263 (table) => [
264 index("notifications_user_unread").on(table.userId, table.readAt),
265 index("notifications_user_created").on(table.userId, table.createdAt),
266 ]
267);
268
269/**
270 * Releases — named snapshots of a repo at a tag/commit.
271 * AI-generated changelogs bundled in notes field.
272 */
273export const releases = pgTable(
274 "releases",
275 {
276 id: uuid("id").primaryKey().defaultRandom(),
277 repositoryId: uuid("repository_id")
278 .notNull()
279 .references(() => repositories.id, { onDelete: "cascade" }),
280 authorId: uuid("author_id")
281 .notNull()
282 .references(() => users.id),
283 tag: text("tag").notNull(),
284 name: text("name").notNull(),
285 body: text("body"), // AI-generated release notes + changelog
286 targetCommit: text("target_commit").notNull(),
287 isDraft: boolean("is_draft").default(false).notNull(),
288 isPrerelease: boolean("is_prerelease").default(false).notNull(),
289 createdAt: timestamp("created_at").defaultNow().notNull(),
290 publishedAt: timestamp("published_at"),
291 },
292 (table) => [
293 uniqueIndex("releases_repo_tag").on(table.repositoryId, table.tag),
294 ]
295);
296
297/**
298 * Milestones — group issues + PRs toward a shared goal.
299 */
300export const milestones = pgTable(
301 "milestones",
302 {
303 id: uuid("id").primaryKey().defaultRandom(),
304 repositoryId: uuid("repository_id")
305 .notNull()
306 .references(() => repositories.id, { onDelete: "cascade" }),
307 title: text("title").notNull(),
308 description: text("description"),
309 state: text("state").notNull().default("open"), // open, closed
310 dueDate: timestamp("due_date"),
311 createdAt: timestamp("created_at").defaultNow().notNull(),
312 closedAt: timestamp("closed_at"),
313 },
314 (table) => [index("milestones_repo_state").on(table.repositoryId, table.state)]
315);
316
317/**
318 * Reactions on issues, PRs, and comments. Universal target pointer.
319 */
320export const reactions = pgTable(
321 "reactions",
322 {
323 id: uuid("id").primaryKey().defaultRandom(),
324 userId: uuid("user_id")
325 .notNull()
326 .references(() => users.id, { onDelete: "cascade" }),
327 targetType: text("target_type").notNull(), // issue, pr, issue_comment, pr_comment
328 targetId: uuid("target_id").notNull(),
329 emoji: text("emoji").notNull(), // thumbs_up, thumbs_down, rocket, heart, eyes, laugh, hooray, confused
330 createdAt: timestamp("created_at").defaultNow().notNull(),
331 },
332 (table) => [
333 uniqueIndex("reactions_unique").on(
334 table.userId,
335 table.targetType,
336 table.targetId,
337 table.emoji
338 ),
339 index("reactions_target").on(table.targetType, table.targetId),
340 ]
341);
342
343/**
344 * PR reviews (formal approve/request-changes).
345 * Separate from inline comments in pr_comments.
346 */
347export const prReviews = pgTable(
348 "pr_reviews",
349 {
350 id: uuid("id").primaryKey().defaultRandom(),
351 pullRequestId: uuid("pull_request_id")
352 .notNull()
353 .references(() => pullRequests.id, { onDelete: "cascade" }),
354 reviewerId: uuid("reviewer_id")
355 .notNull()
356 .references(() => users.id),
357 state: text("state").notNull(), // approved, changes_requested, commented
358 body: text("body"),
359 isAi: boolean("is_ai").default(false).notNull(),
360 createdAt: timestamp("created_at").defaultNow().notNull(),
361 },
362 (table) => [index("pr_reviews_pr").on(table.pullRequestId)]
363);
364
365/**
366 * Code owners — who owns which paths (auto-request review on PR).
367 * Parsed from a CODEOWNERS file at the root of the default branch.
368 */
369export const codeOwners = pgTable(
370 "code_owners",
371 {
372 id: uuid("id").primaryKey().defaultRandom(),
373 repositoryId: uuid("repository_id")
374 .notNull()
375 .references(() => repositories.id, { onDelete: "cascade" }),
376 pathPattern: text("path_pattern").notNull(),
377 ownerUsernames: text("owner_usernames").notNull(), // comma-separated
378 createdAt: timestamp("created_at").defaultNow().notNull(),
379 },
380 (table) => [index("code_owners_repo").on(table.repositoryId)]
381);
382
383/**
384 * Per-repo AI chat sessions — conversational repo assistant.
385 */
386export const aiChats = pgTable(
387 "ai_chats",
388 {
389 id: uuid("id").primaryKey().defaultRandom(),
390 userId: uuid("user_id")
391 .notNull()
392 .references(() => users.id, { onDelete: "cascade" }),
393 repositoryId: uuid("repository_id").references(() => repositories.id, {
394 onDelete: "cascade",
395 }),
396 title: text("title"),
397 messages: text("messages").notNull().default("[]"), // JSON array of {role, content}
398 createdAt: timestamp("created_at").defaultNow().notNull(),
399 updatedAt: timestamp("updated_at").defaultNow().notNull(),
400 },
401 (table) => [
402 index("ai_chats_user").on(table.userId),
403 index("ai_chats_repo").on(table.repositoryId),
404 ]
405);
406
407/**
408 * Audit log — every sensitive action. Who did what, when, from where.
409 */
410export const auditLog = pgTable(
411 "audit_log",
412 {
413 id: uuid("id").primaryKey().defaultRandom(),
414 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
415 repositoryId: uuid("repository_id").references(() => repositories.id, {
416 onDelete: "set null",
417 }),
418 action: text("action").notNull(), // repo.create, repo.delete, repo.transfer, token.create, token.revoke, merge, force_push, branch_protection.update, deploy, ...
419 targetType: text("target_type"),
420 targetId: text("target_id"),
421 ip: text("ip"),
422 userAgent: text("user_agent"),
423 metadata: text("metadata"), // JSON for extra context
424 createdAt: timestamp("created_at").defaultNow().notNull(),
425 },
426 (table) => [
427 index("audit_log_user").on(table.userId),
428 index("audit_log_repo").on(table.repositoryId),
429 index("audit_log_created").on(table.createdAt),
430 ]
431);
432
433/**
434 * Deployments — tracks every deploy to downstream systems (Crontech, etc).
435 * Each deploy is gated on ALL green gates passing.
436 */
437export const deployments = pgTable(
438 "deployments",
439 {
440 id: uuid("id").primaryKey().defaultRandom(),
441 repositoryId: uuid("repository_id")
442 .notNull()
443 .references(() => repositories.id, { onDelete: "cascade" }),
444 environment: text("environment").notNull().default("production"),
445 commitSha: text("commit_sha").notNull(),
446 ref: text("ref").notNull(),
a76d984Claude447 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
3ef4c9dClaude448 blockedReason: text("blocked_reason"),
449 target: text("target"), // e.g. "crontech", "fly.io"
450 triggeredBy: uuid("triggered_by").references(() => users.id),
a76d984Claude451 /**
452 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
453 * NULL = no wait; non-null = autopilot flips status from "waiting_timer"
454 * to "pending" once `now() >= ready_after`.
455 */
456 readyAfter: timestamp("ready_after"),
3ef4c9dClaude457 createdAt: timestamp("created_at").defaultNow().notNull(),
458 completedAt: timestamp("completed_at"),
459 },
460 (table) => [
461 index("deployments_repo").on(table.repositoryId),
462 index("deployments_created").on(table.createdAt),
463 ]
464);
465
466/**
467 * Rate-limit buckets — in-memory or persisted counter per IP / token / route.
468 */
469export const rateLimitBuckets = pgTable(
470 "rate_limit_buckets",
471 {
472 id: uuid("id").primaryKey().defaultRandom(),
473 bucketKey: text("bucket_key").notNull().unique(), // "ip:1.2.3.4:api" or "token:abc:api"
474 count: integer("count").default(0).notNull(),
475 windowStart: timestamp("window_start").defaultNow().notNull(),
476 expiresAt: timestamp("expires_at").notNull(),
477 },
478 (table) => [index("rate_limit_expires").on(table.expiresAt)]
fc1817aClaude479);
480
06d5ffeClaude481export const stars = pgTable(
482 "stars",
483 {
484 id: uuid("id").primaryKey().defaultRandom(),
485 userId: uuid("user_id")
486 .notNull()
487 .references(() => users.id, { onDelete: "cascade" }),
488 repositoryId: uuid("repository_id")
489 .notNull()
490 .references(() => repositories.id, { onDelete: "cascade" }),
491 createdAt: timestamp("created_at").defaultNow().notNull(),
492 },
79136bbClaude493 (table) => [
494 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
495 ]
496);
497
498export const issues = pgTable(
499 "issues",
500 {
501 id: uuid("id").primaryKey().defaultRandom(),
502 number: serial("number"),
503 repositoryId: uuid("repository_id")
504 .notNull()
505 .references(() => repositories.id, { onDelete: "cascade" }),
506 authorId: uuid("author_id")
507 .notNull()
508 .references(() => users.id),
509 title: text("title").notNull(),
510 body: text("body"),
511 state: text("state").notNull().default("open"), // open, closed
512 createdAt: timestamp("created_at").defaultNow().notNull(),
513 updatedAt: timestamp("updated_at").defaultNow().notNull(),
514 closedAt: timestamp("closed_at"),
515 },
516 (table) => [
517 index("issues_repo_state").on(table.repositoryId, table.state),
518 index("issues_repo_number").on(table.repositoryId, table.number),
519 ]
520);
521
522export const issueComments = pgTable(
523 "issue_comments",
524 {
525 id: uuid("id").primaryKey().defaultRandom(),
526 issueId: uuid("issue_id")
527 .notNull()
528 .references(() => issues.id, { onDelete: "cascade" }),
529 authorId: uuid("author_id")
530 .notNull()
531 .references(() => users.id),
532 body: text("body").notNull(),
533 createdAt: timestamp("created_at").defaultNow().notNull(),
534 updatedAt: timestamp("updated_at").defaultNow().notNull(),
535 },
536 (table) => [index("comments_issue").on(table.issueId)]
537);
538
539export const labels = pgTable(
540 "labels",
541 {
542 id: uuid("id").primaryKey().defaultRandom(),
543 repositoryId: uuid("repository_id")
544 .notNull()
545 .references(() => repositories.id, { onDelete: "cascade" }),
546 name: text("name").notNull(),
547 color: text("color").notNull().default("#8b949e"),
548 description: text("description"),
549 },
550 (table) => [
551 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
552 ]
553);
554
555export const issueLabels = pgTable(
556 "issue_labels",
557 {
558 id: uuid("id").primaryKey().defaultRandom(),
559 issueId: uuid("issue_id")
560 .notNull()
561 .references(() => issues.id, { onDelete: "cascade" }),
562 labelId: uuid("label_id")
563 .notNull()
564 .references(() => labels.id, { onDelete: "cascade" }),
565 },
566 (table) => [
567 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
568 ]
06d5ffeClaude569);
570
0074234Claude571export const pullRequests = pgTable(
572 "pull_requests",
573 {
574 id: uuid("id").primaryKey().defaultRandom(),
575 number: serial("number"),
576 repositoryId: uuid("repository_id")
577 .notNull()
578 .references(() => repositories.id, { onDelete: "cascade" }),
579 authorId: uuid("author_id")
580 .notNull()
581 .references(() => users.id),
582 title: text("title").notNull(),
583 body: text("body"),
584 state: text("state").notNull().default("open"), // open, closed, merged
585 baseBranch: text("base_branch").notNull(),
586 headBranch: text("head_branch").notNull(),
3ef4c9dClaude587 isDraft: boolean("is_draft").default(false).notNull(),
588 mergeStrategy: text("merge_strategy").default("merge").notNull(), // merge, squash, rebase
589 milestoneId: uuid("milestone_id"),
0074234Claude590 mergedAt: timestamp("merged_at"),
591 mergedBy: uuid("merged_by").references(() => users.id),
592 createdAt: timestamp("created_at").defaultNow().notNull(),
593 updatedAt: timestamp("updated_at").defaultNow().notNull(),
594 closedAt: timestamp("closed_at"),
595 },
596 (table) => [
597 index("prs_repo_state").on(table.repositoryId, table.state),
598 index("prs_repo_number").on(table.repositoryId, table.number),
599 ]
600);
601
602export const prComments = pgTable(
603 "pr_comments",
604 {
605 id: uuid("id").primaryKey().defaultRandom(),
606 pullRequestId: uuid("pull_request_id")
607 .notNull()
608 .references(() => pullRequests.id, { onDelete: "cascade" }),
609 authorId: uuid("author_id")
610 .notNull()
611 .references(() => users.id),
612 body: text("body").notNull(),
613 isAiReview: boolean("is_ai_review").default(false).notNull(),
614 filePath: text("file_path"),
615 lineNumber: integer("line_number"),
616 createdAt: timestamp("created_at").defaultNow().notNull(),
617 updatedAt: timestamp("updated_at").defaultNow().notNull(),
618 },
619 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
620);
621
622export const activityFeed = pgTable(
623 "activity_feed",
624 {
625 id: uuid("id").primaryKey().defaultRandom(),
626 repositoryId: uuid("repository_id")
627 .notNull()
628 .references(() => repositories.id, { onDelete: "cascade" }),
629 userId: uuid("user_id").references(() => users.id),
630 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
631 targetType: text("target_type"), // issue, pr, commit
632 targetId: text("target_id"),
633 metadata: text("metadata"), // JSON string for extra data
634 createdAt: timestamp("created_at").defaultNow().notNull(),
635 },
636 (table) => [
637 index("activity_repo").on(table.repositoryId),
638 index("activity_user").on(table.userId),
639 ]
640);
641
c81ab7aClaude642export const webhooks = pgTable(
643 "webhooks",
644 {
645 id: uuid("id").primaryKey().defaultRandom(),
646 repositoryId: uuid("repository_id")
647 .notNull()
648 .references(() => repositories.id, { onDelete: "cascade" }),
649 url: text("url").notNull(),
650 secret: text("secret"),
651 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
652 isActive: boolean("is_active").default(true).notNull(),
653 lastDeliveredAt: timestamp("last_delivered_at"),
654 lastStatus: integer("last_status"),
655 createdAt: timestamp("created_at").defaultNow().notNull(),
656 },
657 (table) => [index("webhooks_repo").on(table.repositoryId)]
658);
659
660export const apiTokens = pgTable("api_tokens", {
661 id: uuid("id").primaryKey().defaultRandom(),
662 userId: uuid("user_id")
663 .notNull()
664 .references(() => users.id, { onDelete: "cascade" }),
665 name: text("name").notNull(),
666 tokenHash: text("token_hash").notNull(),
667 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
668 scopes: text("scopes").notNull().default("repo"), // comma-separated
669 lastUsedAt: timestamp("last_used_at"),
670 expiresAt: timestamp("expires_at"),
671 createdAt: timestamp("created_at").defaultNow().notNull(),
672});
673
674export const repoTopics = pgTable(
675 "repo_topics",
676 {
677 id: uuid("id").primaryKey().defaultRandom(),
678 repositoryId: uuid("repository_id")
679 .notNull()
680 .references(() => repositories.id, { onDelete: "cascade" }),
681 topic: text("topic").notNull(),
682 },
683 (table) => [
684 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
685 index("topics_name").on(table.topic),
686 ]
687);
688
fc1817aClaude689export const sshKeys = pgTable("ssh_keys", {
690 id: uuid("id").primaryKey().defaultRandom(),
691 userId: uuid("user_id")
692 .notNull()
06d5ffeClaude693 .references(() => users.id, { onDelete: "cascade" }),
fc1817aClaude694 title: text("title").notNull(),
695 fingerprint: text("fingerprint").notNull(),
696 publicKey: text("public_key").notNull(),
697 lastUsedAt: timestamp("last_used_at"),
698 createdAt: timestamp("created_at").defaultNow().notNull(),
699});
700
534f04aClaude701// Block M2 — Web Push subscriptions. One row per (user, endpoint).
702// Endpoint is the browser-issued push URL; p256dh + auth are the W3C
703// payload-encryption keys (base64url). Stale endpoints (HTTP 410) are
704// deleted lazily on next `sendPushToUser` pass.
705export const pushSubscriptions = pgTable(
706 "push_subscriptions",
707 {
708 id: uuid("id").primaryKey().defaultRandom(),
709 userId: uuid("user_id")
710 .notNull()
711 .references(() => users.id, { onDelete: "cascade" }),
712 endpoint: text("endpoint").notNull(),
713 p256dh: text("p256dh").notNull(),
714 auth: text("auth").notNull(),
715 userAgent: text("user_agent"),
716 createdAt: timestamp("created_at").defaultNow().notNull(),
717 lastUsedAt: timestamp("last_used_at"),
718 },
719 (table) => [
720 uniqueIndex("push_subscriptions_user_endpoint").on(
721 table.userId,
722 table.endpoint
723 ),
724 index("idx_push_subscriptions_user").on(table.userId),
725 ]
726);
727
fc1817aClaude728export type User = typeof users.$inferSelect;
729export type NewUser = typeof users.$inferInsert;
730export type Repository = typeof repositories.$inferSelect;
731export type NewRepository = typeof repositories.$inferInsert;
06d5ffeClaude732export type Session = typeof sessions.$inferSelect;
733export type Star = typeof stars.$inferSelect;
734export type SshKey = typeof sshKeys.$inferSelect;
534f04aClaude735export type PushSubscription = typeof pushSubscriptions.$inferSelect;
736export type NewPushSubscription = typeof pushSubscriptions.$inferInsert;
79136bbClaude737export type Issue = typeof issues.$inferSelect;
738export type IssueComment = typeof issueComments.$inferSelect;
739export type Label = typeof labels.$inferSelect;
0074234Claude740export type PullRequest = typeof pullRequests.$inferSelect;
741export type PrComment = typeof prComments.$inferSelect;
742export type ActivityEntry = typeof activityFeed.$inferSelect;
c81ab7aClaude743export type Webhook = typeof webhooks.$inferSelect;
744export type ApiToken = typeof apiTokens.$inferSelect;
745export type RepoTopic = typeof repoTopics.$inferSelect;
3ef4c9dClaude746export type RepoSettings = typeof repoSettings.$inferSelect;
747export type BranchProtection = typeof branchProtection.$inferSelect;
748export type GateRun = typeof gateRuns.$inferSelect;
749export type Notification = typeof notifications.$inferSelect;
750export type Release = typeof releases.$inferSelect;
751export type Milestone = typeof milestones.$inferSelect;
752export type Reaction = typeof reactions.$inferSelect;
753export type PrReview = typeof prReviews.$inferSelect;
754export type CodeOwner = typeof codeOwners.$inferSelect;
755export type AiChat = typeof aiChats.$inferSelect;
756export type AuditLogEntry = typeof auditLog.$inferSelect;
757export type Deployment = typeof deployments.$inferSelect;
24cf2caClaude758
759/**
760 * Saved replies — per-user canned responses, insertable into any
761 * issue / PR comment textarea. Shortcut name must be unique per user.
762 */
763export const savedReplies = pgTable(
764 "saved_replies",
765 {
766 id: uuid("id").primaryKey().defaultRandom(),
767 userId: uuid("user_id")
768 .notNull()
769 .references(() => users.id, { onDelete: "cascade" }),
770 shortcut: text("shortcut").notNull(),
771 body: text("body").notNull(),
772 createdAt: timestamp("created_at").defaultNow().notNull(),
773 updatedAt: timestamp("updated_at").defaultNow().notNull(),
774 },
775 (table) => [
776 uniqueIndex("saved_replies_user_shortcut").on(table.userId, table.shortcut),
777 ]
778);
779
780export type SavedReply = typeof savedReplies.$inferSelect;
5cc5d95Claude781
782/**
783 * Organizations (Block B1) — multi-user namespaces. Distinct from `users`.
784 * An org has members (with org-level roles) and may contain teams.
785 * Repos can be owned by an org via `repositories.orgId` (added in Block B2).
786 *
787 * Slug is globally unique against itself; collision with a username is
788 * checked at create time in the route handler (no DB-level cross-table
789 * uniqueness in Postgres).
790 */
791export const organizations = pgTable("organizations", {
792 id: uuid("id").primaryKey().defaultRandom(),
793 slug: text("slug").notNull().unique(),
794 name: text("name").notNull(),
795 description: text("description"),
796 avatarUrl: text("avatar_url"),
797 billingEmail: text("billing_email"),
798 createdById: uuid("created_by_id")
799 .notNull()
800 .references(() => users.id, { onDelete: "restrict" }),
801 createdAt: timestamp("created_at").defaultNow().notNull(),
802 updatedAt: timestamp("updated_at").defaultNow().notNull(),
803});
804
805/**
806 * Org membership. Roles: owner (full control, billing), admin (manage
807 * members + teams + repos), member (default; can be added to teams).
808 */
809export const orgMembers = pgTable(
810 "org_members",
811 {
812 id: uuid("id").primaryKey().defaultRandom(),
813 orgId: uuid("org_id")
814 .notNull()
815 .references(() => organizations.id, { onDelete: "cascade" }),
816 userId: uuid("user_id")
817 .notNull()
818 .references(() => users.id, { onDelete: "cascade" }),
819 role: text("role").notNull().default("member"), // owner | admin | member
820 createdAt: timestamp("created_at").defaultNow().notNull(),
821 },
822 (table) => [
823 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
824 index("org_members_user").on(table.userId),
825 ]
826);
827
828/**
829 * Teams within an org. Slug is unique within an org.
830 * `parentTeamId` allows nesting (GitHub-style child teams). Optional.
831 */
832export const teams = pgTable(
833 "teams",
834 {
835 id: uuid("id").primaryKey().defaultRandom(),
836 orgId: uuid("org_id")
837 .notNull()
838 .references(() => organizations.id, { onDelete: "cascade" }),
839 slug: text("slug").notNull(),
840 name: text("name").notNull(),
841 description: text("description"),
842 parentTeamId: uuid("parent_team_id"),
843 createdAt: timestamp("created_at").defaultNow().notNull(),
844 updatedAt: timestamp("updated_at").defaultNow().notNull(),
845 },
846 (table) => [uniqueIndex("teams_org_slug").on(table.orgId, table.slug)]
847);
848
849/**
850 * Team membership. Roles: maintainer (can edit team), member (default).
851 * A user can belong to many teams; team membership requires org membership
852 * but that invariant is enforced at the route layer, not the DB layer.
853 */
854export const teamMembers = pgTable(
855 "team_members",
856 {
857 id: uuid("id").primaryKey().defaultRandom(),
858 teamId: uuid("team_id")
859 .notNull()
860 .references(() => teams.id, { onDelete: "cascade" }),
861 userId: uuid("user_id")
862 .notNull()
863 .references(() => users.id, { onDelete: "cascade" }),
864 role: text("role").notNull().default("member"), // maintainer | member
865 createdAt: timestamp("created_at").defaultNow().notNull(),
866 },
867 (table) => [
868 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
869 index("team_members_user").on(table.userId),
870 ]
871);
872
873export type Organization = typeof organizations.$inferSelect;
874export type OrgMember = typeof orgMembers.$inferSelect;
875export type Team = typeof teams.$inferSelect;
876export type TeamMember = typeof teamMembers.$inferSelect;
877export type OrgRole = "owner" | "admin" | "member";
878export type TeamRole = "maintainer" | "member";
7298a17Claude879
880/**
881 * 2FA / TOTP (Block B4).
882 *
883 * Secret is stored in plain Base32 for now — the DB has row-level-secure
884 * access and the app boundary is the only code that reads it. A follow-up
885 * (B4.1) will wrap it with AES-GCM at rest once we standardise the KEK.
886 *
887 * `enabledAt` is set only after the user has successfully entered their
888 * first code (confirming the authenticator was set up correctly). Rows with
889 * `enabledAt = NULL` represent pending enrolment.
890 */
891export const userTotp = pgTable("user_totp", {
892 userId: uuid("user_id")
893 .primaryKey()
894 .references(() => users.id, { onDelete: "cascade" }),
895 secret: text("secret").notNull(),
896 enabledAt: timestamp("enabled_at"),
897 lastUsedAt: timestamp("last_used_at"),
898 createdAt: timestamp("created_at").defaultNow().notNull(),
899});
900
901/**
902 * Recovery codes — single-use fallback when the authenticator is lost.
903 * Stored as SHA-256 hashes; used rows are marked with `usedAt` rather than
904 * deleted so the audit log keeps the full history.
905 */
906export const userRecoveryCodes = pgTable(
907 "user_recovery_codes",
908 {
909 id: uuid("id").primaryKey().defaultRandom(),
910 userId: uuid("user_id")
911 .notNull()
912 .references(() => users.id, { onDelete: "cascade" }),
913 codeHash: text("code_hash").notNull(),
914 usedAt: timestamp("used_at"),
915 createdAt: timestamp("created_at").defaultNow().notNull(),
916 },
917 (table) => [
918 index("recovery_codes_user").on(table.userId),
919 uniqueIndex("recovery_codes_user_hash").on(table.userId, table.codeHash),
920 ]
921);
922
923export type UserTotp = typeof userTotp.$inferSelect;
924export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
2df1f8cClaude925
926/**
927 * WebAuthn passkeys (Block B5).
928 *
929 * Each row is one registered authenticator. The `credentialId` is the
930 * globally-unique identifier the browser returns; `publicKey` is the
931 * COSE-encoded public key we use to verify signatures. `counter` tracks
932 * the authenticator's signature counter for replay-protection.
933 *
934 * `transports` is a JSON array (stored as text) of the
935 * AuthenticatorTransport values ("usb" | "nfc" | "ble" | "internal" | "hybrid").
936 */
937export const userPasskeys = pgTable(
938 "user_passkeys",
939 {
940 id: uuid("id").primaryKey().defaultRandom(),
941 userId: uuid("user_id")
942 .notNull()
943 .references(() => users.id, { onDelete: "cascade" }),
944 credentialId: text("credential_id").notNull().unique(),
945 publicKey: text("public_key").notNull(), // base64url of COSE key
946 counter: integer("counter").default(0).notNull(),
947 transports: text("transports"), // JSON array string
948 name: text("name").notNull().default("Passkey"),
949 lastUsedAt: timestamp("last_used_at"),
950 createdAt: timestamp("created_at").defaultNow().notNull(),
951 },
952 (table) => [index("passkeys_user").on(table.userId)]
953);
954
955/**
956 * Short-lived WebAuthn challenges. A row is written when we issue options
957 * (registration or authentication) and deleted after the verify step or when
958 * it expires (5 min). Keeping them in the DB lets us verify without sticky
959 * sessions or client-side state.
960 */
961export const webauthnChallenges = pgTable(
962 "webauthn_challenges",
963 {
964 id: uuid("id").primaryKey().defaultRandom(),
965 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
966 // For passwordless login we don't know the user yet, so userId is nullable
967 // and we bind the challenge to a short-lived cookie token instead.
968 sessionKey: text("session_key").notNull().unique(),
969 challenge: text("challenge").notNull(),
970 kind: text("kind").notNull(), // "register" | "authenticate"
971 expiresAt: timestamp("expires_at").notNull(),
972 createdAt: timestamp("created_at").defaultNow().notNull(),
973 },
974 (table) => [index("webauthn_challenges_expires").on(table.expiresAt)]
975);
976
977export type UserPasskey = typeof userPasskeys.$inferSelect;
978export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
bfdb5e7Claude979
980/**
981 * OAuth 2.0 provider (Block B6).
982 *
983 * `oauthApps` is a third-party app registered by a developer. Each app has
984 * a public `client_id`, a hashed `client_secret`, and one or more allowed
985 * `redirect_uris` (newline-separated). The plaintext secret is shown to the
986 * developer exactly once at creation and cannot be recovered; they can
987 * rotate it instead.
988 *
989 * `oauthAuthorizations` is a short-lived authorization code issued after
990 * the user consents at /oauth/authorize. Single-use: `usedAt` is set on
991 * redemption so a replay after-the-fact fails.
992 *
993 * `oauthAccessTokens` is a long-lived bearer token plus an optional
994 * refresh token. Both are stored as SHA-256 hashes; the plaintext values
995 * are only returned to the client once in the /oauth/token response.
996 */
997export const oauthApps = pgTable(
998 "oauth_apps",
999 {
1000 id: uuid("id").primaryKey().defaultRandom(),
1001 ownerId: uuid("owner_id")
1002 .notNull()
1003 .references(() => users.id, { onDelete: "cascade" }),
1004 name: text("name").notNull(),
1005 clientId: text("client_id").notNull().unique(),
1006 clientSecretHash: text("client_secret_hash").notNull(),
1007 clientSecretPrefix: text("client_secret_prefix").notNull(), // first 8 chars for display
1008 /** Newline-separated list of allowed redirect URIs. */
1009 redirectUris: text("redirect_uris").notNull(),
1010 homepageUrl: text("homepage_url"),
1011 description: text("description"),
1012 /**
1013 * If `true`, the app must present its client_secret at /oauth/token.
1014 * Public SPA/mobile apps should set this to `false` and use PKCE.
1015 */
1016 confidential: boolean("confidential").default(true).notNull(),
1017 revokedAt: timestamp("revoked_at"),
1018 createdAt: timestamp("created_at").defaultNow().notNull(),
1019 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1020 },
1021 (table) => [index("oauth_apps_owner").on(table.ownerId)]
1022);
1023
1024export const oauthAuthorizations = pgTable(
1025 "oauth_authorizations",
1026 {
1027 id: uuid("id").primaryKey().defaultRandom(),
1028 appId: uuid("app_id")
1029 .notNull()
1030 .references(() => oauthApps.id, { onDelete: "cascade" }),
1031 userId: uuid("user_id")
1032 .notNull()
1033 .references(() => users.id, { onDelete: "cascade" }),
1034 codeHash: text("code_hash").notNull().unique(),
1035 redirectUri: text("redirect_uri").notNull(),
1036 scopes: text("scopes").notNull().default(""),
1037 codeChallenge: text("code_challenge"),
1038 codeChallengeMethod: text("code_challenge_method"), // "S256" | "plain"
1039 expiresAt: timestamp("expires_at").notNull(),
1040 usedAt: timestamp("used_at"),
1041 createdAt: timestamp("created_at").defaultNow().notNull(),
1042 },
1043 (table) => [index("oauth_authorizations_expires").on(table.expiresAt)]
1044);
1045
1046export const oauthAccessTokens = pgTable(
1047 "oauth_access_tokens",
1048 {
1049 id: uuid("id").primaryKey().defaultRandom(),
1050 appId: uuid("app_id")
1051 .notNull()
1052 .references(() => oauthApps.id, { onDelete: "cascade" }),
1053 userId: uuid("user_id")
1054 .notNull()
1055 .references(() => users.id, { onDelete: "cascade" }),
1056 accessTokenHash: text("access_token_hash").notNull().unique(),
1057 refreshTokenHash: text("refresh_token_hash").unique(),
1058 scopes: text("scopes").notNull().default(""),
1059 expiresAt: timestamp("expires_at").notNull(),
1060 refreshExpiresAt: timestamp("refresh_expires_at"),
1061 revokedAt: timestamp("revoked_at"),
1062 lastUsedAt: timestamp("last_used_at"),
1063 createdAt: timestamp("created_at").defaultNow().notNull(),
1064 },
1065 (table) => [
1066 index("oauth_access_tokens_user").on(table.userId),
1067 index("oauth_access_tokens_app").on(table.appId),
1068 index("oauth_access_tokens_expires").on(table.expiresAt),
1069 ]
1070);
1071
1072export type OauthApp = typeof oauthApps.$inferSelect;
1073export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
1074export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
eafe8c6Claude1075
1076/**
1077 * Actions-equivalent workflow runner (Block C1).
1078 *
1079 * `workflows` rows are the YAML files discovered at `.gluecron/workflows/*.yml`
1080 * on the repo's default branch. `parsed` is the normalised JSON form used by
1081 * the runner so we don't re-parse on every trigger.
1082 *
1083 * `workflow_runs` is one execution: one row per trigger event. Status
1084 * progression: queued → running → success|failure|cancelled. `conclusion`
1085 * stays null until `status` is terminal.
1086 *
1087 * `workflow_jobs` is a single job within a run — each has its own steps
1088 * array and concatenated logs. We keep logs inline for v1 (no streaming)
1089 * to avoid a fifth table; they're truncated at the runner.
1090 *
1091 * `workflow_artifacts` persist files a job uploaded. `content` is a bytea;
1092 * we'll move this to object storage once we hit size limits.
1093 */
1094export const workflows = pgTable(
1095 "workflows",
1096 {
1097 id: uuid("id").primaryKey().defaultRandom(),
1098 repositoryId: uuid("repository_id")
1099 .notNull()
1100 .references(() => repositories.id, { onDelete: "cascade" }),
1101 name: text("name").notNull(),
1102 path: text("path").notNull(), // e.g. ".gluecron/workflows/ci.yml"
1103 yaml: text("yaml").notNull(),
1104 parsed: text("parsed").notNull(), // JSON string
1105 onEvents: text("on_events").notNull().default("[]"), // JSON array of event names
1106 disabled: boolean("disabled").default(false).notNull(),
1107 createdAt: timestamp("created_at").defaultNow().notNull(),
1108 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1109 },
1110 (table) => [
1111 index("workflows_repo").on(table.repositoryId),
1112 uniqueIndex("workflows_repo_path").on(table.repositoryId, table.path),
1113 ]
1114);
1115
1116export const workflowRuns = pgTable(
1117 "workflow_runs",
1118 {
1119 id: uuid("id").primaryKey().defaultRandom(),
1120 workflowId: uuid("workflow_id")
1121 .notNull()
1122 .references(() => workflows.id, { onDelete: "cascade" }),
1123 repositoryId: uuid("repository_id")
1124 .notNull()
1125 .references(() => repositories.id, { onDelete: "cascade" }),
1126 runNumber: integer("run_number").notNull(),
1127 event: text("event").notNull(), // "push" | "pull_request" | "manual" | ...
1128 ref: text("ref"),
1129 commitSha: text("commit_sha"),
1130 triggeredBy: uuid("triggered_by").references(() => users.id, {
1131 onDelete: "set null",
1132 }),
1133 status: text("status").notNull().default("queued"), // queued|running|success|failure|cancelled
1134 conclusion: text("conclusion"),
1135 queuedAt: timestamp("queued_at").defaultNow().notNull(),
1136 startedAt: timestamp("started_at"),
1137 finishedAt: timestamp("finished_at"),
1138 createdAt: timestamp("created_at").defaultNow().notNull(),
1139 },
1140 (table) => [
1141 index("workflow_runs_repo").on(table.repositoryId),
1142 index("workflow_runs_status").on(table.status),
1143 index("workflow_runs_workflow").on(table.workflowId),
1144 ]
1145);
1146
1147export const workflowJobs = pgTable(
1148 "workflow_jobs",
1149 {
1150 id: uuid("id").primaryKey().defaultRandom(),
1151 runId: uuid("run_id")
1152 .notNull()
1153 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1154 name: text("name").notNull(),
1155 jobOrder: integer("job_order").default(0).notNull(),
1156 runsOn: text("runs_on").notNull().default("default"),
1157 status: text("status").notNull().default("queued"),
1158 conclusion: text("conclusion"),
1159 exitCode: integer("exit_code"),
1160 steps: text("steps").notNull().default("[]"), // JSON array of step results
1161 logs: text("logs").notNull().default(""),
1162 startedAt: timestamp("started_at"),
1163 finishedAt: timestamp("finished_at"),
1164 createdAt: timestamp("created_at").defaultNow().notNull(),
1165 },
1166 (table) => [index("workflow_jobs_run").on(table.runId)]
1167);
1168
1169export const workflowArtifacts = pgTable(
1170 "workflow_artifacts",
1171 {
1172 id: uuid("id").primaryKey().defaultRandom(),
1173 runId: uuid("run_id")
1174 .notNull()
1175 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1176 jobId: uuid("job_id").references(() => workflowJobs.id, {
1177 onDelete: "set null",
1178 }),
1179 name: text("name").notNull(),
1180 sizeBytes: integer("size_bytes").default(0).notNull(),
1181 contentType: text("content_type")
1182 .default("application/octet-stream")
1183 .notNull(),
1184 // bytea — drizzle doesn't have a built-in bytea type at the level we use
1185 // elsewhere; store as text (base64) for v1. Migration uses real bytea so
1186 // we can swap the column type later.
1187 content: text("content"),
1188 createdAt: timestamp("created_at").defaultNow().notNull(),
1189 },
1190 (table) => [index("workflow_artifacts_run").on(table.runId)]
1191);
1192
1193export type Workflow = typeof workflows.$inferSelect;
1194export type WorkflowRun = typeof workflowRuns.$inferSelect;
1195export type WorkflowJob = typeof workflowJobs.$inferSelect;
1196export type WorkflowArtifact = typeof workflowArtifacts.$inferSelect;
e2da5c6Claude1197
1198// ---------------------------------------------------------------------------
1199// Block C2 — Package registry (npm-compatible)
1200// ---------------------------------------------------------------------------
1201
1202export const packages = pgTable(
1203 "packages",
1204 {
1205 id: uuid("id").primaryKey().defaultRandom(),
1206 repositoryId: uuid("repository_id")
1207 .notNull()
1208 .references(() => repositories.id, { onDelete: "cascade" }),
1209 ecosystem: text("ecosystem").notNull().default("npm"), // "npm" | "container"
1210 scope: text("scope"), // "@acme" for npm; null for unscoped
1211 name: text("name").notNull(), // "my-lib" (without scope)
1212 description: text("description"),
1213 readme: text("readme"),
1214 homepage: text("homepage"),
1215 license: text("license"),
1216 visibility: text("visibility").notNull().default("public"), // "public" | "private"
1217 createdAt: timestamp("created_at").defaultNow().notNull(),
1218 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1219 },
1220 (table) => [
1221 index("packages_repo").on(table.repositoryId),
1222 uniqueIndex("packages_eco_scope_name").on(
1223 table.ecosystem,
1224 table.scope,
1225 table.name
1226 ),
1227 ]
1228);
1229
1230export const packageVersions = pgTable(
1231 "package_versions",
1232 {
1233 id: uuid("id").primaryKey().defaultRandom(),
1234 packageId: uuid("package_id")
1235 .notNull()
1236 .references(() => packages.id, { onDelete: "cascade" }),
1237 version: text("version").notNull(), // "1.2.3"
1238 shasum: text("shasum").notNull(), // sha1 (for npm compat) hex
1239 integrity: text("integrity"), // "sha512-..." base64
1240 sizeBytes: integer("size_bytes").default(0).notNull(),
1241 metadata: text("metadata").notNull().default("{}"), // package.json JSON
1242 tarball: text("tarball"), // base64-encoded; bytea in migration
1243 publishedBy: uuid("published_by").references(() => users.id, {
1244 onDelete: "set null",
1245 }),
1246 yanked: boolean("yanked").default(false).notNull(),
1247 yankedReason: text("yanked_reason"),
1248 publishedAt: timestamp("published_at").defaultNow().notNull(),
1249 },
1250 (table) => [
1251 index("package_versions_pkg").on(table.packageId),
1252 uniqueIndex("package_versions_pkg_version").on(table.packageId, table.version),
1253 ]
1254);
1255
1256export const packageTags = pgTable(
1257 "package_tags",
1258 {
1259 id: uuid("id").primaryKey().defaultRandom(),
1260 packageId: uuid("package_id")
1261 .notNull()
1262 .references(() => packages.id, { onDelete: "cascade" }),
1263 tag: text("tag").notNull(), // "latest" | "beta" | ...
1264 versionId: uuid("version_id")
1265 .notNull()
1266 .references(() => packageVersions.id, { onDelete: "cascade" }),
1267 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1268 },
1269 (table) => [
1270 uniqueIndex("package_tags_pkg_tag").on(table.packageId, table.tag),
1271 ]
1272);
1273
1274export type Package = typeof packages.$inferSelect;
1275export type PackageVersion = typeof packageVersions.$inferSelect;
1276export type PackageTag = typeof packageTags.$inferSelect;
1277
1278// ---------------------------------------------------------------------------
1279// Block C3 — Pages / static hosting
1280// ---------------------------------------------------------------------------
1281
1282export const pagesDeployments = pgTable(
1283 "pages_deployments",
1284 {
1285 id: uuid("id").primaryKey().defaultRandom(),
1286 repositoryId: uuid("repository_id")
1287 .notNull()
1288 .references(() => repositories.id, { onDelete: "cascade" }),
1289 ref: text("ref").notNull().default("refs/heads/gh-pages"),
1290 commitSha: text("commit_sha").notNull(),
1291 status: text("status").notNull().default("success"), // "success" | "failed"
1292 triggeredBy: uuid("triggered_by").references(() => users.id, {
1293 onDelete: "set null",
1294 }),
1295 createdAt: timestamp("created_at").defaultNow().notNull(),
1296 },
1297 (table) => [
1298 index("pages_deployments_repo").on(table.repositoryId),
1299 index("pages_deployments_created").on(table.createdAt),
1300 ]
1301);
1302
1303export const pagesSettings = pgTable("pages_settings", {
1304 repositoryId: uuid("repository_id")
1305 .primaryKey()
1306 .references(() => repositories.id, { onDelete: "cascade" }),
1307 enabled: boolean("enabled").default(true).notNull(),
1308 sourceBranch: text("source_branch").notNull().default("gh-pages"),
1309 sourceDir: text("source_dir").notNull().default("/"), // e.g. "/" or "/docs"
1310 customDomain: text("custom_domain"),
1311 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1312});
1313
1314export type PagesDeployment = typeof pagesDeployments.$inferSelect;
1315export type PagesSettings = typeof pagesSettings.$inferSelect;
1316
1317// ---------------------------------------------------------------------------
1318// Block C4 — Environments with protected approvals
1319// ---------------------------------------------------------------------------
1320
1321export const environments = pgTable(
1322 "environments",
1323 {
1324 id: uuid("id").primaryKey().defaultRandom(),
1325 repositoryId: uuid("repository_id")
1326 .notNull()
1327 .references(() => repositories.id, { onDelete: "cascade" }),
1328 name: text("name").notNull(), // "production" | "staging" | "preview"
1329 requireApproval: boolean("require_approval").default(false).notNull(),
1330 // JSON array of user IDs that can approve deploys.
1331 reviewers: text("reviewers").notNull().default("[]"),
1332 waitTimerMinutes: integer("wait_timer_minutes").default(0).notNull(),
1333 allowedBranches: text("allowed_branches").notNull().default("[]"), // JSON glob patterns
1334 createdAt: timestamp("created_at").defaultNow().notNull(),
1335 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1336 },
1337 (table) => [
1338 uniqueIndex("environments_repo_name").on(table.repositoryId, table.name),
1339 ]
1340);
1341
1342export const deploymentApprovals = pgTable(
1343 "deployment_approvals",
1344 {
1345 id: uuid("id").primaryKey().defaultRandom(),
1346 deploymentId: uuid("deployment_id")
1347 .notNull()
1348 .references(() => deployments.id, { onDelete: "cascade" }),
1349 userId: uuid("user_id")
1350 .notNull()
1351 .references(() => users.id, { onDelete: "cascade" }),
1352 decision: text("decision").notNull(), // "approved" | "rejected"
1353 comment: text("comment"),
1354 createdAt: timestamp("created_at").defaultNow().notNull(),
1355 },
1356 (table) => [
1357 index("deployment_approvals_deployment").on(table.deploymentId),
1358 ]
1359);
1360
1361export type Environment = typeof environments.$inferSelect;
1362export type DeploymentApproval = typeof deploymentApprovals.$inferSelect;
3cbe3d6Claude1363
1364// ---------------------------------------------------------------------------
1365// Block D — AI-native differentiation (migration 0012)
1366// ---------------------------------------------------------------------------
1367
1368// D6 — cached "explain this codebase" markdown keyed on commit sha.
1369export const codebaseExplanations = pgTable(
1370 "codebase_explanations",
1371 {
1372 id: uuid("id").primaryKey().defaultRandom(),
1373 repositoryId: uuid("repository_id")
1374 .notNull()
1375 .references(() => repositories.id, { onDelete: "cascade" }),
1376 commitSha: text("commit_sha").notNull(),
1377 summary: text("summary").notNull(),
1378 markdown: text("markdown").notNull(),
1379 model: text("model").notNull(),
1380 generatedAt: timestamp("generated_at").defaultNow().notNull(),
1381 },
1382 (table) => [
1383 uniqueIndex("codebase_explanations_repo_sha").on(
1384 table.repositoryId,
1385 table.commitSha
1386 ),
1387 ]
1388);
1389
1390// D2 — AI dependency bumper run history.
1391export const depUpdateRuns = pgTable(
1392 "dep_update_runs",
1393 {
1394 id: uuid("id").primaryKey().defaultRandom(),
1395 repositoryId: uuid("repository_id")
1396 .notNull()
1397 .references(() => repositories.id, { onDelete: "cascade" }),
1398 status: text("status").notNull().default("pending"), // pending|running|success|failed|no_updates
1399 ecosystem: text("ecosystem").notNull(), // npm|bun
1400 manifestPath: text("manifest_path").notNull(),
1401 attemptedBumps: text("attempted_bumps").notNull().default("[]"), // JSON
1402 appliedBumps: text("applied_bumps").notNull().default("[]"), // JSON
1403 branchName: text("branch_name"),
1404 prNumber: integer("pr_number"),
1405 errorMessage: text("error_message"),
1406 triggeredBy: uuid("triggered_by").references(() => users.id, {
1407 onDelete: "set null",
1408 }),
1409 createdAt: timestamp("created_at").defaultNow().notNull(),
1410 completedAt: timestamp("completed_at"),
1411 },
1412 (table) => [
1413 index("dep_update_runs_repo").on(table.repositoryId),
1414 index("dep_update_runs_created").on(table.createdAt),
1415 ]
1416);
1417
1418// D1 — code chunks for semantic search. Embedding stored as JSON-encoded
1419// number array in text to avoid requiring pgvector; cosine similarity is
1420// computed in JS. Upgrade path: ALTER COLUMN embedding TYPE vector(1024).
1421export const codeChunks = pgTable(
1422 "code_chunks",
1423 {
1424 id: uuid("id").primaryKey().defaultRandom(),
1425 repositoryId: uuid("repository_id")
1426 .notNull()
1427 .references(() => repositories.id, { onDelete: "cascade" }),
1428 commitSha: text("commit_sha").notNull(),
1429 path: text("path").notNull(),
1430 startLine: integer("start_line").notNull(),
1431 endLine: integer("end_line").notNull(),
1432 content: text("content").notNull(),
1433 embedding: text("embedding"), // JSON number[]
1434 embeddingModel: text("embedding_model"),
1435 createdAt: timestamp("created_at").defaultNow().notNull(),
1436 },
1437 (table) => [
1438 index("code_chunks_repo").on(table.repositoryId),
1439 index("code_chunks_repo_path").on(table.repositoryId, table.path),
1440 ]
1441);
1442
1443export type CodebaseExplanation = typeof codebaseExplanations.$inferSelect;
1444export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
1e162a8Claude1445
1446// ---------------------------------------------------------------------------
1447// Block E2 — Discussions (migration 0013)
1448// ---------------------------------------------------------------------------
1449
1450/**
1451 * Discussions — forum-style threaded conversations attached to a repo.
1452 * Similar to GitHub Discussions: categorised + pinnable + answerable.
1453 */
1454export const discussions = pgTable(
1455 "discussions",
1456 {
1457 id: uuid("id").primaryKey().defaultRandom(),
1458 number: serial("number"),
1459 repositoryId: uuid("repository_id")
1460 .notNull()
1461 .references(() => repositories.id, { onDelete: "cascade" }),
1462 authorId: uuid("author_id")
1463 .notNull()
1464 .references(() => users.id),
1465 // one of: "general" | "q-and-a" | "ideas" | "announcements" | "show-and-tell"
1466 category: text("category").notNull().default("general"),
1467 title: text("title").notNull(),
1468 body: text("body"),
1469 state: text("state").notNull().default("open"), // open, closed
1470 locked: boolean("locked").notNull().default(false),
1471 answerCommentId: uuid("answer_comment_id"),
1472 pinned: boolean("pinned").notNull().default(false),
1473 createdAt: timestamp("created_at").defaultNow().notNull(),
1474 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1475 },
1476 (table) => [
1477 index("discussions_repo").on(table.repositoryId),
1478 uniqueIndex("discussions_repo_number").on(
1479 table.repositoryId,
1480 table.number
1481 ),
1482 ]
1483);
1484
1485export const discussionComments = pgTable(
1486 "discussion_comments",
1487 {
1488 id: uuid("id").primaryKey().defaultRandom(),
1489 discussionId: uuid("discussion_id")
1490 .notNull()
1491 .references(() => discussions.id, { onDelete: "cascade" }),
1492 parentCommentId: uuid("parent_comment_id"),
1493 authorId: uuid("author_id")
1494 .notNull()
1495 .references(() => users.id),
1496 body: text("body").notNull(),
1497 isAnswer: boolean("is_answer").notNull().default(false),
1498 createdAt: timestamp("created_at").defaultNow().notNull(),
1499 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1500 },
1501 (table) => [
1502 index("discussion_comments_discussion").on(table.discussionId),
1503 ]
1504);
1505
1506export type Discussion = typeof discussions.$inferSelect;
1507export type DiscussionComment = typeof discussionComments.$inferSelect;
3cbe3d6Claude1508export type CodeChunk = typeof codeChunks.$inferSelect;
1e162a8Claude1509
1510// ---------------------------------------------------------------------------
1511// Block E4 — Gists (migration 0014)
1512// ---------------------------------------------------------------------------
1513//
1514// User-owned small snippets/files that behave like tiny repos. DB-backed
1515// for v1 (no bare git repo): each gist owns a collection of gist_files and
1516// every edit appends a gist_revisions row with a JSON snapshot.
1517
1518export const gists = pgTable(
1519 "gists",
1520 {
1521 id: uuid("id").primaryKey().defaultRandom(),
1522 ownerId: uuid("owner_id")
1523 .notNull()
1524 .references(() => users.id, { onDelete: "cascade" }),
1525 // 8-char hex slug used in pretty URLs (e.g. /gists/a1b2c3d4).
1526 slug: text("slug").notNull().unique(),
1527 title: text("title").notNull().default(""),
1528 description: text("description").notNull().default(""),
1529 isPublic: boolean("is_public").default(true).notNull(),
1530 createdAt: timestamp("created_at").defaultNow().notNull(),
1531 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1532 },
1533 (table) => [index("gists_owner").on(table.ownerId)]
1534);
1535
1536export const gistFiles = pgTable(
1537 "gist_files",
1538 {
1539 id: uuid("id").primaryKey().defaultRandom(),
1540 gistId: uuid("gist_id")
1541 .notNull()
1542 .references(() => gists.id, { onDelete: "cascade" }),
1543 filename: text("filename").notNull(),
1544 // Optional explicit language override; falls back to filename detection.
1545 language: text("language"),
1546 content: text("content").notNull().default(""),
1547 sizeBytes: integer("size_bytes").default(0).notNull(),
1548 },
1549 (table) => [
1550 index("gist_files_gist").on(table.gistId),
1551 uniqueIndex("gist_files_gist_filename").on(table.gistId, table.filename),
1552 ]
1553);
1554
1555export const gistRevisions = pgTable(
1556 "gist_revisions",
1557 {
1558 id: uuid("id").primaryKey().defaultRandom(),
1559 gistId: uuid("gist_id")
1560 .notNull()
1561 .references(() => gists.id, { onDelete: "cascade" }),
1562 revision: integer("revision").notNull(),
1563 // JSON-encoded {filename: content} map capturing the full snapshot at
1564 // this revision. Stored as text to avoid requiring jsonb.
1565 snapshot: text("snapshot").notNull().default("{}"),
1566 authorId: uuid("author_id")
1567 .notNull()
1568 .references(() => users.id, { onDelete: "cascade" }),
1569 message: text("message"),
1570 createdAt: timestamp("created_at").defaultNow().notNull(),
1571 },
1572 (table) => [
1573 index("gist_revisions_gist_rev").on(table.gistId, table.revision),
1574 ]
1575);
1576
1577export const gistStars = pgTable(
1578 "gist_stars",
1579 {
1580 id: uuid("id").primaryKey().defaultRandom(),
1581 gistId: uuid("gist_id")
1582 .notNull()
1583 .references(() => gists.id, { onDelete: "cascade" }),
1584 userId: uuid("user_id")
1585 .notNull()
1586 .references(() => users.id, { onDelete: "cascade" }),
1587 createdAt: timestamp("created_at").defaultNow().notNull(),
1588 },
1589 (table) => [uniqueIndex("gist_stars_gist_user").on(table.gistId, table.userId)]
1590);
1591
1592export type Gist = typeof gists.$inferSelect;
1593export type GistFile = typeof gistFiles.$inferSelect;
1594export type GistRevision = typeof gistRevisions.$inferSelect;
1595export type GistStar = typeof gistStars.$inferSelect;
1596
1597// ---------------------------------------------------------------------------
1598// Block E1 — Projects / kanban (migration 0015)
1599// ---------------------------------------------------------------------------
1600
1601export const projects = pgTable(
1602 "projects",
1603 {
1604 id: uuid("id").primaryKey().defaultRandom(),
1605 number: serial("number"),
1606 repositoryId: uuid("repository_id")
1607 .notNull()
1608 .references(() => repositories.id, { onDelete: "cascade" }),
1609 ownerId: uuid("owner_id")
1610 .notNull()
1611 .references(() => users.id),
1612 title: text("title").notNull(),
1613 description: text("description").notNull().default(""),
1614 state: text("state").notNull().default("open"), // open | closed
1615 createdAt: timestamp("created_at").defaultNow().notNull(),
1616 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1617 },
1618 (table) => [
1619 index("projects_repo").on(table.repositoryId),
1620 uniqueIndex("projects_repo_number").on(table.repositoryId, table.number),
1621 ]
1622);
1623
1624export const projectColumns = pgTable(
1625 "project_columns",
1626 {
1627 id: uuid("id").primaryKey().defaultRandom(),
1628 projectId: uuid("project_id")
1629 .notNull()
1630 .references(() => projects.id, { onDelete: "cascade" }),
1631 name: text("name").notNull(),
1632 position: integer("position").notNull().default(0),
1633 createdAt: timestamp("created_at").defaultNow().notNull(),
1634 },
1635 (table) => [index("project_columns_project").on(table.projectId)]
1636);
1637
1638export const projectItems = pgTable(
1639 "project_items",
1640 {
1641 id: uuid("id").primaryKey().defaultRandom(),
1642 projectId: uuid("project_id")
1643 .notNull()
1644 .references(() => projects.id, { onDelete: "cascade" }),
1645 columnId: uuid("column_id")
1646 .notNull()
1647 .references(() => projectColumns.id, { onDelete: "cascade" }),
1648 position: integer("position").notNull().default(0),
1649 // "note" | "issue" | "pr" — application-level FK on itemId by type
1650 itemType: text("item_type").notNull().default("note"),
1651 itemId: uuid("item_id"),
1652 title: text("title").notNull().default(""),
1653 note: text("note").notNull().default(""),
1654 createdAt: timestamp("created_at").defaultNow().notNull(),
1655 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1656 },
1657 (table) => [
1658 index("project_items_project").on(table.projectId),
1659 index("project_items_column").on(table.columnId, table.position),
1660 ]
1661);
1662
1663export type Project = typeof projects.$inferSelect;
1664export type ProjectColumn = typeof projectColumns.$inferSelect;
1665export type ProjectItem = typeof projectItems.$inferSelect;
1666
1667// ---------------------------------------------------------------------------
1668// Block E3 — Wikis (migration 0016)
1669// ---------------------------------------------------------------------------
1670// DB-backed for v1; git-backed mirror is a future upgrade.
1671
1672export const wikiPages = pgTable(
1673 "wiki_pages",
1674 {
1675 id: uuid("id").primaryKey().defaultRandom(),
1676 repositoryId: uuid("repository_id")
1677 .notNull()
1678 .references(() => repositories.id, { onDelete: "cascade" }),
1679 slug: text("slug").notNull(),
1680 title: text("title").notNull(),
1681 body: text("body").notNull().default(""),
1682 revision: integer("revision").notNull().default(1),
1683 createdAt: timestamp("created_at").defaultNow().notNull(),
1684 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1685 updatedBy: uuid("updated_by").references(() => users.id),
1686 },
1687 (table) => [
1688 index("wiki_pages_repo").on(table.repositoryId),
1689 uniqueIndex("wiki_pages_repo_slug").on(table.repositoryId, table.slug),
1690 ]
1691);
1692
1693export const wikiRevisions = pgTable(
1694 "wiki_revisions",
1695 {
1696 id: uuid("id").primaryKey().defaultRandom(),
1697 pageId: uuid("page_id")
1698 .notNull()
1699 .references(() => wikiPages.id, { onDelete: "cascade" }),
1700 revision: integer("revision").notNull(),
1701 title: text("title").notNull(),
1702 body: text("body").notNull().default(""),
1703 message: text("message"),
1704 authorId: uuid("author_id")
1705 .notNull()
1706 .references(() => users.id),
1707 createdAt: timestamp("created_at").defaultNow().notNull(),
1708 },
1709 (table) => [index("wiki_revisions_page").on(table.pageId, table.revision)]
1710);
1711
1712export type WikiPage = typeof wikiPages.$inferSelect;
1713export type WikiRevision = typeof wikiRevisions.$inferSelect;
a79a9edClaude1714
1715// ---------------------------------------------------------------------------
1716// Block E5 — Merge queues (migration 0017)
1717// ---------------------------------------------------------------------------
1718
1719export const mergeQueueEntries = pgTable(
1720 "merge_queue_entries",
1721 {
1722 id: uuid("id").primaryKey().defaultRandom(),
1723 repositoryId: uuid("repository_id")
1724 .notNull()
1725 .references(() => repositories.id, { onDelete: "cascade" }),
1726 pullRequestId: uuid("pull_request_id")
1727 .notNull()
1728 .references(() => pullRequests.id, { onDelete: "cascade" }),
1729 baseBranch: text("base_branch").notNull(),
1730 // queued | running | merged | failed | dequeued
1731 state: text("state").notNull().default("queued"),
1732 position: integer("position").notNull().default(0),
1733 enqueuedBy: uuid("enqueued_by").references(() => users.id),
1734 enqueuedAt: timestamp("enqueued_at").defaultNow().notNull(),
1735 startedAt: timestamp("started_at"),
1736 finishedAt: timestamp("finished_at"),
1737 errorMessage: text("error_message"),
1738 },
1739 (table) => [
1740 index("merge_queue_repo_branch").on(
1741 table.repositoryId,
1742 table.baseBranch,
1743 table.state
1744 ),
1745 ]
1746);
1747
1748export type MergeQueueEntry = typeof mergeQueueEntries.$inferSelect;
1749
1750// ---------------------------------------------------------------------------
1751// Block E6 — Required status checks matrix (migration 0018)
1752// ---------------------------------------------------------------------------
1753
1754export const branchRequiredChecks = pgTable(
1755 "branch_required_checks",
1756 {
1757 id: uuid("id").primaryKey().defaultRandom(),
1758 branchProtectionId: uuid("branch_protection_id")
1759 .notNull()
1760 .references(() => branchProtection.id, { onDelete: "cascade" }),
1761 checkName: text("check_name").notNull(),
1762 createdAt: timestamp("created_at").defaultNow().notNull(),
1763 },
1764 (table) => [
1765 index("branch_required_checks_rule").on(table.branchProtectionId),
1766 uniqueIndex("branch_required_checks_unique").on(
1767 table.branchProtectionId,
1768 table.checkName
1769 ),
1770 ]
1771);
1772
1773export type BranchRequiredCheck = typeof branchRequiredChecks.$inferSelect;
1774
1775// ---------------------------------------------------------------------------
1776// Block E7 — Protected tags (migration 0019)
1777// ---------------------------------------------------------------------------
1778
1779export const protectedTags = pgTable(
1780 "protected_tags",
1781 {
1782 id: uuid("id").primaryKey().defaultRandom(),
1783 repositoryId: uuid("repository_id")
1784 .notNull()
1785 .references(() => repositories.id, { onDelete: "cascade" }),
1786 pattern: text("pattern").notNull(),
1787 createdAt: timestamp("created_at").defaultNow().notNull(),
1788 createdBy: uuid("created_by").references(() => users.id),
1789 },
1790 (table) => [
1791 index("protected_tags_repo").on(table.repositoryId),
1792 uniqueIndex("protected_tags_repo_pattern").on(
1793 table.repositoryId,
1794 table.pattern
1795 ),
1796 ]
1797);
1798
1799export type ProtectedTag = typeof protectedTags.$inferSelect;
8f50ed0Claude1800
1801// ---------------------------------------------------------------------------
1802// Block F — Observability + admin (migration 0020)
1803// ---------------------------------------------------------------------------
1804
1805// F1 — Traffic analytics per repo
1806export const repoTrafficEvents = pgTable(
1807 "repo_traffic_events",
1808 {
1809 id: uuid("id").primaryKey().defaultRandom(),
1810 repositoryId: uuid("repository_id")
1811 .notNull()
1812 .references(() => repositories.id, { onDelete: "cascade" }),
1813 kind: text("kind").notNull(), // view | clone | api | ui
1814 path: text("path"),
1815 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
1816 ipHash: text("ip_hash"),
1817 userAgent: text("user_agent"),
1818 referer: text("referer"),
1819 createdAt: timestamp("created_at").defaultNow().notNull(),
1820 },
1821 (table) => [
1822 index("repo_traffic_events_repo_time").on(
1823 table.repositoryId,
1824 table.createdAt
1825 ),
1826 index("repo_traffic_events_kind").on(
1827 table.repositoryId,
1828 table.kind,
1829 table.createdAt
1830 ),
1831 ]
1832);
1833
1834export type RepoTrafficEvent = typeof repoTrafficEvents.$inferSelect;
1835
1836// F3 — Admin panel (site admins + toggleable flags)
1837export const systemFlags = pgTable("system_flags", {
1838 key: text("key").primaryKey(),
1839 value: text("value").notNull().default(""),
1840 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1841 updatedBy: uuid("updated_by").references(() => users.id),
1842});
1843
1844export type SystemFlag = typeof systemFlags.$inferSelect;
1845
1846export const siteAdmins = pgTable("site_admins", {
1847 userId: uuid("user_id")
1848 .primaryKey()
1849 .references(() => users.id, { onDelete: "cascade" }),
1850 grantedAt: timestamp("granted_at").defaultNow().notNull(),
1851 grantedBy: uuid("granted_by").references(() => users.id),
1852});
1853
1854export type SiteAdmin = typeof siteAdmins.$inferSelect;
1855
509c376Claude1856// /admin/integrations — DB-stored platform integration secrets (migration 0055).
1857// Loaded into process.env at boot so the existing synchronous config getters
1858// keep working transparently. Never exposes DATABASE_URL / SELF_HOST_REPO /
1859// PORT / GIT_REPOS_PATH — those stay env-only.
1860export const systemConfig = pgTable("system_config", {
1861 key: text("key").primaryKey(),
1862 value: text("value").notNull().default(""),
1863 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1864 updatedByUserId: uuid("updated_by_user_id").references(() => users.id, {
1865 onDelete: "set null",
1866 }),
1867});
1868
1869export type SystemConfig = typeof systemConfig.$inferSelect;
1870
8f50ed0Claude1871// F4 — Billing + quotas
1872export const billingPlans = pgTable("billing_plans", {
1873 id: uuid("id").primaryKey().defaultRandom(),
1874 slug: text("slug").notNull().unique(),
1875 name: text("name").notNull(),
1876 priceCents: integer("price_cents").notNull().default(0),
1877 repoLimit: integer("repo_limit").notNull().default(10),
1878 storageMbLimit: integer("storage_mb_limit").notNull().default(1024),
1879 aiTokensMonthly: integer("ai_tokens_monthly").notNull().default(100000),
1880 bandwidthGbMonthly: integer("bandwidth_gb_monthly").notNull().default(10),
1881 privateRepos: boolean("private_repos").notNull().default(false),
1882 createdAt: timestamp("created_at").defaultNow().notNull(),
1883});
1884
1885export type BillingPlan = typeof billingPlans.$inferSelect;
1886
1887export const userQuotas = pgTable("user_quotas", {
1888 userId: uuid("user_id")
1889 .primaryKey()
1890 .references(() => users.id, { onDelete: "cascade" }),
1891 planSlug: text("plan_slug").notNull().default("free"),
1892 storageMbUsed: integer("storage_mb_used").notNull().default(0),
1893 aiTokensUsedThisMonth: integer("ai_tokens_used_this_month")
1894 .notNull()
1895 .default(0),
1896 bandwidthGbUsedThisMonth: integer("bandwidth_gb_used_this_month")
1897 .notNull()
1898 .default(0),
1899 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
1900 updatedAt: timestamp("updated_at").defaultNow().notNull(),
619109aClaude1901 // Stripe linkage (migration 0038). Null until the user first upgrades.
1902 stripeCustomerId: text("stripe_customer_id"),
1903 stripeSubscriptionId: text("stripe_subscription_id"),
1904 stripeSubscriptionStatus: text("stripe_subscription_status"),
1905 currentPeriodEnd: timestamp("current_period_end"),
8f50ed0Claude1906});
1907
1908export type UserQuota = typeof userQuotas.$inferSelect;
06139e6Claude1909
1910// Block H — App marketplace + bot identities (GitHub Apps equivalent)
1911
1912export const apps = pgTable(
1913 "apps",
1914 {
1915 id: uuid("id").primaryKey().defaultRandom(),
1916 slug: text("slug").notNull().unique(),
1917 name: text("name").notNull(),
1918 description: text("description").notNull().default(""),
1919 iconUrl: text("icon_url"),
1920 homepageUrl: text("homepage_url"),
1921 webhookUrl: text("webhook_url"),
1922 webhookSecret: text("webhook_secret"),
1923 creatorId: uuid("creator_id")
1924 .notNull()
1925 .references(() => users.id, { onDelete: "cascade" }),
1926 permissions: text("permissions").notNull().default("[]"), // JSON array
1927 defaultEvents: text("default_events").notNull().default("[]"), // JSON array
1928 isPublic: boolean("is_public").notNull().default(true),
1929 createdAt: timestamp("created_at").defaultNow().notNull(),
1930 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1931 },
1932 (table) => [index("apps_public_slug").on(table.isPublic, table.slug)]
1933);
1934
1935export type App = typeof apps.$inferSelect;
1936
1937export const appInstallations = pgTable(
1938 "app_installations",
1939 {
1940 id: uuid("id").primaryKey().defaultRandom(),
1941 appId: uuid("app_id")
1942 .notNull()
1943 .references(() => apps.id, { onDelete: "cascade" }),
1944 installedBy: uuid("installed_by")
1945 .notNull()
1946 .references(() => users.id, { onDelete: "cascade" }),
1947 targetType: text("target_type").notNull(), // user | org | repository
1948 targetId: uuid("target_id").notNull(),
1949 grantedPermissions: text("granted_permissions").notNull().default("[]"),
1950 suspendedAt: timestamp("suspended_at"),
1951 createdAt: timestamp("created_at").defaultNow().notNull(),
1952 uninstalledAt: timestamp("uninstalled_at"),
1953 },
1954 (table) => [
1955 index("app_installations_app").on(table.appId),
1956 index("app_installations_target").on(table.targetType, table.targetId),
1957 ]
1958);
1959
1960export type AppInstallation = typeof appInstallations.$inferSelect;
1961
1962export const appBots = pgTable("app_bots", {
1963 id: uuid("id").primaryKey().defaultRandom(),
1964 appId: uuid("app_id")
1965 .notNull()
1966 .unique()
1967 .references(() => apps.id, { onDelete: "cascade" }),
1968 username: text("username").notNull().unique(),
1969 displayName: text("display_name").notNull(),
1970 avatarUrl: text("avatar_url"),
1971 createdAt: timestamp("created_at").defaultNow().notNull(),
1972});
1973
1974export type AppBot = typeof appBots.$inferSelect;
1975
1976export const appInstallTokens = pgTable(
1977 "app_install_tokens",
1978 {
1979 id: uuid("id").primaryKey().defaultRandom(),
1980 installationId: uuid("installation_id")
1981 .notNull()
1982 .references(() => appInstallations.id, { onDelete: "cascade" }),
1983 tokenHash: text("token_hash").notNull().unique(),
1984 expiresAt: timestamp("expires_at").notNull(),
1985 createdAt: timestamp("created_at").defaultNow().notNull(),
1986 revokedAt: timestamp("revoked_at"),
1987 },
1988 (table) => [index("app_install_tokens_hash").on(table.tokenHash)]
1989);
1990
1991export type AppInstallToken = typeof appInstallTokens.$inferSelect;
1992
1993export const appEvents = pgTable(
1994 "app_events",
1995 {
1996 id: uuid("id").primaryKey().defaultRandom(),
1997 appId: uuid("app_id")
1998 .notNull()
1999 .references(() => apps.id, { onDelete: "cascade" }),
2000 installationId: uuid("installation_id"),
2001 kind: text("kind").notNull(), // installed | uninstalled | delivery_ok | delivery_fail
2002 payload: text("payload"),
2003 responseStatus: integer("response_status"),
2004 createdAt: timestamp("created_at").defaultNow().notNull(),
2005 },
2006 (table) => [index("app_events_app_time").on(table.appId, table.createdAt)]
2007);
2008
2009export type AppEvent = typeof appEvents.$inferSelect;
71cd5ecClaude2010
2011// ---------- Block I3 — Repository transfer history ----------
2012
2013export const repoTransfers = pgTable(
2014 "repo_transfers",
2015 {
2016 id: uuid("id").primaryKey().defaultRandom(),
2017 repositoryId: uuid("repository_id")
2018 .notNull()
2019 .references(() => repositories.id, { onDelete: "cascade" }),
2020 fromOwnerId: uuid("from_owner_id").notNull(),
2021 fromOrgId: uuid("from_org_id"),
2022 toOwnerId: uuid("to_owner_id").notNull(),
2023 toOrgId: uuid("to_org_id"),
2024 initiatedBy: uuid("initiated_by")
2025 .notNull()
2026 .references(() => users.id, { onDelete: "cascade" }),
2027 createdAt: timestamp("created_at").defaultNow().notNull(),
2028 },
2029 (table) => [
2030 index("repo_transfers_repo").on(table.repositoryId, table.createdAt),
2031 ]
2032);
2033
2034export type RepoTransfer = typeof repoTransfers.$inferSelect;
08420cdClaude2035
2036// ---------- Block I6 — Sponsors ----------
2037
2038export const sponsorshipTiers = pgTable(
2039 "sponsorship_tiers",
2040 {
2041 id: uuid("id").primaryKey().defaultRandom(),
2042 maintainerId: uuid("maintainer_id")
2043 .notNull()
2044 .references(() => users.id, { onDelete: "cascade" }),
2045 name: text("name").notNull(),
2046 description: text("description").default("").notNull(),
2047 monthlyCents: integer("monthly_cents").notNull(),
2048 oneTimeAllowed: boolean("one_time_allowed").default(true).notNull(),
2049 isActive: boolean("is_active").default(true).notNull(),
2050 createdAt: timestamp("created_at").defaultNow().notNull(),
2051 },
2052 (table) => [
2053 index("sponsor_tiers_maintainer").on(table.maintainerId, table.isActive),
2054 ]
2055);
2056
2057export type SponsorshipTier = typeof sponsorshipTiers.$inferSelect;
2058
2059export const sponsorships = pgTable(
2060 "sponsorships",
2061 {
2062 id: uuid("id").primaryKey().defaultRandom(),
2063 sponsorId: uuid("sponsor_id")
2064 .notNull()
2065 .references(() => users.id, { onDelete: "cascade" }),
2066 maintainerId: uuid("maintainer_id")
2067 .notNull()
2068 .references(() => users.id, { onDelete: "cascade" }),
2069 tierId: uuid("tier_id"),
2070 amountCents: integer("amount_cents").notNull(),
2071 kind: text("kind").notNull(), // one_time | monthly
2072 note: text("note"),
2073 isPublic: boolean("is_public").default(true).notNull(),
2074 externalRef: text("external_ref"),
2075 cancelledAt: timestamp("cancelled_at"),
2076 createdAt: timestamp("created_at").defaultNow().notNull(),
2077 },
2078 (table) => [
2079 index("sponsorships_maintainer").on(
2080 table.maintainerId,
2081 table.createdAt
2082 ),
2083 index("sponsorships_sponsor").on(table.sponsorId, table.createdAt),
2084 ]
2085);
2086
2087export type Sponsorship = typeof sponsorships.$inferSelect;
4c8f666Claude2088
2089// Block I8 — Code symbol index for xref navigation.
2090export const codeSymbols = pgTable(
2091 "code_symbols",
2092 {
2093 id: uuid("id").primaryKey().defaultRandom(),
2094 repositoryId: uuid("repository_id")
2095 .notNull()
2096 .references(() => repositories.id, { onDelete: "cascade" }),
2097 commitSha: text("commit_sha").notNull(),
2098 name: text("name").notNull(),
2099 kind: text("kind").notNull(), // function | class | interface | type | const | variable
2100 path: text("path").notNull(),
2101 line: integer("line").notNull(),
2102 signature: text("signature"),
2103 createdAt: timestamp("created_at").defaultNow().notNull(),
2104 },
2105 (table) => [
2106 index("code_symbols_repo_name_idx").on(table.repositoryId, table.name),
2107 index("code_symbols_repo_path_idx").on(table.repositoryId, table.path),
2108 ]
2109);
2110
2111export type CodeSymbol = typeof codeSymbols.$inferSelect;
4a0dea1Claude2112
2113// Block I9 — Repository mirroring. One row per mirrored repo + an
2114// append-only log of sync attempts.
2115export const repoMirrors = pgTable("repo_mirrors", {
2116 id: uuid("id").primaryKey().defaultRandom(),
2117 repositoryId: uuid("repository_id")
2118 .notNull()
2119 .unique()
2120 .references(() => repositories.id, { onDelete: "cascade" }),
2121 upstreamUrl: text("upstream_url").notNull(),
2122 intervalMinutes: integer("interval_minutes").default(1440).notNull(),
2123 lastSyncedAt: timestamp("last_synced_at"),
2124 lastStatus: text("last_status"), // "ok" | "error"
2125 lastError: text("last_error"),
2126 isEnabled: boolean("is_enabled").default(true).notNull(),
2127 createdAt: timestamp("created_at").defaultNow().notNull(),
2128 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2129});
2130
2131export type RepoMirror = typeof repoMirrors.$inferSelect;
2132
2133export const repoMirrorRuns = pgTable(
2134 "repo_mirror_runs",
2135 {
2136 id: uuid("id").primaryKey().defaultRandom(),
2137 mirrorId: uuid("mirror_id")
2138 .notNull()
2139 .references(() => repoMirrors.id, { onDelete: "cascade" }),
2140 startedAt: timestamp("started_at").defaultNow().notNull(),
2141 finishedAt: timestamp("finished_at"),
2142 status: text("status").default("running").notNull(),
2143 message: text("message"),
2144 exitCode: integer("exit_code"),
2145 },
2146 (table) => [
2147 index("repo_mirror_runs_mirror_id_idx").on(table.mirrorId, table.startedAt),
2148 ]
2149);
2150
2151export type RepoMirrorRun = typeof repoMirrorRuns.$inferSelect;
edf7c36Claude2152
2153// ----------------------------------------------------------------------------
2154// Block I10 — Enterprise SSO (OIDC)
2155// ----------------------------------------------------------------------------
2156
2157/** Site-wide SSO provider. Singleton row with id = 'default'. */
2158export const ssoConfig = pgTable("sso_config", {
2159 id: text("id").primaryKey(),
2160 enabled: boolean("enabled").default(false).notNull(),
2161 providerName: text("provider_name").default("SSO").notNull(),
2162 issuer: text("issuer"),
2163 authorizationEndpoint: text("authorization_endpoint"),
2164 tokenEndpoint: text("token_endpoint"),
2165 userinfoEndpoint: text("userinfo_endpoint"),
2166 clientId: text("client_id"),
2167 clientSecret: text("client_secret"),
2168 scopes: text("scopes").default("openid profile email").notNull(),
2169 allowedEmailDomains: text("allowed_email_domains"),
2170 autoCreateUsers: boolean("auto_create_users").default(true).notNull(),
2171 createdAt: timestamp("created_at").defaultNow().notNull(),
2172 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2173});
2174
2175export type SsoConfig = typeof ssoConfig.$inferSelect;
2176
2177/** Maps a local user to an IdP `sub` claim. */
2178export const ssoUserLinks = pgTable(
2179 "sso_user_links",
2180 {
2181 id: uuid("id").primaryKey().defaultRandom(),
2182 userId: uuid("user_id")
2183 .notNull()
2184 .references(() => users.id, { onDelete: "cascade" }),
2185 subject: text("subject").notNull().unique(),
2186 emailAtLink: text("email_at_link").notNull(),
2187 linkedAt: timestamp("linked_at").defaultNow().notNull(),
2188 },
2189 (table) => [index("sso_user_links_user_id_idx").on(table.userId)]
2190);
2191
2192export type SsoUserLink = typeof ssoUserLinks.$inferSelect;
8098672Claude2193
2194// ----------------------------------------------------------------------------
2195// Block J1 — Dependency graph
2196// ----------------------------------------------------------------------------
2197
2198/**
2199 * Last known set of dependencies parsed from manifest files. Each reindex
2200 * replaces the prior rows for that repo. One row per (ecosystem, name,
2201 * manifest_path) — same name in multiple manifests is kept.
2202 */
2203export const repoDependencies = pgTable(
2204 "repo_dependencies",
2205 {
2206 id: uuid("id").primaryKey().defaultRandom(),
2207 repositoryId: uuid("repository_id")
2208 .notNull()
2209 .references(() => repositories.id, { onDelete: "cascade" }),
2210 ecosystem: text("ecosystem").notNull(),
2211 name: text("name").notNull(),
2212 versionSpec: text("version_spec"),
2213 manifestPath: text("manifest_path").notNull(),
2214 isDev: boolean("is_dev").default(false).notNull(),
2215 commitSha: text("commit_sha").notNull(),
2216 indexedAt: timestamp("indexed_at").defaultNow().notNull(),
2217 },
2218 (table) => [
2219 index("repo_dependencies_repo_id_idx").on(
2220 table.repositoryId,
2221 table.ecosystem
2222 ),
2223 index("repo_dependencies_name_idx").on(table.name),
2224 ]
2225);
2226
2227export type RepoDependency = typeof repoDependencies.$inferSelect;
f60ccdeClaude2228
2229// ----------------------------------------------------------------------------
2230// Block J2 — Security advisories + alerts
2231// ----------------------------------------------------------------------------
2232
2233/** CVE-style package advisories. Populated via seed + admin import. */
2234export const securityAdvisories = pgTable(
2235 "security_advisories",
2236 {
2237 id: uuid("id").primaryKey().defaultRandom(),
2238 ghsaId: text("ghsa_id").unique(),
2239 cveId: text("cve_id"),
2240 summary: text("summary").notNull(),
2241 severity: text("severity").default("moderate").notNull(),
2242 ecosystem: text("ecosystem").notNull(),
2243 packageName: text("package_name").notNull(),
2244 affectedRange: text("affected_range").notNull(),
2245 fixedVersion: text("fixed_version"),
2246 referenceUrl: text("reference_url"),
2247 publishedAt: timestamp("published_at").defaultNow().notNull(),
2248 },
2249 (table) => [
2250 index("security_advisories_pkg_idx").on(
2251 table.ecosystem,
2252 table.packageName
2253 ),
2254 ]
2255);
2256
2257export type SecurityAdvisory = typeof securityAdvisories.$inferSelect;
2258
2259/** Per-repo match state. One row per (repo, advisory, manifest_path). */
2260export const repoAdvisoryAlerts = pgTable(
2261 "repo_advisory_alerts",
2262 {
2263 id: uuid("id").primaryKey().defaultRandom(),
2264 repositoryId: uuid("repository_id")
2265 .notNull()
2266 .references(() => repositories.id, { onDelete: "cascade" }),
2267 advisoryId: uuid("advisory_id")
2268 .notNull()
2269 .references(() => securityAdvisories.id, { onDelete: "cascade" }),
2270 dependencyName: text("dependency_name").notNull(),
2271 dependencyVersion: text("dependency_version"),
2272 manifestPath: text("manifest_path").notNull(),
2273 status: text("status").default("open").notNull(),
2274 dismissedReason: text("dismissed_reason"),
2275 createdAt: timestamp("created_at").defaultNow().notNull(),
2276 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2277 },
2278 (table) => [
2279 index("repo_advisory_alerts_status_idx").on(
2280 table.repositoryId,
2281 table.status
2282 ),
2283 ]
2284);
2285
2286export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
3951454Claude2287
2288// ----------------------------------------------------------------------------
2289// Block J3 — Commit signature verification (GPG + SSH)
2290// ----------------------------------------------------------------------------
2291
2292/** Per-user GPG/SSH public keys for commit signing. */
2293export const signingKeys = pgTable(
2294 "signing_keys",
2295 {
2296 id: uuid("id").primaryKey().defaultRandom(),
2297 userId: uuid("user_id")
2298 .notNull()
2299 .references(() => users.id, { onDelete: "cascade" }),
2300 keyType: text("key_type").notNull(), // 'gpg' | 'ssh'
2301 title: text("title").notNull(),
2302 fingerprint: text("fingerprint").notNull(),
2303 publicKey: text("public_key").notNull(),
2304 email: text("email"),
2305 expiresAt: timestamp("expires_at"),
2306 lastUsedAt: timestamp("last_used_at"),
2307 createdAt: timestamp("created_at").defaultNow().notNull(),
2308 },
2309 (table) => [
2310 uniqueIndex("signing_keys_fp_unique").on(table.keyType, table.fingerprint),
2311 index("signing_keys_user_idx").on(table.userId),
2312 ]
2313);
2314
2315export type SigningKey = typeof signingKeys.$inferSelect;
2316
2317/**
2318 * Cached verification result for a (repo, commit) pair. Repopulated on demand;
2319 * rows are invalidated implicitly by CASCADE when either side is removed.
2320 */
2321export const commitVerifications = pgTable(
2322 "commit_verifications",
2323 {
2324 id: uuid("id").primaryKey().defaultRandom(),
2325 repositoryId: uuid("repository_id")
2326 .notNull()
2327 .references(() => repositories.id, { onDelete: "cascade" }),
2328 commitSha: text("commit_sha").notNull(),
2329 verified: boolean("verified").default(false).notNull(),
2330 reason: text("reason").notNull(),
2331 signatureType: text("signature_type"),
2332 signerKeyId: uuid("signer_key_id").references(() => signingKeys.id, {
2333 onDelete: "set null",
2334 }),
2335 signerUserId: uuid("signer_user_id").references(() => users.id, {
2336 onDelete: "set null",
2337 }),
2338 signerFingerprint: text("signer_fingerprint"),
2339 verifiedAt: timestamp("verified_at").defaultNow().notNull(),
2340 },
2341 (table) => [
2342 uniqueIndex("commit_verifications_sha_unique").on(
2343 table.repositoryId,
2344 table.commitSha
2345 ),
2346 ]
2347);
2348
2349export type CommitVerification = typeof commitVerifications.$inferSelect;
7aa8b99Claude2350
2351// ----------------------------------------------------------------------------
2352// Block J4 — User following
2353// ----------------------------------------------------------------------------
2354
2355/**
2356 * Directed user→user follow edges. Primary key is the composite
2357 * (follower_id, following_id) at the SQL level; drizzle sees it as a
2358 * regular table with a unique index plus a secondary index on the
2359 * reverse-lookup column.
2360 */
2361export const userFollows = pgTable(
2362 "user_follows",
2363 {
2364 followerId: uuid("follower_id")
2365 .notNull()
2366 .references(() => users.id, { onDelete: "cascade" }),
2367 followingId: uuid("following_id")
2368 .notNull()
2369 .references(() => users.id, { onDelete: "cascade" }),
2370 createdAt: timestamp("created_at").defaultNow().notNull(),
2371 },
2372 (table) => [
2373 uniqueIndex("user_follows_pair_unique").on(
2374 table.followerId,
2375 table.followingId
2376 ),
2377 index("user_follows_following_idx").on(table.followingId),
2378 ]
2379);
2380
2381export type UserFollow = typeof userFollows.$inferSelect;
9ff7128Claude2382
2383// ----------------------------------------------------------------------------
2384// Block J6 — Repository rulesets
2385// ----------------------------------------------------------------------------
2386
2387/**
2388 * A ruleset groups N rules under a named policy at enforcement level active /
2389 * evaluate / disabled. Unique per (repo, name) so a repo can carry multiple
2390 * overlapping rulesets (e.g. "release branches" vs "everywhere").
2391 */
2392export const repoRulesets = pgTable(
2393 "repo_rulesets",
2394 {
2395 id: uuid("id").primaryKey().defaultRandom(),
2396 repositoryId: uuid("repository_id")
2397 .notNull()
2398 .references(() => repositories.id, { onDelete: "cascade" }),
2399 name: text("name").notNull(),
2400 enforcement: text("enforcement").default("active").notNull(),
2401 createdBy: uuid("created_by").references(() => users.id, {
2402 onDelete: "set null",
2403 }),
2404 createdAt: timestamp("created_at").defaultNow().notNull(),
2405 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2406 },
2407 (table) => [
2408 index("repo_rulesets_repo_idx").on(table.repositoryId),
2409 uniqueIndex("repo_rulesets_repo_name_unique").on(
2410 table.repositoryId,
2411 table.name
2412 ),
2413 ]
2414);
2415
2416export type RepoRuleset = typeof repoRulesets.$inferSelect;
2417
2418/** Individual rule — type tag plus JSON params. */
2419export const rulesetRules = pgTable(
2420 "ruleset_rules",
2421 {
2422 id: uuid("id").primaryKey().defaultRandom(),
2423 rulesetId: uuid("ruleset_id")
2424 .notNull()
2425 .references(() => repoRulesets.id, { onDelete: "cascade" }),
2426 ruleType: text("rule_type").notNull(),
2427 params: text("params").default("{}").notNull(),
2428 createdAt: timestamp("created_at").defaultNow().notNull(),
2429 },
2430 (table) => [index("ruleset_rules_set_idx").on(table.rulesetId)]
2431);
2432
2433export type RulesetRule = typeof rulesetRules.$inferSelect;
0cdfd89Claude2434
2435// ---------------------------------------------------------------------------
2436// Block J8 — Commit statuses.
2437// ---------------------------------------------------------------------------
2438
2439/**
2440 * External CI / automation posts per-commit (sha, context) statuses. Upsert
2441 * semantics keyed on (repository, commit_sha, context). State vocabulary:
2442 * pending | success | failure | error. Combined rollup logic lives in
2443 * `src/lib/commit-statuses.ts`.
2444 */
2445export const commitStatuses = pgTable(
2446 "commit_statuses",
2447 {
2448 id: uuid("id").primaryKey().defaultRandom(),
2449 repositoryId: uuid("repository_id")
2450 .notNull()
2451 .references(() => repositories.id, { onDelete: "cascade" }),
2452 commitSha: text("commit_sha").notNull(),
2453 state: text("state").notNull(),
2454 context: text("context").default("default").notNull(),
2455 description: text("description"),
2456 targetUrl: text("target_url"),
2457 creatorId: uuid("creator_id").references(() => users.id, {
2458 onDelete: "set null",
2459 }),
2460 createdAt: timestamp("created_at").defaultNow().notNull(),
2461 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2462 },
2463 (table) => [
2464 uniqueIndex("commit_statuses_repo_sha_context_unique").on(
2465 table.repositoryId,
2466 table.commitSha,
2467 table.context
2468 ),
2469 index("commit_statuses_repo_sha_idx").on(
2470 table.repositoryId,
2471 table.commitSha
2472 ),
2473 ]
2474);
2475
2476export type CommitStatus = typeof commitStatuses.$inferSelect;
23d1a81Claude2477
2478// ---------------------------------------------------------------------------
2479// Collaborators — per-repo role grants (read / write / admin).
2480// ---------------------------------------------------------------------------
2481
2482/**
2483 * A user granted access to a repository beyond ownership. Roles are
2484 * hierarchical — 'admin' implies write, write implies read. `invitedBy` tracks
2485 * who added them (nullable once that user is deleted); `acceptedAt` is null
2486 * until the invitee explicitly accepts. Unique per (repo, user) so a given
2487 * user has at most one role per repo.
2488 */
2489export const repoCollaborators = pgTable(
2490 "repo_collaborators",
2491 {
2492 id: uuid("id").primaryKey().defaultRandom(),
2493 repositoryId: uuid("repository_id")
2494 .notNull()
2495 .references(() => repositories.id, { onDelete: "cascade" }),
2496 userId: uuid("user_id")
2497 .notNull()
2498 .references(() => users.id, { onDelete: "cascade" }),
2499 role: text("role", { enum: ["read", "write", "admin"] })
2500 .notNull()
2501 .default("read"),
2502 invitedBy: uuid("invited_by").references(() => users.id, {
2503 onDelete: "set null",
2504 }),
2505 invitedAt: timestamp("invited_at").defaultNow().notNull(),
2506 acceptedAt: timestamp("accepted_at"),
febd4f0Claude2507 // sha256(plaintext) of the outstanding invite token. Set when the owner
2508 // sends an invite, cleared when the invitee accepts. NULL on older rows
2509 // that were auto-accepted before the email flow existed.
2510 inviteTokenHash: text("invite_token_hash"),
23d1a81Claude2511 },
2512 (table) => [
2513 uniqueIndex("repo_collaborators_repo_user_uq").on(
2514 table.repositoryId,
2515 table.userId
2516 ),
2517 index("repo_collaborators_repo_idx").on(table.repositoryId),
2518 index("repo_collaborators_user_idx").on(table.userId),
2519 ]
2520);
2521
2522export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
abfa9adClaude2523
2524// ---------------------------------------------------------------------------
2525// Workflow engine v2 — Sprint 1 additions (drizzle/0037_workflow_engine_v2.sql)
2526//
2527// Strictly additive to Block C1. The original workflow tables defined above
2528// (workflows / workflow_runs / workflow_jobs / workflow_artifacts) are locked
2529// and must not be altered in-place; the four tables below extend the runner
2530// with secrets, workflow_dispatch inputs, a content-addressable cache, and a
2531// warm-runner worker pool.
2532// ---------------------------------------------------------------------------
2533
2534/**
2535 * Per-repo encrypted secrets exposed to workflow jobs as env vars.
2536 *
2537 * `encrypted_value` is base64 of `iv || authTag || ciphertext` produced by
2538 * AES-256-GCM — the crypto lives in `src/lib/workflow-crypto.ts` (Agent 2);
2539 * the DB only stores opaque ciphertext. `name` is validated against
2540 * `[A-Z_][A-Z0-9_]*` at the write-site, not the DB. Unique per (repo, name).
2541 */
2542export const workflowSecrets = pgTable(
2543 "workflow_secrets",
2544 {
2545 id: uuid("id").primaryKey().defaultRandom(),
2546 repositoryId: uuid("repository_id")
2547 .notNull()
2548 .references(() => repositories.id, { onDelete: "cascade" }),
2549 name: text("name").notNull(),
2550 encryptedValue: text("encrypted_value").notNull(),
2551 createdBy: uuid("created_by").references(() => users.id, {
2552 onDelete: "set null",
2553 }),
2554 createdAt: timestamp("created_at").defaultNow().notNull(),
2555 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2556 },
2557 (table) => [
2558 uniqueIndex("workflow_secrets_repo_name_uq").on(
2559 table.repositoryId,
2560 table.name
2561 ),
2562 index("workflow_secrets_repo_idx").on(table.repositoryId),
2563 ]
2564);
2565
2566export type WorkflowSecret = typeof workflowSecrets.$inferSelect;
2567export type NewWorkflowSecret = typeof workflowSecrets.$inferInsert;
2568
2569/**
2570 * Parameter schema for the `workflow_dispatch` trigger. One row per input
2571 * declared in the workflow YAML. `type` is constrained to the four values
2572 * GitHub Actions supports; `options` is only meaningful for type='choice'
2573 * (a JSON array of allowed strings). `default_value` and `description` are
2574 * optional. Unique per (workflow, name) so two inputs on the same workflow
2575 * can't share an identifier.
2576 */
2577export const workflowDispatchInputs = pgTable(
2578 "workflow_dispatch_inputs",
2579 {
2580 id: uuid("id").primaryKey().defaultRandom(),
2581 workflowId: uuid("workflow_id")
2582 .notNull()
2583 .references(() => workflows.id, { onDelete: "cascade" }),
2584 name: text("name").notNull(),
2585 type: text("type", {
2586 enum: ["string", "boolean", "choice", "number"],
2587 }).notNull(),
2588 required: boolean("required").default(false).notNull(),
2589 defaultValue: text("default_value"),
2590 // JSON array of strings; only populated when type = 'choice'.
2591 options: jsonb("options"),
2592 description: text("description"),
2593 },
2594 (table) => [
2595 uniqueIndex("workflow_dispatch_inputs_wf_name_uq").on(
2596 table.workflowId,
2597 table.name
2598 ),
2599 ]
2600);
2601
2602export type WorkflowDispatchInput =
2603 typeof workflowDispatchInputs.$inferSelect;
2604export type NewWorkflowDispatchInput =
2605 typeof workflowDispatchInputs.$inferInsert;
2606
2607/**
2608 * Content-addressable cache backing the `gluecron/cache@v1` action. Rows are
2609 * keyed by user-chosen `cache_key` within a `scope` — 'repo' for repo-wide
2610 * entries, 'branch' or 'tag' when isolation is desired (with `scope_ref`
2611 * holding the branch/tag name). `content_hash` is the sha256 of `content`
2612 * so jobs can short-circuit redundant writes. `content` is real bytea; the
2613 * 100MB payload cap is enforced at the write-site, not by the DB. LRU
2614 * eviction reads (`repository_id`, `last_accessed_at`).
2615 */
2616export const workflowRunCache = pgTable(
2617 "workflow_run_cache",
2618 {
2619 id: uuid("id").primaryKey().defaultRandom(),
2620 repositoryId: uuid("repository_id")
2621 .notNull()
2622 .references(() => repositories.id, { onDelete: "cascade" }),
2623 cacheKey: text("cache_key").notNull(),
2624 // 'repo' | 'branch' | 'tag' — constraint enforced at the write-site.
2625 scope: text("scope").default("repo").notNull(),
2626 scopeRef: text("scope_ref"),
2627 contentHash: text("content_hash").notNull(),
2628 content: bytea("content").notNull(),
2629 sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
2630 createdAt: timestamp("created_at").defaultNow().notNull(),
2631 lastAccessedAt: timestamp("last_accessed_at").defaultNow().notNull(),
2632 },
2633 (table) => [
2634 uniqueIndex("workflow_run_cache_repo_key_scope_uq").on(
2635 table.repositoryId,
2636 table.cacheKey,
2637 table.scope,
2638 table.scopeRef
2639 ),
2640 index("workflow_run_cache_repo_lru_idx").on(
2641 table.repositoryId,
2642 table.lastAccessedAt
2643 ),
2644 ]
2645);
2646
2647export type WorkflowRunCache = typeof workflowRunCache.$inferSelect;
2648export type NewWorkflowRunCache = typeof workflowRunCache.$inferInsert;
2649
2650/**
2651 * Warm-runner worker registry. The scheduler reads idle rows to dispatch
2652 * queued jobs without paying cold-start cost; workers heartbeat here so
2653 * stale entries (>N seconds since `last_heartbeat_at`) can be reaped.
2654 * `worker_id` is a stable string (hostname:pid or similar) and is globally
2655 * unique so a restarted worker naturally replaces its predecessor.
2656 * `current_run_id` is set when status='busy' and cleared on completion.
2657 */
2658export const workflowRunnerPool = pgTable(
2659 "workflow_runner_pool",
2660 {
2661 id: uuid("id").primaryKey().defaultRandom(),
2662 workerId: text("worker_id").notNull().unique(),
2663 status: text("status", {
2664 enum: ["idle", "busy", "draining", "dead"],
2665 }).notNull(),
2666 currentRunId: uuid("current_run_id").references(() => workflowRuns.id, {
2667 onDelete: "set null",
2668 }),
2669 warmedAt: timestamp("warmed_at").defaultNow().notNull(),
2670 lastHeartbeatAt: timestamp("last_heartbeat_at").defaultNow().notNull(),
2671 capacity: integer("capacity").default(1).notNull(),
2672 },
2673 (table) => [index("workflow_runner_pool_status_idx").on(table.status)]
2674);
2675
2676export type WorkflowRunnerPoolEntry =
2677 typeof workflowRunnerPool.$inferSelect;
2678export type NewWorkflowRunnerPoolEntry =
2679 typeof workflowRunnerPool.$inferInsert;
e6bad81Claude2680
2681
2682/**
2683 * Repair Flywheel — every auto-repair attempt is recorded here so future
2684 * failures with the same signature can short-circuit straight to the cached
2685 * patch (Tier 0). After ~5000 entries the cache dominates and AI calls drop.
2686 * Migration: 0039_repair_flywheel.sql
2687 */
2688export const repairFlywheel = pgTable(
2689 "repair_flywheel",
2690 {
2691 id: uuid("id").primaryKey().defaultRandom(),
2692 repositoryId: uuid("repository_id").references(() => repositories.id, {
2693 onDelete: "cascade",
2694 }),
2695 // Fingerprint of the normalised failure text (variables/paths stripped).
2696 failureSignature: text("failure_signature").notNull(),
2697 // Original failure text (capped at write-site, ~4KB).
2698 failureText: text("failure_text").notNull(),
2699 // Mechanical classification or NULL if AI/human-driven.
2700 failureClassification: text("failure_classification"),
2701 // 'cached' | 'mechanical' | 'ai-sonnet' | 'human'
2702 repairTier: text("repair_tier").notNull(),
2703 patchSummary: text("patch_summary").notNull(),
2704 filesChanged: jsonb("files_changed").notNull().default([]),
2705 commitSha: text("commit_sha"),
2706 // 'pending' | 'success' | 'failed' | 'reverted'
2707 outcome: text("outcome").notNull().default("pending"),
2708 appliedAt: timestamp("applied_at").defaultNow().notNull(),
2709 outcomeAt: timestamp("outcome_at"),
2710 parentPatternId: uuid("parent_pattern_id"),
2711 cacheHitCount: integer("cache_hit_count").default(0).notNull(),
2712 isPublicPattern: boolean("is_public_pattern").default(false).notNull(),
2713 createdAt: timestamp("created_at").defaultNow().notNull(),
2714 },
2715 (table) => [
2716 index("repair_flywheel_signature_idx").on(table.failureSignature),
2717 index("repair_flywheel_repo_idx").on(table.repositoryId),
2718 index("repair_flywheel_outcome_idx").on(table.outcome),
2719 index("repair_flywheel_classification_idx").on(table.failureClassification),
2720 index("repair_flywheel_lookup_idx").on(
2721 table.repositoryId,
2722 table.failureSignature,
2723 table.outcome
2724 ),
2725 ]
2726);
2727
2728export type RepairFlywheelEntry = typeof repairFlywheel.$inferSelect;
2729export type NewRepairFlywheelEntry = typeof repairFlywheel.$inferInsert;
2730
534f04aClaude2731/**
2732 * Block M3 — AI pre-merge risk score cache.
2733 *
2734 * One row per (pull_request_id, commit_sha). The score is computed by a
2735 * transparent formula over `signals`; the LLM only writes `ai_summary`.
2736 * Pinning by head SHA means a new push invalidates the cache implicitly
2737 * (the new SHA won't have a row yet → cache miss → recompute).
2738 *
2739 * Migration: 0044_pr_risk_scores.sql
2740 */
2741export const prRiskScores = pgTable(
2742 "pr_risk_scores",
2743 {
2744 id: uuid("id").primaryKey().defaultRandom(),
2745 pullRequestId: uuid("pull_request_id")
2746 .notNull()
2747 .references(() => pullRequests.id, { onDelete: "cascade" }),
2748 commitSha: text("commit_sha").notNull(),
2749 // 0..10 integer score.
2750 score: integer("score").notNull(),
2751 // 'low' | 'medium' | 'high' | 'critical'
2752 band: text("band").notNull(),
2753 // Raw signal map — see PrRiskSignals in src/lib/pr-risk.ts.
2754 signals: jsonb("signals").notNull(),
2755 aiSummary: text("ai_summary"),
2756 generatedAt: timestamp("generated_at", { withTimezone: true })
2757 .defaultNow()
2758 .notNull(),
2759 },
2760 (table) => [
2761 uniqueIndex("pr_risk_scores_pr_sha_uq").on(
2762 table.pullRequestId,
2763 table.commitSha
2764 ),
2765 index("pr_risk_scores_pr_idx").on(table.pullRequestId),
2766 ]
2767);
2768
2769export type PrRiskScoreRow = typeof prRiskScores.$inferSelect;
2770export type NewPrRiskScoreRow = typeof prRiskScores.$inferInsert;
2771
c63b860Claude2772/**
2773 * Block P1 — Password reset tokens.
2774 *
2775 * One row per outstanding reset request. `tokenHash` is a SHA-256 of the
2776 * plaintext token mailed to the user; the plaintext never persists.
2777 * `usedAt` is flipped on consume so a replayed link cannot rotate the
2778 * password twice. `expiresAt` is enforced at consume time (1-hour TTL).
2779 *
2780 * Migration: 0047_password_reset_tokens.sql
2781 */
2782export const passwordResetTokens = pgTable(
2783 "password_reset_tokens",
2784 {
2785 id: uuid("id").primaryKey().defaultRandom(),
2786 userId: uuid("user_id")
2787 .notNull()
2788 .references(() => users.id, { onDelete: "cascade" }),
2789 tokenHash: text("token_hash").notNull().unique(),
2790 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2791 usedAt: timestamp("used_at", { withTimezone: true }),
2792 requestIp: text("request_ip"),
2793 createdAt: timestamp("created_at", { withTimezone: true })
2794 .defaultNow()
2795 .notNull(),
2796 },
2797 (table) => [
2798 index("idx_password_reset_tokens_user").on(table.userId),
2799 index("idx_password_reset_tokens_expires").on(table.expiresAt),
2800 ]
2801);
2802
2803export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
2804export type NewPasswordResetToken = typeof passwordResetTokens.$inferInsert;
2805
2806/**
2807 * Block P2 — email verification tokens.
2808 *
2809 * SHA-256 hashed at rest. `email` captured at issuance so a verification
2810 * link still resolves the right address even after a profile-email change.
2811 * Migration: drizzle/0048_email_verification.sql
2812 */
2813export const emailVerificationTokens = pgTable(
2814 "email_verification_tokens",
2815 {
2816 id: uuid("id").primaryKey().defaultRandom(),
2817 userId: uuid("user_id")
2818 .notNull()
2819 .references(() => users.id, { onDelete: "cascade" }),
2820 email: text("email").notNull(),
2821 tokenHash: text("token_hash").notNull().unique(),
2822 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2823 usedAt: timestamp("used_at", { withTimezone: true }),
2824 createdAt: timestamp("created_at", { withTimezone: true })
2825 .defaultNow()
2826 .notNull(),
2827 },
2828 (table) => [index("idx_email_verify_tokens_user").on(table.userId)]
2829);
2830
2831export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect;
2832export type NewEmailVerificationToken = typeof emailVerificationTokens.$inferInsert;
2833
cd4f63bTest User2834/**
2835 * Block Q2 — Magic-link sign-in tokens.
2836 *
2837 * Structurally identical to passwordResetTokens / emailVerificationTokens:
2838 * a short plaintext token is mailed to the user, only its sha256 hash is
2839 * persisted, the token is single-use (usedAt) and time-limited (expiresAt).
2840 *
2841 * Difference: `userId` is NULLABLE. When a user enters an email that does
2842 * NOT yet have an account, we still mint a row — consume will create the
2843 * account on click (autoCreate=true), using the click itself as proof the
2844 * recipient owns the address. See `src/lib/magic-link.ts`.
2845 *
2846 * Migration: drizzle/0051_magic_link_tokens.sql
2847 */
2848export const magicLinkTokens = pgTable(
2849 "magic_link_tokens",
2850 {
2851 id: uuid("id").primaryKey().defaultRandom(),
2852 email: text("email").notNull(),
2853 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
2854 tokenHash: text("token_hash").notNull().unique(),
2855 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2856 usedAt: timestamp("used_at", { withTimezone: true }),
2857 requestIp: text("request_ip"),
2858 createdAt: timestamp("created_at", { withTimezone: true })
2859 .defaultNow()
2860 .notNull(),
2861 },
2862 (table) => [
2863 index("idx_magic_link_tokens_email").on(table.email),
2864 index("idx_magic_link_tokens_user").on(table.userId),
2865 index("idx_magic_link_tokens_expires").on(table.expiresAt),
2866 ]
2867);
2868
2869export type MagicLinkToken = typeof magicLinkTokens.$inferSelect;
2870export type NewMagicLinkToken = typeof magicLinkTokens.$inferInsert;
2871
b1be050CC LABS App2872// ---------------------------------------------------------------------------
2873// BLOCK S4 — Synthetic monitor history.
2874//
2875// Every autopilot tick runs `runSyntheticChecks()` (see
2876// `src/lib/synthetic-monitor.ts`) and inserts one row per check here.
2877// The /admin/status page reads the most-recent row per check_name and
2878// renders the red/green dashboard. On a green->red transition we fire
2879// MONITOR_ALERT_WEBHOOK_URL so the owner sees the site is on fire.
2880// ---------------------------------------------------------------------------
2881export const syntheticChecks = pgTable(
2882 "synthetic_checks",
2883 {
2884 id: uuid("id").primaryKey().defaultRandom(),
2885 checkName: text("check_name").notNull(),
2886 status: text("status").notNull(), // "green" | "red" | "yellow"
2887 statusCode: integer("status_code"),
2888 durationMs: integer("duration_ms").notNull(),
2889 error: text("error"),
2890 checkedAt: timestamp("checked_at", { withTimezone: true })
2891 .defaultNow()
2892 .notNull(),
2893 },
2894 (table) => [
2895 index("idx_synthetic_checks_checked_at").on(table.checkedAt),
2896 index("idx_synthetic_checks_name_checked_at").on(
2897 table.checkName,
2898 table.checkedAt
2899 ),
2900 ]
2901);
2902
2903export type SyntheticCheckRow = typeof syntheticChecks.$inferSelect;
2904export type NewSyntheticCheckRow = typeof syntheticChecks.$inferInsert;
2905