@abloatai/ablo 0.14.0 → 0.15.0

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.
@@ -39,6 +39,13 @@ export interface StaleContextConflict extends ConflictBase {
39
39
  * cosmetic field. See `docs/internal/per-field-conflict-detection.md`.
40
40
  */
41
41
  readonly conflictingFields?: readonly string[];
42
+ /**
43
+ * The committer's declared `onStale` intent for this op. The default policy
44
+ * honors it: `'notify'` → notify+hold, anything else → reject. A custom policy
45
+ * may override (e.g. gate notify on claim ownership). Absent ⇒ treat as
46
+ * `'reject'` (the unguarded-write default).
47
+ */
48
+ readonly requestedMode?: 'reject' | 'overwrite' | 'notify';
42
49
  }
43
50
  export interface ClaimHeldConflict extends ConflictBase {
44
51
  readonly kind: 'claim_held';
@@ -83,6 +90,22 @@ export type ConflictDecision = {
83
90
  | {
84
91
  readonly action: 'preempt';
85
92
  readonly reason?: string;
93
+ }
94
+ /**
95
+ * Notify-instead-of-abort (non-coercion). Only meaningful for a
96
+ * `stale_context` conflict, and the engine's aligned disposition: HOLD the
97
+ * conflicting op (don't write it) and return a `StaleNotification` with the
98
+ * current value so the actor (agent or human) resolves and re-commits. The
99
+ * rest of the batch still commits. Maps from `onStale: 'notify'`.
100
+ *
101
+ * Serialization order is supplied by the monotonic `sync_id` landing order
102
+ * (the stale committer always yields/recomputes — an asymmetry that rules out
103
+ * a symmetric notify-rewrite livelock). Unbounded retry is bounded by the
104
+ * client's reconciliation retry cap.
105
+ */
106
+ | {
107
+ readonly action: 'notify';
108
+ readonly reason?: string;
86
109
  };
87
110
  /**
88
111
  * Pluggable decision function. Sync or async.
@@ -98,9 +121,18 @@ export type ConflictDecision = {
98
121
  */
99
122
  export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<ConflictDecision>;
100
123
  /**
101
- * Default: reject every conflict. Safe fallback when no custom policy
102
- * is wired — the engine never silently allows a stale or
103
- * claim-conflicting write through.
124
+ * Default policy.
125
+ *
126
+ * `claim_held` conflicts always reject (a foreign claim is honored unless a
127
+ * privileged policy preempts). `stale_context` conflicts honor the committer's
128
+ * declared `onStale` intent:
129
+ *
130
+ * • `'notify'` → notify + hold (op withheld; the actor resolves)
131
+ * • anything else (incl. `'reject'`, absent) → reject
132
+ *
133
+ * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
134
+ * detection. This preserves the legacy always-reject default for callers that
135
+ * don't opt into `notify`.
104
136
  */
105
137
  export declare const defaultPolicy: ConflictPolicy;
106
138
  /**
@@ -7,14 +7,27 @@
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  /**
10
- * Default: reject every conflict. Safe fallback when no custom policy
11
- * is wired — the engine never silently allows a stale or
12
- * claim-conflicting write through.
10
+ * Default policy.
11
+ *
12
+ * `claim_held` conflicts always reject (a foreign claim is honored unless a
13
+ * privileged policy preempts). `stale_context` conflicts honor the committer's
14
+ * declared `onStale` intent:
15
+ *
16
+ * • `'notify'` → notify + hold (op withheld; the actor resolves)
17
+ * • anything else (incl. `'reject'`, absent) → reject
18
+ *
19
+ * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
20
+ * detection. This preserves the legacy always-reject default for callers that
21
+ * don't opt into `notify`.
13
22
  */
14
- export const defaultPolicy = (conflict) => ({
15
- action: 'reject',
16
- reason: conflict.kind === 'stale_context' ? 'stale_context' : 'claim_conflict',
17
- });
23
+ export const defaultPolicy = (conflict) => {
24
+ if (conflict.kind !== 'stale_context') {
25
+ return { action: 'reject', reason: 'claim_conflict' };
26
+ }
27
+ return conflict.requestedMode === 'notify'
28
+ ? { action: 'notify', reason: 'stale_notify_hold' }
29
+ : { action: 'reject', reason: 'stale_context' };
30
+ };
18
31
  /**
19
32
  * Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
20
33
  * committer holds the `claim.preempt` operation in its capability allowlist
@@ -16,6 +16,7 @@
16
16
  import type { ParticipantKind, ConfirmationState } from '../schema/sync-delta-row.js';
17
17
  import type { ParticipantRef } from '../schema/sync-delta-wire.js';
18
18
  import type { Environment } from '../environment.js';
19
+ import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
19
20
  export interface CommitContext {
20
21
  participantId: string;
21
22
  /**
@@ -86,6 +87,14 @@ export interface CommitContext {
86
87
  * `caused_by_task_id` when present, but client writes leave it `null`.
87
88
  */
88
89
  causedByTaskId?: string | null;
90
+ /**
91
+ * Batch-level read dependencies (the STORM read-set layer). The committer
92
+ * declares rows/groups it READ to form this batch; the engine validates none
93
+ * changed since their `readAt` and fires each entry's `onStale` disposition
94
+ * over the whole batch. Distinct from the per-op `readAt` guard, which only
95
+ * validates the rows being WRITTEN. Omit for write-target-only checking.
96
+ */
97
+ reads?: ReadDependency[] | null;
89
98
  }
90
99
  /**
91
100
  * The receipt of a commit. Pins the exact `sync_deltas` id range the batch
@@ -97,4 +106,12 @@ export interface CommitContext {
97
106
  export interface CommitResult {
98
107
  lastSyncId: number;
99
108
  firstSyncId: number;
109
+ /**
110
+ * Stale-context notifications for ops the committer guarded with
111
+ * `onStale: 'notify'. Present (non-empty) only when a guarded write
112
+ * collided with a concurrent change; the committer self-heals from these
113
+ * rather than receiving an `AbloStaleContextError`. See
114
+ * `StaleNotification` in `coordination/schema.ts`.
115
+ */
116
+ notifications?: StaleNotification[];
100
117
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Data Source reverse-channel wire protocol.
3
+ *
4
+ * The `commit`/`load`/`list` leg of Data Source mode is normally an INBOUND
5
+ * webhook (Ablo Cloud → your HTTPS endpoint). That requires a public URL, so it
6
+ * doesn't work on `localhost` or inside a locked-down VPC with no inbound path.
7
+ *
8
+ * The reverse channel inverts the direction: the customer's connector dials OUT
9
+ * to Ablo Cloud over a WebSocket and serves those same requests over the open
10
+ * socket — the Stripe-CLI `stripe listen` pattern. This module defines the
11
+ * frames that travel over that socket.
12
+ *
13
+ * Trust model is unchanged. A `request` frame carries the SAME Standard Webhooks
14
+ * signature headers (`webhook-id` / `webhook-timestamp` / `webhook-signature`)
15
+ * and the SAME raw body Ablo would have POSTed, so the connector verifies it
16
+ * through the unchanged `verifyAbloSourceRequest`. Only the transport differs.
17
+ *
18
+ * Frames are validated with zod at the boundary (matching `contract.ts`): a
19
+ * malformed frame is rejected at the edge, and every wire type is inferred from
20
+ * one schema so the two sides can never silently drift.
21
+ */
22
+ import { z } from 'zod';
23
+ /**
24
+ * Wire-protocol version. Bumped on any breaking frame-shape change so an old
25
+ * connector talking to a new server (or vice-versa) fails fast at `register`
26
+ * instead of misparsing a frame mid-stream.
27
+ */
28
+ export declare const SOURCE_CONNECTOR_PROTOCOL_VERSION = 1;
29
+ /** Path the connector dials. Appended to the connector's `baseURL`. */
30
+ export declare const SOURCE_CONNECTOR_WS_PATH = "/v1/source/listen";
31
+ /**
32
+ * Negotiated WebSocket subprotocol that marks a reverse-channel source
33
+ * connector (vs. the SDK sync client's `ablo.sync.v1`). The server selects and
34
+ * echoes back ONLY this value, never the token-bearing subprotocol — keeping the
35
+ * credential out of ALB / proxy logs exactly like the sync socket does.
36
+ */
37
+ export declare const WS_SOURCE_SUBPROTOCOL = "ablo.source.v1";
38
+ /**
39
+ * Build the `Sec-WebSocket-Protocol` list a connector offers: the source
40
+ * protocol plus the bearer credential (`ablo.bearer.<apiKey>`). Browsers can't
41
+ * set an `Authorization` header on a WebSocket, so the API key rides as a
42
+ * subprotocol — the same mechanism the SDK sync client uses, read server-side by
43
+ * the shared `extractBearer`.
44
+ */
45
+ export declare function sourceConnectorSubprotocols(apiKey: string): string[];
46
+ /**
47
+ * Connector → server, first frame after the socket opens and authenticates.
48
+ * Auth and source resolution happen at the WS handshake from the API key; this
49
+ * frame only carries protocol negotiation + advisory metadata.
50
+ */
51
+ export declare const registerFrameSchema: z.ZodObject<{
52
+ type: z.ZodLiteral<"register">;
53
+ protocolVersion: z.ZodNumber;
54
+ client: z.ZodOptional<z.ZodString>;
55
+ }, z.core.$strip>;
56
+ export type RegisterFrame = z.infer<typeof registerFrameSchema>;
57
+ /**
58
+ * Server → connector, acknowledges a successful `register`. Echoes the resolved
59
+ * source identity so the connector can log/verify which source it is serving.
60
+ */
61
+ export declare const readyFrameSchema: z.ZodObject<{
62
+ type: z.ZodLiteral<"ready">;
63
+ protocolVersion: z.ZodNumber;
64
+ sourceId: z.ZodOptional<z.ZodString>;
65
+ organizationId: z.ZodOptional<z.ZodString>;
66
+ environment: z.ZodOptional<z.ZodEnum<{
67
+ production: "production";
68
+ sandbox: "sandbox";
69
+ }>>;
70
+ }, z.core.$strip>;
71
+ export type ReadyFrame = z.infer<typeof readyFrameSchema>;
72
+ /**
73
+ * Server → connector, one drained `commit`/`load`/`list` request. `headers` and
74
+ * `body` are byte-identical to what the inbound webhook path would have sent —
75
+ * the connector replays them into a synthesized `Request` so the unchanged
76
+ * handler verifies the signature exactly as in production.
77
+ */
78
+ export declare const requestFrameSchema: z.ZodObject<{
79
+ type: z.ZodLiteral<"request">;
80
+ id: z.ZodString;
81
+ method: z.ZodLiteral<"POST">;
82
+ url: z.ZodString;
83
+ headers: z.ZodRecord<z.ZodString, z.ZodString>;
84
+ body: z.ZodString;
85
+ }, z.core.$strip>;
86
+ export type RequestFrame = z.infer<typeof requestFrameSchema>;
87
+ /**
88
+ * Connector → server, the handler's `Response` for one `request`. The server
89
+ * resolves the pending request keyed by `id` and feeds `status`/`body` back to
90
+ * the `SourceClient` as if an HTTP response had returned.
91
+ */
92
+ export declare const responseFrameSchema: z.ZodObject<{
93
+ type: z.ZodLiteral<"response">;
94
+ id: z.ZodString;
95
+ status: z.ZodNumber;
96
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
97
+ body: z.ZodString;
98
+ }, z.core.$strip>;
99
+ export type ResponseFrame = z.infer<typeof responseFrameSchema>;
100
+ /**
101
+ * Either direction, out-of-band failure not tied to a single request body
102
+ * (auth rejected, unsupported protocol version, malformed frame). When `id` is
103
+ * present the error pertains to that pending request and fails it; otherwise it
104
+ * is a connection-level error.
105
+ */
106
+ export declare const errorFrameSchema: z.ZodObject<{
107
+ type: z.ZodLiteral<"error">;
108
+ id: z.ZodOptional<z.ZodString>;
109
+ code: z.ZodString;
110
+ message: z.ZodString;
111
+ }, z.core.$strip>;
112
+ export type ErrorFrame = z.infer<typeof errorFrameSchema>;
113
+ export declare const connectorFrameSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
114
+ type: z.ZodLiteral<"register">;
115
+ protocolVersion: z.ZodNumber;
116
+ client: z.ZodOptional<z.ZodString>;
117
+ }, z.core.$strip>, z.ZodObject<{
118
+ type: z.ZodLiteral<"ready">;
119
+ protocolVersion: z.ZodNumber;
120
+ sourceId: z.ZodOptional<z.ZodString>;
121
+ organizationId: z.ZodOptional<z.ZodString>;
122
+ environment: z.ZodOptional<z.ZodEnum<{
123
+ production: "production";
124
+ sandbox: "sandbox";
125
+ }>>;
126
+ }, z.core.$strip>, z.ZodObject<{
127
+ type: z.ZodLiteral<"request">;
128
+ id: z.ZodString;
129
+ method: z.ZodLiteral<"POST">;
130
+ url: z.ZodString;
131
+ headers: z.ZodRecord<z.ZodString, z.ZodString>;
132
+ body: z.ZodString;
133
+ }, z.core.$strip>, z.ZodObject<{
134
+ type: z.ZodLiteral<"response">;
135
+ id: z.ZodString;
136
+ status: z.ZodNumber;
137
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
138
+ body: z.ZodString;
139
+ }, z.core.$strip>, z.ZodObject<{
140
+ type: z.ZodLiteral<"error">;
141
+ id: z.ZodOptional<z.ZodString>;
142
+ code: z.ZodString;
143
+ message: z.ZodString;
144
+ }, z.core.$strip>], "type">;
145
+ export type ConnectorFrame = z.infer<typeof connectorFrameSchema>;
146
+ /** Thrown when an incoming frame fails to parse or validate. */
147
+ export declare class ConnectorProtocolError extends Error {
148
+ readonly code = "source_connector_protocol_error";
149
+ constructor(message: string);
150
+ }
151
+ /** Serialize a frame for transmission. */
152
+ export declare function encodeFrame(frame: ConnectorFrame): string;
153
+ /**
154
+ * Parse + validate an incoming frame. Accepts the string or binary payloads the
155
+ * `ws` library / WebSocket `message` events deliver. Throws
156
+ * `ConnectorProtocolError` on any malformed or unknown frame so callers can
157
+ * reject the connection rather than act on garbage.
158
+ */
159
+ export declare function decodeFrame(raw: string | ArrayBuffer | Uint8Array): ConnectorFrame;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Data Source reverse-channel wire protocol.
3
+ *
4
+ * The `commit`/`load`/`list` leg of Data Source mode is normally an INBOUND
5
+ * webhook (Ablo Cloud → your HTTPS endpoint). That requires a public URL, so it
6
+ * doesn't work on `localhost` or inside a locked-down VPC with no inbound path.
7
+ *
8
+ * The reverse channel inverts the direction: the customer's connector dials OUT
9
+ * to Ablo Cloud over a WebSocket and serves those same requests over the open
10
+ * socket — the Stripe-CLI `stripe listen` pattern. This module defines the
11
+ * frames that travel over that socket.
12
+ *
13
+ * Trust model is unchanged. A `request` frame carries the SAME Standard Webhooks
14
+ * signature headers (`webhook-id` / `webhook-timestamp` / `webhook-signature`)
15
+ * and the SAME raw body Ablo would have POSTed, so the connector verifies it
16
+ * through the unchanged `verifyAbloSourceRequest`. Only the transport differs.
17
+ *
18
+ * Frames are validated with zod at the boundary (matching `contract.ts`): a
19
+ * malformed frame is rejected at the edge, and every wire type is inferred from
20
+ * one schema so the two sides can never silently drift.
21
+ */
22
+ import { z } from 'zod';
23
+ import { WS_BEARER_SUBPROTOCOL_PREFIX } from '../auth/credentialSource.js';
24
+ /**
25
+ * Wire-protocol version. Bumped on any breaking frame-shape change so an old
26
+ * connector talking to a new server (or vice-versa) fails fast at `register`
27
+ * instead of misparsing a frame mid-stream.
28
+ */
29
+ export const SOURCE_CONNECTOR_PROTOCOL_VERSION = 1;
30
+ /** Path the connector dials. Appended to the connector's `baseURL`. */
31
+ export const SOURCE_CONNECTOR_WS_PATH = '/v1/source/listen';
32
+ /**
33
+ * Negotiated WebSocket subprotocol that marks a reverse-channel source
34
+ * connector (vs. the SDK sync client's `ablo.sync.v1`). The server selects and
35
+ * echoes back ONLY this value, never the token-bearing subprotocol — keeping the
36
+ * credential out of ALB / proxy logs exactly like the sync socket does.
37
+ */
38
+ export const WS_SOURCE_SUBPROTOCOL = 'ablo.source.v1';
39
+ /**
40
+ * Build the `Sec-WebSocket-Protocol` list a connector offers: the source
41
+ * protocol plus the bearer credential (`ablo.bearer.<apiKey>`). Browsers can't
42
+ * set an `Authorization` header on a WebSocket, so the API key rides as a
43
+ * subprotocol — the same mechanism the SDK sync client uses, read server-side by
44
+ * the shared `extractBearer`.
45
+ */
46
+ export function sourceConnectorSubprotocols(apiKey) {
47
+ return [WS_SOURCE_SUBPROTOCOL, `${WS_BEARER_SUBPROTOCOL_PREFIX}${apiKey}`];
48
+ }
49
+ const headerRecord = z.record(z.string(), z.string());
50
+ /**
51
+ * Connector → server, first frame after the socket opens and authenticates.
52
+ * Auth and source resolution happen at the WS handshake from the API key; this
53
+ * frame only carries protocol negotiation + advisory metadata.
54
+ */
55
+ export const registerFrameSchema = z.object({
56
+ type: z.literal('register'),
57
+ protocolVersion: z.number().int(),
58
+ /**
59
+ * Advisory client identifier (e.g. `@abloatai/ablo@0.12.0`) for server-side
60
+ * logging. Not trusted for any decision.
61
+ */
62
+ client: z.string().optional(),
63
+ });
64
+ /**
65
+ * Server → connector, acknowledges a successful `register`. Echoes the resolved
66
+ * source identity so the connector can log/verify which source it is serving.
67
+ */
68
+ export const readyFrameSchema = z.object({
69
+ type: z.literal('ready'),
70
+ protocolVersion: z.number().int(),
71
+ sourceId: z.string().optional(),
72
+ organizationId: z.string().optional(),
73
+ environment: z.enum(['production', 'sandbox']).optional(),
74
+ });
75
+ /**
76
+ * Server → connector, one drained `commit`/`load`/`list` request. `headers` and
77
+ * `body` are byte-identical to what the inbound webhook path would have sent —
78
+ * the connector replays them into a synthesized `Request` so the unchanged
79
+ * handler verifies the signature exactly as in production.
80
+ */
81
+ export const requestFrameSchema = z.object({
82
+ type: z.literal('request'),
83
+ /** Correlation id; the matching `response` frame carries the same value. */
84
+ id: z.string().min(1),
85
+ method: z.literal('POST'),
86
+ /** Synthetic absolute URL used only to construct the `Request` object. */
87
+ url: z.string().min(1),
88
+ /** Signed Standard Webhooks headers + `Content-Type`. */
89
+ headers: headerRecord,
90
+ /** Raw JSON request body — exactly the bytes that were signed. */
91
+ body: z.string(),
92
+ });
93
+ /**
94
+ * Connector → server, the handler's `Response` for one `request`. The server
95
+ * resolves the pending request keyed by `id` and feeds `status`/`body` back to
96
+ * the `SourceClient` as if an HTTP response had returned.
97
+ */
98
+ export const responseFrameSchema = z.object({
99
+ type: z.literal('response'),
100
+ id: z.string().min(1),
101
+ status: z.number().int(),
102
+ headers: headerRecord.optional(),
103
+ /** Raw JSON response body. */
104
+ body: z.string(),
105
+ });
106
+ /**
107
+ * Either direction, out-of-band failure not tied to a single request body
108
+ * (auth rejected, unsupported protocol version, malformed frame). When `id` is
109
+ * present the error pertains to that pending request and fails it; otherwise it
110
+ * is a connection-level error.
111
+ */
112
+ export const errorFrameSchema = z.object({
113
+ type: z.literal('error'),
114
+ id: z.string().min(1).optional(),
115
+ code: z.string().min(1),
116
+ message: z.string(),
117
+ });
118
+ export const connectorFrameSchema = z.discriminatedUnion('type', [
119
+ registerFrameSchema,
120
+ readyFrameSchema,
121
+ requestFrameSchema,
122
+ responseFrameSchema,
123
+ errorFrameSchema,
124
+ ]);
125
+ /** Thrown when an incoming frame fails to parse or validate. */
126
+ export class ConnectorProtocolError extends Error {
127
+ code = 'source_connector_protocol_error';
128
+ constructor(message) {
129
+ super(message);
130
+ this.name = 'ConnectorProtocolError';
131
+ }
132
+ }
133
+ /** Serialize a frame for transmission. */
134
+ export function encodeFrame(frame) {
135
+ return JSON.stringify(frame);
136
+ }
137
+ /**
138
+ * Parse + validate an incoming frame. Accepts the string or binary payloads the
139
+ * `ws` library / WebSocket `message` events deliver. Throws
140
+ * `ConnectorProtocolError` on any malformed or unknown frame so callers can
141
+ * reject the connection rather than act on garbage.
142
+ */
143
+ export function decodeFrame(raw) {
144
+ const text = typeof raw === 'string' ? raw : decodeBinary(raw);
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(text);
148
+ }
149
+ catch {
150
+ throw new ConnectorProtocolError('Frame is not valid JSON');
151
+ }
152
+ const result = connectorFrameSchema.safeParse(parsed);
153
+ if (!result.success) {
154
+ throw new ConnectorProtocolError(`Invalid connector frame: ${result.error.message}`);
155
+ }
156
+ return result.data;
157
+ }
158
+ function decodeBinary(raw) {
159
+ const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
160
+ return new TextDecoder().decode(bytes);
161
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Customer-side Data Source reverse-channel connector.
3
+ *
4
+ * The dial-out half of the reverse channel (see `connector-protocol.ts`). The
5
+ * customer runs this next to their database; it opens an OUTBOUND WebSocket to
6
+ * Ablo Cloud and serves the `commit`/`load`/`list` leg over that socket instead
7
+ * of receiving inbound webhooks. This is the symmetric primitive to
8
+ * `createPushQueue` (which already gives the `events` leg an outbound transport)
9
+ * and mirrors the Stripe CLI's `stripe listen`.
10
+ *
11
+ * The connector does NOT reimplement any handler logic. It wraps the SAME
12
+ * `(request: Request) => Promise<Response>` the customer's deployed route uses:
13
+ *
14
+ * import { dataSource, createSourceConnector } from '@abloatai/ablo';
15
+ * import { sourceOptions } from './ablo.source'; // shared with route.ts
16
+ *
17
+ * const connector = createSourceConnector({
18
+ * apiKey: process.env.ABLO_API_KEY!,
19
+ * handler: dataSource(sourceOptions),
20
+ * });
21
+ * await connector.run(controller.signal);
22
+ *
23
+ * Each drained `request` frame is replayed into a synthesized `Request` carrying
24
+ * the original Standard Webhooks signature headers, so the handler verifies it
25
+ * through the unchanged `verifyAbloSourceRequest` — identical to the webhook
26
+ * path. The transport changes; the trust model does not.
27
+ */
28
+ /**
29
+ * Reconnect backoff, in ms, indexed by consecutive failed connect attempts.
30
+ * Unlike the (multi-day) Standard Webhooks delivery schedule, a long-lived
31
+ * control socket should re-establish quickly and cap at a steady interval, so
32
+ * this is a short capped curve. The last entry repeats for further attempts. A
33
+ * clean `ready` resets the counter to 0.
34
+ */
35
+ export declare const DEFAULT_RECONNECT_SCHEDULE: readonly number[];
36
+ /**
37
+ * Minimal structural WebSocket surface — the browser/`globalThis.WebSocket` API,
38
+ * which Node 24+ implements natively. The `ws` package's default export also
39
+ * satisfies this (it exposes `addEventListener`). Injectable for tests.
40
+ */
41
+ export interface ConnectorWebSocket {
42
+ send(data: string): void;
43
+ close(code?: number, reason?: string): void;
44
+ readonly readyState: number;
45
+ addEventListener(type: 'open', listener: () => void): void;
46
+ addEventListener(type: 'message', listener: (event: {
47
+ data: unknown;
48
+ }) => void): void;
49
+ addEventListener(type: 'close', listener: (event: {
50
+ code?: number;
51
+ reason?: string;
52
+ }) => void): void;
53
+ addEventListener(type: 'error', listener: (event: unknown) => void): void;
54
+ }
55
+ export type ConnectorWebSocketFactory = (url: string, protocols: readonly string[]) => ConnectorWebSocket;
56
+ /** Lifecycle of the connector's socket, surfaced via `onStatus`. */
57
+ export type ConnectorStatus = 'connecting' | 'ready' | 'disconnected';
58
+ export interface SourceConnectorOptions {
59
+ /**
60
+ * Ablo project API key. Defaults gate to `sk_test_*` (local-dev / sandbox);
61
+ * an `sk_live_*` key is only accepted when the source has opted into
62
+ * reverse-channel for production server-side.
63
+ */
64
+ readonly apiKey: string;
65
+ /**
66
+ * The unchanged Data Source handler — `dataSource(options)` /
67
+ * `abloSource(options)`. The connector feeds it synthesized `Request`s and
68
+ * relays the `Response`s back; it never inspects or alters them.
69
+ */
70
+ readonly handler: (request: Request) => Promise<Response>;
71
+ /** Ablo Cloud base URL. Default `https://api.abloatai.com`. */
72
+ readonly baseURL?: string;
73
+ /** Inject a WebSocket implementation. Default `globalThis.WebSocket`. */
74
+ readonly webSocket?: ConnectorWebSocketFactory;
75
+ /** Override reconnect backoff. Default `DEFAULT_RECONNECT_SCHEDULE`. */
76
+ readonly reconnectSchedule?: readonly number[];
77
+ /** Random jitter on reconnect delays. Default ±10%. Set 0 to disable. */
78
+ readonly jitter?: number;
79
+ /** Advisory client id sent in the `register` frame for server-side logs. */
80
+ readonly client?: string;
81
+ /** Pluggable clock (tests). */
82
+ readonly now?: () => number;
83
+ /** Observe connection lifecycle transitions. */
84
+ readonly onStatus?: (status: ConnectorStatus) => void;
85
+ /** Observe non-fatal errors (decode failures, handler throws, socket errors). */
86
+ readonly onError?: (error: unknown) => void;
87
+ }
88
+ export interface SourceConnector {
89
+ /**
90
+ * Run the connect → serve → reconnect loop until `signal` aborts. Resolves
91
+ * when aborted. Rejects only on a fatal, non-retryable condition (e.g. no
92
+ * WebSocket implementation available).
93
+ */
94
+ run(signal: AbortSignal): Promise<void>;
95
+ }
96
+ export declare function createSourceConnector(options: SourceConnectorOptions): SourceConnector;