@streamotter/contracts 0.1.0-rc.1

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/src/schema.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { canonicalJson, codePointLength, isPlainObject, MAX_NESTING_DEPTH } from "./primitives.ts";
2
+ import type { ConfigIssue, Json, Params, Schema } from "./types.ts";
3
+
4
+ const KEYWORDS: Readonly<Record<string, readonly string[]>> = {
5
+ string: ["type", "minLength", "maxLength", "enum"],
6
+ number: ["type", "minimum", "maximum"],
7
+ integer: ["type", "minimum", "maximum"],
8
+ boolean: ["type"],
9
+ null: ["type"],
10
+ array: ["type", "items", "maxItems"],
11
+ object: ["type", "properties", "required", "additionalProperties"]
12
+ };
13
+
14
+ /** JSON-Pointer path segment escaping. */
15
+ export function pointer(base: string, segment: string | number): string {
16
+ return `${base}/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`;
17
+ }
18
+
19
+ function isNonNegativeSafeInteger(value: unknown): value is number {
20
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
21
+ }
22
+
23
+ /**
24
+ * Validates a schema definition against the V1 dialect. Unsupported keywords are
25
+ * rejected rather than ignored. Returns true when no issues were added.
26
+ */
27
+ export function validateSchemaDefinition(schema: unknown, path: string, issues: ConfigIssue[], depth = 1): schema is Schema {
28
+ const before = issues.length;
29
+ if (depth > MAX_NESTING_DEPTH) {
30
+ issues.push({ path, code: "SCHEMA_DEPTH_EXCEEDED", message: `Schemas may nest at most ${MAX_NESTING_DEPTH} levels.` });
31
+ return false;
32
+ }
33
+ if (!isPlainObject(schema)) {
34
+ issues.push({ path, code: "INVALID_TYPE", message: "A schema must be an object." });
35
+ return false;
36
+ }
37
+ const type = schema["type"];
38
+ if (typeof type !== "string" || !Object.hasOwn(KEYWORDS, type)) {
39
+ issues.push({
40
+ path: pointer(path, "type"),
41
+ code: "SCHEMA_UNSUPPORTED_TYPE",
42
+ message: "type must be one of string, number, integer, boolean, null, array, or object (unions are not supported in V1)."
43
+ });
44
+ return false;
45
+ }
46
+ const allowed = KEYWORDS[type] ?? [];
47
+ for (const key of Object.keys(schema)) {
48
+ if (!allowed.includes(key)) {
49
+ issues.push({
50
+ path: pointer(path, key),
51
+ code: "SCHEMA_UNSUPPORTED_KEYWORD",
52
+ message: `"${key}" is not supported for ${type} schemas in V1.`
53
+ });
54
+ }
55
+ }
56
+ switch (type) {
57
+ case "string": {
58
+ const { minLength, maxLength } = schema;
59
+ if (minLength !== undefined && !isNonNegativeSafeInteger(minLength)) {
60
+ issues.push({ path: pointer(path, "minLength"), code: "INVALID_VALUE", message: "minLength must be a non-negative integer." });
61
+ }
62
+ if (maxLength !== undefined && !isNonNegativeSafeInteger(maxLength)) {
63
+ issues.push({ path: pointer(path, "maxLength"), code: "INVALID_VALUE", message: "maxLength must be a non-negative integer." });
64
+ }
65
+ if (isNonNegativeSafeInteger(minLength) && isNonNegativeSafeInteger(maxLength) && minLength > maxLength) {
66
+ issues.push({ path: pointer(path, "minLength"), code: "INVALID_VALUE", message: "minLength must not exceed maxLength." });
67
+ }
68
+ const values = schema["enum"];
69
+ if (values !== undefined) {
70
+ if (!Array.isArray(values) || values.length === 0 || !values.every(item => typeof item === "string")) {
71
+ issues.push({ path: pointer(path, "enum"), code: "INVALID_VALUE", message: "enum must be a non-empty array of strings." });
72
+ } else if (new Set(values).size !== values.length) {
73
+ issues.push({ path: pointer(path, "enum"), code: "INVALID_VALUE", message: "enum values must be unique." });
74
+ }
75
+ }
76
+ break;
77
+ }
78
+ case "number":
79
+ case "integer": {
80
+ const { minimum, maximum } = schema;
81
+ for (const [key, bound] of [["minimum", minimum], ["maximum", maximum]] as const) {
82
+ if (bound === undefined) continue;
83
+ if (typeof bound !== "number" || !Number.isFinite(bound) || (type === "integer" && !Number.isSafeInteger(bound))) {
84
+ issues.push({ path: pointer(path, key), code: "INVALID_VALUE", message: `${key} must be a finite ${type === "integer" ? "safe integer" : "number"}.` });
85
+ }
86
+ }
87
+ if (typeof minimum === "number" && typeof maximum === "number" && minimum > maximum) {
88
+ issues.push({ path: pointer(path, "minimum"), code: "INVALID_VALUE", message: "minimum must not exceed maximum." });
89
+ }
90
+ break;
91
+ }
92
+ case "array": {
93
+ if (!isNonNegativeSafeInteger(schema["maxItems"])) {
94
+ issues.push({ path: pointer(path, "maxItems"), code: "REQUIRED", message: "Arrays require a non-negative integer maxItems bound." });
95
+ }
96
+ if (schema["items"] === undefined) {
97
+ issues.push({ path: pointer(path, "items"), code: "REQUIRED", message: "Arrays require an items schema." });
98
+ } else {
99
+ validateSchemaDefinition(schema["items"], pointer(path, "items"), issues, depth + 1);
100
+ }
101
+ break;
102
+ }
103
+ case "object": {
104
+ if (schema["additionalProperties"] !== false) {
105
+ issues.push({ path: pointer(path, "additionalProperties"), code: "REQUIRED", message: "Objects must declare additionalProperties: false." });
106
+ }
107
+ const properties = schema["properties"];
108
+ if (!isPlainObject(properties)) {
109
+ issues.push({ path: pointer(path, "properties"), code: "REQUIRED", message: "Objects require a properties map." });
110
+ } else {
111
+ for (const [name, child] of Object.entries(properties)) {
112
+ validateSchemaDefinition(child, pointer(pointer(path, "properties"), name), issues, depth + 1);
113
+ }
114
+ }
115
+ const required = schema["required"];
116
+ if (!Array.isArray(required) || !required.every(item => typeof item === "string")) {
117
+ issues.push({ path: pointer(path, "required"), code: "REQUIRED", message: "Objects require a required array of property names." });
118
+ } else {
119
+ if (new Set(required).size !== required.length) {
120
+ issues.push({ path: pointer(path, "required"), code: "INVALID_VALUE", message: "required names must be unique." });
121
+ }
122
+ if (isPlainObject(properties)) {
123
+ for (const name of required) {
124
+ if (!Object.hasOwn(properties, name)) {
125
+ issues.push({ path: pointer(path, "required"), code: "UNKNOWN_REFERENCE", message: `required property "${name}" is not declared in properties.` });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ break;
131
+ }
132
+ default:
133
+ break;
134
+ }
135
+ return issues.length === before;
136
+ }
137
+
138
+ /**
139
+ * Parameter schemas are closed objects whose properties are all required and
140
+ * are strings, booleans, or integers. Call after validateSchemaDefinition.
141
+ */
142
+ export function validateParamsSchema(schema: Schema, path: string, issues: ConfigIssue[]): boolean {
143
+ const before = issues.length;
144
+ if (schema.type !== "object") {
145
+ issues.push({ path, code: "INVALID_PARAMS_SCHEMA", message: "Parameter schemas must be objects." });
146
+ return false;
147
+ }
148
+ const names = Object.keys(schema.properties);
149
+ for (const name of names) {
150
+ const child = schema.properties[name];
151
+ if (child === undefined) continue;
152
+ if (child.type !== "string" && child.type !== "boolean" && child.type !== "integer") {
153
+ issues.push({
154
+ path: pointer(pointer(path, "properties"), name),
155
+ code: "INVALID_PARAMS_SCHEMA",
156
+ message: "Parameters may only be strings, booleans, or integers in V1."
157
+ });
158
+ }
159
+ if (!schema.required.includes(name)) {
160
+ issues.push({
161
+ path: pointer(pointer(path, "properties"), name),
162
+ code: "INVALID_PARAMS_SCHEMA",
163
+ message: "Every parameter must be required; optional parameters are not supported in V1."
164
+ });
165
+ }
166
+ }
167
+ return issues.length === before;
168
+ }
169
+
170
+ export interface ValueIssue { path: string; message: string }
171
+
172
+ /** Validates a value against a schema. Returns the first issue, or null when valid. */
173
+ export function validateValue(schema: Schema, value: unknown, path = "$", depth = 1): ValueIssue | null {
174
+ if (depth > MAX_NESTING_DEPTH) return { path, message: `Values may nest at most ${MAX_NESTING_DEPTH} levels.` };
175
+ switch (schema.type) {
176
+ case "string": {
177
+ if (typeof value !== "string") return { path, message: "Expected a string." };
178
+ if (schema.enum !== undefined && !schema.enum.includes(value)) return { path, message: "Value is not one of the allowed strings." };
179
+ const length = (schema.minLength !== undefined || schema.maxLength !== undefined) ? codePointLength(value) : 0;
180
+ if (schema.minLength !== undefined && length < schema.minLength) return { path, message: `Expected at least ${schema.minLength} characters.` };
181
+ if (schema.maxLength !== undefined && length > schema.maxLength) return { path, message: `Expected at most ${schema.maxLength} characters.` };
182
+ return null;
183
+ }
184
+ case "number":
185
+ case "integer": {
186
+ if (typeof value !== "number" || !Number.isFinite(value)) return { path, message: "Expected a finite number." };
187
+ if (schema.type === "integer" && !Number.isSafeInteger(value)) return { path, message: "Expected a safe integer." };
188
+ if (schema.minimum !== undefined && value < schema.minimum) return { path, message: `Expected a value of at least ${schema.minimum}.` };
189
+ if (schema.maximum !== undefined && value > schema.maximum) return { path, message: `Expected a value of at most ${schema.maximum}.` };
190
+ return null;
191
+ }
192
+ case "boolean":
193
+ return typeof value === "boolean" ? null : { path, message: "Expected a boolean." };
194
+ case "null":
195
+ return value === null ? null : { path, message: "Expected null." };
196
+ case "array": {
197
+ if (!Array.isArray(value)) return { path, message: "Expected an array." };
198
+ if (value.length > schema.maxItems) return { path, message: `Expected at most ${schema.maxItems} items.` };
199
+ for (let i = 0; i < value.length; i++) {
200
+ const issue = validateValue(schema.items, value[i], `${path}[${i}]`, depth + 1);
201
+ if (issue !== null) return issue;
202
+ }
203
+ return null;
204
+ }
205
+ case "object": {
206
+ if (!isPlainObject(value)) return { path, message: "Expected an object." };
207
+ for (const key of Object.keys(value)) {
208
+ if (!Object.hasOwn(schema.properties, key)) return { path: `${path}.${key}`, message: "Property is not allowed." };
209
+ }
210
+ for (const key of schema.required) {
211
+ if (!Object.hasOwn(value, key)) return { path: `${path}.${key}`, message: "Required property is missing." };
212
+ }
213
+ for (const [key, child] of Object.entries(schema.properties)) {
214
+ if (!Object.hasOwn(value, key)) continue;
215
+ const issue = validateValue(child, value[key], `${path}.${key}`, depth + 1);
216
+ if (issue !== null) return issue;
217
+ }
218
+ return null;
219
+ }
220
+ default:
221
+ return { path, message: "Unsupported schema." };
222
+ }
223
+ }
224
+
225
+ export type CanonicalParamsResult =
226
+ | { ok: true; params: Params; canonical: string }
227
+ | { ok: false; issue: ValueIssue };
228
+
229
+ /**
230
+ * Validates parameters and produces their canonical encoding: -0 normalized to 0,
231
+ * keys sorted, JSON encoded. String case and Unicode are not normalized.
232
+ */
233
+ export function canonicalizeParams(schema: Schema, params: unknown): CanonicalParamsResult {
234
+ const issue = validateValue(schema, params);
235
+ if (issue !== null) return { ok: false, issue };
236
+ const normalized: Record<string, string | boolean | number> = {};
237
+ for (const key of Object.keys(params as Record<string, unknown>).sort()) {
238
+ const value = (params as Record<string, string | boolean | number>)[key];
239
+ if (value === undefined) continue;
240
+ Object.defineProperty(normalized, key, {
241
+ value: typeof value === "number" && Object.is(value, -0) ? 0 : value,
242
+ enumerable: true, writable: true, configurable: true
243
+ });
244
+ }
245
+ return { ok: true, params: normalized, canonical: canonicalJson(normalized) };
246
+ }
247
+
248
+ export function isJsonData(value: unknown): value is Json {
249
+ try {
250
+ canonicalJson(value);
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
package/src/types.ts ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * StreamOtter V1 public type contracts. docs/V1_API.md governs behavior; these
3
+ * declarations govern public types. contracts/v1/api.ts re-exports them.
4
+ */
5
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
6
+ export type Params = Readonly<Record<string, string | boolean | number>>;
7
+ export type Revision = string; // Canonical unsigned decimal; validated at runtime.
8
+ export type Unlisten = () => void;
9
+ export type Awaitable<T> = T | Promise<T>;
10
+
11
+ export interface ChannelContract<P extends Params = Params, D extends Json = Json, V extends number = number> {
12
+ params: P;
13
+ data: D;
14
+ version: V;
15
+ }
16
+ export type ChannelMap = Record<string, ChannelContract>;
17
+
18
+ export type ErrorCode =
19
+ | "UNAUTHENTICATED" | "FORBIDDEN" | "INVALID_PARAMS" | "CHANNEL_NOT_FOUND"
20
+ | "CHANNEL_VERSION_UNSUPPORTED" | "SOURCE_UNAVAILABLE" | "INVALID_PAYLOAD"
21
+ | "OVERLOADED" | "RESYNC_REQUIRED" | "UNSUPPORTED_CAPABILITY" | "INVALID_REQUEST"
22
+ | "CONFIG_INVALID" | "TIMEOUT" | "CANCELLED" | "CLIENT_CLOSED"
23
+ | "HANDLER_FAILED" | "REVISION_CONFLICT" | "TRACE_CURSOR_EXPIRED" | "INTERNAL";
24
+ export interface StreamError {
25
+ code: ErrorCode;
26
+ message: string;
27
+ retryable: boolean;
28
+ requestId: string;
29
+ details?: Readonly<Record<string, Json>>;
30
+ }
31
+ export interface StreamEvent<D extends Json = Json> {
32
+ id: string;
33
+ channel: string;
34
+ channelVersion: number;
35
+ kind: "snapshot" | "update";
36
+ data: D;
37
+ revision: Revision;
38
+ receivedAt: string;
39
+ }
40
+ export type ConnectionState = "idle" | "connecting" | "connected" | "reconnecting" | "auth-required" | "closed";
41
+ export type SubscriptionState = "idle" | "authorizing" | "synchronizing" | "live" | "stale" | "resync-required" | "failed" | "closed";
42
+ export interface StateChange<S extends string> {
43
+ state: S;
44
+ reason?: ErrorCode;
45
+ }
46
+ export interface WaitOptions { timeoutMs?: number; signal?: AbortSignal }
47
+ export interface Subscription<D extends Json> {
48
+ readonly id: string;
49
+ readonly state: SubscriptionState;
50
+ on(event: "data", listener: (event: StreamEvent<D>) => void): Unlisten;
51
+ on(event: "state", listener: (state: StateChange<SubscriptionState>) => void): Unlisten;
52
+ on(event: "error", listener: (error: StreamError) => void): Unlisten;
53
+ ready(options?: WaitOptions): Promise<void>;
54
+ resync(options?: WaitOptions): Promise<void>;
55
+ unsubscribe(): Promise<void>;
56
+ }
57
+ export interface ClientOptions {
58
+ origin?: string; // Absolute HTTP(S) origin; defaults to current browser origin.
59
+ path?: string; // Engine.IO HTTP path; default /streamotter/socket.io.
60
+ getToken: (context: { signal: AbortSignal }) => Awaitable<string>;
61
+ }
62
+ export interface Client<C extends ChannelMap> {
63
+ readonly state: ConnectionState;
64
+ subscribe<K extends keyof C & string>(channel: K, options: {
65
+ channelVersion: C[K]["version"];
66
+ params: C[K]["params"];
67
+ }): Subscription<C[K]["data"]>;
68
+ on(event: "state", listener: (state: StateChange<ConnectionState>) => void): Unlisten;
69
+ on(event: "error", listener: (error: StreamError) => void): Unlisten;
70
+ reconnect(options?: WaitOptions): Promise<void>;
71
+ close(): Promise<void>;
72
+ }
73
+
74
+ /** Deliberately limited JSON Schema dialect; see the specification. */
75
+ export type Schema =
76
+ | { type: "string"; minLength?: number; maxLength?: number; enum?: readonly string[] }
77
+ | { type: "number" | "integer"; minimum?: number; maximum?: number }
78
+ | { type: "boolean" | "null" }
79
+ | { type: "array"; items: Schema; maxItems: number }
80
+ | { type: "object"; properties: Readonly<Record<string, Schema>>; required: readonly string[]; additionalProperties: false };
81
+ export type SecretRef = { env: string };
82
+ export interface KafkaConnection {
83
+ brokers: readonly string[];
84
+ tls: false | { caFile?: string };
85
+ sasl?: {
86
+ mechanism: "plain" | "scram-sha-256" | "scram-sha-512";
87
+ username: SecretRef;
88
+ password: SecretRef;
89
+ };
90
+ }
91
+ export type Source = {
92
+ kind: "kafka";
93
+ generation: string;
94
+ connectionRef: string;
95
+ topics: readonly string[];
96
+ consumerGroup: string;
97
+ codec: "json";
98
+ startFrom: "latest" | "earliest";
99
+ } | {
100
+ kind: "fixture";
101
+ generation: string;
102
+ fixtureRef: string;
103
+ };
104
+ export interface Limits {
105
+ maxConnections: number;
106
+ maxSubscriptionsPerConnection: number;
107
+ maxSourceRecordBytes: number;
108
+ maxDataFrameBytes: number;
109
+ maxParamsBytes: number;
110
+ maxPendingFramesPerSubscription: number;
111
+ maxPendingBytesPerSubscription: number;
112
+ maxPendingBytesPerConnection: number;
113
+ maxPendingBytesGateway: number;
114
+ maxMapOutputs: number;
115
+ maxConcurrentSnapshots: number;
116
+ handlerTimeoutMs: number;
117
+ snapshotTimeoutMs: number;
118
+ receiptTimeoutMs: number;
119
+ maxSyncAttempts: number;
120
+ maxTraceEntries: number;
121
+ maxTraceBytes: number;
122
+ maxControlFrameBytes: number;
123
+ controlRequestsPerSecond: number;
124
+ }
125
+ export type ProjectConfig<C extends ChannelMap = ChannelMap> = {
126
+ configVersion: 1;
127
+ projectId: string;
128
+ gateway: {
129
+ host: string;
130
+ port: number;
131
+ path: string;
132
+ allowedOrigins: readonly string[];
133
+ };
134
+ connections: Readonly<Record<string, KafkaConnection>>;
135
+ sources: Readonly<Record<string, Source>>;
136
+ schemas: Readonly<Record<string, Schema>>;
137
+ channels: {
138
+ readonly [K in keyof C]: {
139
+ version: C[K]["version"];
140
+ source: string;
141
+ paramsSchema: string;
142
+ payloadSchema: string;
143
+ handlersRef: K & string;
144
+ delivery: { kind: "state"; overflow: "resync" };
145
+ }
146
+ };
147
+ limits?: Partial<Limits>;
148
+ };
149
+
150
+ export interface Principal {
151
+ subject: string;
152
+ tenantId: string;
153
+ sessionId: string;
154
+ expiresAt: string;
155
+ claims: Readonly<Record<string, Json>>;
156
+ }
157
+ export interface HandlerContext { signal: AbortSignal; requestId: string }
158
+ export interface SourceRecord {
159
+ id: string;
160
+ sourceId: string;
161
+ key: string | null;
162
+ value: Json;
163
+ receivedAt: string;
164
+ position: { kind: "kafka"; topic: string; partition: number; offset: string }
165
+ | { kind: "fixture"; index: string };
166
+ }
167
+ export interface MappedState<P extends Params, D extends Json> {
168
+ tenantId: string;
169
+ params: P;
170
+ revision: Revision;
171
+ data: D;
172
+ }
173
+ export interface ChannelHandlers<C extends ChannelContract> {
174
+ authorize(input: HandlerContext & { principal: Principal; params: C["params"] }): Awaitable<boolean>;
175
+ map(input: HandlerContext & { record: SourceRecord }): Awaitable<readonly MappedState<C["params"], C["data"]>[]>;
176
+ snapshot(input: HandlerContext & { principal: Principal; params: C["params"] }): Awaitable<{
177
+ revision: Revision;
178
+ data: C["data"];
179
+ }>;
180
+ }
181
+ export interface HandlerRegistry<C extends ChannelMap> {
182
+ authenticate(input: HandlerContext & { token: string; origin: string }): Awaitable<Principal | null>;
183
+ channels: { readonly [K in keyof C]: ChannelHandlers<C[K]> };
184
+ }
185
+ export type Revocation =
186
+ | { kind: "session"; tenantId: string; sessionId: string }
187
+ | { kind: "subject"; tenantId: string; subject: string }
188
+ | { kind: "channel"; tenantId: string; subject: string; channel: string; channelVersion: number; params?: Params };
189
+ export interface Gateway {
190
+ start(): Promise<{ origin: string; path: string }>;
191
+ stop(options?: { timeoutMs?: number }): Promise<void>;
192
+ revoke(request: Revocation): Promise<{ closedSubscriptions: number; closedConnections: number }>;
193
+ resumeSource(sourceId: string): Promise<void>;
194
+ }
195
+ /** Redacted operator diagnostics. Never receives credentials or payloads. */
196
+ export interface GatewayLogger {
197
+ info(message: string, fields?: Readonly<Record<string, Json>>): void;
198
+ warn(message: string, fields?: Readonly<Record<string, Json>>): void;
199
+ error(message: string, fields?: Readonly<Record<string, Json>>): void;
200
+ }
201
+ export interface DevelopmentOptions {
202
+ principals: Readonly<Record<string, Principal>>;
203
+ fixtures: Readonly<Record<string, readonly { key: string | null; value: Json }[]>>;
204
+ }
205
+ export interface GatewayOptions<C extends ChannelMap> {
206
+ config: ProjectConfig<C>;
207
+ handlers: HandlerRegistry<C>;
208
+ mode: "development" | "production";
209
+ development?: DevelopmentOptions;
210
+ /** Directory for resolving relative CA file paths; defaults to the process working directory. */
211
+ configDir?: string;
212
+ /** Operator diagnostics sink; defaults to structured console output. */
213
+ logger?: GatewayLogger;
214
+ }
215
+
216
+ export interface Capabilities {
217
+ protocolVersion: 1;
218
+ configVersions: readonly [1];
219
+ transport: "socket.io";
220
+ deliveryModes: readonly ["state"];
221
+ operations: readonly ["subscribe", "unsubscribe", "resync", "receipt"];
222
+ }
223
+ export interface SubscribeRequest {
224
+ requestId: string;
225
+ subscriptionId: string;
226
+ channel: string;
227
+ channelVersion: number;
228
+ params: Params;
229
+ }
230
+ export type ControlRequest = { requestId: string; subscriptionId: string };
231
+ export type Result<T> = { ok: true; requestId: string; data: T }
232
+ | { ok: false; requestId: string; error: StreamError };
233
+ export interface DataFrame {
234
+ subscriptionId: string;
235
+ epoch: string;
236
+ sequence: number;
237
+ event: StreamEvent;
238
+ }
239
+ export interface SubscriptionFrame {
240
+ subscriptionId: string;
241
+ epoch: string;
242
+ state: SubscriptionState;
243
+ reason?: ErrorCode;
244
+ }
245
+ export interface Receipt { subscriptionId: string; epoch: string; sequence: number }
246
+ export type Hello = Capabilities & { connectionId: string; identityKey: string; authExpiresAt: string };
247
+ export interface ErrorFrame { subscriptionId?: string; epoch?: string; error: StreamError }
248
+ export interface ClientToServerEvents {
249
+ "so:subscribe": (request: SubscribeRequest, reply: (result: Result<{ subscriptionId: string }>) => void) => void;
250
+ "so:unsubscribe": (request: ControlRequest, reply: (result: Result<null>) => void) => void;
251
+ "so:resync": (request: ControlRequest, reply: (result: Result<null>) => void) => void;
252
+ "so:receipt": (receipt: Receipt) => void;
253
+ }
254
+ export interface ServerToClientEvents {
255
+ "so:hello": (hello: Hello) => void;
256
+ "so:state": (state: SubscriptionFrame) => void;
257
+ "so:data": (frame: DataFrame) => void;
258
+ "so:error": (error: ErrorFrame) => void;
259
+ }
260
+ export interface SocketAuth { token: string; protocolVersion: 1 }
261
+
262
+ export type TraceStage = "source" | "validate" | "map" | "authorize" | "snapshot" | "queue" | "send" | "receipt" | "commit";
263
+ export interface Trace {
264
+ id: string;
265
+ requestId: string;
266
+ at: string;
267
+ stage: TraceStage;
268
+ outcome: "ok" | "filtered" | "rejected" | "failed";
269
+ sourceId?: string;
270
+ channel?: string;
271
+ subscriptionId?: string;
272
+ errorCode?: ErrorCode;
273
+ }
274
+ export interface Page<T> { items: readonly T[]; nextCursor: string | null }
275
+ export interface SourceStatus {
276
+ sourceId: string;
277
+ kind: Source["kind"];
278
+ status: "starting" | "healthy" | "degraded" | "paused" | "stopped";
279
+ reason?: ErrorCode;
280
+ }
281
+ export interface ChannelSummary {
282
+ name: string;
283
+ version: number;
284
+ source: string;
285
+ delivery: "state";
286
+ paramsSchema: string;
287
+ payloadSchema: string;
288
+ }
289
+ export interface ConfigIssue { path: string; message: string; code: string }
290
+ export interface DiagnosticStep {
291
+ stage: "resolve" | "connect" | "tls" | "authenticate" | "metadata";
292
+ outcome: "ok" | "failed" | "skipped";
293
+ message: string;
294
+ }
295
+ export interface DevelopmentPrincipalSummary { ref: string; tenantId: string; subject: string }
296
+ /** Paths include their method. Every response below is wrapped in Result<T>. */
297
+ export interface ManagementOperations {
298
+ "GET /management/v1/capabilities": { request: null; response: Capabilities };
299
+ "GET /management/v1/health": { request: null; response: { ready: boolean; sources: readonly SourceStatus[] } };
300
+ "GET /management/v1/sources": { request: null; response: { items: readonly SourceStatus[] } };
301
+ "GET /management/v1/channels": { request: null; response: { items: readonly ChannelSummary[] } };
302
+ "GET /management/v1/config": { request: null; response: { config: ProjectConfig; fingerprint: string } };
303
+ "POST /management/v1/source-checks": { request: { sourceId: string }; response: { steps: readonly DiagnosticStep[] } };
304
+ "POST /management/v1/config/validate": { request: { config: Json }; response: { valid: boolean; issues: readonly ConfigIssue[] } };
305
+ "POST /management/v1/config/export": { request: { config: Json }; response: { filename: "streamotter.json"; content: string; fingerprint: string } };
306
+ "GET /management/v1/traces": { request: { limit?: number; cursor?: string; sourceId?: string; channel?: string; outcome?: Trace["outcome"] }; response: Page<Trace> };
307
+ "POST /management/v1/sources/resume": { request: { sourceId: string }; response: SourceStatus };
308
+ "POST /management/v1/preview-sessions": { request: { fixturePrincipalRef: string }; response: { token: string; expiresAt: string; previewSessionId: string } };
309
+ "GET /management/v1/dev/principals": { request: null; response: { items: readonly DevelopmentPrincipalSummary[] } };
310
+ "POST /management/v1/dev/fixtures/advance": { request: { sourceId: string; count: number }; response: { advanced: number } };
311
+ "POST /management/v1/dev/disconnect": { request: { previewSessionId: string }; response: null };
312
+ }