@catalyst-cloud/schema 0.1.5 → 0.1.10
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 +2 -2
- package/src/index.ts +25 -0
- package/src/migrations.generated.ts +35 -0
- package/src/mirror.ts +99 -0
- package/src/skew.ts +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/schema",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": "
|
|
33
|
+
"@catalyst-cloud/typescript-config": "0.0.0",
|
|
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
|
@@ -30,6 +30,9 @@ import {
|
|
|
30
30
|
event_log,
|
|
31
31
|
sync_cursors,
|
|
32
32
|
fleet_activity,
|
|
33
|
+
agent_sessions,
|
|
34
|
+
agent_activities,
|
|
35
|
+
fleet_activity_pruned,
|
|
33
36
|
pull_sweeps,
|
|
34
37
|
} from "./mirror.js";
|
|
35
38
|
|
|
@@ -45,6 +48,18 @@ export {
|
|
|
45
48
|
type MigrationJournalEntry,
|
|
46
49
|
} from "./migrate.js";
|
|
47
50
|
|
|
51
|
+
// CTC-471 — schema-skew detection. A consumer reports the bundle it LOADED (not the one its lockfile
|
|
52
|
+
// claims) and the hub places that tail in its own journal. Exported here so the SDK, the replica and
|
|
53
|
+
// the Worker all compare the same way; see ./skew.ts for why a version string cannot do this job.
|
|
54
|
+
export {
|
|
55
|
+
loadedSchemaIdentity,
|
|
56
|
+
schemaSkew,
|
|
57
|
+
schemaSkewNeedsAttention,
|
|
58
|
+
schemaIncludesMigration,
|
|
59
|
+
type SchemaIdentity,
|
|
60
|
+
type SchemaSkew,
|
|
61
|
+
} from "./skew.js";
|
|
62
|
+
|
|
48
63
|
/** Every table in the Mirror DO store — pass as `drizzle(storage, { schema: mirrorSchema })`. */
|
|
49
64
|
export const mirrorSchema = {
|
|
50
65
|
issues,
|
|
@@ -71,6 +86,13 @@ export const mirrorSchema = {
|
|
|
71
86
|
// migration bundle must produce) but DELIBERATELY ABSENT from `mirrorEntityTables` below — see the
|
|
72
87
|
// no-feed arithmetic on the table definition in ./mirror.ts.
|
|
73
88
|
pull_sweeps,
|
|
89
|
+
// CTC-393: the fleet_activity anti-resurrection tombstone. Same posture as pull_sweeps — a real
|
|
90
|
+
// table the DO creates, but pure internal bookkeeping that must NEVER ride the change-feed (a
|
|
91
|
+
// consumer has no use for "a row was once terminal here"; see the doc on the table itself).
|
|
92
|
+
// CTC-524: agent sessions + their activity stream.
|
|
93
|
+
agent_sessions,
|
|
94
|
+
agent_activities,
|
|
95
|
+
fleet_activity_pruned,
|
|
74
96
|
} as const;
|
|
75
97
|
|
|
76
98
|
/**
|
|
@@ -107,6 +129,9 @@ export const mirrorEntityTables = {
|
|
|
107
129
|
// CTC-350: fleet coordination current-state — replicates like any other entity (one row per
|
|
108
130
|
// host_id/ticket), unlike the infra tables above which never ride a delta.
|
|
109
131
|
fleet_activity,
|
|
132
|
+
// CTC-524: agent sessions + their activities.
|
|
133
|
+
agent_sessions,
|
|
134
|
+
agent_activities,
|
|
110
135
|
} as const;
|
|
111
136
|
|
|
112
137
|
/** The wire `entity` name of a change-feed-carried table (keys of mirrorEntityTables). */
|
|
@@ -123,6 +123,34 @@ export const MIRROR_MIGRATIONS = {
|
|
|
123
123
|
tag: "0015_brainy_lady_ursula",
|
|
124
124
|
breakpoints: true,
|
|
125
125
|
},
|
|
126
|
+
{
|
|
127
|
+
idx: 16,
|
|
128
|
+
version: "6",
|
|
129
|
+
when: 1786723845155,
|
|
130
|
+
tag: "0016_vengeful_the_stranger",
|
|
131
|
+
breakpoints: true,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
idx: 17,
|
|
135
|
+
version: "6",
|
|
136
|
+
when: 1786770512483,
|
|
137
|
+
tag: "0017_clammy_doctor_octopus",
|
|
138
|
+
breakpoints: true,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
idx: 18,
|
|
142
|
+
version: "6",
|
|
143
|
+
when: 1786783490609,
|
|
144
|
+
tag: "0018_brainy_clint_barton",
|
|
145
|
+
breakpoints: true,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
idx: 19,
|
|
149
|
+
version: "6",
|
|
150
|
+
when: 1786809590855,
|
|
151
|
+
tag: "0019_mushy_sinister_six",
|
|
152
|
+
breakpoints: true,
|
|
153
|
+
},
|
|
126
154
|
],
|
|
127
155
|
},
|
|
128
156
|
migrations: {
|
|
@@ -157,5 +185,12 @@ export const MIRROR_MIGRATIONS = {
|
|
|
157
185
|
"-- 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
186
|
"0015_brainy_lady_ursula":
|
|
159
187
|
"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",
|
|
188
|
+
"0016_vengeful_the_stranger":
|
|
189
|
+
"CREATE TABLE `fleet_activity_pruned` (\n\t`host_id` text NOT NULL,\n\t`ticket` text NOT NULL,\n\t`last_event_ts` integer NOT NULL,\n\t`pruned_at` integer NOT NULL,\n\tPRIMARY KEY(`host_id`, `ticket`)\n);\n",
|
|
190
|
+
"0017_clammy_doctor_octopus": "ALTER TABLE `users` ADD `email` text;",
|
|
191
|
+
"0018_brainy_clint_barton":
|
|
192
|
+
"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
|
+
"0019_mushy_sinister_six":
|
|
194
|
+
"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;",
|
|
160
195
|
},
|
|
161
196
|
} as const;
|
package/src/mirror.ts
CHANGED
|
@@ -116,6 +116,10 @@ export const users = sqliteTable("users", {
|
|
|
116
116
|
avatar_url: text("avatar_url"),
|
|
117
117
|
type: text("type"), // human | agent | bot | app
|
|
118
118
|
source: text("source"), // linear | github
|
|
119
|
+
// CTC-506 — only ever populated for source='linear' rows (the roster sweep is the one caller that
|
|
120
|
+
// fetches it; every other upsert site omits the column entirely rather than writing null over it,
|
|
121
|
+
// see users/identity.ts's userRow()). Feeds the registry-side identity join, never read for github.
|
|
122
|
+
email: text("email"),
|
|
119
123
|
updated_at: integer("updated_at"),
|
|
120
124
|
});
|
|
121
125
|
|
|
@@ -197,6 +201,74 @@ export const issue_history = sqliteTable(
|
|
|
197
201
|
// progress, health, and presentation (icon/color). `target_date` / `start_date` are Linear
|
|
198
202
|
// TimelessDates ("YYYY-MM-DD") — stored AS-IS as TEXT, NEVER through isoToMs (the same trap as
|
|
199
203
|
// issues.due_date). `progress` is a Float (0..1).
|
|
204
|
+
/**
|
|
205
|
+
* CTC-524 — a Linear agent session: one delegated unit of agent work on an issue.
|
|
206
|
+
*
|
|
207
|
+
* ⛔ WHY THIS TABLE EXISTS AT ALL. CTC-499's responder ACKNOWLEDGES an `AgentSessionEvent` inside
|
|
208
|
+
* Linear's 10-second deadline and enqueues it — and the queue then dropped it, because
|
|
209
|
+
* `normalizeLinearEvent`'s switch had no case and fell through to `default: return []`. Acknowledged,
|
|
210
|
+
* queued, normalized to nothing, discarded. So nothing could render a session: CTC-503's own first
|
|
211
|
+
* payload — 14 dead sessions since Aug 10 — lived only in Linear.
|
|
212
|
+
*
|
|
213
|
+
* ⚠️ `status` IS LINEAR'S, NOT OURS. `active` / `awaitingInput` / `complete` / `error` — stored as the
|
|
214
|
+
* provider sends it rather than mapped into a local vocabulary, so a reader can tell "Linear says
|
|
215
|
+
* awaitingInput" from any judgement we layer on top. The abandoned-vs-awaiting distinction CTC-503
|
|
216
|
+
* needs is DERIVED from `status` + `updated_at`, and derivation belongs above this row, not in it.
|
|
217
|
+
*/
|
|
218
|
+
export const agent_sessions = sqliteTable(
|
|
219
|
+
"agent_sessions",
|
|
220
|
+
{
|
|
221
|
+
id: text("id").primaryKey(), // Linear agentSession id
|
|
222
|
+
issue_id: text("issue_id"), // FK → issues.id; the session is issue-scoped (no session above it)
|
|
223
|
+
/** Linear's own session status, verbatim. */
|
|
224
|
+
status: text("status"),
|
|
225
|
+
/** The app/user the session belongs to (FK → users.id) — the agent that was delegated to. */
|
|
226
|
+
app_user_id: text("app_user_id"),
|
|
227
|
+
/** Who created it. ⚠️ MEASURED NULL in practice (ARCHITECT-7) — a session created by the app
|
|
228
|
+
* itself has no human creator, which is exactly why CTC-500 needs an explicit target. */
|
|
229
|
+
creator_id: text("creator_id"),
|
|
230
|
+
created_at: integer("created_at"),
|
|
231
|
+
updated_at: integer("updated_at"), // drives the last-write-wins upsert guard
|
|
232
|
+
removed_at: integer("removed_at"),
|
|
233
|
+
// CTC-529 — a session can END in ways that are NOT the same as being abandoned, and a reader has
|
|
234
|
+
// to tell them apart. `archived_at`/`dismissed_at` are Linear's own terminal markers; a dismissed
|
|
235
|
+
// session was closed BY SOMEONE (`dismissed_by`), which is a different story from one that simply
|
|
236
|
+
// stopped emitting. Promoted out of `raw` to real columns because CTC-503's render branches on
|
|
237
|
+
// them — a render that has to json_extract its own state machine is one bad path from silently
|
|
238
|
+
// reading a dismissed session as still-running.
|
|
239
|
+
archived_at: integer("archived_at"),
|
|
240
|
+
dismissed_at: integer("dismissed_at"),
|
|
241
|
+
dismissed_by: text("dismissed_by"), // FK → users.id; null when Linear reported no actor
|
|
242
|
+
raw: text("raw"), // full fidelity / forward-compat, like issues.raw
|
|
243
|
+
},
|
|
244
|
+
(t) => [index("idx_agent_sessions_issue").on(t.issue_id, t.updated_at)],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* CTC-524 — one activity within a session: the thought/action/response/elicitation stream.
|
|
249
|
+
*
|
|
250
|
+
* ⚠️ `type` CARRIES THE NOTIFY SEMANTICS and must not be flattened. `elicitation` and `error` reach a
|
|
251
|
+
* human's Inbox; `thought`/`action`/`response` do not (CTC-501, and Linear's behaviour rather than our
|
|
252
|
+
* preference). A reader that cannot tell an elicitation from a thought cannot tell a question from
|
|
253
|
+
* narration.
|
|
254
|
+
*/
|
|
255
|
+
export const agent_activities = sqliteTable(
|
|
256
|
+
"agent_activities",
|
|
257
|
+
{
|
|
258
|
+
id: text("id").primaryKey(),
|
|
259
|
+
session_id: text("session_id").notNull(), // FK → agent_sessions.id
|
|
260
|
+
/** thought | action | response | elicitation | error — Linear's content type, verbatim. */
|
|
261
|
+
type: text("type"),
|
|
262
|
+
body: text("body"),
|
|
263
|
+
/** Linear's `signal` on the activity (e.g. stop), when present. */
|
|
264
|
+
signal: text("signal"),
|
|
265
|
+
created_at: integer("created_at"),
|
|
266
|
+
updated_at: integer("updated_at"),
|
|
267
|
+
raw: text("raw"),
|
|
268
|
+
},
|
|
269
|
+
(t) => [index("idx_agent_activities_session_created").on(t.session_id, t.created_at)],
|
|
270
|
+
);
|
|
271
|
+
|
|
200
272
|
export const projects = sqliteTable("projects", {
|
|
201
273
|
id: text("id").primaryKey(),
|
|
202
274
|
name: text("name"),
|
|
@@ -455,6 +527,33 @@ export const fleet_activity = sqliteTable(
|
|
|
455
527
|
(t) => [primaryKey({ columns: [t.host_id, t.ticket] })],
|
|
456
528
|
);
|
|
457
529
|
|
|
530
|
+
/**
|
|
531
|
+
* CTC-393 — a tombstone of the last `last_event_ts` a (host_id, ticket) reached TERMINAL disposition
|
|
532
|
+
* at, kept for a while AFTER `fleet_activity`'s own row is hard-pruned.
|
|
533
|
+
*
|
|
534
|
+
* ⛔ THE BUG THIS CLOSES. `fleet_activity` has no soft-delete column — a terminal row past its
|
|
535
|
+
* retention window is genuinely DELETEd, not marked removed. Without this table, a stale/delayed
|
|
536
|
+
* pre-terminal event arriving AFTER that deletion finds no existing row (`readFleetRow` → null), the
|
|
537
|
+
* reducer treats that as "first seen", and a DEAD ticket is silently RESURRECTED as an active row
|
|
538
|
+
* with no disposition — orphaned forever, since nothing prunes an active row.
|
|
539
|
+
*
|
|
540
|
+
* Bounded the same way `fleet_activity` itself is bounded: a tombstone is written once per prune and
|
|
541
|
+
* pruned again after its own retention window (see `fleet-activity.ts`'s prune step), so this table
|
|
542
|
+
* never grows past "recently terminal tickets, twice over" — never proportional to all-time history.
|
|
543
|
+
*/
|
|
544
|
+
export const fleet_activity_pruned = sqliteTable(
|
|
545
|
+
"fleet_activity_pruned",
|
|
546
|
+
{
|
|
547
|
+
host_id: text("host_id").notNull(),
|
|
548
|
+
ticket: text("ticket").notNull(),
|
|
549
|
+
// The watermark the deleted row reached terminal at — a later event must beat THIS, not just
|
|
550
|
+
// "any row", to be accepted as a genuine reopening rather than a stale replay.
|
|
551
|
+
last_event_ts: integer("last_event_ts").notNull(),
|
|
552
|
+
pruned_at: integer("pruned_at").notNull(), // ms epoch — when the live row was deleted; ages this out
|
|
553
|
+
},
|
|
554
|
+
(t) => [primaryKey({ columns: [t.host_id, t.ticket] })],
|
|
555
|
+
);
|
|
556
|
+
|
|
458
557
|
/**
|
|
459
558
|
* ⭐ THE PER-PR ENRICHMENT RECEIPT — the row that makes "this PR has no CI" distinguishable from
|
|
460
559
|
* "nobody ever swept this PR". CTC-436 follow-up.
|
package/src/skew.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// CTC-471 — "is this consumer's schema bundle older than mine?", answered from the bundle a consumer
|
|
2
|
+
// ACTUALLY LOADED rather than from what its lockfile claims.
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT THIS CLOSES. A host ran `@catalyst-cloud/schema@0.1.3` for 21+ days while the published
|
|
5
|
+
// latest was 0.1.5. Every column added since 0.1.3 — `head_ref`, `linear_issue_identifier` — was
|
|
6
|
+
// dropped from every replica upsert, and the host reported healthy the entire time: the replica's
|
|
7
|
+
// migration tail and the installed bundle's journal tail agreed (`0008_optimal_rattler`), because the
|
|
8
|
+
// host was faithfully applying the stale bundle it had. Nothing anywhere compared that tail to the
|
|
9
|
+
// hub's.
|
|
10
|
+
//
|
|
11
|
+
// ⛔ WHY A VERSION STRING CANNOT BE THE ANSWER. The obvious fix — report `package.json`'s version and
|
|
12
|
+
// compare semver — fails on the exact fleet that motivated the ticket, for three independent reasons:
|
|
13
|
+
//
|
|
14
|
+
// 1. `bun.lock` pins a RESOLUTION, not a range. `^0.1.3` would admit 0.1.5, but the lock records
|
|
15
|
+
// 0.1.3 exactly, so `bun install` re-installs 0.1.3 deterministically, forever.
|
|
16
|
+
// 2. A nested `node_modules` SHADOWS the root install. This fleet has already run one SDK version
|
|
17
|
+
// while the lockfile claimed another — so an install-time check would have PASSED while the
|
|
18
|
+
// replica sat at 0008.
|
|
19
|
+
// 3. The daemon holds old code in memory; anything on disk is inert until restart.
|
|
20
|
+
//
|
|
21
|
+
// Every one of those is a gap between LOCKED and LOADED. So the reported value here is derived from
|
|
22
|
+
// the `MIRROR_MIGRATIONS` array **at runtime**, in the same module graph that will do the applying. A
|
|
23
|
+
// shadowed install reports the shadowed bundle's tail, because that is genuinely the bundle whose
|
|
24
|
+
// columns the replica will have. That property is the whole point — see {@link loadedSchemaIdentity}.
|
|
25
|
+
//
|
|
26
|
+
// ⛔ AND "DID NOT REPORT" IS NOT "UP TO DATE". `unreported` is its own state and must never fold into
|
|
27
|
+
// `current`. CTC-479 cost 27 hours precisely because a null read as a clean bill of health: an
|
|
28
|
+
// absence with no reason is indistinguishable from proven-healthy, and an operator (and an agent
|
|
29
|
+
// actively hunting the bug) both read it as fine. The invariant `team-liveness.ts` states for the
|
|
30
|
+
// Linear legs — "every way of not proving liveness resolves to a RECORDED REASON, never to a clean
|
|
31
|
+
// bill of health inferred from silence" — is the same invariant, one layer up.
|
|
32
|
+
|
|
33
|
+
import { MIRROR_MIGRATIONS } from "./migrations.generated.js";
|
|
34
|
+
import type { MigrationBundle } from "./migrate.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a consumer reports about the migration bundle it LOADED — not the one it installed, locked, or
|
|
38
|
+
* intended.
|
|
39
|
+
*
|
|
40
|
+
* `count` is not redundant with `tail`: it is what distinguishes a consumer running AHEAD of the hub
|
|
41
|
+
* (a newer bundle, whose tail the hub has never heard of) from a consumer reporting garbage (a tail
|
|
42
|
+
* the hub has never heard of, from a bundle no bigger than its own). Without it both collapse into
|
|
43
|
+
* "unrecognized" and a mid-deploy fleet looks broken.
|
|
44
|
+
*/
|
|
45
|
+
export interface SchemaIdentity {
|
|
46
|
+
/** The last journal tag in the loaded bundle, e.g. `0015_brainy_lady_ursula`. Null iff empty. */
|
|
47
|
+
tail: string | null;
|
|
48
|
+
/** How many migrations the loaded bundle carries. */
|
|
49
|
+
count: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The hub's verdict on one consumer's reported identity.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ Five states, deliberately. Collapsing any of them into a boolean recreates the defect: a
|
|
56
|
+
* consumer that is behind, a consumer that never reported, and a consumer whose bundle the hub cannot
|
|
57
|
+
* place are three DIFFERENT operational situations, and exactly one of them (`current`) is healthy.
|
|
58
|
+
*/
|
|
59
|
+
export type SchemaSkew =
|
|
60
|
+
/** The consumer's tail is the hub's tail. The only healthy state. */
|
|
61
|
+
| { state: "current"; tail: string }
|
|
62
|
+
/** The consumer is missing migrations the hub already has — it WILL drop those columns. */
|
|
63
|
+
| { state: "behind"; tail: string; missing: string[] }
|
|
64
|
+
/**
|
|
65
|
+
* The consumer carries migrations the hub does not — normal and transient mid-deploy (a host
|
|
66
|
+
* upgraded first), so it is NOT an error, but it is not `current` either and must stay visible.
|
|
67
|
+
*/
|
|
68
|
+
| { state: "ahead"; tail: string; extra: number }
|
|
69
|
+
/**
|
|
70
|
+
* The consumer reported a tail the hub cannot place, from a bundle no larger than its own. Neither
|
|
71
|
+
* behind nor ahead: the question is unanswerable, and the unanswerable-ness is what gets recorded.
|
|
72
|
+
*/
|
|
73
|
+
| { state: "unrecognized"; tail: string }
|
|
74
|
+
/**
|
|
75
|
+
* ⛔ The consumer reported nothing. NOT healthy, NOT `current` — see the header. Pre-CTC-471 SDKs
|
|
76
|
+
* land here, which is correct: the hub genuinely does not know what they are running.
|
|
77
|
+
*/
|
|
78
|
+
| { state: "unreported" };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The identity of the bundle THIS process actually loaded.
|
|
82
|
+
*
|
|
83
|
+
* ⭐ Derived from the imported `MIRROR_MIGRATIONS` array, never from `package.json`, a lockfile, or a
|
|
84
|
+
* build-time constant. That is what makes it a LOADED fact rather than a LOCKED claim: if a nested
|
|
85
|
+
* `node_modules` shadows the root install, this returns the shadowing bundle's tail — which is
|
|
86
|
+
* genuinely the schema the replica will have, and genuinely what the hub needs to know.
|
|
87
|
+
*
|
|
88
|
+
* The default argument exists so tests can pass a synthetic bundle; production callers pass nothing.
|
|
89
|
+
*/
|
|
90
|
+
export function loadedSchemaIdentity(bundle: MigrationBundle = MIRROR_MIGRATIONS): SchemaIdentity {
|
|
91
|
+
const entries = bundle.journal.entries;
|
|
92
|
+
const last = entries.length > 0 ? entries[entries.length - 1] : undefined;
|
|
93
|
+
return { tail: last?.tag ?? null, count: entries.length };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compare a consumer's reported identity against the hub's own bundle.
|
|
98
|
+
*
|
|
99
|
+
* `reported` is deliberately typed to accept `null | undefined`: a consumer that predates CTC-471
|
|
100
|
+
* sends nothing, and the caller must not have to invent a value for it. That path returns
|
|
101
|
+
* `unreported`, never `current`.
|
|
102
|
+
*/
|
|
103
|
+
export function schemaSkew(
|
|
104
|
+
reported: SchemaIdentity | null | undefined,
|
|
105
|
+
bundle: MigrationBundle = MIRROR_MIGRATIONS,
|
|
106
|
+
): SchemaSkew {
|
|
107
|
+
if (reported == null || reported.tail == null) return { state: "unreported" };
|
|
108
|
+
|
|
109
|
+
const tags = bundle.journal.entries.map((e) => e.tag);
|
|
110
|
+
const idx = tags.indexOf(reported.tail);
|
|
111
|
+
|
|
112
|
+
if (idx === -1) {
|
|
113
|
+
// Not in our journal. A LARGER bundle than ours is a consumer that upgraded first — expected
|
|
114
|
+
// during a rolling deploy. A same-or-smaller bundle we cannot place is genuinely unrecognized.
|
|
115
|
+
if (reported.count > tags.length) {
|
|
116
|
+
return { state: "ahead", tail: reported.tail, extra: reported.count - tags.length };
|
|
117
|
+
}
|
|
118
|
+
return { state: "unrecognized", tail: reported.tail };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ⚠️ The tail alone is not enough to trust `current`/`behind` — see the doc on `SchemaIdentity.count`.
|
|
122
|
+
// A tail that matches our journal but a count that DISAGREES with that tail's position is an
|
|
123
|
+
// internally-inconsistent report (a corrupted client, or a future bug elsewhere), not a verified
|
|
124
|
+
// one: it cannot have come from actually walking this bundle. Codex flagged this — a mismatched
|
|
125
|
+
// count at the LAST tag would otherwise still read `current`, recreating the exact false-clean-bill
|
|
126
|
+
// condition this ticket exists to close.
|
|
127
|
+
if (reported.count !== idx + 1) return { state: "unrecognized", tail: reported.tail };
|
|
128
|
+
|
|
129
|
+
if (idx === tags.length - 1) return { state: "current", tail: reported.tail };
|
|
130
|
+
|
|
131
|
+
return { state: "behind", tail: reported.tail, missing: tags.slice(idx + 1) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Is this verdict one an operator must act on? `behind` and `unrecognized` mean the replica cannot
|
|
136
|
+
* represent what the hub is sending. `unreported` is included ON PURPOSE — an unknown consumer is a
|
|
137
|
+
* thing to chase, not a thing to assume fine.
|
|
138
|
+
*
|
|
139
|
+
* ⚠️ `ahead` is excluded: it is expected mid-deploy and alerting on it would train operators to
|
|
140
|
+
* ignore this signal, which is strictly worse than the silence it replaces (the same argument
|
|
141
|
+
* `team-liveness.ts` makes for not black-flagging a quiet workspace).
|
|
142
|
+
*/
|
|
143
|
+
export function schemaSkewNeedsAttention(skew: SchemaSkew): boolean {
|
|
144
|
+
return skew.state === "behind" || skew.state === "unrecognized" || skew.state === "unreported";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* CTC-393 — does a consumer's LOADED bundle include a specific migration (by journal tag)?
|
|
149
|
+
*
|
|
150
|
+
* ⭐ This is a different, more precise question than {@link schemaSkew}'s five-state verdict. A host
|
|
151
|
+
* can be `behind` the hub's exact tail (missing a LATER, unrelated migration) while still safely
|
|
152
|
+
* having every column a SPECIFIC earlier migration added — `schemaSkew` alone would wrongly treat it
|
|
153
|
+
* as ineligible. Use this when the question is "does this consumer support entity X", not "is this
|
|
154
|
+
* consumer on our exact tail" — e.g. gating a newly-replicated entity to only the hosts whose bundle
|
|
155
|
+
* already contains the migration that created its table, regardless of what shipped after.
|
|
156
|
+
*
|
|
157
|
+
* Reuses the identical journal-position lookup `schemaSkew` uses internally, so a host that reports
|
|
158
|
+
* nothing (`reported == null`) or a tail this hub cannot place resolves to `false` — the safe
|
|
159
|
+
* default-EXCLUDE this ticket requires, with no separate "unreported" branch to get wrong twice.
|
|
160
|
+
*
|
|
161
|
+
* `requiredTag` is asserted to exist in `bundle`'s own journal — a typo'd or since-renumbered tag
|
|
162
|
+
* would otherwise silently exclude EVERY consumer forever with no signal anything is wrong.
|
|
163
|
+
*/
|
|
164
|
+
export function schemaIncludesMigration(
|
|
165
|
+
reported: SchemaIdentity | null | undefined,
|
|
166
|
+
requiredTag: string,
|
|
167
|
+
bundle: MigrationBundle = MIRROR_MIGRATIONS,
|
|
168
|
+
): boolean {
|
|
169
|
+
const tags = bundle.journal.entries.map((e) => e.tag);
|
|
170
|
+
const requiredIdx = tags.indexOf(requiredTag);
|
|
171
|
+
if (requiredIdx === -1) {
|
|
172
|
+
throw new Error(`schemaIncludesMigration: "${requiredTag}" is not in this bundle's journal`);
|
|
173
|
+
}
|
|
174
|
+
if (reported == null || reported.tail == null) return false;
|
|
175
|
+
const reportedIdx = tags.indexOf(reported.tail);
|
|
176
|
+
// Not in our journal at all: only a LARGER unrecognized bundle (a rolling-deploy "ahead" consumer,
|
|
177
|
+
// per schemaSkew's own reasoning) can be trusted to include an old migration; a same-or-smaller
|
|
178
|
+
// unplaceable tail is unrecognized garbage, never assumed to include anything.
|
|
179
|
+
if (reportedIdx === -1) return reported.count > tags.length;
|
|
180
|
+
return reportedIdx >= requiredIdx;
|
|
181
|
+
}
|