@abloatai/ablo 0.14.0 → 0.15.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/CHANGELOG.md +51 -0
- package/dist/Database.d.ts +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +1 -0
- package/dist/cli.cjs +54 -1
- package/dist/client/Ablo.d.ts +34 -3
- package/dist/client/Ablo.js +11 -4
- package/dist/client/ApiClient.js +3 -0
- package/dist/client/sessionMint.js +1 -0
- package/dist/client/writeOptionsSchema.d.ts +4 -6
- package/dist/client/writeOptionsSchema.js +1 -1
- package/dist/coordination/schema.d.ts +90 -12
- package/dist/coordination/schema.js +99 -4
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +10 -1
- package/dist/interfaces/index.d.ts +23 -2
- package/dist/policy/types.d.ts +35 -3
- package/dist/policy/types.js +20 -7
- package/dist/server/commit.d.ts +26 -0
- package/dist/source/connector-protocol.d.ts +159 -0
- package/dist/source/connector-protocol.js +161 -0
- package/dist/source/connector.d.ts +96 -0
- package/dist/source/connector.js +264 -0
- package/dist/source/contract.d.ts +4 -6
- package/dist/source/contract.js +1 -1
- package/dist/source/index.d.ts +3 -1
- package/dist/source/index.js +6 -0
- package/dist/sync/SyncWebSocket.d.ts +32 -5
- package/dist/sync/SyncWebSocket.js +40 -6
- package/dist/transactions/TransactionQueue.d.ts +7 -1
- package/dist/transactions/TransactionQueue.js +43 -2
- package/dist/wire/frames.d.ts +28 -4
- package/docs/concurrency-convention.md +222 -0
- package/docs/coordination.md +5 -0
- package/docs/data-sources.md +41 -0
- package/package.json +6 -1
package/dist/index.d.ts
CHANGED
|
@@ -57,8 +57,9 @@ export type { AbloPersistence } from './client/persistence.js';
|
|
|
57
57
|
import { Ablo } from './client/Ablo.js';
|
|
58
58
|
export default Ablo;
|
|
59
59
|
export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
60
|
+
export { createSourceConnector, type SourceConnector, type SourceConnectorOptions, type ConnectorStatus, } from './source/connector.js';
|
|
60
61
|
export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
61
|
-
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
|
|
62
|
+
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
|
|
62
63
|
export type { CommitReceipt, RequiredCapability } from './errors.js';
|
|
63
64
|
export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
|
|
64
65
|
export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
|
|
@@ -67,6 +68,8 @@ export type { Environment, KeyPrefixEnvironment } from './environment.js';
|
|
|
67
68
|
export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
|
|
68
69
|
export type { WriteOptionsInput } from './client/writeOptionsSchema.js';
|
|
69
70
|
export type { WriteOptions, MutationOptions } from './interfaces/index.js';
|
|
71
|
+
export { staleNotificationSchema, readDependencySchema } from './coordination/schema.js';
|
|
72
|
+
export type { StaleNotification, ReadDependency } from './coordination/schema.js';
|
|
70
73
|
export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
|
|
71
74
|
export { PUBLIC_MODEL_VERBS, PUBLIC_LIST_OPTION_KEYS, PUBLIC_ABLO_OPTION_KEYS, } from './surface.js';
|
|
72
75
|
export type { ModelVerb, ListOptionKey, AbloOptionKey } from './surface.js';
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,10 @@ export default Ablo;
|
|
|
69
69
|
// canonical, skip this entirely. Type counterparts live under
|
|
70
70
|
// `Ablo.Source.*` (`Ablo.Source.Operation`, `Ablo.Source.Commit.Params`).
|
|
71
71
|
export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
72
|
+
// Reverse-channel connector: serve the Data Source `commit`/`load`/`list` leg
|
|
73
|
+
// over an OUTBOUND WebSocket (localhost / locked-down VPC, no public inbound
|
|
74
|
+
// URL). The dial-out counterpart to `createPushQueue` for the `events` leg.
|
|
75
|
+
export { createSourceConnector, } from './source/connector.js';
|
|
72
76
|
// Schema DSL is intentionally published from `@abloatai/ablo/schema`.
|
|
73
77
|
// Keeping it out of the root import preserves one clean runtime surface:
|
|
74
78
|
// `import Ablo from '@abloatai/ablo'`.
|
|
@@ -80,7 +84,7 @@ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
|
80
84
|
// Typed error hierarchy — Stripe-style. One import gets every class
|
|
81
85
|
// consumers need to discriminate failures (`e instanceof AbloX` or
|
|
82
86
|
// `e.type === 'AbloX'`) plus the HTTP-response translator.
|
|
83
|
-
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
|
|
87
|
+
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
|
|
84
88
|
export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
|
|
85
89
|
export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
|
|
86
90
|
// THE write-options contract — the one Zod schema for the option bag every
|
|
@@ -90,6 +94,11 @@ export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromK
|
|
|
90
94
|
// (e.g. an agent tool's input schema). Runtime twin of `MutationOptions`,
|
|
91
95
|
// drift-guarded at compile time.
|
|
92
96
|
export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
|
|
97
|
+
// Notify-instead-of-abort signal: the value handed back to a committer whose
|
|
98
|
+
// write hit a stale-context conflict under `onStale: 'notify' so its
|
|
99
|
+
// agent can self-heal rather than discard work (see coordination/schema.ts and
|
|
100
|
+
// docs/coordination.md → "Notify, do not abort").
|
|
101
|
+
export { staleNotificationSchema, readDependencySchema } from './coordination/schema.js';
|
|
93
102
|
// Storage-wedge detection — lets app shells render a recovery screen when the
|
|
94
103
|
// IndexedDB backing store is stuck (see core/openIDBWithTimeout.ts).
|
|
95
104
|
export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Consumers implement them to wire in their own logging, observability,
|
|
6
6
|
* GraphQL client, session handling, and analytics.
|
|
7
7
|
*/
|
|
8
|
+
import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
8
9
|
export interface SyncLogger {
|
|
9
10
|
debug(message: string, ...args: unknown[]): void;
|
|
10
11
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -140,6 +141,18 @@ export interface ModelDebugLoggerContract {
|
|
|
140
141
|
/** Result of a successful `commit()` — server's sync cursor after the batch landed. */
|
|
141
142
|
export interface CommitResult {
|
|
142
143
|
lastSyncId: number;
|
|
144
|
+
/**
|
|
145
|
+
* Stale-context notifications (CoAgent/MTPO notify-instead-of-abort). Present
|
|
146
|
+
* only when a write guarded with `onStale: 'notify' collided with a
|
|
147
|
+
* concurrent change; the committer self-heals from these rather than
|
|
148
|
+
* receiving an `AbloStaleContextError`. See `StaleNotification`.
|
|
149
|
+
*/
|
|
150
|
+
notifications?: StaleNotification[];
|
|
151
|
+
/**
|
|
152
|
+
* Ids of UPDATE/DELETE targets that matched ZERO rows (loud 0-row writes).
|
|
153
|
+
* Present (non-empty) only when a write missed.
|
|
154
|
+
*/
|
|
155
|
+
missingIds?: string[];
|
|
143
156
|
}
|
|
144
157
|
/**
|
|
145
158
|
* Per-call knobs attached to any mutation. Mirrors Stripe's options
|
|
@@ -159,7 +172,7 @@ export interface MutationOptions {
|
|
|
159
172
|
label?: string;
|
|
160
173
|
wait?: 'queued' | 'confirmed';
|
|
161
174
|
readAt?: number | null;
|
|
162
|
-
onStale?: 'reject' | '
|
|
175
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
163
176
|
/** Claim-pin attribution: the id (or `{ id }`) of the claim this write
|
|
164
177
|
* belongs to. Distinct from the `claim` HANDLE on the model write params —
|
|
165
178
|
* this is the low-level reference the commit carries to bypass the holder's
|
|
@@ -174,6 +187,14 @@ export interface MutationOptions {
|
|
|
174
187
|
* id). Kept optional for wire-compat; always `null` from the client.
|
|
175
188
|
*/
|
|
176
189
|
causedByTaskId?: string | null;
|
|
190
|
+
/**
|
|
191
|
+
* Batch-level read dependencies (the STORM "did anything I looked at change?"
|
|
192
|
+
* layer). Each entry is a row (`{model,id,readAt,fields?}`) or a sync group
|
|
193
|
+
* (`{group,readAt}`) this write was premised on; the server validates none
|
|
194
|
+
* moved since `readAt` and fires the entry's `onStale` over the batch.
|
|
195
|
+
* Distinct from per-op `readAt` (which guards only the row being written).
|
|
196
|
+
*/
|
|
197
|
+
reads?: ReadDependency[] | null;
|
|
177
198
|
}
|
|
178
199
|
/**
|
|
179
200
|
* The `MutationOptions` subset carried per-write through the offline
|
|
@@ -208,7 +229,7 @@ export interface MutationOperation {
|
|
|
208
229
|
*/
|
|
209
230
|
transactionId?: string;
|
|
210
231
|
readAt?: number | null;
|
|
211
|
-
onStale?: 'reject' | '
|
|
232
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
212
233
|
/**
|
|
213
234
|
* Per-op idempotency + audit metadata. `idempotencyKey` doubles as
|
|
214
235
|
* the `mutation_log.client_tx_id` cache key; `label` is persisted to
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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
|
|
102
|
-
*
|
|
103
|
-
* claim
|
|
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
|
/**
|
package/dist/policy/types.js
CHANGED
|
@@ -7,14 +7,27 @@
|
|
|
7
7
|
* Adding new shapes is additive on the discriminated union.
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
|
-
* Default
|
|
11
|
-
*
|
|
12
|
-
* claim
|
|
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
|
-
|
|
16
|
-
|
|
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
|
package/dist/server/commit.d.ts
CHANGED
|
@@ -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,21 @@ 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[];
|
|
117
|
+
/**
|
|
118
|
+
* Ids of UPDATE/DELETE targets that matched ZERO rows — the row doesn't
|
|
119
|
+
* exist (or is outside the caller's org). The engine has always detected
|
|
120
|
+
* this (and logged it); surfacing it here lets the client turn a silent
|
|
121
|
+
* no-op into a loud `AbloNotFoundError`. Present (non-empty) only when at
|
|
122
|
+
* least one op missed. Ids are globally-unique uuids, so a caller can match
|
|
123
|
+
* its own target id against this set without ambiguity.
|
|
124
|
+
*/
|
|
125
|
+
missingIds?: string[];
|
|
100
126
|
}
|
|
@@ -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
|
+
}
|