@catalyst-cloud/schema 0.1.18 → 0.1.20

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.
@@ -0,0 +1,198 @@
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
+ // Codex P2 (PR #1250): derive the parsed prefix in the TIMESTAMP'S OWN offset, not UTC — a
80
+ // valid instant like 2026-08-24T23:30:00-02:00 is already Aug 25 in UTC, so a UTC comparison
81
+ // rejects a well-formed date the regex deliberately accepts. The rollover check still fires:
82
+ // an out-of-range date (2026-02-30) rolls forward and lands on a different calendar date in
83
+ // ANY fixed offset.
84
+ const offsetMatch = /(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(occurredAt);
85
+ const offsetMs =
86
+ offsetMatch && offsetMatch[1]
87
+ ? (offsetMatch[1] === "-" ? -1 : 1) *
88
+ (Number(offsetMatch[2]) * 3600 + Number(offsetMatch[3]) * 60) *
89
+ 1000
90
+ : 0;
91
+ const parsedDatePrefix = new Date(occurredAtMs + offsetMs).toISOString().slice(0, 10);
92
+ if (roundTripDatePrefix !== parsedDatePrefix) {
93
+ throw new Error(
94
+ `eventArchiveKey: occurredAt ${JSON.stringify(occurredAt)} is not a valid date`,
95
+ );
96
+ }
97
+ if (!Number.isSafeInteger(sequence) || sequence < 0) {
98
+ // `sequence` is SERVER-owned (allocated inside the DO's write transaction, types.ts), so this
99
+ // is not reachable from a producer today — but eventArchiveKey is an exported boundary function
100
+ // in a published package, so its callers are not all in this repo. A malformed value would
101
+ // otherwise break the lexicographic-ordering invariant SEQUENCE_PAD_WIDTH exists to guarantee
102
+ // (e.g. padding "NaN" or a negative number into a key that no longer sorts numerically).
103
+ throw new Error(
104
+ `eventArchiveKey: sequence ${JSON.stringify(sequence)} is not a safe non-negative integer`,
105
+ );
106
+ }
107
+ if (typeof eventId !== "string" || !EVENT_ID_RE.test(eventId)) {
108
+ // `eventId` is PRODUCER-supplied and interpolated into the key with no separator after it — an
109
+ // unvalidated slash would inject an extra path segment (see EVENT_ID_RE comment above). The
110
+ // `typeof` check is load-bearing on its own: `RegExp.prototype.test` COERCES a non-string
111
+ // argument, so `EVENT_ID_RE.test(null)` tests the literal string "null" (which the allowlist
112
+ // matches) and would otherwise silently misfile into a plausible-looking `...-null.json` key —
113
+ // the same coercion-into-a-wrong-partition failure the occurredAt guard above closes for dates.
114
+ throw new Error(
115
+ `eventArchiveKey: eventId ${JSON.stringify(eventId)} contains invalid characters`,
116
+ );
117
+ }
118
+ const d = new Date(occurredAt);
119
+ const yyyy = String(d.getUTCFullYear()).padStart(4, "0");
120
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
121
+ const dd = String(d.getUTCDate()).padStart(2, "0");
122
+ const paddedSequence = String(sequence).padStart(SEQUENCE_PAD_WIDTH, "0");
123
+ return `${tenantId}/${yyyy}/${mm}/${dd}/${paddedSequence}-${eventId}.json`;
124
+ }
125
+
126
+ /**
127
+ * The R2 prefix that scopes a tenant's entire event archive — every key `eventArchiveKey` can ever
128
+ * produce for that tenant starts with this (the trailing "/" is load-bearing: without it, a tenant id
129
+ * that is a string-prefix of another, e.g. "t0" vs "t00", would leak into each other's deletion — see
130
+ * retention.test.ts's dedicated negative case). Throws with the reason named for a non-string, empty,
131
+ * or slash-bearing `tenantId` rather than silently returning a prefix that would leak (empty) or
132
+ * mis-scope (slash) a tenant-scoped deletion — the same posture `eventArchiveKey`'s other coordinates
133
+ * already take.
134
+ *
135
+ * ⚠️ This prefix is NOT distinguishable from `apps/mirror/src/artifacts/key.ts`'s `artifactPrefix({
136
+ * account: tenantId })` — both are a bare `${tenantId}/`. That is only safe under a DEDICATED event-
137
+ * archive bucket (docs/event-backbone/retention-and-deletion.md §4's recommendation). If a future
138
+ * binding reuses the shared `ARTIFACTS` bucket instead, this prefix MUST gain a distinct segment
139
+ * (e.g. `${tenantId}/events/`) before that binding ships, or a tenant-scoped teardown on one prefix
140
+ * will also sweep (or miss) the other.
141
+ */
142
+ export function eventArchiveTenantPrefix(tenantId: string): string {
143
+ assertValidTenantId("eventArchiveTenantPrefix", tenantId);
144
+ return `${tenantId}/`;
145
+ }
146
+
147
+ /** What `mayPruneHotEvent` needs to know about one hot-window event. */
148
+ export interface HotEventPruneCheck {
149
+ /** Epoch ms the R2 archive write was CONFIRMED, or `null` if not yet (or never) confirmed. */
150
+ archivedAt: number | null;
151
+ /** How old the event is, in ms, at the time of the check. */
152
+ ageMs: number;
153
+ }
154
+
155
+ /**
156
+ * May this hot-window event be pruned from DO SQLite? Both conditions are required — the proposal's
157
+ * own invariant: "Prune an event only after its R2 archive is confirmed." An event past the hot
158
+ * window with an UNCONFIRMED archive must NOT be pruned (the only durable copy would be lost); an
159
+ * event with a confirmed archive but still inside the hot window is kept for fast local replay.
160
+ */
161
+ export function mayPruneHotEvent(check: HotEventPruneCheck): boolean {
162
+ return check.archivedAt !== null && check.ageMs > EVENT_HOT_WINDOW_MS;
163
+ }
164
+
165
+ /** Bump when the projection's slot ASSIGNMENT changes (a field moves position, or the slot count
166
+ * changes); additive-at-the-end changes to a slot's semantic meaning still bump, because AE has no
167
+ * schema to enforce it and a silent reinterpretation of an existing blob/double is the failure mode
168
+ * this version guards against. */
169
+ export const EVENT_PROJECTION_VERSION = 1;
170
+
171
+ /** The Analytics Engine binding's slot budget this projection must stay inside — `apps/mirror/src/
172
+ * event-stream/validate.ts`'s own documented caps, mirrored here so a future binding can assert
173
+ * against the same numbers without importing a Workers-shaped module into this package. */
174
+ export const AE_MAX_BLOB_SLOTS = 20;
175
+
176
+ export interface EventProjectionSlots {
177
+ indexes: string[];
178
+ blobs: string[];
179
+ doubles: string[];
180
+ }
181
+
182
+ /**
183
+ * ⭐ THE VERSIONED AE PROJECTION — pinned POSITIONALLY to the layout that already exists and is live:
184
+ * `apps/mirror/src/event-stream/validate.ts:52-67`'s `toEventStreamDataPoint`, which returns
185
+ * `{indexes: [tenantId], blobs: [p.source, p.type, p.action, p.id, p.payloadExcerpt], doubles:
186
+ * [p.seq]}` off a `UnifiedEventPoint`. `sequence` here names the SAME position as that function's
187
+ * `p.seq` — `CatalystEvent.sequence` is this repo's name for the identical value. This function
188
+ * exists so a future phase that VERSIONS the live layout (rather than forking a second, silently
189
+ * divergent one) has a single source of truth to import; it is not consumed by any runtime code yet
190
+ * — that consumption is phase 2 of the vertical slice, out of this ticket's scope.
191
+ */
192
+ export function eventProjectionSlots(): EventProjectionSlots {
193
+ return {
194
+ indexes: ["tenantId"],
195
+ blobs: ["source", "type", "action", "id", "payloadExcerpt"],
196
+ doubles: ["sequence"],
197
+ };
198
+ }
@@ -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,