@abloatai/ablo 0.7.0 → 0.9.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.
- package/CHANGELOG.md +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
|
-
import {
|
|
13
|
+
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
|
+
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
16
17
|
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
@@ -170,16 +171,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
170
171
|
}
|
|
171
172
|
this.isConnecting = true;
|
|
172
173
|
this.isManualClose = false;
|
|
173
|
-
// Pattern: one credential, server-resolved identity. The
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// agentTokenProvider → betterAuthProvider`) resolves identity
|
|
178
|
-
// from the verified credential — userId/organizationId are
|
|
179
|
-
// NEVER read from URL params in production. See
|
|
180
|
-
// `apps/sync-server/src/auth/provider.ts:148` (betterAuthProvider
|
|
181
|
-
// calls `auth.api.getSession({headers})`) and `agentTokenProvider`
|
|
182
|
-
// for the cap-token path.
|
|
174
|
+
// Pattern: one credential, server-resolved identity. The bearer travels
|
|
175
|
+
// in a `Sec-WebSocket-Protocol` value (built below), NOT the URL. The
|
|
176
|
+
// server is bearer-only (`apiKeyProvider`) and resolves identity from the
|
|
177
|
+
// verified token — userId/organizationId are NEVER read from URL params.
|
|
183
178
|
const params = new URLSearchParams({
|
|
184
179
|
// Intentionally omit lastSyncId, versions, capabilities from URL; these are sent in sync_request
|
|
185
180
|
// and ack messages to avoid stale baselines on reconnect.
|
|
@@ -191,22 +186,28 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
191
186
|
if (this.options.kind && this.options.kind !== 'user') {
|
|
192
187
|
params.set('kind', this.options.kind);
|
|
193
188
|
}
|
|
194
|
-
// Capability bearer (query-param form so it works in both Node's
|
|
195
|
-
// global WebSocket — which can't set headers — and browsers).
|
|
196
|
-
if (this.options.capabilityToken) {
|
|
197
|
-
params.set('authorization', `Bearer ${this.options.capabilityToken}`);
|
|
198
|
-
}
|
|
199
189
|
// Add sync groups if provided
|
|
200
190
|
this.options.syncGroups.forEach((group) => {
|
|
201
191
|
params.append('syncGroups', group);
|
|
202
192
|
});
|
|
203
193
|
const wsUrl = `${this.options.url}?${params.toString()}`;
|
|
194
|
+
// Carry the bearer in a `Sec-WebSocket-Protocol` value, NOT the URL. A
|
|
195
|
+
// browser can't set an Authorization header on a WS, but it CAN offer
|
|
196
|
+
// subprotocols — and unlike the query string, those don't land in ALB
|
|
197
|
+
// access logs, proxies, or browser history. The server reads
|
|
198
|
+
// `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol,
|
|
199
|
+
// never echoing the token-bearing value back. (Token is the raw ek_/rk_,
|
|
200
|
+
// which is subprotocol-token-safe — alphanumerics + `_`.)
|
|
201
|
+
const authToken = this.resolveAuthToken();
|
|
202
|
+
const protocols = authToken
|
|
203
|
+
? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
|
|
204
|
+
: [WS_SYNC_SUBPROTOCOL];
|
|
204
205
|
try {
|
|
205
206
|
// Reset the handshake flag before wiring the new socket. Each connect()
|
|
206
207
|
// gets its own lifecycle — a prior successful open on a previous socket
|
|
207
208
|
// must not mask a handshake failure on the new one.
|
|
208
209
|
this._everOpened = false;
|
|
209
|
-
this.ws = new WebSocket(wsUrl);
|
|
210
|
+
this.ws = new WebSocket(wsUrl, protocols);
|
|
210
211
|
this.setupEventHandlers();
|
|
211
212
|
}
|
|
212
213
|
catch (error) {
|
|
@@ -214,7 +215,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
214
215
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
|
|
215
216
|
getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
|
|
216
217
|
this.isConnecting = false;
|
|
217
|
-
this.emit('error', new
|
|
218
|
+
this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
|
|
218
219
|
this.scheduleReconnect();
|
|
219
220
|
}
|
|
220
221
|
}
|
|
@@ -303,11 +304,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
303
304
|
this.handlePresenceUpdate(message);
|
|
304
305
|
break;
|
|
305
306
|
case 'mutation_result': {
|
|
306
|
-
// Ack for a prior `commit` we sent.
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// lastSyncId?, error? } }
|
|
307
|
+
// Ack for a prior `commit` we sent. Canonical shape is
|
|
308
|
+
// `MutationResultMessage` in `@abloatai/ablo/wire`. This stays a
|
|
309
|
+
// DEFENSIVE parse (not a typed cast) because the payload is
|
|
310
|
+
// untrusted wire data that may be malformed or from an older server.
|
|
311
311
|
const p = message.payload ?? message;
|
|
312
312
|
const { clientTxId, success, lastSyncId, error } = p ?? {};
|
|
313
313
|
const pending = typeof clientTxId === 'string'
|
|
@@ -360,38 +360,19 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
360
360
|
else {
|
|
361
361
|
errorMessage = 'mutation failed on server';
|
|
362
362
|
}
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
errorCode
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// pre-commit lease check (`intent_conflict`, the code that
|
|
377
|
-
// reaches clients in practice) and `executeCommit`'s deeper
|
|
378
|
-
// guard (`entity_claimed`). Both mean "claimed", so both route
|
|
379
|
-
// through the typed AbloClaimedError, letting callers
|
|
380
|
-
// `instanceof AbloClaimedError` (or read `e.type` across worker
|
|
381
|
-
// boundaries) and wait/bypass — symmetric with the
|
|
382
|
-
// CapabilityError branch above, and with the HTTP commit path
|
|
383
|
-
// (`translateHttpError`).
|
|
384
|
-
pending.reject(new AbloClaimedError(errorMessage, {
|
|
385
|
-
code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
|
|
386
|
-
httpStatus: 409,
|
|
387
|
-
}));
|
|
388
|
-
}
|
|
389
|
-
else {
|
|
390
|
-
const rejection = new Error(errorMessage);
|
|
391
|
-
if (errorCode)
|
|
392
|
-
rejection.code = errorCode;
|
|
393
|
-
pending.reject(rejection);
|
|
394
|
-
}
|
|
363
|
+
// Build the proper typed AbloError from the wire code via the
|
|
364
|
+
// shared factory — the same code→class mapping the HTTP commit
|
|
365
|
+
// path uses (`translateHttpError`). This keeps rejected commits
|
|
366
|
+
// inside the typed hierarchy (capability denials →
|
|
367
|
+
// CapabilityError with `.requiredCapability`; foreign-claim
|
|
368
|
+
// conflicts → AbloClaimedError; everything else → the subclass
|
|
369
|
+
// its registry `httpStatus` implies) instead of a hand-rolled
|
|
370
|
+
// `new Error`, so callers can `instanceof`/`e.type` it and
|
|
371
|
+
// downstream retry logic can read the contract's retryability.
|
|
372
|
+
pending.reject(errorFromWire(errorMessage, {
|
|
373
|
+
code: errorCode,
|
|
374
|
+
requiredCapability,
|
|
375
|
+
}));
|
|
395
376
|
}
|
|
396
377
|
break;
|
|
397
378
|
}
|
|
@@ -438,11 +419,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
438
419
|
pending.reject(new CapabilityError(code, msg, requiredCapability));
|
|
439
420
|
}
|
|
440
421
|
else {
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
pending.reject(rejection);
|
|
422
|
+
// Route through the shared factory so a failed claim_ack is a
|
|
423
|
+
// typed AbloError (registry code → right subclass), symmetric
|
|
424
|
+
// with the commit `mutation_result` path — never a bare Error.
|
|
425
|
+
pending.reject(errorFromWire(msg, { code }));
|
|
446
426
|
}
|
|
447
427
|
}
|
|
448
428
|
break;
|
|
@@ -539,12 +519,12 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
539
519
|
// Check if we're offline first
|
|
540
520
|
if (!getContext().onlineStatus.isOnline()) {
|
|
541
521
|
getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
|
|
542
|
-
this.emit('error', new
|
|
522
|
+
this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
|
|
543
523
|
return;
|
|
544
524
|
}
|
|
545
525
|
// After session error, suppress Sentry capture — the root cause is already reported.
|
|
546
526
|
// Still emit so SyncedStore can update UI state.
|
|
547
|
-
const error = new
|
|
527
|
+
const error = new AbloConnectionError(`WebSocket connection failed`);
|
|
548
528
|
if (!this._sessionErrorDetected) {
|
|
549
529
|
getContext().observability.captureWebSocketError({
|
|
550
530
|
context: 'connection-error',
|
|
@@ -578,12 +558,16 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
578
558
|
if (this.pendingMutations.size > 0) {
|
|
579
559
|
for (const pending of this.pendingMutations.values()) {
|
|
580
560
|
clearTimeout(pending.timeout);
|
|
581
|
-
|
|
561
|
+
// AbloConnectionError → `isPermanentError` treats it as transient,
|
|
562
|
+
// so TransactionQueue retries the commit on reconnect rather than
|
|
563
|
+
// rolling it back. `diagnostics` is preserved as a property (the
|
|
564
|
+
// queue's failure log walks the cause chain for it).
|
|
565
|
+
pending.reject(Object.assign(new AbloConnectionError(`WebSocket closed while commit was in flight (code=${event.code}` +
|
|
582
566
|
(event.reason ? ` reason=${event.reason}` : '') +
|
|
583
567
|
(this.lastForceCloseReason
|
|
584
568
|
? ` forceCloseReason=${this.lastForceCloseReason}`
|
|
585
569
|
: '') +
|
|
586
|
-
')'), { diagnostics: this.getConnectionDiagnostics() }));
|
|
570
|
+
')', { code: 'commit_no_result' }), { diagnostics: this.getConnectionDiagnostics() }));
|
|
587
571
|
}
|
|
588
572
|
this.pendingMutations.clear();
|
|
589
573
|
}
|
|
@@ -594,7 +578,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
594
578
|
if (this.pendingClaims.size > 0) {
|
|
595
579
|
for (const pending of this.pendingClaims.values()) {
|
|
596
580
|
clearTimeout(pending.timeout);
|
|
597
|
-
pending.reject(new
|
|
581
|
+
pending.reject(new AbloConnectionError(`WebSocket closed while claim was in flight (code=${event.code})`));
|
|
598
582
|
}
|
|
599
583
|
this.pendingClaims.clear();
|
|
600
584
|
}
|
|
@@ -736,6 +720,32 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
736
720
|
}
|
|
737
721
|
}
|
|
738
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Project the SDK's `MutationOperation[]` onto the canonical wire
|
|
725
|
+
* `CommitMessage`. This is the single serialize boundary between the SDK op
|
|
726
|
+
* type (loose `type: string`, plus an SDK-internal `options` the server never
|
|
727
|
+
* reads) and the strict wire contract. The per-field map gives compile-time
|
|
728
|
+
* drift detection (a `CommitOperation` shape change breaks here) and the lone
|
|
729
|
+
* `as` narrows the validated op `type` to the wire union — the only
|
|
730
|
+
* loosening, localized to this boundary.
|
|
731
|
+
*/
|
|
732
|
+
buildCommitFrame(operations, clientTxId, causedByTaskId) {
|
|
733
|
+
const payload = {
|
|
734
|
+
operations: operations.map((op) => ({
|
|
735
|
+
type: op.type,
|
|
736
|
+
model: op.model,
|
|
737
|
+
id: op.id,
|
|
738
|
+
input: op.input,
|
|
739
|
+
transactionId: op.transactionId,
|
|
740
|
+
readAt: op.readAt,
|
|
741
|
+
onStale: op.onStale,
|
|
742
|
+
})),
|
|
743
|
+
clientTxId,
|
|
744
|
+
};
|
|
745
|
+
if (causedByTaskId)
|
|
746
|
+
payload.causedByTaskId = causedByTaskId;
|
|
747
|
+
return { type: 'commit', payload };
|
|
748
|
+
}
|
|
739
749
|
/**
|
|
740
750
|
* Send a `commit` mutation request over the existing WebSocket and
|
|
741
751
|
* resolve when the server's `mutation_result` frame comes back with
|
|
@@ -762,7 +772,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
762
772
|
return new Promise((resolve, reject) => {
|
|
763
773
|
const timeout = setTimeout(() => {
|
|
764
774
|
this.pendingMutations.delete(clientTxId);
|
|
765
|
-
reject(new
|
|
775
|
+
reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
|
|
766
776
|
}, timeoutMs);
|
|
767
777
|
this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
|
|
768
778
|
try {
|
|
@@ -770,17 +780,13 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
770
780
|
// an open turn — keeps the wire shape stable for sessions
|
|
771
781
|
// that don't use turns. Servers that don't know the field
|
|
772
782
|
// ignore it; newer servers stamp it onto every delta.
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
payload.causedByTaskId = causedByTaskId;
|
|
776
|
-
this.ws.send(JSON.stringify({ type: 'commit', payload }));
|
|
783
|
+
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
|
|
784
|
+
this.ws.send(JSON.stringify(frame));
|
|
777
785
|
}
|
|
778
786
|
catch (error) {
|
|
779
787
|
clearTimeout(timeout);
|
|
780
788
|
this.pendingMutations.delete(clientTxId);
|
|
781
|
-
reject(error
|
|
782
|
-
? error
|
|
783
|
-
: new Error(String(error)));
|
|
789
|
+
reject(toAbloError(error));
|
|
784
790
|
}
|
|
785
791
|
});
|
|
786
792
|
}
|
|
@@ -796,10 +802,8 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
796
802
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
797
803
|
throw this.notConnectedError('commit');
|
|
798
804
|
}
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
payload.causedByTaskId = causedByTaskId;
|
|
802
|
-
this.ws.send(JSON.stringify({ type: 'commit', payload }));
|
|
805
|
+
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
|
|
806
|
+
this.ws.send(JSON.stringify(frame));
|
|
803
807
|
}
|
|
804
808
|
/**
|
|
805
809
|
* Activate a participant claim on this connection. Multiplexed
|
|
@@ -826,7 +830,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
826
830
|
return new Promise((resolve, reject) => {
|
|
827
831
|
const timeout = setTimeout(() => {
|
|
828
832
|
this.pendingClaims.delete(claimId);
|
|
829
|
-
reject(new
|
|
833
|
+
reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
|
|
834
|
+
code: 'wait_for_timeout',
|
|
835
|
+
}));
|
|
830
836
|
}, timeoutMs);
|
|
831
837
|
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
832
838
|
try {
|
|
@@ -843,7 +849,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
843
849
|
catch (error) {
|
|
844
850
|
clearTimeout(timeout);
|
|
845
851
|
this.pendingClaims.delete(claimId);
|
|
846
|
-
reject(
|
|
852
|
+
reject(toAbloError(error));
|
|
847
853
|
}
|
|
848
854
|
});
|
|
849
855
|
}
|
|
@@ -864,7 +870,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
864
870
|
if (pending) {
|
|
865
871
|
clearTimeout(pending.timeout);
|
|
866
872
|
this.pendingClaims.delete(claimId);
|
|
867
|
-
pending.reject(new
|
|
873
|
+
pending.reject(new AbloError(`claim ${claimId} released before ack`, {
|
|
874
|
+
code: 'intent_wait_aborted',
|
|
875
|
+
httpStatus: 409,
|
|
876
|
+
}));
|
|
868
877
|
}
|
|
869
878
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
870
879
|
return;
|
|
@@ -876,17 +885,29 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
876
885
|
}
|
|
877
886
|
}
|
|
878
887
|
/**
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
* connections alive past cap expiry until they decide to close, and
|
|
883
|
-
* a forced reconnect would interrupt in-flight deltas. The cap-mint
|
|
884
|
-
* scheduler in `Ablo.ts` calls this on each successful refresh so
|
|
885
|
-
* reconnects after server-initiated close pick up the fresh token.
|
|
888
|
+
* Compatibility setter for direct SyncWebSocket users. The SDK-owned
|
|
889
|
+
* `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
|
|
890
|
+
* shared credential source instead of this copied value.
|
|
886
891
|
*/
|
|
887
892
|
setCapabilityToken(token) {
|
|
888
893
|
this.options.capabilityToken = token;
|
|
889
894
|
}
|
|
895
|
+
getAuthToken() {
|
|
896
|
+
return this.resolveAuthToken();
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Return the credential that will be used by the next WebSocket upgrade.
|
|
900
|
+
* ConnectionManager reads this for HTTP auth probes so visibility/network
|
|
901
|
+
* checks authenticate the same way reconnects do.
|
|
902
|
+
*/
|
|
903
|
+
getCapabilityToken() {
|
|
904
|
+
return this.resolveAuthToken();
|
|
905
|
+
}
|
|
906
|
+
resolveAuthToken = () => {
|
|
907
|
+
return this.options.getAuthToken?.()
|
|
908
|
+
?? this.options.getCapabilityToken?.()
|
|
909
|
+
?? this.options.capabilityToken;
|
|
910
|
+
};
|
|
890
911
|
/**
|
|
891
912
|
* Send spreadsheet selection presence
|
|
892
913
|
*/
|
|
@@ -1172,8 +1193,11 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1172
1193
|
else {
|
|
1173
1194
|
detail = 'never_connected';
|
|
1174
1195
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1196
|
+
// Typed so it lands in the AbloError hierarchy AND `isPermanentError`
|
|
1197
|
+
// sees a transient transport failure (retry on reconnect, don't roll
|
|
1198
|
+
// back). `diagnostics` stays a property — the queue's failure log walks
|
|
1199
|
+
// the cause chain for it.
|
|
1200
|
+
const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
|
|
1177
1201
|
return err;
|
|
1178
1202
|
}
|
|
1179
1203
|
/** Returns the sync groups this connection is subscribed to. */
|
|
@@ -93,6 +93,9 @@ export function createIntentStream(config, transport = null) {
|
|
|
93
93
|
// `settled()`. Absent status means active (wire back-compat).
|
|
94
94
|
if (claim.status && claim.status !== 'active')
|
|
95
95
|
continue;
|
|
96
|
+
const description = typeof claim.meta?.description === 'string'
|
|
97
|
+
? claim.meta.description
|
|
98
|
+
: undefined;
|
|
96
99
|
activeByIntentId.set(claim.intentId, {
|
|
97
100
|
id: claim.intentId,
|
|
98
101
|
heldBy: event.userId,
|
|
@@ -106,6 +109,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
106
109
|
meta: claim.meta,
|
|
107
110
|
},
|
|
108
111
|
reason: claim.action,
|
|
112
|
+
...(description ? { description } : {}),
|
|
109
113
|
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
110
114
|
announcedAt: new Date(claim.declaredAt).toISOString(),
|
|
111
115
|
expiresAt: new Date(claim.expiresAt).toISOString(),
|
|
@@ -227,6 +231,11 @@ export function createIntentStream(config, transport = null) {
|
|
|
227
231
|
},
|
|
228
232
|
});
|
|
229
233
|
}
|
|
234
|
+
function withDescription(meta, description) {
|
|
235
|
+
if (!description)
|
|
236
|
+
return meta;
|
|
237
|
+
return { ...(meta ?? {}), description };
|
|
238
|
+
}
|
|
230
239
|
function mintHandle(args) {
|
|
231
240
|
const intentId = crypto.randomUUID();
|
|
232
241
|
const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
|
|
@@ -273,7 +282,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
273
282
|
path: resolved.path,
|
|
274
283
|
range: resolved.range,
|
|
275
284
|
field: resolved.field,
|
|
276
|
-
meta: resolved.meta,
|
|
285
|
+
meta: withDescription(resolved.meta, opts?.description),
|
|
277
286
|
action: opts?.reason ?? 'editing',
|
|
278
287
|
ttl: opts?.ttl,
|
|
279
288
|
queue: opts?.queue,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scopeKindOf } from '../schema/model.js';
|
|
2
|
+
import { AbloConnectionError, AbloValidationError } from '../errors.js';
|
|
2
3
|
export function createParticipantManager(config) {
|
|
3
4
|
return {
|
|
4
5
|
async join(input, overrides) {
|
|
@@ -10,7 +11,7 @@ export function createParticipantManager(config) {
|
|
|
10
11
|
await config.ready();
|
|
11
12
|
const transport = config.getTransport();
|
|
12
13
|
if (!transport) {
|
|
13
|
-
throw new
|
|
14
|
+
throw new AbloConnectionError('Ablo participant join failed: WebSocket is not connected', { code: 'ws_not_ready' });
|
|
14
15
|
}
|
|
15
16
|
const claimId = createParticipantClaimId();
|
|
16
17
|
if (syncGroups.length > 0) {
|
|
@@ -154,7 +155,9 @@ function createJoinedParticipant(args) {
|
|
|
154
155
|
const requireTarget = (target) => {
|
|
155
156
|
const resolved = target ? targetToEntityRef(target) : currentTarget;
|
|
156
157
|
if (!resolved) {
|
|
157
|
-
throw new
|
|
158
|
+
throw new AbloValidationError('Participant action requires a structured target', {
|
|
159
|
+
code: 'invalid_request',
|
|
160
|
+
});
|
|
158
161
|
}
|
|
159
162
|
return resolved;
|
|
160
163
|
};
|
|
@@ -12,7 +12,7 @@ import { getContext } from '../context.js';
|
|
|
12
12
|
import { getActiveRegistry } from '../ModelRegistry.js';
|
|
13
13
|
import { MutationOperationType } from '../types/index.js';
|
|
14
14
|
import { handleMutationError } from './mutation-error-handler.js';
|
|
15
|
-
import { AbloError, AbloConnectionError } from '../errors.js';
|
|
15
|
+
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
16
16
|
/**
|
|
17
17
|
* Framework-internal keys added by `Model.toJSON()` that must never
|
|
18
18
|
* reach the wire. The server treats each top-level key as a target
|
|
@@ -1482,6 +1482,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1482
1482
|
if (error instanceof AbloConnectionError) {
|
|
1483
1483
|
return false;
|
|
1484
1484
|
}
|
|
1485
|
+
// Registry-driven retryability is authoritative when the error carries a
|
|
1486
|
+
// known wire code: the error contract (errorCodes.ts) decides whether the
|
|
1487
|
+
// same request can succeed on retry, not message string-matching. This is
|
|
1488
|
+
// why rejected commits must arrive as typed AbloErrors (see
|
|
1489
|
+
// `errorFromWire`) — a bare `Error` has no code and falls through to the
|
|
1490
|
+
// heuristics below. Unknown / forward-compat codes (`errorCodeSpec`
|
|
1491
|
+
// returns undefined) also fall through, preserving the safe default.
|
|
1492
|
+
if (error instanceof AbloError && error.code) {
|
|
1493
|
+
const spec = errorCodeSpec(error.code);
|
|
1494
|
+
if (spec)
|
|
1495
|
+
return !spec.retryable;
|
|
1496
|
+
}
|
|
1485
1497
|
const message = error?.message?.toLowerCase() || '';
|
|
1486
1498
|
// Network/connection errors are transient - retry these
|
|
1487
1499
|
const isNetworkError = message.includes('failed to fetch') ||
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -340,6 +340,12 @@ export interface ClaimOptions extends IntentOptions {
|
|
|
340
340
|
* app-specific phases.
|
|
341
341
|
*/
|
|
342
342
|
readonly reason?: string;
|
|
343
|
+
/**
|
|
344
|
+
* Peer-visible explanation of the exact work being performed. This is more
|
|
345
|
+
* specific than `reason`: `reason` is the phase (`'renaming'`), while
|
|
346
|
+
* `description` is the instruction other agents should see.
|
|
347
|
+
*/
|
|
348
|
+
readonly description?: string;
|
|
343
349
|
/**
|
|
344
350
|
* Join the server's fair FIFO queue on contention instead of being
|
|
345
351
|
* rejected. The grant arrives asynchronously (`intent_acquired` if the
|
|
@@ -528,6 +534,7 @@ export interface ActiveIntent extends IntentDeclaration {
|
|
|
528
534
|
* from "user editing X" without string-parsing `heldBy`.
|
|
529
535
|
*/
|
|
530
536
|
readonly participantKind: 'human' | 'agent';
|
|
537
|
+
readonly description?: string;
|
|
531
538
|
readonly announcedAt: string;
|
|
532
539
|
readonly expiresAt: string;
|
|
533
540
|
}
|
|
@@ -565,6 +572,8 @@ export interface Intent {
|
|
|
565
572
|
readonly target: EntityRef;
|
|
566
573
|
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. */
|
|
567
574
|
readonly action: string;
|
|
575
|
+
/** Peer-visible explanation of the work being performed. */
|
|
576
|
+
readonly description?: string;
|
|
568
577
|
/** Participant holding it. */
|
|
569
578
|
readonly heldBy: string;
|
|
570
579
|
readonly participantKind: 'human' | 'agent';
|
package/dist/utils/mobx-setup.js
CHANGED
|
@@ -146,6 +146,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
|
|
|
146
146
|
'markAsPersisted',
|
|
147
147
|
'clearChanges',
|
|
148
148
|
'updateFromData',
|
|
149
|
+
'applyChanges',
|
|
149
150
|
];
|
|
150
151
|
for (const methodName of actionMethods) {
|
|
151
152
|
if (typeof Reflect.get(target, methodName) === 'function') {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Stripe-style webhook event delivered to the customer's endpoint. Verified
|
|
3
|
+
* (via the Standard Webhooks library) before the customer reads it.
|
|
4
|
+
*/
|
|
5
|
+
export interface AbloWebhookEvent {
|
|
6
|
+
/** Stable event id = `String(syncId)`. Dedupe by this (idempotency). */
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/** `<model>.<verb>`, e.g. `"slide.updated"` — switch on this. */
|
|
9
|
+
readonly type: string;
|
|
10
|
+
/** Wire model name, e.g. `"Slide"`. */
|
|
11
|
+
readonly model: string;
|
|
12
|
+
/** The changed row's id. */
|
|
13
|
+
readonly objectId: string;
|
|
14
|
+
/** Monotonic transaction-log position. ORDER by this (and dedupe). */
|
|
15
|
+
readonly syncId: number;
|
|
16
|
+
/** The post-change row (the object), or `null` on a delete. Like Stripe's
|
|
17
|
+
* `event.data.object`. */
|
|
18
|
+
readonly data: Record<string, unknown> | null;
|
|
19
|
+
/** ISO timestamp the change was committed. */
|
|
20
|
+
readonly createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
/** The minimal delta shape the mapping reads (a `ServerSyncDelta` satisfies it). */
|
|
23
|
+
export interface WebhookSourceDelta {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly actionType: string;
|
|
26
|
+
readonly modelName: string;
|
|
27
|
+
readonly modelId: string;
|
|
28
|
+
/** `jsonb` — parsed object, raw JSON string, or null. */
|
|
29
|
+
readonly data: Record<string, unknown> | string | null;
|
|
30
|
+
readonly createdAt: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Map a committed delta to a customer-facing webhook event. Returns `null` for
|
|
34
|
+
* internal sync deltas (permission/group changes) that aren't customer events —
|
|
35
|
+
* the caller skips those (no webhook emitted). Pure: the `syncId` and timestamp
|
|
36
|
+
* come from the delta, so the mapping is deterministic.
|
|
37
|
+
*/
|
|
38
|
+
export declare function deltaToWebhookEvent(delta: WebhookSourceDelta): AbloWebhookEvent | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The customer-facing verb per delta action. Only the CRUD-ish actions become
|
|
3
|
+
* webhook events; `C`overing / `G`roupAdded / `S`groupRemoved are internal sync
|
|
4
|
+
* mechanics (permission/visibility), NOT customer events → no webhook.
|
|
5
|
+
*/
|
|
6
|
+
const ACTION_VERB = {
|
|
7
|
+
I: 'created',
|
|
8
|
+
U: 'updated',
|
|
9
|
+
D: 'deleted',
|
|
10
|
+
A: 'archived',
|
|
11
|
+
V: 'unarchived',
|
|
12
|
+
};
|
|
13
|
+
function parseRow(data) {
|
|
14
|
+
if (data == null)
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof data === 'string') {
|
|
17
|
+
return data === '' ? null : JSON.parse(data);
|
|
18
|
+
}
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Map a committed delta to a customer-facing webhook event. Returns `null` for
|
|
23
|
+
* internal sync deltas (permission/group changes) that aren't customer events —
|
|
24
|
+
* the caller skips those (no webhook emitted). Pure: the `syncId` and timestamp
|
|
25
|
+
* come from the delta, so the mapping is deterministic.
|
|
26
|
+
*/
|
|
27
|
+
export function deltaToWebhookEvent(delta) {
|
|
28
|
+
const verb = ACTION_VERB[delta.actionType];
|
|
29
|
+
if (!verb)
|
|
30
|
+
return null; // C / G / S — internal sync mechanics, not a customer event
|
|
31
|
+
return {
|
|
32
|
+
id: String(delta.id),
|
|
33
|
+
type: `${delta.modelName.toLowerCase()}.${verb}`,
|
|
34
|
+
model: delta.modelName,
|
|
35
|
+
objectId: delta.modelId,
|
|
36
|
+
syncId: delta.id,
|
|
37
|
+
data: parseRow(delta.data),
|
|
38
|
+
createdAt: delta.createdAt,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/webhooks` — the webhook event catalog + delta mapping.
|
|
3
|
+
*
|
|
4
|
+
* Customers import {@link AbloWebhookEvent} to type their handler; the server
|
|
5
|
+
* uses {@link deltaToWebhookEvent} to turn transaction-log deltas into events
|
|
6
|
+
* for Svix to deliver. Signature verification is NOT here — the customer uses
|
|
7
|
+
* the open Standard Webhooks library (`svix` / `standardwebhooks`), so Ablo
|
|
8
|
+
* ships no crypto.
|
|
9
|
+
*/
|
|
10
|
+
export { deltaToWebhookEvent, type AbloWebhookEvent, type WebhookSourceDelta, } from './events.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/webhooks` — the webhook event catalog + delta mapping.
|
|
3
|
+
*
|
|
4
|
+
* Customers import {@link AbloWebhookEvent} to type their handler; the server
|
|
5
|
+
* uses {@link deltaToWebhookEvent} to turn transaction-log deltas into events
|
|
6
|
+
* for Svix to deliver. Signature verification is NOT here — the customer uses
|
|
7
|
+
* the open Standard Webhooks library (`svix` / `standardwebhooks`), so Ablo
|
|
8
|
+
* ships no crypto.
|
|
9
|
+
*/
|
|
10
|
+
export { deltaToWebhookEvent, } from './events.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The canonical wire envelope — Stripe's error-object shape. Every HTTP error
|
|
2
|
+
* response and every structured frame error carries this exact set of keys,
|
|
3
|
+
* regardless of which route or transport produced it. */
|
|
4
|
+
export interface ErrorEnvelope {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly code?: string;
|
|
7
|
+
readonly param?: string;
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly doc_url?: string;
|
|
10
|
+
readonly request_id?: string;
|
|
11
|
+
}
|
|
12
|
+
/** {@link AbloError} subclass → default HTTP status. The subclass is chosen to
|
|
13
|
+
* match status semantics (a validation error is a 400, a permission error a
|
|
14
|
+
* 403), so a throw site only picks the right class + code and the status
|
|
15
|
+
* follows — an explicit `httpStatus` is passed only when it diverges (e.g. a
|
|
16
|
+
* 404 on the base class, a 503 on AbloServerError). Mirrors the same table in
|
|
17
|
+
* apps/sync-server's self-contained `errors.ts`. */
|
|
18
|
+
export declare function statusForType(type: string): number;
|
|
19
|
+
/**
|
|
20
|
+
* Convert ANY thrown value into the canonical {@link ErrorEnvelope} plus an
|
|
21
|
+
* HTTP status. A typed {@link AbloError} is serialized via its own `toJSON`
|
|
22
|
+
* (so `code`/`param`/`doc_url`/structured `details` survive) and gets its
|
|
23
|
+
* status from an explicit `httpStatus` or, failing that, {@link statusForType}.
|
|
24
|
+
* Anything else degrades to a 500 `internal_error` envelope — never a bare
|
|
25
|
+
* framework "Internal Server Error" text body, and never a raw error string
|
|
26
|
+
* leaked onto the wire as an unregistered code.
|
|
27
|
+
*
|
|
28
|
+
* `requestId` is stamped into the body when the error didn't already carry one,
|
|
29
|
+
* so the response and the `x-request-id` header agree for support correlation.
|
|
30
|
+
*/
|
|
31
|
+
export declare function errorEnvelope(err: unknown, requestId?: string): {
|
|
32
|
+
body: ErrorEnvelope;
|
|
33
|
+
status: number;
|
|
34
|
+
};
|