@catalyst-cloud/schema 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/schema",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Typed Drizzle schema = single source of truth for the per-tenant Mirror DO SQLite store (CTC-13 / ADR-0002). Shared by the mirror Worker, the host-sync replica, and the browser OPFS replica.",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -27,7 +27,10 @@ import {
27
27
  reviews,
28
28
  processed_events,
29
29
  change_log,
30
+ event_log,
30
31
  sync_cursors,
32
+ fleet_activity,
33
+ pull_sweeps,
31
34
  } from "./mirror.js";
32
35
 
33
36
  export * from "./mirror.js";
@@ -61,13 +64,29 @@ export const mirrorSchema = {
61
64
  reviews,
62
65
  processed_events,
63
66
  change_log,
67
+ event_log,
64
68
  sync_cursors,
69
+ fleet_activity,
70
+ // The per-PR enrichment receipt. In `mirrorSchema` (it is a real table the DO creates and the
71
+ // migration bundle must produce) but DELIBERATELY ABSENT from `mirrorEntityTables` below — see the
72
+ // no-feed arithmetic on the table definition in ./mirror.ts.
73
+ pull_sweeps,
65
74
  } as const;
66
75
 
67
76
  /**
68
77
  * The domain-entity tables that flow over the change-feed (the keys are the wire `entity` names). The
69
- * infra tables — processed_events / change_log / sync_cursors — are deliberately excluded: they never
70
- * ride a delta.
78
+ * infra tables — processed_events / change_log / event_log / sync_cursors — are deliberately excluded:
79
+ * they never ride a delta.
80
+ *
81
+ * `event_log` in particular must NEVER appear here. It is the raw provider-event passthrough feed
82
+ * (CTC-294 / ADR-0017), served on its own route and its own cursor; leaking it into the entity feed
83
+ * would push an unknown `entity` at every already-deployed replica consumer, which is exactly the
84
+ * fleet-wide breakage this cutover exists to avoid.
85
+ *
86
+ * ⛔ `pull_sweeps` must never appear here either, for a different reason: its clock advances on every
87
+ * reconcile pass, so as an entity it would emit ~60k deltas/day (7 repos × 30 open PRs × 288 passes)
88
+ * into a 200k/7-day ring — the ring would hold under a day and every replica cursor would fall off
89
+ * the end. The arithmetic and the rest of the posture live on the table definition in ./mirror.ts.
71
90
  */
72
91
  export const mirrorEntityTables = {
73
92
  issues,
@@ -85,6 +104,9 @@ export const mirrorEntityTables = {
85
104
  check_runs,
86
105
  commit_statuses,
87
106
  reviews,
107
+ // CTC-350: fleet coordination current-state — replicates like any other entity (one row per
108
+ // host_id/ticket), unlike the infra tables above which never ride a delta.
109
+ fleet_activity,
88
110
  } as const;
89
111
 
90
112
  /** The wire `entity` name of a change-feed-carried table (keys of mirrorEntityTables). */
package/src/migrate.ts CHANGED
@@ -118,8 +118,11 @@ export function applyMigrations(
118
118
  * non-replay-safe when the run-once ledger guard is bypassed — e.g. a crash between a statement
119
119
  * succeeding and the tag being recorded, which reruns the whole migration on the next boot:
120
120
  * • "duplicate column name" on `ALTER TABLE ... ADD COLUMN` — the column is already present.
121
- * • "index ... already exists" on `CREATE INDEX` — the index is already present (drizzle-kit emits
122
- * bare `CREATE INDEX`, not `IF NOT EXISTS`, so the runner makes the replay idempotent here).
121
+ * • "index ... already exists" on `CREATE INDEX` **or `CREATE UNIQUE INDEX`** — the index is already
122
+ * present (drizzle-kit emits a bare form, not `IF NOT EXISTS`, so the runner makes the replay
123
+ * idempotent here). The `UNIQUE` variant is NOT optional to handle: the pattern must match the
124
+ * statement drizzle-kit actually emitted, and a unique index that fell through this guard would
125
+ * throw on replay and leave the DO unable to boot (CTC-294 added the first such index).
123
126
  * • "table ... already exists" on `CREATE TABLE` — a NEW entity table added in a LATER migration
124
127
  * (CTC-USERS): drizzle-kit emits a bare `CREATE TABLE` (no IF NOT EXISTS), so a live DB whose
125
128
  * tables were already created from a newer `schema.sql` (which carries the new table) would throw
@@ -135,7 +138,7 @@ function execReplaySafe(db: MigrationDb, stmt: string): void {
135
138
  } catch (err) {
136
139
  const msg = err instanceof Error ? err.message : String(err);
137
140
  if (/duplicate column name/i.test(msg) && /\bADD\b/i.test(stmt)) return;
138
- if (/already exists/i.test(msg) && /\bCREATE\s+INDEX\b/i.test(stmt)) return;
141
+ if (/already exists/i.test(msg) && /\bCREATE\s+(UNIQUE\s+)?INDEX\b/i.test(stmt)) return;
139
142
  if (/already exists/i.test(msg) && /\bCREATE\s+TABLE\b/i.test(stmt)) return;
140
143
  throw err;
141
144
  }
@@ -74,6 +74,55 @@ export const MIRROR_MIGRATIONS = {
74
74
  tag: "0008_optimal_rattler",
75
75
  breakpoints: true,
76
76
  },
77
+ {
78
+ idx: 9,
79
+ version: "6",
80
+ when: 1784832568791,
81
+ tag: "0009_deep_bill_hollister",
82
+ breakpoints: true,
83
+ },
84
+ {
85
+ idx: 10,
86
+ version: "6",
87
+ when: 1785084189458,
88
+ tag: "0010_red_abomination",
89
+ breakpoints: true,
90
+ },
91
+ {
92
+ idx: 11,
93
+ version: "6",
94
+ when: 1785090228974,
95
+ tag: "0011_hesitant_korath",
96
+ breakpoints: true,
97
+ },
98
+ {
99
+ idx: 12,
100
+ version: "6",
101
+ when: 1786097717165,
102
+ tag: "0012_ordinary_infant_terrible",
103
+ breakpoints: true,
104
+ },
105
+ {
106
+ idx: 13,
107
+ version: "6",
108
+ when: 1786133360284,
109
+ tag: "0013_fair_mach_iv",
110
+ breakpoints: true,
111
+ },
112
+ {
113
+ idx: 14,
114
+ version: "6",
115
+ when: 1786583196993,
116
+ tag: "0014_reviews_state_canonical",
117
+ breakpoints: true,
118
+ },
119
+ {
120
+ idx: 15,
121
+ version: "6",
122
+ when: 1786594623244,
123
+ tag: "0015_brainy_lady_ursula",
124
+ breakpoints: true,
125
+ },
77
126
  ],
78
127
  },
79
128
  migrations: {
@@ -95,5 +144,18 @@ export const MIRROR_MIGRATIONS = {
95
144
  "CREATE TABLE `issue_history` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`issue_id` text NOT NULL,\n\t`actor_id` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`from_state` text,\n\t`to_state` text,\n\t`from_assignee_id` text,\n\t`to_assignee_id` text,\n\t`from_priority` integer,\n\t`to_priority` integer,\n\t`from_estimate` real,\n\t`to_estimate` real,\n\t`from_title` text,\n\t`to_title` text,\n\t`from_cycle_id` text,\n\t`to_cycle_id` text,\n\t`from_project_id` text,\n\t`to_project_id` text,\n\t`from_parent_id` text,\n\t`to_parent_id` text,\n\t`from_team_id` text,\n\t`to_team_id` text,\n\t`from_due_date` text,\n\t`to_due_date` text,\n\t`added_label_ids` text,\n\t`removed_label_ids` text,\n\t`updated_description` integer,\n\t`archived` integer,\n\t`auto_archived` integer,\n\t`auto_closed` integer,\n\t`trashed` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_issue_history_issue_created` ON `issue_history` (`issue_id`,`created_at`);",
96
145
  "0008_optimal_rattler":
97
146
  "ALTER TABLE `issues` ADD `state_id` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `team_key` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `team_name` text;",
147
+ "0009_deep_bill_hollister":
148
+ "ALTER TABLE `pull_requests` ADD `head_ref` text;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `linear_issue_identifier` text;--> statement-breakpoint\nCREATE INDEX `idx_pulls_linear_ident` ON `pull_requests` (`linear_issue_identifier`);",
149
+ "0010_red_abomination":
150
+ "CREATE TABLE `event_log` (\n\t`seq` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`delivery_id` text NOT NULL,\n\t`source` text NOT NULL,\n\t`event_type` text NOT NULL,\n\t`action` text,\n\t`received_at` integer NOT NULL,\n\t`backend_ts` integer,\n\t`payload` text NOT NULL,\n\t`payload_omitted_bytes` integer\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_event_log_delivery` ON `event_log` (`delivery_id`);",
151
+ "0011_hesitant_korath": "ALTER TABLE `event_log` ADD `identity` text;",
152
+ "0012_ordinary_infant_terrible":
153
+ "ALTER TABLE `cycles` ADD `team_id` text;--> statement-breakpoint\nALTER TABLE `cycles` ADD `team_key` text;--> statement-breakpoint\nALTER TABLE `cycles` ADD `team_name` text;",
154
+ "0013_fair_mach_iv":
155
+ "CREATE TABLE `fleet_activity` (\n\t`host_id` text NOT NULL,\n\t`ticket` text NOT NULL,\n\t`phase` text,\n\t`status` text,\n\t`disposition` text,\n\t`pr_number` integer,\n\t`pr_url` text,\n\t`orch_id` text,\n\t`last_event_name` text,\n\t`last_event_ts` integer,\n\t`updated_at` integer,\n\tPRIMARY KEY(`host_id`, `ticket`)\n);\n",
156
+ "0014_reviews_state_canonical":
157
+ "-- CTC-451 — canonicalise `reviews.state` to UPPERCASE.\n--\n-- The two ingestion legs disagreed at the source: REST (`/pulls/{n}/reviews`, the reconcile) returns\n-- `COMMENTED`, while the `pull_request_review` webhook delivers `commented`. Both legs stored the\n-- value verbatim, so the column carried BOTH casings of the same state. Measured on the live corpus\n-- before this migration: COMMENTED 3957 / commented 921 — a raw `WHERE state = 'COMMENTED'` missed\n-- 19% of reviews. Three read paths worked around it in application code, and none of those\n-- workarounds travel to the host replica, which is the fleet's only data path.\n--\n-- Both writers are fixed (apps/mirror/src/normalize/github.ts buildReviewRow), but that alone\n-- converges only rows the reconcile re-sweeps — and the reconcile walks OPEN PRs only, so every\n-- lowercase row on an already-closed PR would keep its casing forever. This backfill is what makes\n-- the corpus canonical rather than just the new writes.\n--\n-- ⭐ Deliberately a DATA migration in the SHARED bundle: the DO and every host replica apply the\n-- same MIRROR_MIGRATIONS through the same runner, so each side canonicalises its OWN rows locally.\n-- That converges the replica WITHOUT emitting ~921 change_log deltas, which would otherwise evict\n-- real deltas from the 200k/7d ring (the CTC-279 flood).\n--\n-- Idempotent by construction: upper() of an already-uppercase value is a no-op, and the WHERE clause\n-- makes the write a no-op too. Safe to re-run.\nUPDATE `reviews` SET `state` = upper(`state`) WHERE `state` IS NOT NULL AND `state` <> upper(`state`);\n",
158
+ "0015_brainy_lady_ursula":
159
+ "CREATE TABLE `pull_sweeps` (\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`head_sha` text NOT NULL,\n\t`swept_at` integer NOT NULL,\n\t`check_run_count` integer,\n\t`checks_error` integer,\n\t`commit_status_count` integer,\n\t`statuses_error` integer,\n\t`review_count` integer,\n\t`reviews_error` integer,\n\tPRIMARY KEY(`repo_id`, `pr_number`)\n);\n",
98
160
  },
99
161
  } as const;
package/src/mirror.ts CHANGED
@@ -16,7 +16,15 @@
16
16
  // • `check_runs` join a PR by head_sha, NOT PR number.
17
17
  // • PR mergeable / mergeable_state are nullable until a reconcile resolves them.
18
18
 
19
- import { sqliteTable, text, integer, real, primaryKey, index } from "drizzle-orm/sqlite-core";
19
+ import {
20
+ sqliteTable,
21
+ text,
22
+ integer,
23
+ real,
24
+ primaryKey,
25
+ index,
26
+ uniqueIndex,
27
+ } from "drizzle-orm/sqlite-core";
20
28
 
21
29
  // ── Linear ───────────────────────────────────────────────────────────────────────────────────────
22
30
 
@@ -217,6 +225,12 @@ export const cycles = sqliteTable("cycles", {
217
225
  scope: real("scope"), // last(scopeHistory) — current total scope points.
218
226
  completed_issue_count: integer("completed_issue_count"), // last(completedIssueCountHistory).
219
227
  issue_count: integer("issue_count"), // last(issueCountHistory).
228
+ team_id: text("team_id"),
229
+ // CTC-358: denormalized team key + name (off the cycle's `team` ref) so a cycle labels its team —
230
+ // several teams' "Cycle 7" rows are otherwise indistinguishable. Mirrors the issues precedent
231
+ // (issues.team_key/team_name). team_id is KEPT.
232
+ team_key: text("team_key"),
233
+ team_name: text("team_name"),
220
234
  starts_at: integer("starts_at"),
221
235
  ends_at: integer("ends_at"),
222
236
  updated_at: integer("updated_at"),
@@ -300,11 +314,20 @@ export const pull_requests = sqliteTable(
300
314
  milestone_title: text("milestone_title"),
301
315
  updated_at: integer("updated_at"),
302
316
  synced_at: integer("synced_at"),
317
+ // CTC-269 ticket↔PR link: the PR's source branch (now persisted — previously read only for
318
+ // head_sha and dropped at normalize) and the Linear ticket key parsed from the PR title/body
319
+ // (e.g. "CTC-268"). Both nullable (a pre-backfill row, or a PR whose text names no ticket). The
320
+ // identifier backs the reverse lookup that surfaces a ticket's PRs and a PR's ticket — existence
321
+ // is gated at READ time (a false-positive parse never surfaces because no issue matches it).
322
+ head_ref: text("head_ref"),
323
+ linear_issue_identifier: text("linear_issue_identifier"),
303
324
  },
304
325
  (t) => [
305
326
  primaryKey({ columns: [t.repo_id, t.number] }),
306
327
  // CTC-59: the pulls list — `ORDER BY updated_at DESC` (buildPullsView).
307
328
  index("idx_pulls_updated").on(t.updated_at),
329
+ // CTC-269: the ticket→PR reverse lookup — `WHERE linear_issue_identifier = ?` (buildIssueDetail).
330
+ index("idx_pulls_linear_ident").on(t.linear_issue_identifier),
308
331
  ],
309
332
  );
310
333
 
@@ -363,6 +386,38 @@ export const change_log = sqliteTable("change_log", {
363
386
  ts: integer("ts"),
364
387
  });
365
388
 
389
+ /**
390
+ * CTC-294 / ADR-0017: the raw provider-webhook passthrough log — the event-delivery feed that
391
+ * replaces the per-host smee tunnels. DELIBERATELY NOT an entity table: it never rides the
392
+ * change-feed, never appears in /snapshot, and carries its OWN `seq` sequence (consumers of
393
+ * /events/stream track a cursor SEPARATE from the change_log cursor — crossing them is a bug).
394
+ *
395
+ * `payload` is the provider's JSON re-serialized VERBATIM — deliberately un-normalized, so a host
396
+ * can feed it through the same mapper it already applies to a direct webhook. `delivery_id` is the
397
+ * provider's own idempotency key (x-github-delivery / linear-delivery) and is UNIQUE, so a queue
398
+ * re-delivery is an INSERT OR IGNORE no-op rather than a duplicate event on the wire.
399
+ */
400
+ export const event_log = sqliteTable(
401
+ "event_log",
402
+ {
403
+ seq: integer("seq").primaryKey({ autoIncrement: true }),
404
+ delivery_id: text("delivery_id").notNull(),
405
+ source: text("source").notNull(), // github | linear
406
+ event_type: text("event_type").notNull(), // x-github-event value | Linear payload.type
407
+ action: text("action"), // payload.action when the provider sends one
408
+ received_at: integer("received_at").notNull(), // webhook receipt ms (edge clock)
409
+ backend_ts: integer("backend_ts"), // provider event time when supplied (Linear webhookTimestamp)
410
+ payload: text("payload").notNull(), // RAW provider JSON, verbatim ("null" when elided)
411
+ // NULL = payload stored intact. Non-null = the payload exceeded the cap and was ELIDED, and this
412
+ // is its original byte size; the record still ships so the consumer sees an attributable gap.
413
+ payload_omitted_bytes: integer("payload_omitted_bytes"),
414
+ // CTC-296: JSON identity tuple extracted BEFORE an over-cap payload is discarded, so the host
415
+ // can scope its reconcile to one entity instead of sweeping the board. Null unless elided.
416
+ identity: text("identity"),
417
+ },
418
+ (t) => [uniqueIndex("idx_event_log_delivery").on(t.delivery_id)],
419
+ );
420
+
366
421
  export const sync_cursors = sqliteTable(
367
422
  "sync_cursors",
368
423
  {
@@ -373,3 +428,97 @@ export const sync_cursors = sqliteTable(
373
428
  },
374
429
  (t) => [primaryKey({ columns: [t.source, t.entity] })],
375
430
  );
431
+
432
+ /**
433
+ * CTC-350: bounded current-state projection of fleet coordination events — one row per
434
+ * (host_id, ticket). Rides change_log (NOT a new cursor); NOT an infra table (it replicates, so it is
435
+ * in mirrorEntityTables). Folded by reduceFleetActivity (apps/mirror/src/do/fleet-activity.ts);
436
+ * terminal rows pruned on ingest. `updated_at` = the event's last_event_ts, so upsertRow's
437
+ * last-write-wins guard (`excluded.updated_at > stored`) drops stale/replayed events by construction
438
+ * (out-of-order tolerance).
439
+ */
440
+ export const fleet_activity = sqliteTable(
441
+ "fleet_activity",
442
+ {
443
+ host_id: text("host_id").notNull(),
444
+ ticket: text("ticket").notNull(),
445
+ phase: text("phase"),
446
+ status: text("status"),
447
+ disposition: text("disposition"), // non-null == terminal (drives ingest-time prune)
448
+ pr_number: integer("pr_number"),
449
+ pr_url: text("pr_url"),
450
+ orch_id: text("orch_id"),
451
+ last_event_name: text("last_event_name"),
452
+ last_event_ts: integer("last_event_ts"), // ms epoch — the reducer watermark
453
+ updated_at: integer("updated_at"), // ms epoch — mirrors last_event_ts; upsertRow guard column
454
+ },
455
+ (t) => [primaryKey({ columns: [t.host_id, t.ticket] })],
456
+ );
457
+
458
+ /**
459
+ * ⭐ THE PER-PR ENRICHMENT RECEIPT — the row that makes "this PR has no CI" distinguishable from
460
+ * "nobody ever swept this PR". CTC-436 follow-up.
461
+ *
462
+ * ⛔ THE DEFECT. The GitHub reconcile writes one `check_runs` row per run GitHub returns and writes
463
+ * NOTHING when it returns `[]`. So a PR with no CI configured and a PR the walk never reached produce
464
+ * the IDENTICAL observation — zero rows — and no column anywhere separates them. Measured: 4 of a
465
+ * repo's 20 newest open PRs had zero check rows and nothing could say which kind of zero they were.
466
+ * `synced_at` does not help: it lives on `pull_requests` and is stamped by step 1's PR sweep, which
467
+ * runs for EVERY PR whether or not the enrichment walk ever reached it.
468
+ *
469
+ * ⭐ HOW A READER DECODES IT — three states, and the third is the one that used to be unrepresentable:
470
+ * • no row for (repo_id, pr_number) → NEVER swept. Not "no CI".
471
+ * • row.head_sha <> pull_requests.head_sha → never swept AT THIS SHA; a push invalidated the
472
+ * receipt. (This is why a bare timestamp is a LIE:
473
+ * step 1 advances head_sha every pass, so a stale
474
+ * per-PR clock reads as "swept" at a SHA nobody
475
+ * ever looked at.)
476
+ * • row.head_sha matches AND check_run_count = 0 → SWEPT, and GitHub genuinely reported zero.
477
+ * • row.head_sha matches AND check_run_count IS NULL
478
+ * AND checks_error = 422 → the fetch FAILED. ⛔ NOT a zero.
479
+ *
480
+ * ⛔ A COUNT COLUMN IS NULLABLE ON PURPOSE AND NULL IS NEVER 0. After CTC-436 each enrichment stage
481
+ * CATCHES an object-scoped 4xx and continues with the empty array it was initialised to, and the
482
+ * resume cursor advances anyway. A receipt that recorded `0` there would be a FORGED PROOF — "swept,
483
+ * source reported zero", with a fresh clock, for a PR nobody successfully swept — which is strictly
484
+ * worse than the missing receipt, because a merge gate would read it as verified-clean. The `*_error`
485
+ * columns carry the HTTP status so the absence states its reason instead of just being absent.
486
+ *
487
+ * ⛔ THIS TABLE MUST NEVER RIDE THE CHANGE FEED — a load-bearing decision, with arithmetic. The sweep
488
+ * clock advances on EVERY pass, so feeding it would emit one delta per open PR per pass:
489
+ * ~7 repos × ~30 open PRs × ~288 passes/day ≈ 60k deltas/day against a 200k-row / 7-day change_log
490
+ * ring. The ring would hold under a day, every host replica's `/changes` cursor would fall off the
491
+ * end, and the fleet would resync-storm. It is therefore deliberately NOT in `mirrorEntityTables`
492
+ * (so it has no MIRROR_TABLE_META, no `EntityName`, no PK_COLUMNS entry), NOT in SNAPSHOT_TABLES,
493
+ * NOT in GITHUB_ENTITIES, and nothing ever calls `appendChange` for it. It is a HUB-READ-ONLY table,
494
+ * the same posture as `sync_cursors` / `event_log`. Do not "finish the job" by wiring it to the feed.
495
+ *
496
+ * No pruner: it is self-limiting at |pull_requests| — one row per PR, overwritten in place.
497
+ *
498
+ * ⚠️ NO `updated_at` COLUMN, DELIBERATELY. `upsertRow` applies its last-write-wins guard
499
+ * (`WHERE excluded.updated_at > stored.updated_at`) iff the row object carries an `updated_at` key.
500
+ * Here the advancing clock IS the payload, so a guard could only ever reject the write that proves
501
+ * freshness — and two sweeps landing in the same millisecond would silently drop the second, which is
502
+ * exactly the write that carries a new `head_sha`. The clock is named `swept_at` so no future edit
503
+ * re-acquires that guard by renaming a column.
504
+ */
505
+ export const pull_sweeps = sqliteTable(
506
+ "pull_sweeps",
507
+ {
508
+ repo_id: text("repo_id").notNull(),
509
+ pr_number: integer("pr_number").notNull(),
510
+ /** The head SHA the enrichment walk actually swept — NOT re-read from pull_requests. */
511
+ head_sha: text("head_sha").notNull(),
512
+ /** When the walk reached this PR (ms). Advances only when a walk actually ran. */
513
+ swept_at: integer("swept_at").notNull(),
514
+ /** Runs GitHub RETURNED (not rows changed). NULL ⇒ the fetch did not succeed — never 0. */
515
+ check_run_count: integer("check_run_count"),
516
+ /** HTTP status when the check-runs fetch failed; NULL when it succeeded. */
517
+ checks_error: integer("checks_error"),
518
+ commit_status_count: integer("commit_status_count"),
519
+ statuses_error: integer("statuses_error"),
520
+ review_count: integer("review_count"),
521
+ reviews_error: integer("reviews_error"),
522
+ },
523
+ (t) => [primaryKey({ columns: [t.repo_id, t.pr_number] })],
524
+ );