@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
|
@@ -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;
|
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
import { SOURCE_CONNECTOR_PROTOCOL_VERSION, SOURCE_CONNECTOR_WS_PATH, sourceConnectorSubprotocols, encodeFrame, decodeFrame, ConnectorProtocolError, } from './connector-protocol.js';
|
|
29
|
+
/** Default Ablo Cloud base. The connector appends `SOURCE_CONNECTOR_WS_PATH`. */
|
|
30
|
+
const DEFAULT_BASE_URL = 'https://api.abloatai.com';
|
|
31
|
+
/**
|
|
32
|
+
* Reconnect backoff, in ms, indexed by consecutive failed connect attempts.
|
|
33
|
+
* Unlike the (multi-day) Standard Webhooks delivery schedule, a long-lived
|
|
34
|
+
* control socket should re-establish quickly and cap at a steady interval, so
|
|
35
|
+
* this is a short capped curve. The last entry repeats for further attempts. A
|
|
36
|
+
* clean `ready` resets the counter to 0.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_RECONNECT_SCHEDULE = [
|
|
39
|
+
0, // immediate first reconnect
|
|
40
|
+
1_000, // 1s
|
|
41
|
+
2_000, // 2s
|
|
42
|
+
5_000, // 5s
|
|
43
|
+
10_000, // 10s
|
|
44
|
+
30_000, // 30s (steady state)
|
|
45
|
+
];
|
|
46
|
+
export function createSourceConnector(options) {
|
|
47
|
+
const baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
48
|
+
const url = toWebSocketUrl(baseURL) + SOURCE_CONNECTOR_WS_PATH;
|
|
49
|
+
const schedule = options.reconnectSchedule ?? DEFAULT_RECONNECT_SCHEDULE;
|
|
50
|
+
const jitter = options.jitter ?? 0.1;
|
|
51
|
+
const factory = options.webSocket ?? defaultWebSocketFactory;
|
|
52
|
+
return {
|
|
53
|
+
async run(signal) {
|
|
54
|
+
let attempt = 0;
|
|
55
|
+
while (!signal.aborted) {
|
|
56
|
+
const delay = backoffFor(schedule, attempt, jitter);
|
|
57
|
+
if (delay > 0)
|
|
58
|
+
await sleep(delay, signal);
|
|
59
|
+
if (signal.aborted)
|
|
60
|
+
return;
|
|
61
|
+
options.onStatus?.('connecting');
|
|
62
|
+
const becameReady = await connectOnce({
|
|
63
|
+
url,
|
|
64
|
+
apiKey: options.apiKey,
|
|
65
|
+
handler: options.handler,
|
|
66
|
+
factory,
|
|
67
|
+
client: options.client,
|
|
68
|
+
onStatus: options.onStatus,
|
|
69
|
+
onError: options.onError,
|
|
70
|
+
signal,
|
|
71
|
+
});
|
|
72
|
+
// A connection that reached `ready` resets the backoff so the next
|
|
73
|
+
// drop reconnects immediately; one that never readied keeps escalating.
|
|
74
|
+
attempt = becameReady ? 0 : attempt + 1;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One connection lifecycle: open → register → serve drained requests until the
|
|
81
|
+
* socket closes or `signal` aborts. Resolves to whether the connection reached
|
|
82
|
+
* the `ready` state (used to reset reconnect backoff). Never rejects — transport
|
|
83
|
+
* failures are normal and drive a reconnect.
|
|
84
|
+
*/
|
|
85
|
+
function connectOnce(params) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let ws;
|
|
88
|
+
try {
|
|
89
|
+
ws = params.factory(params.url, sourceConnectorSubprotocols(params.apiKey));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
params.onError?.(err);
|
|
93
|
+
resolve(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let ready = false;
|
|
97
|
+
let settled = false;
|
|
98
|
+
const finish = () => {
|
|
99
|
+
if (settled)
|
|
100
|
+
return;
|
|
101
|
+
settled = true;
|
|
102
|
+
params.signal.removeEventListener('abort', onAbort);
|
|
103
|
+
params.onStatus?.('disconnected');
|
|
104
|
+
resolve(ready);
|
|
105
|
+
};
|
|
106
|
+
const onAbort = () => {
|
|
107
|
+
try {
|
|
108
|
+
ws.close(1000, 'connector_aborted');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Already closing/closed.
|
|
112
|
+
}
|
|
113
|
+
finish();
|
|
114
|
+
};
|
|
115
|
+
params.signal.addEventListener('abort', onAbort, { once: true });
|
|
116
|
+
ws.addEventListener('open', () => {
|
|
117
|
+
send(ws, {
|
|
118
|
+
type: 'register',
|
|
119
|
+
protocolVersion: SOURCE_CONNECTOR_PROTOCOL_VERSION,
|
|
120
|
+
...(params.client ? { client: params.client } : {}),
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
ws.addEventListener('message', (event) => {
|
|
124
|
+
let frame;
|
|
125
|
+
try {
|
|
126
|
+
frame = decodeFrame(event.data);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
params.onError?.(err instanceof ConnectorProtocolError
|
|
130
|
+
? err
|
|
131
|
+
: new ConnectorProtocolError(String(err)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
handleFrame(frame);
|
|
135
|
+
});
|
|
136
|
+
ws.addEventListener('error', (event) => {
|
|
137
|
+
params.onError?.(event);
|
|
138
|
+
// `close` always follows `error`; finish() runs there.
|
|
139
|
+
});
|
|
140
|
+
ws.addEventListener('close', () => {
|
|
141
|
+
finish();
|
|
142
|
+
});
|
|
143
|
+
function handleFrame(frame) {
|
|
144
|
+
switch (frame.type) {
|
|
145
|
+
case 'ready':
|
|
146
|
+
handleReady(frame);
|
|
147
|
+
return;
|
|
148
|
+
case 'request':
|
|
149
|
+
// Do not await — serve each request concurrently so a slow handler
|
|
150
|
+
// never blocks draining the next frame off the socket.
|
|
151
|
+
void serveRequest(frame);
|
|
152
|
+
return;
|
|
153
|
+
case 'error':
|
|
154
|
+
params.onError?.(new ConnectorProtocolError(`${frame.code}: ${frame.message}`));
|
|
155
|
+
return;
|
|
156
|
+
// `register`/`response` are connector→server only; ignore if echoed.
|
|
157
|
+
case 'register':
|
|
158
|
+
case 'response':
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function handleReady(frame) {
|
|
163
|
+
if (frame.protocolVersion !== SOURCE_CONNECTOR_PROTOCOL_VERSION) {
|
|
164
|
+
params.onError?.(new ConnectorProtocolError(`Server protocol version ${frame.protocolVersion} != ${SOURCE_CONNECTOR_PROTOCOL_VERSION}`));
|
|
165
|
+
try {
|
|
166
|
+
ws.close(1002, 'protocol_version_mismatch');
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// closing
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
ready = true;
|
|
174
|
+
params.onStatus?.('ready');
|
|
175
|
+
}
|
|
176
|
+
async function serveRequest(frame) {
|
|
177
|
+
const response = await runHandler(frame);
|
|
178
|
+
// Best-effort: if the socket dropped while the handler ran, the server
|
|
179
|
+
// times the request out and the SDK retries — same as a webhook timeout.
|
|
180
|
+
send(ws, response);
|
|
181
|
+
}
|
|
182
|
+
async function runHandler(frame) {
|
|
183
|
+
try {
|
|
184
|
+
const request = new Request(frame.url, {
|
|
185
|
+
method: frame.method,
|
|
186
|
+
headers: frame.headers,
|
|
187
|
+
body: frame.body,
|
|
188
|
+
});
|
|
189
|
+
const result = await params.handler(request);
|
|
190
|
+
const body = await result.text();
|
|
191
|
+
return {
|
|
192
|
+
type: 'response',
|
|
193
|
+
id: frame.id,
|
|
194
|
+
status: result.status,
|
|
195
|
+
body,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
params.onError?.(err);
|
|
200
|
+
// Surface as a 500 so the server-side SourceClient treats it as a
|
|
201
|
+
// retryable failure, exactly like a webhook endpoint throwing.
|
|
202
|
+
return {
|
|
203
|
+
type: 'response',
|
|
204
|
+
id: frame.id,
|
|
205
|
+
status: 500,
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
error: 'source_connector_handler_error',
|
|
208
|
+
message: err instanceof Error ? err.message : String(err),
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function send(socket, frame) {
|
|
214
|
+
try {
|
|
215
|
+
socket.send(encodeFrame(frame));
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
params.onError?.(err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function defaultWebSocketFactory(url, protocols) {
|
|
224
|
+
const Ctor = globalThis.WebSocket;
|
|
225
|
+
if (!Ctor) {
|
|
226
|
+
throw new Error('No global WebSocket available. Pass `webSocket` (e.g. the `ws` package) to createSourceConnector.');
|
|
227
|
+
}
|
|
228
|
+
return new Ctor(url, protocols);
|
|
229
|
+
}
|
|
230
|
+
/** `http(s)://` → `ws(s)://`. Leaves an explicit `ws(s)` scheme untouched. */
|
|
231
|
+
function toWebSocketUrl(baseURL) {
|
|
232
|
+
if (baseURL.startsWith('https://'))
|
|
233
|
+
return `wss://${baseURL.slice('https://'.length)}`;
|
|
234
|
+
if (baseURL.startsWith('http://'))
|
|
235
|
+
return `ws://${baseURL.slice('http://'.length)}`;
|
|
236
|
+
return baseURL;
|
|
237
|
+
}
|
|
238
|
+
function backoffFor(schedule, attempt, jitter) {
|
|
239
|
+
if (attempt <= 0)
|
|
240
|
+
return 0;
|
|
241
|
+
const base = schedule[Math.min(attempt, schedule.length - 1)] ?? 0;
|
|
242
|
+
if (jitter <= 0 || base === 0)
|
|
243
|
+
return base;
|
|
244
|
+
const swing = base * jitter;
|
|
245
|
+
return Math.max(0, base + (Math.random() * 2 - 1) * swing);
|
|
246
|
+
}
|
|
247
|
+
function sleep(ms, signal) {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
if (signal.aborted) {
|
|
250
|
+
resolve();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const timer = setTimeout(() => {
|
|
254
|
+
signal.removeEventListener('abort', onAbort);
|
|
255
|
+
resolve();
|
|
256
|
+
}, ms);
|
|
257
|
+
const onAbort = () => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
signal.removeEventListener('abort', onAbort);
|
|
260
|
+
resolve();
|
|
261
|
+
};
|
|
262
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
263
|
+
});
|
|
264
|
+
}
|
|
@@ -44,9 +44,8 @@ export declare const operationSchema: z.ZodObject<{
|
|
|
44
44
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
45
45
|
onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
46
46
|
reject: "reject";
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
merge: "merge";
|
|
47
|
+
overwrite: "overwrite";
|
|
48
|
+
notify: "notify";
|
|
50
49
|
}>>>;
|
|
51
50
|
}, z.core.$strip>;
|
|
52
51
|
export type Operation = z.infer<typeof operationSchema>;
|
|
@@ -71,9 +70,8 @@ export declare const changeSetSchema: z.ZodObject<{
|
|
|
71
70
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
72
71
|
onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
73
72
|
reject: "reject";
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
merge: "merge";
|
|
73
|
+
overwrite: "overwrite";
|
|
74
|
+
notify: "notify";
|
|
77
75
|
}>>>;
|
|
78
76
|
}, z.core.$strip>>;
|
|
79
77
|
clientTxId: z.ZodString;
|
package/dist/source/contract.js
CHANGED
|
@@ -36,7 +36,7 @@ export const operationSchema = z.object({
|
|
|
36
36
|
input: jsonObject.nullish(),
|
|
37
37
|
transactionId: z.string().nullish(),
|
|
38
38
|
readAt: z.number().nullish(),
|
|
39
|
-
onStale: z.enum(['reject', '
|
|
39
|
+
onStale: z.enum(['reject', 'overwrite', 'notify']).nullish(),
|
|
40
40
|
});
|
|
41
41
|
/**
|
|
42
42
|
* The atomic unit an adapter commits: one or more operations under a single
|
package/dist/source/index.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface SourceOperation {
|
|
|
71
71
|
readonly input?: Record<string, unknown> | null;
|
|
72
72
|
readonly transactionId?: string | null;
|
|
73
73
|
readonly readAt?: number | null;
|
|
74
|
-
readonly onStale?: 'reject' | '
|
|
74
|
+
readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
75
75
|
}
|
|
76
76
|
export interface SourceDelta {
|
|
77
77
|
readonly model: string;
|
|
@@ -464,6 +464,8 @@ export type DataSourceResponse<Row = Record<string, unknown>> = SourceResponse<R
|
|
|
464
464
|
export declare function abloSource<const S extends SchemaRecord, TAuth = unknown>(options: AbloSourceOptions<S, TAuth>): (request: Request) => Promise<Response>;
|
|
465
465
|
export declare function dataSource<const S extends SchemaRecord, TAuth = unknown>(options: DataSourceOptions<S, TAuth>): (request: Request) => Promise<Response>;
|
|
466
466
|
export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, type PushQueue, type PushQueueItem, type PushQueueOptions, type PushQueueStorage, } from './pushQueue.js';
|
|
467
|
+
export { createSourceConnector, DEFAULT_RECONNECT_SCHEDULE, type SourceConnector, type SourceConnectorOptions, type ConnectorWebSocket, type ConnectorWebSocketFactory, type ConnectorStatus, } from './connector.js';
|
|
468
|
+
export { SOURCE_CONNECTOR_PROTOCOL_VERSION, SOURCE_CONNECTOR_WS_PATH, WS_SOURCE_SUBPROTOCOL, sourceConnectorSubprotocols, encodeFrame, decodeFrame, ConnectorProtocolError, connectorFrameSchema, type ConnectorFrame, type RegisterFrame, type ReadyFrame, type RequestFrame, type ResponseFrame, type ErrorFrame, } from './connector-protocol.js';
|
|
467
469
|
export { type DataSourceAdapter, type AdapterReadRequest, type AdapterCommitResult, type Row as AdapterRow, } from './adapter.js';
|
|
468
470
|
export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, type Operation, type ChangeSet, type OutboxEvent, type EventsPage, type Migration, type AdapterCapabilities, } from './contract.js';
|
|
469
471
|
export { prismaDataSource, type PrismaLike, type PrismaDataSourceOptions } from './adapters/prisma.js';
|
package/dist/source/index.js
CHANGED
|
@@ -418,6 +418,12 @@ export function dataSource(options) {
|
|
|
418
418
|
return abloSource(options);
|
|
419
419
|
}
|
|
420
420
|
export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, } from './pushQueue.js';
|
|
421
|
+
// ── Reverse-channel connector (outbound transport for the commit/load/list leg) ──
|
|
422
|
+
// The dial-out counterpart to `createPushQueue`. Lets a customer serve Data
|
|
423
|
+
// Source `commit`/`load`/`list` from localhost or a locked-down VPC with no
|
|
424
|
+
// public inbound URL — see `connector-protocol.ts`.
|
|
425
|
+
export { createSourceConnector, DEFAULT_RECONNECT_SCHEDULE, } from './connector.js';
|
|
426
|
+
export { SOURCE_CONNECTOR_PROTOCOL_VERSION, SOURCE_CONNECTOR_WS_PATH, WS_SOURCE_SUBPROTOCOL, sourceConnectorSubprotocols, encodeFrame, decodeFrame, ConnectorProtocolError, connectorFrameSchema, } from './connector-protocol.js';
|
|
421
427
|
export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, } from './contract.js';
|
|
422
428
|
export { prismaDataSource } from './adapters/prisma.js';
|
|
423
429
|
export { adapterTableMigrations } from './migrations.js';
|
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import type { MutationOperation } from '../interfaces/index.js';
|
|
12
12
|
import type { ClientSyncDelta } from '../schema/sync-delta-wire.js';
|
|
13
|
-
import type { ClaimError, ClaimRejection } from '../coordination/schema.js';
|
|
13
|
+
import type { ClaimError, ClaimRejection, StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
14
|
+
/**
|
|
15
|
+
* Resolution value of a commit ack. `notifications` is present only when a
|
|
16
|
+
* guarded write (`onStale: 'notify') hit a concurrent change — the
|
|
17
|
+
* advisory self-heal signal, surfaced both here and via `conflict:notified`.
|
|
18
|
+
*/
|
|
19
|
+
export interface CommitAck {
|
|
20
|
+
lastSyncId: number;
|
|
21
|
+
notifications?: StaleNotification[];
|
|
22
|
+
}
|
|
14
23
|
import { type AuthTokenGetter } from '../auth/credentialSource.js';
|
|
15
24
|
/**
|
|
16
25
|
* The wire delta the client receives. Derived from the canonical
|
|
@@ -266,6 +275,20 @@ export interface CoreSyncEventMap {
|
|
|
266
275
|
claim_queued: [Record<string, unknown>];
|
|
267
276
|
claim_granted: [Record<string, unknown>];
|
|
268
277
|
claim_lost: [Record<string, unknown>];
|
|
278
|
+
/**
|
|
279
|
+
* Notify-instead-of-abort (non-coercion). A committed write guarded with
|
|
280
|
+
* `onStale: 'notify' collided with a concurrent change; rather than
|
|
281
|
+
* forcing an outcome, the engine returned the conflicting field's current
|
|
282
|
+
* value so the actor can solve it. The resolver is the intelligent actor —
|
|
283
|
+
* an agent reasoning over the change, or a human watching the row. The commit
|
|
284
|
+
* SUCCEEDED; held ops ('notify') weren't written and the actor re-issues once
|
|
285
|
+
* it has reconciled. (The claim is the prospective form of the same
|
|
286
|
+
* non-coercion; this is the in-flight form.)
|
|
287
|
+
*/
|
|
288
|
+
'conflict:notified': [{
|
|
289
|
+
clientTxId: string;
|
|
290
|
+
notifications: StaleNotification[];
|
|
291
|
+
}];
|
|
269
292
|
}
|
|
270
293
|
/**
|
|
271
294
|
* Collaboration event — app-specific real-time events (selection, cursors, etc.)
|
|
@@ -461,9 +484,13 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
461
484
|
* NOT auto-retry here — the caller's TransactionQueue owns retry +
|
|
462
485
|
* offline replay semantics and the SDK shouldn't duplicate that logic.
|
|
463
486
|
*/
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
487
|
+
/**
|
|
488
|
+
* Defensively validate the optional `notifications` array off a commit ack.
|
|
489
|
+
* Untrusted wire data — a malformed entry is dropped rather than throwing,
|
|
490
|
+
* so a bad notification never sinks an otherwise-successful commit.
|
|
491
|
+
*/
|
|
492
|
+
private parseNotifications;
|
|
493
|
+
sendCommit(operations: ReadonlyArray<MutationOperation>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): Promise<CommitAck>;
|
|
467
494
|
/**
|
|
468
495
|
* Send a commit frame without waiting for `mutation_result`.
|
|
469
496
|
*
|
|
@@ -472,7 +499,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
472
499
|
* eventual `mutation_result` frame is intentionally ignored by this
|
|
473
500
|
* instance because no pending resolver is registered.
|
|
474
501
|
*/
|
|
475
|
-
sendCommitQueued(operations: ReadonlyArray<MutationOperation>, clientTxId: string, causedByTaskId?: string | null): void;
|
|
502
|
+
sendCommitQueued(operations: ReadonlyArray<MutationOperation>, clientTxId: string, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): void;
|
|
476
503
|
/**
|
|
477
504
|
* Activate a participant claim on this connection. Multiplexed
|
|
478
505
|
* subscription pattern (Phoenix Channels / Pusher) — the same
|
|
@@ -11,7 +11,7 @@ import { EventEmitter } from 'events';
|
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
13
|
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
|
-
import { subscriptionAckPayloadSchema } from '../coordination/schema.js';
|
|
14
|
+
import { subscriptionAckPayloadSchema, staleNotificationSchema, } from '../coordination/schema.js';
|
|
15
15
|
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
@@ -320,6 +320,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
320
320
|
// untrusted wire data that may be malformed or from an older server.
|
|
321
321
|
const p = message.payload ?? message;
|
|
322
322
|
const { clientTxId, success, lastSyncId, error } = p ?? {};
|
|
323
|
+
// Defensive: validate notifications against the canonical schema —
|
|
324
|
+
// untrusted wire data from a possibly-older/newer server.
|
|
325
|
+
const notifications = this.parseNotifications(p?.notifications);
|
|
323
326
|
const pending = typeof clientTxId === 'string'
|
|
324
327
|
? this.pendingMutations.get(clientTxId)
|
|
325
328
|
: undefined;
|
|
@@ -331,8 +334,20 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
331
334
|
// Coerce defensively — bigint columns serialize as strings
|
|
332
335
|
// from older servers (see normalizeWireDelta).
|
|
333
336
|
const ackedSyncId = Number(lastSyncId);
|
|
337
|
+
// Notify-instead-of-abort: a guarded write's premise moved. Emit
|
|
338
|
+
// the advisory signal so an agent loop can self-heal, AND resolve
|
|
339
|
+
// the receipt with it (the commit still succeeded).
|
|
340
|
+
if (notifications && notifications.length > 0) {
|
|
341
|
+
this.emit('conflict:notified', {
|
|
342
|
+
clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
|
|
343
|
+
notifications,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
334
346
|
pending.resolve({
|
|
335
347
|
lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
|
|
348
|
+
...(notifications && notifications.length > 0
|
|
349
|
+
? { notifications }
|
|
350
|
+
: {}),
|
|
336
351
|
});
|
|
337
352
|
}
|
|
338
353
|
else {
|
|
@@ -802,7 +817,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
802
817
|
* `as` narrows the validated op `type` to the wire union — the only
|
|
803
818
|
* loosening, localized to this boundary.
|
|
804
819
|
*/
|
|
805
|
-
buildCommitFrame(operations, clientTxId, causedByTaskId) {
|
|
820
|
+
buildCommitFrame(operations, clientTxId, causedByTaskId, reads) {
|
|
806
821
|
const payload = {
|
|
807
822
|
operations: operations.map((op) => ({
|
|
808
823
|
type: op.type,
|
|
@@ -817,6 +832,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
817
832
|
};
|
|
818
833
|
if (causedByTaskId)
|
|
819
834
|
payload.causedByTaskId = causedByTaskId;
|
|
835
|
+
// Batch-level read-set (STORM layer): rows/groups the batch was premised on.
|
|
836
|
+
if (reads && reads.length > 0)
|
|
837
|
+
payload.reads = [...reads];
|
|
820
838
|
return { type: 'commit', payload };
|
|
821
839
|
}
|
|
822
840
|
/**
|
|
@@ -838,7 +856,23 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
838
856
|
* NOT auto-retry here — the caller's TransactionQueue owns retry +
|
|
839
857
|
* offline replay semantics and the SDK shouldn't duplicate that logic.
|
|
840
858
|
*/
|
|
841
|
-
|
|
859
|
+
/**
|
|
860
|
+
* Defensively validate the optional `notifications` array off a commit ack.
|
|
861
|
+
* Untrusted wire data — a malformed entry is dropped rather than throwing,
|
|
862
|
+
* so a bad notification never sinks an otherwise-successful commit.
|
|
863
|
+
*/
|
|
864
|
+
parseNotifications(raw) {
|
|
865
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
866
|
+
return undefined;
|
|
867
|
+
const out = [];
|
|
868
|
+
for (const entry of raw) {
|
|
869
|
+
const parsed = staleNotificationSchema.safeParse(entry);
|
|
870
|
+
if (parsed.success)
|
|
871
|
+
out.push(parsed.data);
|
|
872
|
+
}
|
|
873
|
+
return out.length > 0 ? out : undefined;
|
|
874
|
+
}
|
|
875
|
+
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads) {
|
|
842
876
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
843
877
|
return Promise.reject(this.notConnectedError('commit'));
|
|
844
878
|
}
|
|
@@ -853,7 +887,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
853
887
|
// an open turn — keeps the wire shape stable for sessions
|
|
854
888
|
// that don't use turns. Servers that don't know the field
|
|
855
889
|
// ignore it; newer servers stamp it onto every delta.
|
|
856
|
-
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
|
|
890
|
+
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
|
|
857
891
|
this.ws.send(JSON.stringify(frame));
|
|
858
892
|
}
|
|
859
893
|
catch (error) {
|
|
@@ -871,11 +905,11 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
871
905
|
* eventual `mutation_result` frame is intentionally ignored by this
|
|
872
906
|
* instance because no pending resolver is registered.
|
|
873
907
|
*/
|
|
874
|
-
sendCommitQueued(operations, clientTxId, causedByTaskId) {
|
|
908
|
+
sendCommitQueued(operations, clientTxId, causedByTaskId, reads) {
|
|
875
909
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
876
910
|
throw this.notConnectedError('commit');
|
|
877
911
|
}
|
|
878
|
-
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
|
|
912
|
+
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
|
|
879
913
|
this.ws.send(JSON.stringify(frame));
|
|
880
914
|
}
|
|
881
915
|
/**
|
|
@@ -12,6 +12,7 @@ import type { Database } from '../Database.js';
|
|
|
12
12
|
import { Model } from '../Model.js';
|
|
13
13
|
import { SyncPosition } from '../sync/syncPosition.js';
|
|
14
14
|
import type { WriteOptions } from '../interfaces/index.js';
|
|
15
|
+
import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
15
16
|
export interface UserContext {
|
|
16
17
|
userId: string;
|
|
17
18
|
organizationId: string;
|
|
@@ -71,9 +72,11 @@ interface CommitTransaction {
|
|
|
71
72
|
input?: Record<string, unknown>;
|
|
72
73
|
transactionId?: string;
|
|
73
74
|
readAt?: number | null;
|
|
74
|
-
onStale?: 'reject' | '
|
|
75
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
75
76
|
}>;
|
|
76
77
|
causedByTaskId?: string | null;
|
|
78
|
+
/** Batch-level read dependencies (STORM read-set), forwarded to the executor. */
|
|
79
|
+
reads?: ReadDependency[] | null;
|
|
77
80
|
status: 'pending' | 'executing' | 'completed' | 'failed';
|
|
78
81
|
createdAt: number;
|
|
79
82
|
attempts: number;
|
|
@@ -151,6 +154,7 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
151
154
|
private config;
|
|
152
155
|
private executingCount;
|
|
153
156
|
private optimisticUpdates;
|
|
157
|
+
private commitNotifications;
|
|
154
158
|
private deltaConfirmationTimeouts;
|
|
155
159
|
private deltaConfirmationRetries;
|
|
156
160
|
private isConnectedFn;
|
|
@@ -337,6 +341,7 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
337
341
|
*/
|
|
338
342
|
enqueueCommit(clientTxId: string, operations: CommitTransaction['operations'], options?: {
|
|
339
343
|
causedByTaskId?: string | null;
|
|
344
|
+
reads?: ReadDependency[] | null;
|
|
340
345
|
}): void;
|
|
341
346
|
/**
|
|
342
347
|
* Drain pending commit-lane envelopes serially. Transient failures
|
|
@@ -353,6 +358,7 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
353
358
|
*/
|
|
354
359
|
waitForCommitReceipt(clientTxId: string): Promise<{
|
|
355
360
|
lastSyncId: number;
|
|
361
|
+
notifications?: StaleNotification[];
|
|
356
362
|
}>;
|
|
357
363
|
private isReorderPayload;
|
|
358
364
|
/**
|