@catalyst-cloud/schema 0.1.17 → 0.1.19
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 +5 -5
- package/src/events/index.ts +8 -0
- package/src/events/registry-support.ts +20 -0
- package/src/events/registry.ts +2068 -0
- package/src/events/retention.ts +186 -0
- package/src/events/type-grammar.ts +64 -0
- package/src/events/types.ts +50 -0
- package/src/index.ts +6 -0
- package/src/migrations.generated.ts +8 -0
- package/src/mirror.ts +31 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// retention.ts — CTC-936 phase 4 (ADR-0039 proposal phase-1 items 4-5). The retention/deletion
|
|
2
|
+
// policy and the versioned Analytics Engine projection layout, as machine-readable constants a
|
|
3
|
+
// future R2/AE binding (phase 2 of the vertical slice, out of THIS ticket's scope) is typed against
|
|
4
|
+
// rather than a prose description that can silently drift from the code.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The hot window kept in DO SQLite before an event is eligible for pruning — the proposal's own
|
|
8
|
+
* words: "initially seven days is a reasonable operating assumption."
|
|
9
|
+
*/
|
|
10
|
+
export const EVENT_HOT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** The coordinates needed to compose one event's R2 archive key. `occurredAt` drives the
|
|
13
|
+
* yyyy/mm/dd partition (UTC, so archival never depends on the writer's local clock). */
|
|
14
|
+
export interface EventArchiveCoords {
|
|
15
|
+
tenantId: string;
|
|
16
|
+
sequence: number;
|
|
17
|
+
eventId: string;
|
|
18
|
+
/** ISO-8601. Uses the event's own `occurredAt`/`recordedAt`, not archival wall-clock time — the
|
|
19
|
+
* proposal's key template partitions by when the fact happened, not when it was archived. */
|
|
20
|
+
occurredAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `sequence` is zero-padded to this width so R2's own lexicographic LIST order agrees with
|
|
24
|
+
* numeric sequence order — the same reason `apps/mirror/src/artifacts/key.ts`'s `artifactKey`
|
|
25
|
+
* zero-pads its `nonce`. 20 digits comfortably exceeds `Number.MAX_SAFE_INTEGER`'s 16 digits. */
|
|
26
|
+
const SEQUENCE_PAD_WIDTH = 20;
|
|
27
|
+
|
|
28
|
+
// `eventId` is PRODUCER-supplied (types.ts) and is interpolated into the R2 key, so it is the one
|
|
29
|
+
// caller-supplied segment validated here — modelled on apps/mirror/src/artifacts/key.ts's NAME_RE,
|
|
30
|
+
// "the same key-injection shape, a different partition." Allowlist rather than a `/`-only denylist
|
|
31
|
+
// so any other path-shaped character (`\`, whitespace, control chars) is caught too.
|
|
32
|
+
const EVENT_ID_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,238}[A-Za-z0-9])?$/;
|
|
33
|
+
|
|
34
|
+
// Full ISO-8601 UTC-or-offset instant shape, anchored so surrounding whitespace and bare-year /
|
|
35
|
+
// bare-number forms (both of which `new Date()` tolerates) are rejected before they ever reach the
|
|
36
|
+
// coercion below.
|
|
37
|
+
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
38
|
+
|
|
39
|
+
// `tenantId` is the one segment BOTH exported key-shaping functions below take, and it defines the
|
|
40
|
+
// tenant-isolation boundary a scoped teardown (eventArchiveTenantPrefix) relies on — so it is
|
|
41
|
+
// validated once, here, rather than duplicated in each caller.
|
|
42
|
+
function assertValidTenantId(caller: string, tenantId: string): void {
|
|
43
|
+
if (typeof tenantId !== "string" || tenantId.length === 0 || tenantId.includes("/")) {
|
|
44
|
+
throw new Error(`${caller}: tenantId ${JSON.stringify(tenantId)} is not valid`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the R2 archive key for one event: `tenant/yyyy/mm/dd/<paddedSequence>-<eventId>.json` — the
|
|
50
|
+
* proposal's own template, verbatim, with the zero-padding decision stated (see SEQUENCE_PAD_WIDTH).
|
|
51
|
+
*/
|
|
52
|
+
export function eventArchiveKey(coords: EventArchiveCoords): string {
|
|
53
|
+
const { tenantId, sequence, eventId, occurredAt } = coords;
|
|
54
|
+
assertValidTenantId("eventArchiveKey", tenantId);
|
|
55
|
+
// Throws with the reason named — never returns a falsy/plausible-looking sentinel (AGENTS.md;
|
|
56
|
+
// type-grammar.ts's assertValidEventType follows the same posture). `occurredAt` is OPTIONAL on
|
|
57
|
+
// CatalystEvent, so a malformed value reaching here on the normal write path must fail loudly
|
|
58
|
+
// rather than silently misfile the only durable copy into an unqueryable `0NaN/NaN/NaN` — or, for
|
|
59
|
+
// `null`, a plausible-looking but wrong `1970/01/01` — partition. Requiring the full ISO_8601_RE
|
|
60
|
+
// shape (rather than accepting whatever `new Date()` merely tolerates) also rejects a bare year
|
|
61
|
+
// ("2026"), a bare epoch-ms-looking number-as-string ("0"), and leading/trailing whitespace before
|
|
62
|
+
// they ever reach the round-trip check below.
|
|
63
|
+
if (typeof occurredAt !== "string" || !ISO_8601_RE.test(occurredAt)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`eventArchiveKey: occurredAt ${JSON.stringify(occurredAt)} is not a valid date`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const occurredAtMs = new Date(occurredAt).getTime();
|
|
69
|
+
if (Number.isNaN(occurredAtMs)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`eventArchiveKey: occurredAt ${JSON.stringify(occurredAt)} is not a valid date`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
// A round-trip check: `new Date()` silently rolls an out-of-range calendar date forward (e.g.
|
|
75
|
+
// "2026-02-30" becomes March 2), which the regex shape check above cannot catch since the string
|
|
76
|
+
// is well-formed. Re-deriving the yyyy-mm-dd prefix from the parsed Date and requiring it to match
|
|
77
|
+
// the input's own date prefix rejects that rollover instead of misfiling into a wrong partition.
|
|
78
|
+
const roundTripDatePrefix = occurredAt.slice(0, 10);
|
|
79
|
+
const parsedDatePrefix = new Date(occurredAtMs).toISOString().slice(0, 10);
|
|
80
|
+
if (roundTripDatePrefix !== parsedDatePrefix) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`eventArchiveKey: occurredAt ${JSON.stringify(occurredAt)} is not a valid date`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
86
|
+
// `sequence` is SERVER-owned (allocated inside the DO's write transaction, types.ts), so this
|
|
87
|
+
// is not reachable from a producer today — but eventArchiveKey is an exported boundary function
|
|
88
|
+
// in a published package, so its callers are not all in this repo. A malformed value would
|
|
89
|
+
// otherwise break the lexicographic-ordering invariant SEQUENCE_PAD_WIDTH exists to guarantee
|
|
90
|
+
// (e.g. padding "NaN" or a negative number into a key that no longer sorts numerically).
|
|
91
|
+
throw new Error(
|
|
92
|
+
`eventArchiveKey: sequence ${JSON.stringify(sequence)} is not a safe non-negative integer`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (typeof eventId !== "string" || !EVENT_ID_RE.test(eventId)) {
|
|
96
|
+
// `eventId` is PRODUCER-supplied and interpolated into the key with no separator after it — an
|
|
97
|
+
// unvalidated slash would inject an extra path segment (see EVENT_ID_RE comment above). The
|
|
98
|
+
// `typeof` check is load-bearing on its own: `RegExp.prototype.test` COERCES a non-string
|
|
99
|
+
// argument, so `EVENT_ID_RE.test(null)` tests the literal string "null" (which the allowlist
|
|
100
|
+
// matches) and would otherwise silently misfile into a plausible-looking `...-null.json` key —
|
|
101
|
+
// the same coercion-into-a-wrong-partition failure the occurredAt guard above closes for dates.
|
|
102
|
+
throw new Error(
|
|
103
|
+
`eventArchiveKey: eventId ${JSON.stringify(eventId)} contains invalid characters`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const d = new Date(occurredAt);
|
|
107
|
+
const yyyy = String(d.getUTCFullYear()).padStart(4, "0");
|
|
108
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
109
|
+
const dd = String(d.getUTCDate()).padStart(2, "0");
|
|
110
|
+
const paddedSequence = String(sequence).padStart(SEQUENCE_PAD_WIDTH, "0");
|
|
111
|
+
return `${tenantId}/${yyyy}/${mm}/${dd}/${paddedSequence}-${eventId}.json`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The R2 prefix that scopes a tenant's entire event archive — every key `eventArchiveKey` can ever
|
|
116
|
+
* produce for that tenant starts with this (the trailing "/" is load-bearing: without it, a tenant id
|
|
117
|
+
* that is a string-prefix of another, e.g. "t0" vs "t00", would leak into each other's deletion — see
|
|
118
|
+
* retention.test.ts's dedicated negative case). Throws with the reason named for a non-string, empty,
|
|
119
|
+
* or slash-bearing `tenantId` rather than silently returning a prefix that would leak (empty) or
|
|
120
|
+
* mis-scope (slash) a tenant-scoped deletion — the same posture `eventArchiveKey`'s other coordinates
|
|
121
|
+
* already take.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ This prefix is NOT distinguishable from `apps/mirror/src/artifacts/key.ts`'s `artifactPrefix({
|
|
124
|
+
* account: tenantId })` — both are a bare `${tenantId}/`. That is only safe under a DEDICATED event-
|
|
125
|
+
* archive bucket (docs/event-backbone/retention-and-deletion.md §4's recommendation). If a future
|
|
126
|
+
* binding reuses the shared `ARTIFACTS` bucket instead, this prefix MUST gain a distinct segment
|
|
127
|
+
* (e.g. `${tenantId}/events/`) before that binding ships, or a tenant-scoped teardown on one prefix
|
|
128
|
+
* will also sweep (or miss) the other.
|
|
129
|
+
*/
|
|
130
|
+
export function eventArchiveTenantPrefix(tenantId: string): string {
|
|
131
|
+
assertValidTenantId("eventArchiveTenantPrefix", tenantId);
|
|
132
|
+
return `${tenantId}/`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What `mayPruneHotEvent` needs to know about one hot-window event. */
|
|
136
|
+
export interface HotEventPruneCheck {
|
|
137
|
+
/** Epoch ms the R2 archive write was CONFIRMED, or `null` if not yet (or never) confirmed. */
|
|
138
|
+
archivedAt: number | null;
|
|
139
|
+
/** How old the event is, in ms, at the time of the check. */
|
|
140
|
+
ageMs: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* May this hot-window event be pruned from DO SQLite? Both conditions are required — the proposal's
|
|
145
|
+
* own invariant: "Prune an event only after its R2 archive is confirmed." An event past the hot
|
|
146
|
+
* window with an UNCONFIRMED archive must NOT be pruned (the only durable copy would be lost); an
|
|
147
|
+
* event with a confirmed archive but still inside the hot window is kept for fast local replay.
|
|
148
|
+
*/
|
|
149
|
+
export function mayPruneHotEvent(check: HotEventPruneCheck): boolean {
|
|
150
|
+
return check.archivedAt !== null && check.ageMs > EVENT_HOT_WINDOW_MS;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Bump when the projection's slot ASSIGNMENT changes (a field moves position, or the slot count
|
|
154
|
+
* changes); additive-at-the-end changes to a slot's semantic meaning still bump, because AE has no
|
|
155
|
+
* schema to enforce it and a silent reinterpretation of an existing blob/double is the failure mode
|
|
156
|
+
* this version guards against. */
|
|
157
|
+
export const EVENT_PROJECTION_VERSION = 1;
|
|
158
|
+
|
|
159
|
+
/** The Analytics Engine binding's slot budget this projection must stay inside — `apps/mirror/src/
|
|
160
|
+
* event-stream/validate.ts`'s own documented caps, mirrored here so a future binding can assert
|
|
161
|
+
* against the same numbers without importing a Workers-shaped module into this package. */
|
|
162
|
+
export const AE_MAX_BLOB_SLOTS = 20;
|
|
163
|
+
|
|
164
|
+
export interface EventProjectionSlots {
|
|
165
|
+
indexes: string[];
|
|
166
|
+
blobs: string[];
|
|
167
|
+
doubles: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* ⭐ THE VERSIONED AE PROJECTION — pinned POSITIONALLY to the layout that already exists and is live:
|
|
172
|
+
* `apps/mirror/src/event-stream/validate.ts:52-67`'s `toEventStreamDataPoint`, which returns
|
|
173
|
+
* `{indexes: [tenantId], blobs: [p.source, p.type, p.action, p.id, p.payloadExcerpt], doubles:
|
|
174
|
+
* [p.seq]}` off a `UnifiedEventPoint`. `sequence` here names the SAME position as that function's
|
|
175
|
+
* `p.seq` — `CatalystEvent.sequence` is this repo's name for the identical value. This function
|
|
176
|
+
* exists so a future phase that VERSIONS the live layout (rather than forking a second, silently
|
|
177
|
+
* divergent one) has a single source of truth to import; it is not consumed by any runtime code yet
|
|
178
|
+
* — that consumption is phase 2 of the vertical slice, out of this ticket's scope.
|
|
179
|
+
*/
|
|
180
|
+
export function eventProjectionSlots(): EventProjectionSlots {
|
|
181
|
+
return {
|
|
182
|
+
indexes: ["tenantId"],
|
|
183
|
+
blobs: ["source", "type", "action", "id", "payloadExcerpt"],
|
|
184
|
+
doubles: ["sequence"],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// type-grammar.ts — CTC-936 phase 1. The variable-free `type` grammar Finding 2 proves the closed
|
|
2
|
+
// registry depends on. See packages/schema/thoughts plan
|
|
3
|
+
// (thoughts/shared/plans/2026-08-24-CTC-936-event-contract-phase-1.md) Finding 2: a full August scan
|
|
4
|
+
// found 5,786 distinct raw `event.name` values, and 5,500 of those embedded a ticket id
|
|
5
|
+
// (`CTC-###`/`CTL-###`/`ADV-###`). Stripping that one variable segment collapses the space to 433
|
|
6
|
+
// types — a size a closed, reviewed registry can actually cover. That collapse is only sound if the
|
|
7
|
+
// `type` field is FORBIDDEN from ever carrying a variable segment again, so this grammar is the
|
|
8
|
+
// precondition for phase 2's registry being satisfiable at all, not a style preference.
|
|
9
|
+
|
|
10
|
+
/** Bump when a field's meaning changes; additive fields do not bump. */
|
|
11
|
+
export const EVENT_SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
/** Dotted lower-kebab segments: `lease.claimed`, `webhook.linear.issue.updated`. At least two
|
|
14
|
+
* segments (one dot) are required — a bare single word is not a `type`. */
|
|
15
|
+
const EVENT_TYPE_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/;
|
|
16
|
+
const MAX_EVENT_TYPE_BYTES = 128;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ⛔ A ticket id in the TYPE is the defect this closes. Measured 2026-08: 5,500 of 5,786 observed
|
|
20
|
+
* event names carried one, which is the entire reason the name space was 5,786 wide instead of 433.
|
|
21
|
+
* The id belongs in `CatalystEvent.entity`, which exists for exactly this. Case-insensitive: the
|
|
22
|
+
* grammar's own lowercase-only rule already refuses an UPPERCASE ticket id, but this check also
|
|
23
|
+
* refuses a hypothetical lowercase one (`ctc-936`) that would otherwise slip past EVENT_TYPE_RE.
|
|
24
|
+
*/
|
|
25
|
+
const VARIABLE_SEGMENT_RE = /\b(?:CTC|CTL|ADV)-\d+\b/i;
|
|
26
|
+
|
|
27
|
+
function byteLength(s: string): number {
|
|
28
|
+
return new TextEncoder().encode(s).length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True iff `t` is a well-formed, variable-free dotted lower-kebab event type. Never throws. */
|
|
32
|
+
export function isValidEventType(t: string): boolean {
|
|
33
|
+
if (typeof t !== "string" || t.length === 0) return false;
|
|
34
|
+
if (byteLength(t) > MAX_EVENT_TYPE_BYTES) return false;
|
|
35
|
+
if (VARIABLE_SEGMENT_RE.test(t)) return false;
|
|
36
|
+
return EVENT_TYPE_RE.test(t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Throws with the reason named — never returns a falsy sentinel (AGENTS.md: a helper that degrades
|
|
41
|
+
* to `undefined` reads as "fine" to every caller). Checks the variable-segment case FIRST so that
|
|
42
|
+
* shape gets its own actionable message rather than being folded into the generic "malformed" one.
|
|
43
|
+
*/
|
|
44
|
+
export function assertValidEventType(t: string): void {
|
|
45
|
+
if (typeof t === "string" && VARIABLE_SEGMENT_RE.test(t)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`event type ${JSON.stringify(t)} carries a variable segment (a ticket id) — move it to ` +
|
|
48
|
+
`CatalystEvent.entity ({ type: "ticket", id }) instead of embedding it in \`type\``,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!isValidEventType(t)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`event type ${JSON.stringify(t)} is not a valid dotted lower-kebab event type ` +
|
|
54
|
+
`(pattern: lower-kebab segments joined by ".", max ${MAX_EVENT_TYPE_BYTES} bytes)`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Splits a type into its leading domain segment plus the full segment list, for registry
|
|
60
|
+
* grouping/reporting. Pure string split — does not itself validate `t`. */
|
|
61
|
+
export function parseEventType(t: string): { domain: string; segments: string[] } {
|
|
62
|
+
const segments = t.split(".");
|
|
63
|
+
return { domain: segments[0] ?? "", segments };
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// types.ts — CTC-936 phase 1. The versioned event envelope, verbatim from the accepted proposal's
|
|
2
|
+
// `CatalystEvent<T>` (thoughts/shared/research/2026-08-23-CTC-936-cloudflare-native-event-backbone-
|
|
3
|
+
// proposal.md), field-for-field. ADR-0039 §"Event envelope".
|
|
4
|
+
//
|
|
5
|
+
// Nothing in this repo consumes this type yet — that is deliberate (phase 1 scope). It is the
|
|
6
|
+
// boundary phase 2's DO outbox, phase 3's R2 archive, and phase 4's replay path are typed against.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One entry in the tenant's ordered, immutable event history.
|
|
10
|
+
*
|
|
11
|
+
* Each field is documented as SERVER-owned or PRODUCER-supplied — the proposal's own words: "The
|
|
12
|
+
* server owns sequence assignment and validates event type/version combinations. Producers submit a
|
|
13
|
+
* typed command or outcome; they do not choose their own tenant sequence."
|
|
14
|
+
*/
|
|
15
|
+
export interface CatalystEvent<T = unknown> {
|
|
16
|
+
/** SERVER-owned. The tenant this event belongs to — the DO's own partition key. */
|
|
17
|
+
tenantId: string;
|
|
18
|
+
/** SERVER-owned. Monotonically increasing per-tenant sequence, allocated inside the DO's write
|
|
19
|
+
* transaction. A producer never chooses this. */
|
|
20
|
+
sequence: number;
|
|
21
|
+
/** PRODUCER-supplied. A stable idempotent identifier for this specific event occurrence. */
|
|
22
|
+
eventId: string;
|
|
23
|
+
/** PRODUCER-supplied. The closed, dotted lower-kebab type — see ./type-grammar.ts and
|
|
24
|
+
* ./registry.ts. Must be variable-free; a ticket id belongs in `entity`, not here. */
|
|
25
|
+
type: string;
|
|
26
|
+
/** PRODUCER-supplied. Which version of `type`'s payload shape this event was written against —
|
|
27
|
+
* see EVENT_SCHEMA_VERSION below. The server validates the (type, schemaVersion) combination. */
|
|
28
|
+
schemaVersion: number;
|
|
29
|
+
/** SERVER-owned. When the DO committed this event — ISO-8601, the DO's own clock. */
|
|
30
|
+
recordedAt: string;
|
|
31
|
+
/** PRODUCER-supplied, optional. When the underlying real-world fact actually occurred, if that
|
|
32
|
+
* differs from `recordedAt` (e.g. a delayed webhook delivery). */
|
|
33
|
+
occurredAt?: string;
|
|
34
|
+
/** PRODUCER-supplied, optional. The domain entity this event is about — this is where a variable
|
|
35
|
+
* identifier (a ticket id, a PR number, a lease id) belongs, never in `type`. */
|
|
36
|
+
entity?: { type: string; id: string };
|
|
37
|
+
/** PRODUCER-supplied, optional. Who/what caused this event — a host, an agent, a webhook sender. */
|
|
38
|
+
actor?: { type: string; id?: string };
|
|
39
|
+
/** PRODUCER-supplied, optional. Groups events that belong to one logical operation across
|
|
40
|
+
* producers/consumers. */
|
|
41
|
+
correlationId?: string;
|
|
42
|
+
/** PRODUCER-supplied, optional. The id of the event (or command) that directly caused this one —
|
|
43
|
+
* the causal edge, distinct from the broader `correlationId` grouping. */
|
|
44
|
+
causationId?: string;
|
|
45
|
+
/** PRODUCER-supplied, optional. A key a producer can reuse across retries so a re-submitted write
|
|
46
|
+
* is deduplicated rather than double-appended. */
|
|
47
|
+
idempotencyKey?: string;
|
|
48
|
+
/** PRODUCER-supplied. The typed body — shape depends on `type` + `schemaVersion`. */
|
|
49
|
+
payload: T;
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,12 @@ export {
|
|
|
76
76
|
type SchemaSkew,
|
|
77
77
|
} from "./skew.js";
|
|
78
78
|
|
|
79
|
+
// CTC-936 phase 1 (ADR-0039) — the event-contract module: the versioned `CatalystEvent<T>` envelope
|
|
80
|
+
// and the variable-free dotted lower-kebab type grammar. Barrel-exported here so the SDK's existing
|
|
81
|
+
// exact pin on this package (bun.lock) reaches it with no new published surface — see
|
|
82
|
+
// packages/schema/src/events/index.ts's own header for what phase 2+ adds to this barrel.
|
|
83
|
+
export * from "./events/index.js";
|
|
84
|
+
|
|
79
85
|
/** Every table in the Mirror DO store — pass as `drizzle(storage, { schema: mirrorSchema })`. */
|
|
80
86
|
export const mirrorSchema = {
|
|
81
87
|
issues,
|
|
@@ -207,6 +207,13 @@ export const MIRROR_MIGRATIONS = {
|
|
|
207
207
|
tag: "0027_closed_miek",
|
|
208
208
|
breakpoints: true,
|
|
209
209
|
},
|
|
210
|
+
{
|
|
211
|
+
idx: 28,
|
|
212
|
+
version: "6",
|
|
213
|
+
when: 1787038129996,
|
|
214
|
+
tag: "0028_burly_nemesis",
|
|
215
|
+
breakpoints: true,
|
|
216
|
+
},
|
|
210
217
|
],
|
|
211
218
|
},
|
|
212
219
|
migrations: {
|
|
@@ -264,5 +271,6 @@ export const MIRROR_MIGRATIONS = {
|
|
|
264
271
|
"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
272
|
"0027_closed_miek":
|
|
266
273
|
"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;",
|
|
274
|
+
"0028_burly_nemesis": "ALTER TABLE `check_suites` ADD `pull_request_numbers` text;",
|
|
267
275
|
},
|
|
268
276
|
} as const;
|
package/src/mirror.ts
CHANGED
|
@@ -843,6 +843,37 @@ export const check_suites = sqliteTable(
|
|
|
843
843
|
app_slug: text("app_slug"),
|
|
844
844
|
/** GitHub's own count of the runs in the suite; useful for spotting a suite still filling up. */
|
|
845
845
|
latest_check_runs_count: integer("latest_check_runs_count"),
|
|
846
|
+
/**
|
|
847
|
+
* CTC-712 — the PRs GitHub itself attributed this suite to, as a JSON array of numbers
|
|
848
|
+
* (`"[123,124]"`). ⛔ THE CONSUMER'S ONLY KEY. `broker/router.mjs:1497` reaches an interest for
|
|
849
|
+
* `github.check_suite.completed` exclusively through `detail.prNumbers`; an empty one means the
|
|
850
|
+
* CI wait is never resolved, and under `enforce` there is no smee copy to recover it from.
|
|
851
|
+
*
|
|
852
|
+
* ⛔⛔ DO NOT DERIVE THIS FROM `head_sha` → `pull_requests.head_sha`. `pull_requests.head_sha` is
|
|
853
|
+
* LAST-STATE, so a suite that ran against a head the PR has since moved past can never be joined
|
|
854
|
+
* back. Measured by CTL on mini-2 (CTC-712): of 202 suites, 93 joined (46%) and 109 missed (54%)
|
|
855
|
+
* — and the misses were active PR branches (`ryan/ctl-1944-direnv-fleet` 25, `ryan/ctl-1949-ask-
|
|
856
|
+
* traps` 19), not main. ⭐ The sharpest row: `ctc-pin-sdk-0.8.13` appears in BOTH the hits (10)
|
|
857
|
+
* and the misses (10) — one branch, where only the suites matching the PR's CURRENT head join.
|
|
858
|
+
* That is the superseded-head pattern isolated in a single branch, so the 46% is structural.
|
|
859
|
+
*
|
|
860
|
+
* ⚠️ Worse than a coverage gap, it is a RACE: the join cannot tell a legitimately-superseded
|
|
861
|
+
* suite from a current one whose PR moved a moment later, and it drops both — silently, as an
|
|
862
|
+
* emit-decline rather than an error. Same class as the `pushes` collapse (CTC-704), fixed the
|
|
863
|
+
* same way: store the EDGE the payload carries, not a projection of state.
|
|
864
|
+
*
|
|
865
|
+
* ⚠️ `null` AND `"[]"` ARE DIFFERENT ANSWERS and the distinction is deliberate. `null` = GitHub's
|
|
866
|
+
* payload carried no `pull_requests` field at all (we do not know); `"[]"` = GitHub told us this
|
|
867
|
+
* suite belongs to no PR (a `main` push, `release-please--*`). A consumer that must not
|
|
868
|
+
* distinguish them collapses both to the empty list — which is exactly what the tunnel producer
|
|
869
|
+
* already does (`webhook-events.ts` `parseCheckSuite` builds `prNumbers: []` for both), so this
|
|
870
|
+
* column is at PARITY with the tunnel it replaces and strictly more informative.
|
|
871
|
+
*
|
|
872
|
+
* ⚠️ KNOWN LIMIT, stated rather than discovered later: GitHub delivers an EMPTY `pull_requests`
|
|
873
|
+
* for a suite on a FORK's PR. That is a real blind spot — but it is the tunnel's blind spot too,
|
|
874
|
+
* for the same reason, so retiring the tunnel loses nothing here.
|
|
875
|
+
*/
|
|
876
|
+
pull_request_numbers: text("pull_request_numbers"),
|
|
846
877
|
updated_at: integer("updated_at"),
|
|
847
878
|
},
|
|
848
879
|
(t) => [index("idx_check_suites_sha").on(t.head_sha)],
|