@catalyst-cloud/schema 0.1.15 → 0.1.17

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.15",
3
+ "version": "0.1.17",
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
@@ -44,6 +44,12 @@ import {
44
44
  pr_events,
45
45
  pr_ancillary_backfill,
46
46
  push_subscriptions,
47
+ pushes,
48
+ push_events,
49
+ deployments,
50
+ deployment_statuses,
51
+ pr_review_threads,
52
+ check_suites,
47
53
  } from "./mirror.js";
48
54
 
49
55
  export * from "./mirror.js";
@@ -117,13 +123,33 @@ export const mirrorSchema = {
117
123
  pr_commits,
118
124
  pr_events,
119
125
  pr_ancillary_backfill,
120
- // CTC-619: a member's Web Push subscription. Rides the change feed like any other entity — a
121
- // member-facing settings UI can reflect subscribe/unsubscribe reactively.
126
+ // CTC-619: a member's Web Push subscription. ⚠️ Whether it rides the change feed is DISPUTED —
127
+ // see mirrorEntityTables below and CTC-671.
122
128
  push_subscriptions,
129
+ // CTC-667: the current head of a pushed git ref — the orchestrator's rebase-detection signal.
130
+ pushes,
131
+ // CTC-704: one row per push DELIVERY, append-only next to the per-ref row above — what lets the
132
+ // feed emit an edge per push instead of collapsing two pushes to one ref into one.
133
+ push_events,
134
+ // CTC-667: a GitHub deployment, its status transitions, and a PR review thread's resolution state
135
+ // — the remaining payload types host dispatch consumes (CTL-1929's last smee tunnel).
136
+ deployments,
137
+ deployment_statuses,
138
+ pr_review_threads,
139
+ // CTC-667 item 4: the check SUITE's own rollup — the highest-volume signal in CTL's census and the
140
+ // one every phase agent's CI wait blocks on.
141
+ check_suites,
123
142
  } as const;
124
143
 
125
144
  /**
126
- * The domain-entity tables that flow over the change-feed (the keys are the wire `entity` names). The
145
+ * ⭐ CTC-671 — THE SINGLE DEFINITION OF CHANGE-FEED MEMBERSHIP. The keys are the wire `entity`
146
+ * names, and everything that answers "is this row safe for every reader of the account's feed"
147
+ * derives from this object: `FEED_ENTITY_NAMES` below, `@catalyst-cloud/types`' `EntityName`,
148
+ * `SNAPSHOT_TABLES`, and the SDK's published `ENTITY_NAMES`. A table that is mirrored but must NEVER
149
+ * be broadcast account-wide goes in `nonFeedEntityTables` instead — see its doc for the reason that
150
+ * distinction exists at all.
151
+ *
152
+ * The
127
153
  * infra tables — processed_events / change_log / event_log / sync_cursors — are deliberately excluded:
128
154
  * they never ride a delta.
129
155
  *
@@ -137,7 +163,7 @@ export const mirrorSchema = {
137
163
  * into a 200k/7-day ring — the ring would hold under a day and every replica cursor would fall off
138
164
  * the end. The arithmetic and the rest of the posture live on the table definition in ./mirror.ts.
139
165
  */
140
- export const mirrorEntityTables = {
166
+ export const feedEntityTables = {
141
167
  issues,
142
168
  labels,
143
169
  users,
@@ -168,10 +194,96 @@ export const mirrorEntityTables = {
168
194
  pr_conversation_comments,
169
195
  pr_commits,
170
196
  pr_events,
171
- // CTC-619: a member's Web Push subscription.
197
+ // CTC-667: the current head of a pushed git ref (one row per repo+ref) — the orchestrator's
198
+ // rebase-detection signal, and the first of the four payload types CTL's dispatch consumes that
199
+ // the mirror did not carry (CTL-1929's last smee tunnel).
200
+ pushes,
201
+ // CTC-704: the per-DELIVERY push row. ⛔ A feed entity precisely because `pushes` cannot be one
202
+ // faithfully — a producer emits one edge per row, so a per-ref row caps the feed at one edge per
203
+ // ref per tick (CTL-48: 101 of 138 unmatched smee events were pushes).
204
+ push_events,
205
+ // CTC-667: a GitHub deployment and its status history — what `phase-monitor-deploy` and the deploy
206
+ // state machine key on (`environment`, `state`, `target_url`, `environment_url`).
207
+ deployments,
208
+ deployment_statuses,
209
+ // CTC-667: a PR review thread's RESOLUTION state — the merge gate AGENTS.md names, and the one
210
+ // fact `pr_review_comments` cannot express (it has no resolution column).
211
+ pr_review_threads,
212
+ // CTC-667 item 4: GitHub's OWN check-suite rollup. ⛔ The row is contractual; a derivation from
213
+ // `check_runs` is NOT — see the table's own header for the two reasons it cannot agree with GitHub.
214
+ check_suites,
215
+ } as const;
216
+
217
+ /**
218
+ * CTC-671 — mirrored tables that must NEVER ride the ACCOUNT-WIDE change feed.
219
+ *
220
+ * ⛔ THIS OBJECT IS THE EXCLUSION, AND IT IS STRUCTURAL RATHER THAN SUBTRACTIVE ON PURPOSE. A table
221
+ * listed here is a real mirror table — the DO creates it, migrations produce it, `MIRROR_TABLE_META`
222
+ * carries it (so `@catalyst-cloud/replicate`'s `knownColumns` forward-compat path is unchanged) — but
223
+ * it is not a member of `feedEntityTables`, so no list derived from that object can contain it. There
224
+ * is no filter to forget and no exclusion array to quietly drop: to put a table on the feed you must
225
+ * type its name into `feedEntityTables`, which is a deliberate act.
226
+ *
227
+ * ⭐ WHY, in one case's words. `push_subscriptions` rows are `endpoint` + `p256dh` + `auth` — together
228
+ * the complete capability to push arbitrary notifications to that person's browser. CTC-641 found
229
+ * them being appended to `change_log` and broadcast account-wide to every host replica and every
230
+ * OTHER member's browser OPFS DB. It fixed the leak by narrowing `MirrorDO.appendAndBroadcast` to
231
+ * `@catalyst-cloud/types`' `EntityName` — but left this table inside `mirrorEntityTables`, next to a
232
+ * comment asserting it "rides the change feed like any other entity". The repo then stated two
233
+ * incompatible things about one table and the MACHINE-READABLE one was the wrong one, so the SDK had
234
+ * to carry a hand-written exclusion list to avoid republishing it (CTC-643 / sdk#24). CTC-671 is that
235
+ * second list being deleted: feed membership now has exactly one definition, `feedEntityTables`.
236
+ *
237
+ * ⚠️ A table belongs here, not merely out of `mirrorEntityTables`, when it must still REPLICATE to
238
+ * the surfaces that legitimately hold it while never being broadcast account-wide. Bookkeeping that
239
+ * no consumer should ever see (`pull_sweeps`, `pr_review_backfill`, `fleet_activity_pruned`) stays
240
+ * out of BOTH objects — see their own table docs.
241
+ */
242
+ export const nonFeedEntityTables = {
172
243
  push_subscriptions,
173
244
  } as const;
174
245
 
246
+ /**
247
+ * Every mirrored ENTITY table — the feed-safe ones plus the deliberately-excluded ones.
248
+ *
249
+ * ⚠️ THIS IS NOT THE FEED LIST. It exists so `MIRROR_TABLE_META` keeps describing every replicable
250
+ * table (PKs, soft-delete posture, and the `columns` set that `@catalyst-cloud/replicate` uses as its
251
+ * forward-compat `knownColumns` default). Deriving "safe for every reader of the account feed" from
252
+ * THIS object is the CTC-671 defect; derive it from `feedEntityTables` / `FEED_ENTITY_NAMES`.
253
+ */
254
+ export const mirrorEntityTables = {
255
+ ...feedEntityTables,
256
+ ...nonFeedEntityTables,
257
+ } as const;
258
+
259
+ /**
260
+ * ⭐ CTC-671 — THE ONE LIST THAT GOVERNS FEED MEMBERSHIP, as a runtime array.
261
+ *
262
+ * `@catalyst-cloud/types`' `EntityName` is a type alias of `FeedEntityName`, `SNAPSHOT_TABLES` is
263
+ * typed `EntityName`, and `@catalyst-cloud/sdk`'s published `ENTITY_NAMES` is pinned to this array by
264
+ * `test/entity-names-drift.test.ts`. Add a table to `feedEntityTables` and all three follow; leave it
265
+ * out and none of them can name it.
266
+ */
267
+ export const FEED_ENTITY_NAMES: readonly FeedEntityName[] = keysOf(feedEntityTables);
268
+
269
+ /** The excluded set as a runtime array — so a test can assert the exclusion still DOES something. */
270
+ export const NON_FEED_ENTITY_NAMES: readonly NonFeedEntityName[] = keysOf(nonFeedEntityTables);
271
+
272
+ /**
273
+ * `Object.keys` with the key type preserved. TypeScript types `Object.keys` as `string[]` because an
274
+ * object may carry extra keys at runtime; these two objects are `as const` literals declared in this
275
+ * file, so the narrowing is sound. Contained here so it is the only such assertion in the module.
276
+ */
277
+ function keysOf<T extends object>(o: T): (keyof T & string)[] {
278
+ return Object.keys(o) as (keyof T & string)[];
279
+ }
280
+
281
+ /** The wire `entity` name of a FEED-CARRIED table (keys of feedEntityTables) — the contract. */
282
+ export type FeedEntityName = keyof typeof feedEntityTables;
283
+
284
+ /** The name of a mirrored-but-never-broadcast table (keys of nonFeedEntityTables). */
285
+ export type NonFeedEntityName = keyof typeof nonFeedEntityTables;
286
+
175
287
  /** The wire `entity` name of a change-feed-carried table (keys of mirrorEntityTables). */
176
288
  export type MirrorEntityName = keyof typeof mirrorEntityTables;
177
289
 
@@ -186,6 +186,27 @@ export const MIRROR_MIGRATIONS = {
186
186
  tag: "0024_long_spencer_smythe",
187
187
  breakpoints: true,
188
188
  },
189
+ {
190
+ idx: 25,
191
+ version: "6",
192
+ when: 1787013352521,
193
+ tag: "0025_outgoing_morgan_stark",
194
+ breakpoints: true,
195
+ },
196
+ {
197
+ idx: 26,
198
+ version: "6",
199
+ when: 1787014505223,
200
+ tag: "0026_curvy_micromacro",
201
+ breakpoints: true,
202
+ },
203
+ {
204
+ idx: 27,
205
+ version: "6",
206
+ when: 1787034085255,
207
+ tag: "0027_closed_miek",
208
+ breakpoints: true,
209
+ },
189
210
  ],
190
211
  },
191
212
  migrations: {
@@ -237,5 +258,11 @@ export const MIRROR_MIGRATIONS = {
237
258
  "CREATE TABLE `push_subscriptions` (\n\t`endpoint` text PRIMARY KEY NOT NULL,\n\t`member_id` text NOT NULL,\n\t`p256dh` text NOT NULL,\n\t`auth` text NOT NULL,\n\t`created_at` integer,\n\t`last_used_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_push_subscriptions_member` ON `push_subscriptions` (`member_id`);",
238
259
  "0024_long_spencer_smythe":
239
260
  "CREATE TABLE `team_workflow_mapping` (\n\t`team_id` text NOT NULL,\n\t`slot` text NOT NULL,\n\t`linear_state_id` text NOT NULL,\n\t`linear_state_name` text,\n\t`linear_state_type` text,\n\t`workflow_rev` integer,\n\t`updated_at` integer,\n\t`removed_at` integer,\n\tPRIMARY KEY(`team_id`, `slot`)\n);\n",
261
+ "0025_outgoing_morgan_stark":
262
+ "CREATE TABLE `pushes` (\n\t`repo_id` text NOT NULL,\n\t`ref` text NOT NULL,\n\t`before` text,\n\t`after` text,\n\t`forced` integer,\n\t`created` integer,\n\t`deleted` integer,\n\t`base_ref` text,\n\t`pusher_id` text,\n\t`head_commit_sha` text,\n\t`updated_at` integer,\n\tPRIMARY KEY(`repo_id`, `ref`)\n);\n",
263
+ "0026_curvy_micromacro":
264
+ "CREATE TABLE `deployment_statuses` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`deployment_id` text NOT NULL,\n\t`state` text,\n\t`environment` text,\n\t`target_url` text,\n\t`environment_url` text,\n\t`description` text,\n\t`creator_id` text,\n\t`created_at` integer,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_deployment_statuses_deployment` ON `deployment_statuses` (`deployment_id`,`created_at`);--> statement-breakpoint\nCREATE TABLE `deployments` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`ref` text,\n\t`sha` text,\n\t`task` text,\n\t`environment` text,\n\t`production_environment` integer,\n\t`transient_environment` integer,\n\t`description` text,\n\t`creator_id` text,\n\t`created_at` integer,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_deployments_repo_env` ON `deployments` (`repo_id`,`environment`,`created_at`);--> statement-breakpoint\nCREATE TABLE `pr_review_threads` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`resolved` integer,\n\t`resolved_at` integer,\n\t`resolver_id` text,\n\t`first_comment_id` text,\n\t`comment_count` integer,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_pr_review_threads_pr` ON `pr_review_threads` (`repo_id`,`pr_number`);",
265
+ "0027_closed_miek":
266
+ "CREATE TABLE `check_suites` (\n\t`repo_id` text NOT NULL,\n\t`check_suite_id` text PRIMARY KEY NOT NULL,\n\t`head_sha` text,\n\t`head_branch` text,\n\t`status` text,\n\t`conclusion` text,\n\t`app_slug` text,\n\t`latest_check_runs_count` integer,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_check_suites_sha` ON `check_suites` (`head_sha`);--> statement-breakpoint\nCREATE TABLE `push_events` (\n\t`delivery_id` text PRIMARY KEY NOT NULL,\n\t`repo_id` text NOT NULL,\n\t`ref` text NOT NULL,\n\t`before` text,\n\t`after` text,\n\t`forced` integer,\n\t`created` integer,\n\t`deleted` integer,\n\t`base_ref` text,\n\t`pusher_id` text,\n\t`head_commit_sha` text,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `idx_push_events_repo_ref` ON `push_events` (`repo_id`,`ref`,`updated_at`);--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `merge_commit_sha` text;",
240
267
  },
241
268
  } as const;
package/src/mirror.ts CHANGED
@@ -465,6 +465,11 @@ export const pull_requests = sqliteTable(
465
465
  // is gated at READ time (a false-positive parse never surfaces because no issue matches it).
466
466
  head_ref: text("head_ref"),
467
467
  linear_issue_identifier: text("linear_issue_identifier"),
468
+ // CTC-691: the merge commit SHA GitHub assigns when a PR merges — the join key the host uses
469
+ // to match a github.pr.merged event to its deployment (setFilterStateMerged). Nullable: GitHub
470
+ // also sends a throwaway test-merge SHA on UNMERGED PRs, and pre-backfill rows lack it; stored
471
+ // verbatim because the host discriminates on `merged`, not on SHA presence.
472
+ merge_commit_sha: text("merge_commit_sha"),
468
473
  },
469
474
  (t) => [
470
475
  primaryKey({ columns: [t.repo_id, t.number] }),
@@ -647,6 +652,353 @@ export const pr_events = sqliteTable(
647
652
  (t) => [index("idx_pr_events_pr").on(t.repo_id, t.pr_number, t.created_at)],
648
653
  );
649
654
 
655
+ /**
656
+ * CTC-667 — the CURRENT head of a pushed git ref, one row per `(repo_id, ref)`.
657
+ *
658
+ * ⛔ WHY THIS EXISTS. `github.push` is the orchestrator's REBASE-DETECTION signal (broker/router.mjs
659
+ * :1582) and the mirror did not ingest it at all, so the last GitHub smee tunnel cannot be retired
660
+ * without a host going blind on branch movement (CTL-1929). Live volume on mini-2 for 2026-08: 3,746
661
+ * push events.
662
+ *
663
+ * ⭐ CURRENT STATE, NOT AN EVENT LOG — the same modelling choice every other table here makes, and it
664
+ * is deliberate rather than lossy. Rebase detection asks "has this ref moved since I last looked",
665
+ * which a per-ref head answers exactly; the ORDERED sequence of pushes, for anyone who genuinely
666
+ * needs it, is already the raw passthrough feed's job (`event_log` / `/events/stream`, ADR-0017).
667
+ * Modelling pushes as rows-per-event would make this table unbounded on a busy repo for a question
668
+ * nobody in the census asks.
669
+ *
670
+ * `before`/`after` are kept even though `after` duplicates the ref head: the PAIR is what
671
+ * distinguishes a fast-forward from a force-push when read alongside `forced`, and a consumer that
672
+ * only ever sees the latest row would otherwise have to infer it.
673
+ *
674
+ * ⚠️ WEBHOOK-ONLY, NO BOOTSTRAP — a ref whose last push predates this rollout has NO row until its
675
+ * next push, and on a quiet branch that is never. Raised on #758 (Codex P2). Adding this table to
676
+ * `SNAPSHOT_TABLES` seeds a fresh replica from the DO, which is right and necessary, but it cannot
677
+ * invent rows the DO never received: an unpushed-since-rollout ref is currently indistinguishable
678
+ * from one that never existed — the exact ambiguity the `deleted = 1` rule below exists to avoid for
679
+ * the OTHER case. The fix is the same backfill as `pr_review_threads` above (CTC-677): seed from
680
+ * GitHub's current refs rather than only from future webhooks.
681
+ *
682
+ * ⚠️ `created` / `deleted` are the webhook's own booleans for branch creation and deletion. A DELETED
683
+ * ref keeps its row with `deleted = 1` rather than being removed: "this branch is gone" is a fact a
684
+ * consumer needs, and a vanished row is indistinguishable from one that never existed.
685
+ */
686
+ export const pushes = sqliteTable(
687
+ "pushes",
688
+ {
689
+ repo_id: text("repo_id").notNull(),
690
+ /** Full git ref as GitHub sends it, e.g. `refs/heads/main` — NOT shortened, so tags are unambiguous. */
691
+ ref: text("ref").notNull(),
692
+ /** The ref's head BEFORE this push (all-zero sha when the ref was just created). */
693
+ before: text("before"),
694
+ /** The ref's head AFTER this push (all-zero sha when the ref was deleted). */
695
+ after: text("after"),
696
+ /** True when the push was a force-push — the discriminator rebase detection actually keys on. */
697
+ forced: integer("forced"),
698
+ /** True when this push created the ref. */
699
+ created: integer("created"),
700
+ /** True when this push deleted the ref. */
701
+ deleted: integer("deleted"),
702
+ /** The ref this branch was created FROM, when GitHub reports one (`base_ref`); usually null. */
703
+ base_ref: text("base_ref"),
704
+ /** Who pushed — the resolvable `github:<login>` key, same convention as pr_events.actor_id. */
705
+ pusher_id: text("pusher_id"),
706
+ /** Head commit message/sha of the pushed head, when the payload carried a head_commit. */
707
+ head_commit_sha: text("head_commit_sha"),
708
+ updated_at: integer("updated_at"),
709
+ },
710
+ (t) => [primaryKey({ columns: [t.repo_id, t.ref] })],
711
+ );
712
+
713
+ /**
714
+ * CTC-704 — ONE ROW PER PUSH DELIVERY, append-only, alongside the per-ref `pushes` row above.
715
+ *
716
+ * ⛔ WHY THIS EXISTS, and it is a parity blocker rather than a nicety. `pushes` is keyed
717
+ * `(repo_id, ref)` and carries that ref's CURRENT head — deliberately, and that choice is still
718
+ * right for the question it answers. But a feed producer emits one edge per ROW, so it can never
719
+ * emit more than one edge per ref: **two pushes to the same ref between two producer ticks collapse
720
+ * to one edge**, and the second is unrecoverable from the mirror. CTL-48 measured the consequence
721
+ * against real traffic — **101 of 138** unmatched smee events were `github.push`, which makes GitHub
722
+ * parity structurally INCONCLUSIVE and the last smee tunnel un-retirable (CTL-1929).
723
+ *
724
+ * ⚠️ The 101 is an UPPER BOUND from a replay, not the live rate: a replay sees one row per ref, while
725
+ * live only pushes inside a single tick collapse. The number that matters is the shadow window's.
726
+ *
727
+ * ⭐ WHY A SECOND TABLE RATHER THAN RE-KEYING `pushes`. This is the same shape `deployment_statuses`
728
+ * takes next to `deployments`, and for the same stated reason: a consumer that acts on the ARRIVAL of
729
+ * a transition needs every arrival, while a consumer asking "where is this ref now" needs exactly one
730
+ * row. Re-keying `pushes` per push would serve the first and break the second — rebase detection
731
+ * (broker/router.mjs:1582) reads the per-ref head, `SNAPSHOT_TABLES` seeds it as CURRENT STATE, and
732
+ * both would then have to page an unbounded log to answer a one-row question. Splitting keeps
733
+ * ⭐ ref-latest EXACTLY derivable — it is the untouched `pushes` row — and costs one additive table.
734
+ *
735
+ * ⚠️ NOT SEEDED (absent from `SNAPSHOT_TABLES`), and that is the deliberate half of the trade. This
736
+ * table grows with every push — 3,746/month on mini-2 — and it is the unboundedness argument in
737
+ * `pushes`' own doc that makes it wrong to hand a fresh replica the entire history. A new host reads
738
+ * ref-latest from the seeded `pushes` row and per-push edges from its cursor forward, which is what a
739
+ * feed CONSUMER needs; nobody in the census asks a fresh replica for last month's push sequence.
740
+ *
741
+ * ⭐ THE PK IS THE WEBHOOK DELIVERY ID, which makes a redelivery idempotent for free. GitHub resends
742
+ * with the same `x-github-delivery`, and `processed_events.delivery_id` already drops a redelivery
743
+ * BEFORE the normalizer runs (MirrorDO.ts:2276) — so this table cannot double-count even if that
744
+ * upstream guard were bypassed. ⛔ A synthetic/random id would NOT have this property, and
745
+ * `(repo_id, ref, after)` would not either: the `pushes` doc records that two separate pushes can
746
+ * carry the SAME head sha (a revert-and-repush, a branch reset onto an existing sha), so keying on
747
+ * `after` silently collapses exactly the force-push case this table exists to preserve.
748
+ *
749
+ * ⚠️ WEBHOOK-ONLY, NO BACKFILL — like `pushes`, and here it is honest rather than a gap: an event log
750
+ * cannot invent deliveries that were never received. Parity is measured from the cutover forward.
751
+ */
752
+ export const push_events = sqliteTable(
753
+ "push_events",
754
+ {
755
+ /** GitHub's `x-github-delivery` for the push webhook — see the header on why this, not a uuid. */
756
+ delivery_id: text("delivery_id").primaryKey(),
757
+ repo_id: text("repo_id").notNull(),
758
+ /** Full git ref as GitHub sends it, e.g. `refs/heads/main` — NOT shortened, so tags are unambiguous. */
759
+ ref: text("ref").notNull(),
760
+ /** The ref's head BEFORE this push (all-zero sha when the ref was just created). */
761
+ before: text("before"),
762
+ /** The ref's head AFTER this push (all-zero sha when the ref was deleted). */
763
+ after: text("after"),
764
+ /** True when the push was a force-push — the discriminator rebase detection actually keys on. */
765
+ forced: integer("forced"),
766
+ /** True when this push created the ref. */
767
+ created: integer("created"),
768
+ /** True when this push deleted the ref. */
769
+ deleted: integer("deleted"),
770
+ /** The ref this branch was created FROM, when GitHub reports one (`base_ref`); usually null. */
771
+ base_ref: text("base_ref"),
772
+ /** Who pushed — the resolvable `github:<login>` key, same convention as pr_events.actor_id. */
773
+ pusher_id: text("pusher_id"),
774
+ /** Head commit sha of the pushed head, when the payload carried a head_commit. */
775
+ head_commit_sha: text("head_commit_sha"),
776
+ /**
777
+ * ⛔ DELIVERY TIME, not the head commit's timestamp — the same rule `pushes.updated_at` follows
778
+ * and for the same reason (a force-push to an older commit moves a commit clock BACKWARD). Here
779
+ * it is also the producer's ORDERING key, so it must be monotonic in arrival order.
780
+ */
781
+ updated_at: integer("updated_at"),
782
+ },
783
+ (t) => [index("idx_push_events_repo_ref").on(t.repo_id, t.ref, t.updated_at)],
784
+ );
785
+
786
+ /**
787
+ * CTC-667 item 4 — a GitHub CHECK SUITE's completion, one row per suite id.
788
+ *
789
+ * ⛔ WHY THIS EXISTS. `github.check_suite.completed` is the orchestrator's CI wait
790
+ * (broker/router.mjs:1497) and the HIGHEST-VOLUME signal in CTL's census — 20,400 events on mini-2 in
791
+ * 2026-08 — and the mirror stored NO row for it: `normalizeGithub`'s `check_suite` case was a
792
+ * deliberate `return []`. So the one thing every phase agent blocks on had nothing to read, and the
793
+ * last GitHub smee tunnel could not be retired.
794
+ *
795
+ * ⭐⭐ THE ANSWER TO CTL'S QUESTION — "a suite row or an agreed derivation? just say which is
796
+ * contractual" — IS **THIS ROW**, AND A DERIVATION FROM `check_runs` IS EXPLICITLY *NOT*
797
+ * CONTRACTUAL. GitHub computes the rollup itself, and its answer is not reproducible from the
798
+ * constituent runs without replicating rules we do not have:
799
+ *
800
+ * • ⛔ A DERIVATION MUST NOT BE "every run concluded `success`". `neutral` and `skipped` are
801
+ * NON-FAILING under GitHub's own rollup, and this fleet has real rows of both — check run
802
+ * `84696876797` is a genuine `neutral` (measured while fixing CTC-673). The naive rule reports a
803
+ * GREEN suite as not-green, which for a CI wait means a phase agent blocking forever on a build
804
+ * that already passed.
805
+ * • ⛔ REQUIRED vs NON-REQUIRED checks are a BRANCH-PROTECTION fact, not a check_runs fact. A suite
806
+ * can conclude `success` with a failing non-required run in it. Nothing in `check_runs` carries
807
+ * the required-ness, so no derivation over that table can agree with GitHub in general.
808
+ *
809
+ * Storing GitHub's own `conclusion` sidesteps both. ⚠️ IF a consumer ever must derive one anyway
810
+ * (e.g. for suites predating this table), the ONLY defensible rule is **"no run failed, and all runs
811
+ * completed"** — never "all concluded success". Written down here so the second definition CTL was
812
+ * right to fear does not get invented independently.
813
+ *
814
+ * ⭐ CURRENT STATE, one row per suite id, same as every table here. A suite's interesting movement is
815
+ * `status`/`conclusion` converging, and the ORDERED history is the raw passthrough feed's job
816
+ * (ADR-0017) — exactly the argument `pushes` above makes.
817
+ *
818
+ * ⚠️ `conclusion` IS MUTABLE AFTER COMPLETION and this is the CTC-673 trap in a new place: a re-run
819
+ * or a late correction changes `conclusion` while `updated_at` (derived from an EVENT time) can stay
820
+ * put. CTC-673 fixed the upsert guard to accept an equal-timestamp write whose CONTENT differs, which
821
+ * is what lets this row converge at all — do not re-narrow that guard.
822
+ *
823
+ * ⚠️ `app_slug` distinguishes suites from different CI providers on one SHA (GitHub Actions, CodeQL,
824
+ * a third-party app). A consumer that waits on "the" suite for a SHA without it would pick one
825
+ * arbitrarily and report the wrong verdict on any repo with two providers — which this one has.
826
+ */
827
+ export const check_suites = sqliteTable(
828
+ "check_suites",
829
+ {
830
+ repo_id: text("repo_id").notNull(),
831
+ /** GitHub's own check-suite id, as a string (same convention as check_runs.check_run_id). */
832
+ check_suite_id: text("check_suite_id").primaryKey(),
833
+ /** The commit this suite ran against — the join key every CI wait uses. */
834
+ head_sha: text("head_sha"),
835
+ /** The branch GitHub attributes the suite to, when it reports one. */
836
+ head_branch: text("head_branch"),
837
+ /** `queued` | `in_progress` | `completed` — a conclusion is only meaningful once completed. */
838
+ status: text("status"),
839
+ /** GitHub's OWN rollup: success | failure | neutral | cancelled | timed_out | action_required |
840
+ * stale | skipped | startup_failure, or null while the suite is still running. THE contract. */
841
+ conclusion: text("conclusion"),
842
+ /** Which app produced this suite — see the app_slug note in the header. */
843
+ app_slug: text("app_slug"),
844
+ /** GitHub's own count of the runs in the suite; useful for spotting a suite still filling up. */
845
+ latest_check_runs_count: integer("latest_check_runs_count"),
846
+ updated_at: integer("updated_at"),
847
+ },
848
+ (t) => [index("idx_check_suites_sha").on(t.head_sha)],
849
+ );
850
+
851
+ /**
852
+ * CTC-667 — a GitHub DEPLOYMENT, one row per deployment id.
853
+ *
854
+ * ⛔ WHY THIS EXISTS. `github.deployment.created` is consumed by the orchestrator's deploy state
855
+ * machine (broker/router.mjs:1446/:1566) and the mirror ingested it not at all, so the last GitHub
856
+ * smee tunnel cannot be retired without `phase-monitor-deploy` going blind. Live volume on mini-2 for
857
+ * 2026-08: 68 `deployment.created`.
858
+ *
859
+ * ⭐ CURRENT STATE, one row per deployment — a deployment object is mutable only in trivial ways
860
+ * (description/payload), and its interesting movement lives entirely in its STATUSES, which get their
861
+ * own table below. `environment` is the field every consumer keys on.
862
+ *
863
+ * ⚠️ `sha` and `ref` are both kept and they are NOT redundant: a deployment is created against a ref
864
+ * (`main`, a tag, a PR head) and pinned to the sha that ref resolved to at creation. A consumer
865
+ * correlating a deploy with a merge commit needs the sha; one correlating it with a branch needs the
866
+ * ref. `task` is GitHub's own discriminator (`deploy` vs `deploy:migrations` etc.) and is what
867
+ * distinguishes two deployments to the same environment in the same second.
868
+ */
869
+ export const deployments = sqliteTable(
870
+ "deployments",
871
+ {
872
+ /** GitHub's numeric deployment id, as text (same convention as the other GitHub id keys here). */
873
+ id: text("id").primaryKey(),
874
+ repo_id: text("repo_id").notNull(),
875
+ /** The git ref the deployment was created against, as sent (`main`, `refs/tags/v1`, a PR head). */
876
+ ref: text("ref"),
877
+ /** The commit sha that `ref` resolved to at creation — the correlation key for a merge commit. */
878
+ sha: text("sha"),
879
+ /** GitHub's task discriminator (`deploy`, `deploy:migrations`, …); distinguishes sibling deploys. */
880
+ task: text("task"),
881
+ /** The environment name — the field the deploy state machine keys on (`staging`, `production`). */
882
+ environment: text("environment"),
883
+ /** GitHub's own "is this the live one" flag for the environment, when the payload carries it. */
884
+ production_environment: integer("production_environment"),
885
+ /** True when GitHub marks the environment transient (a preview/PR env that will be torn down). */
886
+ transient_environment: integer("transient_environment"),
887
+ /** Human description, when supplied by whoever created the deployment. */
888
+ description: text("description"),
889
+ /** Who created it — the resolvable `github:<login>` key, same convention as pr_events.actor_id. */
890
+ creator_id: text("creator_id"),
891
+ created_at: integer("created_at"),
892
+ updated_at: integer("updated_at"),
893
+ },
894
+ (t) => [index("idx_deployments_repo_env").on(t.repo_id, t.environment, t.created_at)],
895
+ );
896
+
897
+ /**
898
+ * CTC-667 — a deployment's STATUS transitions, one row per status id (append-only).
899
+ *
900
+ * ⛔ WHY A SECOND TABLE RATHER THAN A `state` COLUMN ON `deployments`. The orchestrator routes on
901
+ * `github.deployment_status.success|failure|error` (broker/router.mjs:1456/:1570) — it acts on the
902
+ * ARRIVAL of a transition, not on a current value. Folding statuses into the deployment row would
903
+ * collapse `pending → in_progress → success` into whatever the replica happened to observe last, so
904
+ * two transitions landing between one host's polls would silently lose the first. GitHub's statuses
905
+ * are immutable and carry their own ids, which makes append-only the honest model — the same choice
906
+ * `pr_events` makes, and for the same reason.
907
+ *
908
+ * ⭐ AND THE VOLUME MAKES IT FREE. `deployment_status.*` ran 136 events on mini-2 for all of 2026-08.
909
+ * The unboundedness argument that made `pushes` current-state (3,746/month, one question) does not
910
+ * apply here: this table grows by low hundreds a month and answers a question about ORDER.
911
+ *
912
+ * ⚠️ `target_url` and `environment_url` are BOTH carried and are different things — `target_url` is
913
+ * where the deploy's LOGS are (the CI run), `environment_url` is where the deployed thing IS. The
914
+ * census names both because the state machine reads both; keeping only one would look complete.
915
+ */
916
+ export const deployment_statuses = sqliteTable(
917
+ "deployment_statuses",
918
+ {
919
+ /** GitHub's numeric deployment-status id, as text. Immutable — a status is never rewritten. */
920
+ id: text("id").primaryKey(),
921
+ repo_id: text("repo_id").notNull(),
922
+ /** FK to `deployments.id`. Kept as text for the same reason `id` is. */
923
+ deployment_id: text("deployment_id").notNull(),
924
+ /** pending | queued | in_progress | success | failure | error | inactive — GitHub's own vocabulary. */
925
+ state: text("state"),
926
+ /** The environment, denormalized from the status (GitHub sends it) so a reader needs no join. */
927
+ environment: text("environment"),
928
+ /** Where the deploy's LOGS are — the CI run URL. */
929
+ target_url: text("target_url"),
930
+ /** Where the deployed thing IS — the live URL for this environment. */
931
+ environment_url: text("environment_url"),
932
+ description: text("description"),
933
+ /** Who/what reported the status — the resolvable `github:<login>` key. */
934
+ creator_id: text("creator_id"),
935
+ created_at: integer("created_at"),
936
+ updated_at: integer("updated_at"),
937
+ },
938
+ (t) => [index("idx_deployment_statuses_deployment").on(t.deployment_id, t.created_at)],
939
+ );
940
+
941
+ /**
942
+ * CTC-667 — a PR review THREAD's resolution state, one row per thread.
943
+ *
944
+ * ⛔ WHY THIS EXISTS, and it is a merge gate rather than a nicety. AGENTS.md makes "every review
945
+ * thread has been addressed and resolved" a condition for merging, the orchestrator routes on
946
+ * `github.pr_review_thread.resolved` (broker/router.mjs:1562), and `pr_review_comments` has NO
947
+ * resolution column — so with the smee tunnel gone, thread resolution is not merely stale, it is
948
+ * UNRECONSTRUCTABLE from the replica. Live volume on mini-2 for 2026-08: 2,111 resolutions.
949
+ *
950
+ * ⭐ CURRENT STATE, one row per thread, and here that IS the question: the gate asks "is this thread
951
+ * resolved NOW", not "how many times was it toggled". A thread that is resolved, reopened and
952
+ * resolved again ends in the same place a consumer needs to read.
953
+ *
954
+ * ⚠️ `id` IS GITHUB'S GRAPHQL `node_id`, NOT A NUMERIC REST ID — the `pull_request_review_thread`
955
+ * webhook's `thread` object carries no numeric id at all. This is the only GitHub table here whose
956
+ * key is a node id, which is why it is said out loud: a reader assuming the numeric convention would
957
+ * write a joiner that never matches.
958
+ *
959
+ * ⛔⛔ WEBHOOK-ONLY, NO BACKFILL — SO THIS TABLE IS NOT YET A MERGE GATE, AND READING IT AS ONE IS A
960
+ * FALSE GREEN. Raised by a reviewer on #758 (Codex P1) and it is right: GitHub emits
961
+ * `pull_request_review_thread` ONLY on `resolved` / `unresolved`. A thread that has been OPENED and
962
+ * never touched again produces no event at all, and nothing else writes here — the
963
+ * `pull_request_review_comment` webhook carries no thread id, and there is no reconcile leg for this
964
+ * table. So a PR whose threads are all still open holds ZERO rows, which is byte-identical to a PR
965
+ * with nothing outstanding.
966
+ *
967
+ * ⚠️ That error points the WORST possible direction for the question this table exists to answer:
968
+ * "does this PR have unresolved threads" would read NO for exactly the PRs that most need a yes. Until
969
+ * a backfill lands (CTC-677 — seed a PR's thread set from GitHub's GraphQL `pullRequest.reviewThreads`
970
+ * on the same reconcile pass that already sweeps its comments), this table answers only the NARROWER
971
+ * question it can actually answer: "of the threads we have observed a resolution event for, what is
972
+ * their current state". Do not wire the AGENTS.md merge gate to it before then.
973
+ *
974
+ * ⚠️ `first_comment_id` is the anchor INTO `pr_review_comments` (the earliest comment the thread's
975
+ * payload carries), because the reverse link does not exist — the `pull_request_review_comment`
976
+ * webhook carries no thread id, so a comment cannot name its thread. Nullable: a thread payload
977
+ * arriving with an empty `comments` array is not an error, it is a thread whose comments were
978
+ * deleted.
979
+ */
980
+ export const pr_review_threads = sqliteTable(
981
+ "pr_review_threads",
982
+ {
983
+ /** GitHub's GraphQL `node_id` for the thread — NOT a numeric REST id (see the doc above). */
984
+ id: text("id").primaryKey(),
985
+ repo_id: text("repo_id").notNull(),
986
+ pr_number: integer("pr_number").notNull(),
987
+ /** 1 resolved / 0 unresolved / null unknown — never coerce an absent action to "unresolved". */
988
+ resolved: integer("resolved"),
989
+ /** When it was last resolved (ms epoch); null while unresolved or never resolved. */
990
+ resolved_at: integer("resolved_at"),
991
+ /** Who resolved it — the resolvable `github:<login>` key; null when unresolved. */
992
+ resolver_id: text("resolver_id"),
993
+ /** The earliest comment id in the thread — the anchor into pr_review_comments (see doc). */
994
+ first_comment_id: text("first_comment_id"),
995
+ /** How many comments the thread's payload carried, so "empty thread" is distinguishable from null. */
996
+ comment_count: integer("comment_count"),
997
+ updated_at: integer("updated_at"),
998
+ },
999
+ (t) => [index("idx_pr_review_threads_pr").on(t.repo_id, t.pr_number)],
1000
+ );
1001
+
650
1002
  // ── Mirror infra (not domain entities — never ride the change-feed) ─────────────────────────────────
651
1003
 
652
1004
  export const processed_events = sqliteTable("processed_events", {