@catalyst-cloud/schema 0.1.10 → 0.1.12

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.10",
3
+ "version": "0.1.12",
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",
@@ -30,7 +30,7 @@
30
30
  "drizzle-orm": "0.44.7"
31
31
  },
32
32
  "devDependencies": {
33
- "@catalyst-cloud/typescript-config": "0.0.0",
33
+ "@catalyst-cloud/typescript-config": "workspace:*",
34
34
  "@types/node": "^25.9.3",
35
35
  "drizzle-kit": "^0.31.0",
36
36
  "prettier": "^3.8.4",
package/src/index.ts CHANGED
@@ -34,6 +34,12 @@ import {
34
34
  agent_activities,
35
35
  fleet_activity_pruned,
36
36
  pull_sweeps,
37
+ pr_review_backfill,
38
+ pr_review_comments,
39
+ pr_conversation_comments,
40
+ pr_commits,
41
+ pr_events,
42
+ pr_ancillary_backfill,
37
43
  } from "./mirror.js";
38
44
 
39
45
  export * from "./mirror.js";
@@ -86,6 +92,9 @@ export const mirrorSchema = {
86
92
  // migration bundle must produce) but DELIBERATELY ABSENT from `mirrorEntityTables` below — see the
87
93
  // no-feed arithmetic on the table definition in ./mirror.ts.
88
94
  pull_sweeps,
95
+ // CTC-575: the closed/merged-PR review-body backfill marker. Same posture as pull_sweeps — a real
96
+ // table, deliberately absent from mirrorEntityTables (see the doc on the table itself).
97
+ pr_review_backfill,
89
98
  // CTC-393: the fleet_activity anti-resurrection tombstone. Same posture as pull_sweeps — a real
90
99
  // table the DO creates, but pure internal bookkeeping that must NEVER ride the change-feed (a
91
100
  // consumer has no use for "a row was once terminal here"; see the doc on the table itself).
@@ -93,6 +102,14 @@ export const mirrorSchema = {
93
102
  agent_sessions,
94
103
  agent_activities,
95
104
  fleet_activity_pruned,
105
+ // CTC-575/577: inline review comments, PR conversation comments, PR commits, PR timeline events —
106
+ // all four ARE change-feed entities (see mirrorEntityTables below). The fifth new table,
107
+ // pr_ancillary_backfill, is hub-read-only like pr_review_backfill (see its own doc).
108
+ pr_review_comments,
109
+ pr_conversation_comments,
110
+ pr_commits,
111
+ pr_events,
112
+ pr_ancillary_backfill,
96
113
  } as const;
97
114
 
98
115
  /**
@@ -132,6 +149,11 @@ export const mirrorEntityTables = {
132
149
  // CTC-524: agent sessions + their activities.
133
150
  agent_sessions,
134
151
  agent_activities,
152
+ // CTC-575/577: inline review comments, PR conversation comments, PR commits, PR timeline events.
153
+ pr_review_comments,
154
+ pr_conversation_comments,
155
+ pr_commits,
156
+ pr_events,
135
157
  } as const;
136
158
 
137
159
  /** The wire `entity` name of a change-feed-carried table (keys of mirrorEntityTables). */
@@ -151,6 +151,20 @@ export const MIRROR_MIGRATIONS = {
151
151
  tag: "0019_mushy_sinister_six",
152
152
  breakpoints: true,
153
153
  },
154
+ {
155
+ idx: 20,
156
+ version: "6",
157
+ when: 1786856395245,
158
+ tag: "0020_wealthy_snowbird",
159
+ breakpoints: true,
160
+ },
161
+ {
162
+ idx: 21,
163
+ version: "6",
164
+ when: 1786857732587,
165
+ tag: "0021_worried_annihilus",
166
+ breakpoints: true,
167
+ },
154
168
  ],
155
169
  },
156
170
  migrations: {
@@ -192,5 +206,9 @@ export const MIRROR_MIGRATIONS = {
192
206
  "CREATE TABLE `agent_activities` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`session_id` text NOT NULL,\n\t`type` text,\n\t`body` text,\n\t`signal` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_agent_activities_session_created` ON `agent_activities` (`session_id`,`created_at`);--> statement-breakpoint\nCREATE TABLE `agent_sessions` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`issue_id` text,\n\t`status` text,\n\t`app_user_id` text,\n\t`creator_id` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`removed_at` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_agent_sessions_issue` ON `agent_sessions` (`issue_id`,`updated_at`);",
193
207
  "0019_mushy_sinister_six":
194
208
  "ALTER TABLE `agent_sessions` ADD `archived_at` integer;--> statement-breakpoint\nALTER TABLE `agent_sessions` ADD `dismissed_at` integer;--> statement-breakpoint\nALTER TABLE `agent_sessions` ADD `dismissed_by` text;",
209
+ "0020_wealthy_snowbird":
210
+ "CREATE TABLE `pr_review_backfill` (\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`backfilled_at` integer NOT NULL,\n\tPRIMARY KEY(`repo_id`, `pr_number`)\n);\n--> statement-breakpoint\nALTER TABLE `reviews` ADD `body` text;",
211
+ "0021_worried_annihilus":
212
+ "CREATE TABLE `pr_ancillary_backfill` (\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`backfilled_at` integer NOT NULL,\n\tPRIMARY KEY(`repo_id`, `pr_number`)\n);\n--> statement-breakpoint\nCREATE TABLE `pr_commits` (\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`sha` text NOT NULL,\n\t`message` text,\n\t`author_login` text,\n\t`author_name` text,\n\t`author_email` text,\n\t`authored_at` integer,\n\t`committed_at` integer,\n\tPRIMARY KEY(`repo_id`, `pr_number`, `sha`)\n);\n--> statement-breakpoint\nCREATE TABLE `pr_conversation_comments` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`author_id` text,\n\t`body` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`removed_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_pr_conversation_comments_pr` ON `pr_conversation_comments` (`repo_id`,`pr_number`);--> statement-breakpoint\nCREATE TABLE `pr_events` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`action` text,\n\t`actor_id` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_pr_events_pr` ON `pr_events` (`repo_id`,`pr_number`,`created_at`);--> statement-breakpoint\nCREATE TABLE `pr_review_comments` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`review_id` text,\n\t`commit_id` text,\n\t`path` text,\n\t`line` integer,\n\t`diff_hunk` text,\n\t`in_reply_to_id` text,\n\t`author_id` text,\n\t`body` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`removed_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_pr_review_comments_pr` ON `pr_review_comments` (`repo_id`,`pr_number`);",
195
213
  },
196
214
  } as const;
package/src/mirror.ts CHANGED
@@ -438,8 +438,143 @@ export const reviews = sqliteTable("reviews", {
438
438
  user_id: text("user_id"),
439
439
  state: text("state"),
440
440
  submitted_at: integer("submitted_at"),
441
+ // CTC-575: the review's text. GitHub always sends it; it was destructured away at the type boundary
442
+ // in three places (github/queries.ts, normalize/github.ts ×2) before this. ⛔ `reviews` has NO
443
+ // updated_at, so upsertRow's last-write-wins guard is inert here — the reconcile's hand-rolled
444
+ // read-before-emit (github-reconcile.ts) is the ONLY thing that decides whether an edited body
445
+ // re-emits a change_log delta, and it must compare `body` too or an edit never propagates.
446
+ body: text("body"),
441
447
  });
442
448
 
449
+ /**
450
+ * CTC-575 — inline PR review comments (the diff-attached kind, `GET /pulls/{n}/comments` +
451
+ * the `pull_request_review_comment` webhook). Distinct from {@link pr_conversation_comments} below —
452
+ * GitHub itself models these as two different REST resources and two different webhook events; this
453
+ * table only ever holds the diff-anchored kind (`path`/`line`/`diff_hunk` are meaningful here and
454
+ * absent on a conversation comment).
455
+ *
456
+ * `updated_at` IS present (unlike `reviews`) — GitHub bumps it on an edit, so the ordinary
457
+ * `upsertRow` last-write-wins guard is live here; no hand-rolled read-before-emit needed.
458
+ * Soft-delete (`removed_at`): the webhook sends `action: "deleted"` for a genuinely deleted comment.
459
+ */
460
+ export const pr_review_comments = sqliteTable(
461
+ "pr_review_comments",
462
+ {
463
+ id: text("id").primaryKey(),
464
+ repo_id: text("repo_id").notNull(),
465
+ pr_number: integer("pr_number").notNull(),
466
+ // The review this comment belongs to (GitHub's pull_request_review_id) — FK → reviews.review_id.
467
+ // Nullable: GitHub's own field is optional on some legacy/edge payloads.
468
+ review_id: text("review_id"),
469
+ commit_id: text("commit_id"),
470
+ path: text("path"),
471
+ line: integer("line"),
472
+ diff_hunk: text("diff_hunk"),
473
+ // Threading: the parent comment id when this is a reply. Nullable — most comments are top-level.
474
+ in_reply_to_id: text("in_reply_to_id"),
475
+ // The resolvable `github:<login>` users key (CTC-USERS), same convention as reviews.user_id.
476
+ author_id: text("author_id"),
477
+ body: text("body"),
478
+ created_at: integer("created_at"),
479
+ updated_at: integer("updated_at"),
480
+ removed_at: integer("removed_at"),
481
+ },
482
+ (t) => [index("idx_pr_review_comments_pr").on(t.repo_id, t.pr_number)],
483
+ );
484
+
485
+ /**
486
+ * CTC-577 — PR conversation-tab comments (`GET /issues/{n}/comments` + the `issue_comment` webhook,
487
+ * filtered to deliveries whose `issue.pull_request` is present — GitHub fires `issue_comment` for
488
+ * BOTH a plain repo issue comment and a PR conversation comment, and does not distinguish them at the
489
+ * event-type level; the payload's `issue.pull_request` field is the only signal).
490
+ *
491
+ * ⚠️ DELIBERATELY A SEPARATE TABLE FROM LINEAR'S `comments`. `comments` is keyed `issue_id` for
492
+ * LINEAR issues, with no `source`/`repo_id`/`pr_number` column — reusing it would have been a real
493
+ * schema change (a new discriminator + nullable Linear-only columns going unused on every GitHub row),
494
+ * not a free ride. A separate table makes "a Linear comment and a PR comment are never confused" true
495
+ * by construction: there is no shared row space for them to collide in.
496
+ */
497
+ export const pr_conversation_comments = sqliteTable(
498
+ "pr_conversation_comments",
499
+ {
500
+ id: text("id").primaryKey(),
501
+ repo_id: text("repo_id").notNull(),
502
+ pr_number: integer("pr_number").notNull(),
503
+ author_id: text("author_id"),
504
+ body: text("body"),
505
+ created_at: integer("created_at"),
506
+ updated_at: integer("updated_at"),
507
+ removed_at: integer("removed_at"),
508
+ },
509
+ (t) => [index("idx_pr_conversation_comments_pr").on(t.repo_id, t.pr_number)],
510
+ );
511
+
512
+ /**
513
+ * CTC-575 — commits on a PR (`GET /pulls/{n}/commits`). The mirror previously stored only the scalar
514
+ * `pull_requests.head_sha`; this is the full commit list. Composite PK (repo_id, pr_number, sha): the
515
+ * SAME commit sha can legitimately appear under more than one PR (e.g. a shared base branch, or a
516
+ * commit cherry-picked into a second PR), so the PR is part of the identity, not just the commit.
517
+ *
518
+ * NO `updated_at` and NO `removed_at`, deliberately: a git commit's content is immutable once
519
+ * authored — there is no legitimate "edit" of a stored row, only a fresh INSERT for a sha not seen
520
+ * before. (A force-push that drops a commit from the PR's list leaves a stale row here, same posture
521
+ * as `check_runs`/`commit_statuses` never retracting a run/status GitHub stops reporting.)
522
+ */
523
+ export const pr_commits = sqliteTable(
524
+ "pr_commits",
525
+ {
526
+ repo_id: text("repo_id").notNull(),
527
+ pr_number: integer("pr_number").notNull(),
528
+ sha: text("sha").notNull(),
529
+ message: text("message"),
530
+ // The resolvable `github:<login>` users key when GitHub matched the commit author to an account;
531
+ // null for an unmatched/external author (common on a rebased or externally-authored commit).
532
+ author_login: text("author_login"),
533
+ // The raw git author name/email — kept even when author_login is null, so the commit is never
534
+ // author-less in the UI just because GitHub couldn't match it to an account.
535
+ author_name: text("author_name"),
536
+ author_email: text("author_email"),
537
+ authored_at: integer("authored_at"),
538
+ committed_at: integer("committed_at"),
539
+ },
540
+ (t) => [primaryKey({ columns: [t.repo_id, t.pr_number, t.sha] })],
541
+ );
542
+
543
+ /**
544
+ * CTC-575 — a PR's timeline events (immutable, `issue_history`-shaped), capturing the `action` field
545
+ * that every `pull_request` webhook already carries and that normalizePullRequest has always
546
+ * discarded. Curated to the FIVE actions the ticket names — ready_for_review, merged, closed,
547
+ * reopened, review_requested — not every `pull_request` action GitHub sends: `synchronize` fires on
548
+ * every push and `labeled`/`unlabeled`/`edited` etc. are not "lifecycle" events, so capturing every
549
+ * action verbatim would flood this table with noise the ticket never asked for. `merged` is OUR OWN
550
+ * vocabulary layered on GitHub's `action: "closed"` + `pull_request.merged: true` — GitHub has no
551
+ * separate "merged" action.
552
+ *
553
+ * ⛔ WEBHOOK-ONLY, NO BACKFILL. Unlike the three tables above, there is no REST endpoint that
554
+ * reconstructs this history for a PR predating the webhook subscription — GitHub's PR object carries
555
+ * no event log, only the current state. A PR's timeline before this ships is simply not recoverable.
556
+ *
557
+ * `id` is a SYNTHETIC key (repo_id:pr_number:action:updated_at ms) — GitHub's `pull_request` webhook
558
+ * carries no event-level id the way Linear's IssueHistory nodes do. HARD upsert (no removed_at, like
559
+ * issue_history) — an event, once recorded, never changes or vanishes.
560
+ */
561
+ export const pr_events = sqliteTable(
562
+ "pr_events",
563
+ {
564
+ id: text("id").primaryKey(),
565
+ repo_id: text("repo_id").notNull(),
566
+ pr_number: integer("pr_number").notNull(),
567
+ // ready_for_review | merged | closed | reopened | review_requested (see the curation note above).
568
+ action: text("action"),
569
+ // Who triggered it (the webhook's top-level `sender`) — the resolvable `github:<login>` key.
570
+ actor_id: text("actor_id"),
571
+ created_at: integer("created_at"), // event time (ms epoch) — the timeline sort key
572
+ updated_at: integer("updated_at"), // = created_at (immutable) — drives the upsert guard
573
+ raw: text("raw"), // JSON.stringify of the relevant webhook fields — full fidelity / forward-compat
574
+ },
575
+ (t) => [index("idx_pr_events_pr").on(t.repo_id, t.pr_number, t.created_at)],
576
+ );
577
+
443
578
  // ── Mirror infra (not domain entities — never ride the change-feed) ─────────────────────────────────
444
579
 
445
580
  export const processed_events = sqliteTable("processed_events", {
@@ -621,3 +756,71 @@ export const pull_sweeps = sqliteTable(
621
756
  },
622
757
  (t) => [primaryKey({ columns: [t.repo_id, t.pr_number] })],
623
758
  );
759
+
760
+ /**
761
+ * CTC-575 — a marker that a CLOSED/MERGED PR's reviews have been swept AT LEAST ONCE for `body`.
762
+ *
763
+ * ⛔ WHY THIS EXISTS. `reconcileGithub`'s open-PR walk sweeps reviews only for PRs whose `state` is
764
+ * still `open` — a closed/merged PR's reviews are frozen at whatever the last webhook or open-PR sweep
765
+ * wrote, which for every review that predates this ticket is `body: null` (the column did not exist).
766
+ * Nothing else ever revisits a closed PR, so without a dedicated backfill leg every historical review
767
+ * body on every already-closed PR would stay null forever.
768
+ *
769
+ * ⛔ WHY A MARKER PER PR, NOT A SINGLE RESUME CURSOR. PR numbers are monotonic by CREATION order, not
770
+ * by CLOSE order — PR #5 can close well after PR #50 already did. A single "highest number backfilled"
771
+ * watermark (the `resumeEntity`/`mergeableEntity` pattern above) would permanently skip a low-numbered
772
+ * PR that closes late, once the watermark has already passed its number. A per-(repo_id, pr_number)
773
+ * marker is checked independently, so a PR is swept exactly once, whenever it happens to close.
774
+ *
775
+ * ⛔ DELIBERATELY NOT `pull_sweeps`. That table's three-state decode (no row / SHA-mismatch / count-or-
776
+ * error) is a documented invariant for the OPEN-PR CI-enrichment receipt (check_runs/commit_statuses),
777
+ * and this backfill sweeps reviews ONLY for a closed PR — writing a `pull_sweeps` row with
778
+ * check_run_count/commit_status_count left null and no *_error set would forge a fourth, undocumented
779
+ * state indistinguishable from "the checks fetch failed". A dedicated table keeps the two receipts'
780
+ * invariants independently true.
781
+ *
782
+ * ⛔ HUB-READ-ONLY, same posture as `pull_sweeps` / `fleet_activity_pruned`: never rides the change
783
+ * feed (absent from mirrorEntityTables / SNAPSHOT_TABLES / GITHUB_ENTITIES, no appendChange) — a
784
+ * consumer has no use for "we once swept this PR's reviews", and self-limiting at |closed PRs in the
785
+ * repo| (one row per PR, written once, never revisited).
786
+ */
787
+ export const pr_review_backfill = sqliteTable(
788
+ "pr_review_backfill",
789
+ {
790
+ repo_id: text("repo_id").notNull(),
791
+ pr_number: integer("pr_number").notNull(),
792
+ /** When the backfill swept this PR's reviews (ms). */
793
+ backfilled_at: integer("backfilled_at").notNull(),
794
+ },
795
+ (t) => [primaryKey({ columns: [t.repo_id, t.pr_number] })],
796
+ );
797
+
798
+ /**
799
+ * CTC-575/577 — a marker that a PR's ANCILLARY GitHub entities (review comments, commits,
800
+ * conversation comments) have been fetched at least once. Unlike {@link pr_review_backfill} (which
801
+ * tracks a NARROWER re-fetch of already-existing review rows for their new `body` column), this
802
+ * marker covers three BRAND-NEW tables that start with zero rows for every PR, open or closed — so
803
+ * the ancillary backfill walks the WHOLE PR list (not just closed ones), gated per-PR by this marker
804
+ * so a PR already caught up is never re-swept.
805
+ *
806
+ * ⚠️ DOES NOT COVER `pr_events` — that table is webhook-only (see its own doc: no REST endpoint can
807
+ * reconstruct PR timeline history), so there is nothing for a backfill to fetch.
808
+ *
809
+ * ⚠️ `pr_commits` HAS NO WEBHOOK, so a PR's commit list is only ever as fresh as its last backfill —
810
+ * an open PR that receives new commits AFTER being marked backfilled does not get them until this
811
+ * marker is cleared. Documented trade-off (the ticket asks for a backfill, not continuous freshness);
812
+ * a future ticket that wants `pr_commits` live on open PRs should sweep it in step 2 as a peer of
813
+ * checks/statuses/reviews.
814
+ *
815
+ * ⛔ HUB-READ-ONLY, same posture as `pr_review_backfill` / `pull_sweeps`: never rides the change feed.
816
+ */
817
+ export const pr_ancillary_backfill = sqliteTable(
818
+ "pr_ancillary_backfill",
819
+ {
820
+ repo_id: text("repo_id").notNull(),
821
+ pr_number: integer("pr_number").notNull(),
822
+ /** When the backfill swept this PR's review comments + commits + conversation comments (ms). */
823
+ backfilled_at: integer("backfilled_at").notNull(),
824
+ },
825
+ (t) => [primaryKey({ columns: [t.repo_id, t.pr_number] })],
826
+ );