@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.
@@ -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
- force: "force";
48
- flag: "flag";
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
- force: "force";
75
- flag: "flag";
76
- merge: "merge";
73
+ overwrite: "overwrite";
74
+ notify: "notify";
77
75
  }>>>;
78
76
  }, z.core.$strip>>;
79
77
  clientTxId: z.ZodString;
@@ -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', 'force', 'flag', 'merge']).nullish(),
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
@@ -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' | 'force' | 'flag' | 'merge' | null;
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';
@@ -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
- sendCommit(operations: ReadonlyArray<MutationOperation>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null): Promise<{
465
- lastSyncId: number;
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
- sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId) {
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' | 'force' | 'flag' | 'merge' | null;
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
  /**
@@ -409,6 +409,10 @@ export class TransactionQueue extends EventEmitter {
409
409
  executingCount = 0;
410
410
  // Optimistic update tracking
411
411
  optimisticUpdates = new Map();
412
+ // Stale-context notifications (CoAgent/MTPO notify-instead-of-abort) keyed by
413
+ // transaction id, populated from the commit ack and drained by
414
+ // `waitForCommitReceipt` so the receipt carries the self-heal signal.
415
+ commitNotifications = new Map();
412
416
  // LINEAR PATTERN: Track delta confirmation timeouts for awaiting_delta transactions
413
417
  // Following Replicache/PowerSync pattern: retry with backoff instead of rolling back
414
418
  deltaConfirmationTimeouts = new Map();
@@ -1127,6 +1131,20 @@ export class TransactionQueue extends EventEmitter {
1127
1131
  const result = await this.mutationExecutor.commit(operations);
1128
1132
  const lastSyncId = result?.lastSyncId ?? 0;
1129
1133
  this.noteAck(lastSyncId);
1134
+ // Notify-instead-of-abort: the server returned stale-context
1135
+ // notifications for `onStale: 'notify'` ops whose premise moved.
1136
+ // Every notified op was HELD (not written) — its optimistic state
1137
+ // must be rolled back here; no delta will ever confirm it. We also
1138
+ // stamp the signal so `waitForCommitReceipt` carries it onto the
1139
+ // receipt.
1140
+ const notifications = result?.notifications;
1141
+ const heldIds = new Set((notifications ?? []).map((n) => n.id));
1142
+ for (const { tx } of batchOps) {
1143
+ const txNotifs = notifications?.filter((n) => n.id === tx.modelId);
1144
+ if (txNotifs && txNotifs.length > 0) {
1145
+ this.commitNotifications.set(tx.id, txNotifs);
1146
+ }
1147
+ }
1130
1148
  // Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
1131
1149
  if (lastSyncId === 0) {
1132
1150
  getContext().observability.captureCommitZeroSyncId({
@@ -1137,6 +1155,19 @@ export class TransactionQueue extends EventEmitter {
1137
1155
  // LINEAR PATTERN: Mark as awaiting_delta with syncId threshold
1138
1156
  // Transactions will be confirmed when any delta with id >= lastSyncId arrives
1139
1157
  for (const { tx } of batchOps) {
1158
+ // Held op ('notify'): the server withheld the write, so no delta
1159
+ // will confirm it. Roll back the optimistic update (server
1160
+ // wins) and complete the transaction now — the agent self-heals
1161
+ // from the notification rather than waiting out the delta
1162
+ // timeout. The receipt still resolves (commit succeeded).
1163
+ if (heldIds.has(tx.modelId)) {
1164
+ await this.rollbackOptimistic(tx, 'conflict_server_wins');
1165
+ this.store.updateStatus(tx.id, 'completed');
1166
+ this.emit('transaction:completed', tx);
1167
+ this.emit(`transaction:completed:${tx.id}`, tx);
1168
+ this.optimisticUpdates.delete(tx.id);
1169
+ continue;
1170
+ }
1140
1171
  tx.syncIdNeededForCompletion = lastSyncId;
1141
1172
  // Safety net: when lastSyncId is 0, DELETE transactions should be confirmed
1142
1173
  // immediately. DELETEs are idempotent — if no delta was emitted, the entity
@@ -1528,6 +1559,7 @@ export class TransactionQueue extends EventEmitter {
1528
1559
  kind: 'commit',
1529
1560
  operations: [...operations],
1530
1561
  causedByTaskId: options.causedByTaskId ?? null,
1562
+ ...(options.reads ? { reads: options.reads } : {}),
1531
1563
  status: 'pending',
1532
1564
  createdAt: Date.now(),
1533
1565
  attempts: 0,
@@ -1565,6 +1597,7 @@ export class TransactionQueue extends EventEmitter {
1565
1597
  const result = await this.mutationExecutor.commit(tx.operations, {
1566
1598
  idempotencyKey: tx.id,
1567
1599
  causedByTaskId: tx.causedByTaskId ?? undefined,
1600
+ ...(tx.reads ? { reads: tx.reads } : {}),
1568
1601
  });
1569
1602
  tx.lastSyncId = result?.lastSyncId ?? 0;
1570
1603
  this.noteAck(tx.lastSyncId);
@@ -1610,10 +1643,18 @@ export class TransactionQueue extends EventEmitter {
1610
1643
  * of `ablo.commits.create()`.
1611
1644
  */
1612
1645
  waitForCommitReceipt(clientTxId) {
1646
+ // Drain any stale-context notifications stamped for this tx on the ack.
1647
+ const drainNotifications = () => {
1648
+ const n = this.commitNotifications.get(clientTxId);
1649
+ if (!n)
1650
+ return undefined;
1651
+ this.commitNotifications.delete(clientTxId);
1652
+ return n.length > 0 ? n : undefined;
1653
+ };
1613
1654
  return new Promise((resolve, reject) => {
1614
1655
  const existing = this.commitStore.get(clientTxId);
1615
1656
  if (existing?.status === 'completed') {
1616
- resolve({ lastSyncId: existing.lastSyncId ?? 0 });
1657
+ resolve({ lastSyncId: existing.lastSyncId ?? 0, notifications: drainNotifications() });
1617
1658
  return;
1618
1659
  }
1619
1660
  if (existing?.status === 'failed' && existing.error) {
@@ -1622,7 +1663,7 @@ export class TransactionQueue extends EventEmitter {
1622
1663
  }
1623
1664
  const onCompleted = (tx) => {
1624
1665
  cleanup();
1625
- resolve({ lastSyncId: tx.lastSyncId ?? 0 });
1666
+ resolve({ lastSyncId: tx.lastSyncId ?? 0, notifications: drainNotifications() });
1626
1667
  };
1627
1668
  const onFailed = ({ error }) => {
1628
1669
  cleanup();