@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.
Files changed (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. 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 { AbloClaimedError, CapabilityError, SyncSessionError, } from '../errors.js';
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 WS URL
174
- // carries the credential (cap-token bearer in `?authorization=`
175
- // for the cap path; session cookie in headers for the cookie
176
- // path). The server's AuthProvider chain (`apiKeyProvider
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 Error(errorMessage));
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. Wire format (mirrors
307
- // apps/sync-server/src/hub/types.ts MutationResultMessage):
308
- // { type: 'mutation_result',
309
- // payload: { clientTxId, serverTxId, success,
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
- // Capability denials route through the typed CapabilityError
364
- // so callers can `instanceof CapabilityError` and read
365
- // `.requiredCapability` to attenuate-and-retry without
366
- // string-matching the error code.
367
- if (errorCode === 'capability_scope_denied' ||
368
- errorCode === 'capability_invalid') {
369
- pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
370
- }
371
- else if (errorCode === 'intent_conflict' ||
372
- errorCode === 'claim_conflict' ||
373
- errorCode === 'entity_claimed') {
374
- // Claim enforcement: another participant holds a live claim on
375
- // a targeted entity. Two server layers reject this — the Hub's
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
- // Attach `code` as a property on the rejection so callers
442
- // can discriminate (`scope_conflict`, `malformed_claim`,
443
- // ...) without parsing the message.
444
- const rejection = Object.assign(new Error(`${code}: ${msg}`), { code });
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 Error('Network is offline'));
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 Error(`WebSocket connection failed`);
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
- pending.reject(Object.assign(new Error(`WebSocket closed while commit was in flight (code=${event.code}` +
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 Error(`WebSocket closed while claim was in flight (code=${event.code})`));
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 Error(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`));
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 payload = { operations, clientTxId };
774
- if (causedByTaskId)
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 instanceof 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 payload = { operations, clientTxId };
800
- if (causedByTaskId)
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 Error(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`));
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(error instanceof Error ? error : new Error(String(error)));
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 Error(`claim ${claimId} released before ack`));
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
- * Replace the capability token used for authentication. The new
880
- * value is read by the next URL-build (i.e., next connect / reconnect
881
- * cycle). The currently-open WS is NOT torn down — servers keep
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
- const err = new Error(`SyncWebSocket not connected cannot send ${action} (${detail})`);
1176
- err.diagnostics = d;
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 Error('Ablo participant join failed: WebSocket is not connected');
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 Error('Participant action requires a structured target');
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') ||
@@ -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';
@@ -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
+ };