@abloatai/ablo 0.6.0 → 0.8.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 (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -3,7 +3,7 @@
3
3
  * Removed problematic caching that was serving stale data
4
4
  */
5
5
  import { getContext } from '../context.js';
6
- import { SyncSessionError, AbloConnectionError, translateHttpError } from '../errors.js';
6
+ import { SyncSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '../errors.js';
7
7
  // SyncObservability replaced by getContext().observability
8
8
  import { parseBootstrapResponse } from './schemas.js';
9
9
  export class BootstrapHelper {
@@ -60,7 +60,9 @@ export class BootstrapHelper {
60
60
  createTimeoutPromise(ms, operation) {
61
61
  return new Promise((_, reject) => {
62
62
  setTimeout(() => {
63
- reject(new Error(`Bootstrap ${operation} timed out after ${ms}ms`));
63
+ reject(new AbloConnectionError(`Bootstrap ${operation} timed out after ${ms}ms`, {
64
+ code: 'bootstrap_fetch_timeout',
65
+ }));
64
66
  }, ms);
65
67
  });
66
68
  }
@@ -138,6 +140,17 @@ export class BootstrapHelper {
138
140
  });
139
141
  throw error;
140
142
  }
143
+ // Don't retry NON-retryable errors. A 401/403/4xx auth or client error
144
+ // (api_key_required, jwt_issuer_untrusted, …) will NOT succeed by
145
+ // repeating the same request with the same credential — retrying just
146
+ // hammers the server and floods the console with doomed requests. Only
147
+ // transient failures (5xx, 429, timeouts, network blips, or an
148
+ // unclassified error with no code) flow through to the retry/backoff.
149
+ const ablo = toAbloError(error);
150
+ if (ablo.code && !isRetryableCode(ablo.code)) {
151
+ getContext().observability.breadcrumb('Bootstrap non-retryable error — failing fast', 'sync.bootstrap', 'warning', { code: ablo.code, httpStatus: ablo.httpStatus });
152
+ throw ablo;
153
+ }
141
154
  lastError = error;
142
155
  getContext().observability.breadcrumb('Bootstrap fetch failed', 'sync.bootstrap', 'warning', {
143
156
  attempt: attempt + 1,
@@ -157,7 +170,11 @@ export class BootstrapHelper {
157
170
  });
158
171
  return cached;
159
172
  }
160
- throw lastError || new Error('Failed to fetch bootstrap data');
173
+ throw lastError
174
+ ? toAbloError(lastError)
175
+ : new AbloConnectionError('Failed to fetch bootstrap data', {
176
+ code: 'bootstrap_fetch_timeout',
177
+ });
161
178
  }
162
179
  /**
163
180
  * Fetch bootstrap with ETag, returning 304 hints
@@ -198,17 +215,6 @@ export class BootstrapHelper {
198
215
  return { notModified: true, etag };
199
216
  }
200
217
  if (!res.ok) {
201
- // Check for session/auth errors - these should redirect to login
202
- if (SyncSessionError.isSessionErrorResponse(res.status)) {
203
- let body = '';
204
- try {
205
- body = await res.text();
206
- }
207
- catch {
208
- // Ignore body parsing errors
209
- }
210
- throw new SyncSessionError(body || `Session expired or invalid: ${res.status}`, res.status);
211
- }
212
218
  const bodyText = await res.text().catch(() => '');
213
219
  let parsed = bodyText;
214
220
  if (bodyText) {
@@ -219,7 +225,21 @@ export class BootstrapHelper {
219
225
  // Keep as string.
220
226
  }
221
227
  }
222
- throw translateHttpError(res.status, parsed || `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
228
+ // Translate the canonical envelope FIRST so the server's specific code +
229
+ // message survive (e.g. `api_key_required`, `jwt_issuer_untrusted`).
230
+ const translated = translateHttpError(res.status, parsed || `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
231
+ // Only a genuine session/JWT EXPIRY — or a bare auth failure carrying no
232
+ // structured code — should drive the sign-in redirect. A specific auth
233
+ // code like `api_key_required` is NOT an expired session: re-logging-in
234
+ // mints the same credential and loops. Surface it as its real typed error
235
+ // instead of a `session_expired` wrapping the stringified body.
236
+ if (translated.code === 'session_expired' ||
237
+ translated.code === 'jwt_expired' ||
238
+ ((res.status === 401 || res.status === 403) &&
239
+ translated.code === undefined)) {
240
+ throw new SyncSessionError(translated.message, res.status);
241
+ }
242
+ throw translated;
223
243
  }
224
244
  const rawJson = await res.json();
225
245
  const data = parseBootstrapResponse(rawJson);
@@ -275,17 +295,6 @@ export class BootstrapHelper {
275
295
  }
276
296
  clearTimeout(timeoutId);
277
297
  if (!response.ok) {
278
- // Check for session/auth errors - these should redirect to login
279
- if (SyncSessionError.isSessionErrorResponse(response.status)) {
280
- let body = '';
281
- try {
282
- body = await response.text();
283
- }
284
- catch {
285
- // Ignore body parsing errors
286
- }
287
- throw new SyncSessionError(body || `Session expired or invalid: ${response.status}`, response.status);
288
- }
289
298
  const bodyText = await response.text().catch(() => '');
290
299
  let parsed = bodyText;
291
300
  if (bodyText) {
@@ -296,7 +305,17 @@ export class BootstrapHelper {
296
305
  // Keep as string.
297
306
  }
298
307
  }
299
- throw translateHttpError(response.status, parsed || `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
308
+ // Same code-aware handling as the primary bootstrap fetch: preserve the
309
+ // server's specific code/message; only a genuine expiry (or a bare,
310
+ // code-less auth failure) drives the sign-in redirect.
311
+ const translated = translateHttpError(response.status, parsed || `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
312
+ if (translated.code === 'session_expired' ||
313
+ translated.code === 'jwt_expired' ||
314
+ ((response.status === 401 || response.status === 403) &&
315
+ translated.code === undefined)) {
316
+ throw new SyncSessionError(translated.message, response.status);
317
+ }
318
+ throw translated;
300
319
  }
301
320
  const rawJson = await response.json();
302
321
  const data = parseBootstrapResponse(rawJson);
@@ -34,7 +34,7 @@
34
34
  * instead of hard-reloading an already-offline browser.
35
35
  */
36
36
  import { type ProbeResult } from './NetworkProbe.js';
37
- export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'session_expired';
37
+ export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'auth_blocked' | 'session_expired';
38
38
  export type ConnectionEvent = {
39
39
  type: 'NETWORK_LOST';
40
40
  } | {
@@ -52,6 +52,8 @@ export type ConnectionEvent = {
52
52
  } | {
53
53
  type: 'PROBE_SUCCESS';
54
54
  sessionValid: boolean;
55
+ } | {
56
+ type: 'PROBE_AUTH_BLOCKED';
55
57
  } | {
56
58
  type: 'PROBE_FAILED';
57
59
  } | {
@@ -162,6 +162,8 @@ export class ConnectionManager {
162
162
  switch (event.type) {
163
163
  case 'PROBE_SUCCESS':
164
164
  return event.sessionValid ? 'reconnecting' : 'session_expired';
165
+ case 'PROBE_AUTH_BLOCKED':
166
+ return 'auth_blocked';
165
167
  case 'PROBE_FAILED':
166
168
  return 'waiting_for_network';
167
169
  case 'NETWORK_LOST':
@@ -185,6 +187,8 @@ export class ConnectionManager {
185
187
  switch (event.type) {
186
188
  case 'PROBE_SUCCESS':
187
189
  return event.sessionValid ? 'reconnecting' : 'session_expired';
190
+ case 'PROBE_AUTH_BLOCKED':
191
+ return 'auth_blocked';
188
192
  case 'NETWORK_LOST':
189
193
  return 'offline';
190
194
  default:
@@ -227,6 +231,25 @@ export class ConnectionManager {
227
231
  default:
228
232
  return null;
229
233
  }
234
+ case 'auth_blocked':
235
+ // Reachable, but the data-plane rejected the credential (non-retryable,
236
+ // non-expiry — e.g. api_key_required, jwt_issuer_untrusted). Don't
237
+ // auto-reconnect and don't sign out. Allow a manual retry or a
238
+ // tab-focus / network-return re-probe (e.g. after a server deploy);
239
+ // a network drop parks offline; a genuine session error still expires.
240
+ switch (event.type) {
241
+ case 'MANUAL_RETRY':
242
+ case 'TAB_VISIBLE':
243
+ case 'NETWORK_ONLINE':
244
+ return 'probing_network';
245
+ case 'NETWORK_LOST':
246
+ return 'offline';
247
+ case 'WS_SESSION_ERROR':
248
+ case 'BOOTSTRAP_FAILED_SESSION':
249
+ return 'session_expired';
250
+ default:
251
+ return null;
252
+ }
230
253
  case 'session_expired':
231
254
  return null; // terminal
232
255
  default:
@@ -255,6 +278,16 @@ export class ConnectionManager {
255
278
  case 'backoff':
256
279
  this.scheduleBackoff();
257
280
  break;
281
+ case 'auth_blocked':
282
+ // Stop — reachable but the credential was rejected (e.g.
283
+ // api_key_required / jwt_issuer_untrusted from the data plane). Neither
284
+ // reconnecting nor re-auth fixes it. Drop the socket and wait for a
285
+ // manual retry / re-probe. Crucially NOT onSessionExpired (no sign-out)
286
+ // and NOT a reconnect — that's the whole point of this state.
287
+ this.clearBackoffTimer();
288
+ this.callbacks?.onDisconnectWebSocket();
289
+ getContext().observability.breadcrumb('Auth blocked — reachable but credential rejected; not reconnecting or signing out', 'sync.offline', 'error');
290
+ break;
258
291
  case 'session_expired':
259
292
  this.clearBackoffTimer();
260
293
  this.callbacks?.onDisconnectWebSocket();
@@ -270,7 +303,10 @@ export class ConnectionManager {
270
303
  runInAction(() => {
271
304
  this.lastProbeResult = result;
272
305
  });
273
- if (result.reachable) {
306
+ if (result.authBlocked) {
307
+ this.send({ type: 'PROBE_AUTH_BLOCKED' });
308
+ }
309
+ else if (result.reachable) {
274
310
  this.send({ type: 'PROBE_SUCCESS', sessionValid: result.sessionValid ?? true });
275
311
  }
276
312
  else {
@@ -113,6 +113,8 @@ export declare class HydrationCoordinator {
113
113
  private hydrateExpanded;
114
114
  private persistToIdb;
115
115
  private resolveTypename;
116
+ private columnizeField;
117
+ private columnizeClause;
116
118
  }
117
119
  /**
118
120
  * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
@@ -20,6 +20,7 @@
20
20
  * models accessed by id/where after the engine is ready.
21
21
  */
22
22
  import { ModelScope } from '../ObjectPool.js';
23
+ import { AbloValidationError } from '../errors.js';
23
24
  import { postQuery } from '../query/client.js';
24
25
  export class HydrationCoordinator {
25
26
  opts;
@@ -53,8 +54,8 @@ export class HydrationCoordinator {
53
54
  const ModelClass = this.opts.registry.getModelByName(typename)
54
55
  ?? this.opts.registry.getModelByName(modelName);
55
56
  if (!ModelClass) {
56
- throw new Error(`HydrationCoordinator.fetch: unknown model "${modelName}" — ` +
57
- `not registered in the schema.`);
57
+ throw new AbloValidationError(`HydrationCoordinator.fetch: unknown model "${modelName}" — ` +
58
+ `not registered in the schema.`, { code: 'model_not_registered' });
58
59
  }
59
60
  const clauses = normalizeWhere(options?.where);
60
61
  const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit);
@@ -161,10 +162,10 @@ export class HydrationCoordinator {
161
162
  const firstOrder = orderEntries[0];
162
163
  const query = {
163
164
  model: typename,
164
- where: clauses.map((c) => columnizeClause(c)),
165
+ where: clauses.map((c) => this.columnizeClause(modelName, c)),
165
166
  ...(firstOrder
166
167
  ? {
167
- orderBy: columnize(firstOrder[0]),
168
+ orderBy: this.columnizeField(modelName, firstOrder[0]),
168
169
  order: firstOrder[1] ?? 'asc',
169
170
  }
170
171
  : {}),
@@ -256,6 +257,27 @@ export class HydrationCoordinator {
256
257
  .models?.[modelName];
257
258
  return def?.typename ?? modelName;
258
259
  }
260
+ columnizeField(modelName, field) {
261
+ const fields = this.opts.schema.models?.[modelName]?.fields;
262
+ if (fields) {
263
+ const direct = fields[field]?.column;
264
+ if (direct)
265
+ return direct;
266
+ for (const [fieldName, meta] of Object.entries(fields)) {
267
+ const conventional = columnize(fieldName);
268
+ if (field === fieldName || field === conventional || field === meta.column) {
269
+ return meta.column ?? conventional;
270
+ }
271
+ }
272
+ }
273
+ return /[A-Z]/.test(field) ? columnize(field) : field;
274
+ }
275
+ columnizeClause(modelName, clause) {
276
+ const finalCol = this.columnizeField(modelName, clause[0]);
277
+ if (clause.length === 2)
278
+ return [finalCol, clause[1]];
279
+ return [finalCol, clause[1], clause[2]];
280
+ }
259
281
  }
260
282
  // ── Helpers ────────────────────────────────────────────────────────────
261
283
  function stableKey(modelName, clauses, orderBy, limit) {
@@ -350,21 +372,6 @@ export function normalizeWhere(where) {
350
372
  }
351
373
  return [];
352
374
  }
353
- /**
354
- * Apply `columnize` to the column name of a wire-bound clause so the
355
- * server sees `slide_id` instead of `slideId`. Tuple-form clauses from
356
- * callers are passed through unchanged — they already supply the wire
357
- * column name (matches what existing `postQuery` consumers do).
358
- */
359
- function columnizeClause(clause) {
360
- const col = clause[0];
361
- // If the column already looks snake_case (no uppercase letters), assume
362
- // the caller is already using server-side naming. Otherwise camelize→snake.
363
- const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
364
- if (clause.length === 2)
365
- return [finalCol, clause[1]];
366
- return [finalCol, clause[1], clause[2]];
367
- }
368
375
  /** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
369
376
  function extractEqClauses(clauses) {
370
377
  const out = {};
@@ -27,6 +27,14 @@ export interface ProbeResult {
27
27
  reachable: boolean;
28
28
  /** Whether the session cookie is still valid (null if server unreachable) */
29
29
  sessionValid: boolean | null;
30
+ /**
31
+ * Reachable, but a NON-retryable auth/config failure that is NOT a session
32
+ * expiry (e.g. `api_key_required`, `jwt_issuer_untrusted`). The session is
33
+ * fine — the data-plane rejected the credential TYPE — so neither
34
+ * reconnecting nor re-authenticating will help. The manager stops instead of
35
+ * looping. Distinct from `sessionValid: false` (genuine expiry → sign in).
36
+ */
37
+ authBlocked?: boolean;
30
38
  /** Round-trip time in ms (null if failed) */
31
39
  latencyMs: number | null;
32
40
  }
@@ -22,7 +22,7 @@
22
22
  * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
23
23
  */
24
24
  import { getContext } from '../context.js';
25
- import { SyncSessionError } from '../errors.js';
25
+ import { SyncSessionError, isRetryableCode } from '../errors.js';
26
26
  const PROBE_TIMEOUT_MS = 4000;
27
27
  /**
28
28
  * Derive the probe URL from a sync-server base URL. Accepts `ws://`,
@@ -72,7 +72,17 @@ export async function probeNetwork(baseUrl) {
72
72
  headers: { 'Cache-Control': 'no-cache' },
73
73
  });
74
74
  const latencyMs = Math.round(performance.now() - start);
75
- if (SyncSessionError.isSessionErrorResponse(response.status)) {
75
+ // The probe is a HEAD (no body), but the sync-server sets `X-Auth-Failure:
76
+ // <code>` on every auth rejection — feed that to the code-aware detector so
77
+ // only a genuine session/JWT EXPIRY marks the session invalid. A non-expiry
78
+ // auth failure (e.g. api_key_required, jwt_issuer_untrusted) leaves
79
+ // sessionValid alone — the user IS logged in; signing them out wouldn't fix
80
+ // a credential-type/config problem and just bounces them to /signin.
81
+ const authFailure = response.headers.get('x-auth-failure');
82
+ const failureBody = authFailure
83
+ ? JSON.stringify({ code: authFailure })
84
+ : undefined;
85
+ if (SyncSessionError.isSessionErrorResponse(response.status, failureBody)) {
76
86
  // Server reachable but session expired/invalid
77
87
  getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
78
88
  status: response.status,
@@ -80,6 +90,18 @@ export async function probeNetwork(baseUrl) {
80
90
  });
81
91
  return { reachable: true, sessionValid: false, latencyMs };
82
92
  }
93
+ // Reachable, but a NON-retryable auth/config failure that is NOT a session
94
+ // expiry (api_key_required, jwt_issuer_untrusted, …). Re-auth won't fix it
95
+ // and retrying won't either — signal authBlocked so the manager STOPS
96
+ // rather than reconnect-looping or signing the user out.
97
+ if (authFailure && !isRetryableCode(authFailure)) {
98
+ getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
99
+ status: response.status,
100
+ code: authFailure,
101
+ latencyMs,
102
+ });
103
+ return { reachable: true, sessionValid: true, authBlocked: true, latencyMs };
104
+ }
83
105
  // 2xx (including 204) means reachable + session valid.
84
106
  // 3xx/4xx (non-auth) still prove connectivity even though the probe
85
107
  // expected 204; log a warning so misconfigurations surface instead of
@@ -271,7 +271,7 @@ export interface CoreSyncEventMap {
271
271
  /**
272
272
  * Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
273
273
  * entry `status: 'queued'` + `position`. Broadcast to entity peers on every
274
- * queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
274
+ * queue mutation — powers the reactive `ablo.<model>.claim.queue(id)` read.
275
275
  */
276
276
  intent_queue: [Record<string, unknown>];
277
277
  intent_acquired: [Record<string, unknown>];
@@ -10,7 +10,7 @@
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
14
  // ---------------------------------------------------------------------------
15
15
  // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
16
16
  // Consumers pass their own event types as TCollaboration generic parameter.
@@ -214,7 +214,7 @@ export class SyncWebSocket extends EventEmitter {
214
214
  const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
215
215
  getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
216
216
  this.isConnecting = false;
217
- this.emit('error', new Error(errorMessage));
217
+ this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
218
218
  this.scheduleReconnect();
219
219
  }
220
220
  }
@@ -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
  }
@@ -762,7 +746,7 @@ export class SyncWebSocket extends EventEmitter {
762
746
  return new Promise((resolve, reject) => {
763
747
  const timeout = setTimeout(() => {
764
748
  this.pendingMutations.delete(clientTxId);
765
- reject(new Error(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`));
749
+ reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
766
750
  }, timeoutMs);
767
751
  this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
768
752
  try {
@@ -778,9 +762,7 @@ export class SyncWebSocket extends EventEmitter {
778
762
  catch (error) {
779
763
  clearTimeout(timeout);
780
764
  this.pendingMutations.delete(clientTxId);
781
- reject(error instanceof Error
782
- ? error
783
- : new Error(String(error)));
765
+ reject(toAbloError(error));
784
766
  }
785
767
  });
786
768
  }
@@ -826,7 +808,9 @@ export class SyncWebSocket extends EventEmitter {
826
808
  return new Promise((resolve, reject) => {
827
809
  const timeout = setTimeout(() => {
828
810
  this.pendingClaims.delete(claimId);
829
- reject(new Error(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`));
811
+ reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
812
+ code: 'wait_for_timeout',
813
+ }));
830
814
  }, timeoutMs);
831
815
  this.pendingClaims.set(claimId, { resolve, reject, timeout });
832
816
  try {
@@ -843,7 +827,7 @@ export class SyncWebSocket extends EventEmitter {
843
827
  catch (error) {
844
828
  clearTimeout(timeout);
845
829
  this.pendingClaims.delete(claimId);
846
- reject(error instanceof Error ? error : new Error(String(error)));
830
+ reject(toAbloError(error));
847
831
  }
848
832
  });
849
833
  }
@@ -864,7 +848,10 @@ export class SyncWebSocket extends EventEmitter {
864
848
  if (pending) {
865
849
  clearTimeout(pending.timeout);
866
850
  this.pendingClaims.delete(claimId);
867
- pending.reject(new Error(`claim ${claimId} released before ack`));
851
+ pending.reject(new AbloError(`claim ${claimId} released before ack`, {
852
+ code: 'intent_wait_aborted',
853
+ httpStatus: 409,
854
+ }));
868
855
  }
869
856
  if (this.ws?.readyState !== WebSocket.OPEN)
870
857
  return;
@@ -1172,8 +1159,11 @@ export class SyncWebSocket extends EventEmitter {
1172
1159
  else {
1173
1160
  detail = 'never_connected';
1174
1161
  }
1175
- const err = new Error(`SyncWebSocket not connected cannot send ${action} (${detail})`);
1176
- err.diagnostics = d;
1162
+ // Typed so it lands in the AbloError hierarchy AND `isPermanentError`
1163
+ // sees a transient transport failure (retry on reconnect, don't roll
1164
+ // back). `diagnostics` stays a property — the queue's failure log walks
1165
+ // the cause chain for it.
1166
+ const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
1177
1167
  return err;
1178
1168
  }
1179
1169
  /** Returns the sync groups this connection is subscribed to. */
@@ -11,7 +11,8 @@
11
11
  * Wire contract (apps/sync-server/src/hub/types.ts):
12
12
  * • Outbound: `{ type: 'intent_begin', payload: { intentId,
13
13
  * entityType, entityId, action, field?, estimatedMs? } }`
14
- * • Outbound: `{ type: 'intent_abandon', payload: { intentId } }`
14
+ * • Outbound: `{ type: 'intent_abandon', payload: { intentId,
15
+ * entityType?, entityId? } }`
15
16
  * • Inbound (via presence): `event.activeIntents: IntentClaim[]`
16
17
  * stamped with `declaredAt`, `expiresAt`.
17
18
  * • Inbound: `intent_rejected` event with conflict metadata.