@abloatai/ablo 0.8.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 (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +86 -50
  52. package/dist/mutators/UndoManager.js +129 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
@@ -16,7 +16,7 @@ export function validateAbloOptions(input) {
16
16
  const kind = options.kind ?? 'user';
17
17
  if (!url) {
18
18
  return new AbloValidationError('Ablo: `url` is required. Pass the sync server URL, e.g. ' +
19
- `Ablo({ baseURL: 'wss://sync.abloatai.com', schema, user })`, { code: 'base_url_missing' });
19
+ `Ablo({ baseURL: 'wss://api.abloatai.com', schema, user })`, { code: 'base_url_missing' });
20
20
  }
21
21
  // Schema is optional for the model-first API:
22
22
  // Ablo({ apiKey }).model('clauses').retrieve(...)
@@ -8,7 +8,7 @@
8
8
  * Follows Ablo's architecture for database management.
9
9
  */
10
10
  import { getContext } from '../context.js';
11
- import { openIDBWithTimeout } from './openIDBWithTimeout.js';
11
+ import { openIDBWithTimeout, deleteIDBWithTimeout, IDBOpenTimeoutError, } from './openIDBWithTimeout.js';
12
12
  import { AbloConnectionError } from '../errors.js';
13
13
  import { getActiveRegistry, hasActiveRegistry } from '../ModelRegistry.js';
14
14
  /**
@@ -30,7 +30,7 @@ export class DatabaseManager {
30
30
  * Initialize the meta database (ablo_databases)
31
31
  */
32
32
  async initializeMetaDatabase() {
33
- this.metaDb = await openIDBWithTimeout(this.metaDbName, 1, {
33
+ const open = () => openIDBWithTimeout(this.metaDbName, 1, {
34
34
  onUpgrade: (request) => {
35
35
  const db = request.result;
36
36
  if (!db.objectStoreNames.contains('databases')) {
@@ -42,6 +42,34 @@ export class DatabaseManager {
42
42
  }
43
43
  },
44
44
  });
45
+ try {
46
+ this.metaDb = await open();
47
+ }
48
+ catch (error) {
49
+ // Self-heal a wedged meta DB. When `ablo_databases`'s backing store gets
50
+ // stuck (a corrupted store, or a leaked connection from a prior
51
+ // timed-out open), every open of that name hangs with no event and the
52
+ // app is permanently bricked until the user manually clears site data —
53
+ // the "open did not resolve within 10000ms" dead end. The registry this
54
+ // DB holds is rebuildable from the server on the next bootstrap, so it is
55
+ // safe to delete and re-create. Try exactly once: delete, then re-open.
56
+ if (!(error instanceof IDBOpenTimeoutError))
57
+ throw error;
58
+ getContext().logger.warn('[sync-engine] meta DB open timed out — attempting self-heal (delete + retry)', { db: this.metaDbName, reason: error.reason });
59
+ getContext().observability.captureBootstrapFailure(error, {
60
+ type: 'meta-db-open-timeout',
61
+ });
62
+ const deleted = await deleteIDBWithTimeout(this.metaDbName);
63
+ if (!deleted) {
64
+ // The delete itself was blocked/stuck — a live connection in another
65
+ // window or a deadlocked backing store. We cannot recover in-page;
66
+ // rethrow so the provider surfaces the real (now actionable) error.
67
+ throw error;
68
+ }
69
+ // Fresh store — this open creates `ablo_databases` from scratch.
70
+ this.metaDb = await open();
71
+ getContext().logger.info('[sync-engine] meta DB self-heal succeeded');
72
+ }
45
73
  }
46
74
  /**
47
75
  * Calculate database info for a user/workspace combination
@@ -16,12 +16,48 @@
16
16
  export declare class IDBOpenTimeoutError extends Error {
17
17
  readonly dbName: string;
18
18
  readonly reason: 'blocked' | 'timeout';
19
+ /**
20
+ * Stable, transport-independent code. `toAbloError` preserves a string
21
+ * `.code`, so this survives the wrap into `AbloError` and reaches the
22
+ * provider's `onError` intact — letting the app distinguish a wedged-storage
23
+ * failure (show a recovery screen) from any other bootstrap error without a
24
+ * brittle message match.
25
+ */
26
+ readonly code = "storage_open_timeout";
19
27
  constructor(dbName: string, reason: 'blocked' | 'timeout', message: string);
20
28
  }
29
+ /** True for the wedged-IndexedDB failure, after it has been wrapped anywhere. */
30
+ export declare function isStorageOpenTimeout(err: unknown): boolean;
21
31
  export interface OpenIDBOptions {
22
32
  /** Called inside `onupgradeneeded` — mirrors `IDBOpenDBRequest.onupgradeneeded`. */
23
33
  onUpgrade?: (request: IDBOpenDBRequest, event: IDBVersionChangeEvent) => void;
24
34
  /** Max milliseconds to wait for the open request to resolve. Default 10_000. */
25
35
  timeoutMs?: number;
36
+ /**
37
+ * Called when another context (a new tab, a fresh deploy, or our own
38
+ * `deleteIDBWithTimeout` self-heal) fires `versionchange` on this connection.
39
+ * By default the connection is `close()`d immediately — the W3C/MDN-mandated
40
+ * behavior that lets the other context's upgrade/delete proceed instead of
41
+ * blocking forever. Provide this to ALSO react (e.g. prompt a reload) AFTER
42
+ * the close. Throwing here is swallowed.
43
+ */
44
+ onVersionChange?: () => void;
26
45
  }
27
46
  export declare function openIDBWithTimeout(name: string, version: number | undefined, options?: OpenIDBOptions): Promise<IDBDatabase>;
47
+ /**
48
+ * Bounded `indexedDB.deleteDatabase()` — the delete counterpart of
49
+ * `openIDBWithTimeout`. Used by the meta-DB self-heal: when opening
50
+ * `ablo_databases` times out (a wedged backing store), we attempt to delete it
51
+ * and re-create from scratch. The registry it holds is rebuildable from the
52
+ * server on the next bootstrap, so dropping it is safe.
53
+ *
54
+ * Like `open`, `deleteDatabase` can hang indefinitely: if another live
55
+ * connection holds the DB it fires `onblocked` and waits, and on a truly stuck
56
+ * store it fires *no* event at all. Both become a bounded rejection here so the
57
+ * caller can fall through to surfacing a real error instead of spinning.
58
+ *
59
+ * Resolves `true` on a clean delete, `false` if it was blocked or timed out
60
+ * (caller decides whether to retry the open regardless — a no-op delete still
61
+ * leaves us no worse off).
62
+ */
63
+ export declare function deleteIDBWithTimeout(name: string, timeoutMs?: number): Promise<boolean>;
@@ -16,6 +16,14 @@
16
16
  export class IDBOpenTimeoutError extends Error {
17
17
  dbName;
18
18
  reason;
19
+ /**
20
+ * Stable, transport-independent code. `toAbloError` preserves a string
21
+ * `.code`, so this survives the wrap into `AbloError` and reaches the
22
+ * provider's `onError` intact — letting the app distinguish a wedged-storage
23
+ * failure (show a recovery screen) from any other bootstrap error without a
24
+ * brittle message match.
25
+ */
26
+ code = 'storage_open_timeout';
19
27
  constructor(dbName, reason, message) {
20
28
  super(message);
21
29
  this.dbName = dbName;
@@ -23,6 +31,12 @@ export class IDBOpenTimeoutError extends Error {
23
31
  this.name = 'IDBOpenTimeoutError';
24
32
  }
25
33
  }
34
+ /** True for the wedged-IndexedDB failure, after it has been wrapped anywhere. */
35
+ export function isStorageOpenTimeout(err) {
36
+ return (typeof err === 'object' &&
37
+ err !== null &&
38
+ err.code === 'storage_open_timeout');
39
+ }
26
40
  export function openIDBWithTimeout(name, version, options = {}) {
27
41
  const timeoutMs = options.timeoutMs ?? 10_000;
28
42
  return new Promise((resolve, reject) => {
@@ -42,7 +56,47 @@ export function openIDBWithTimeout(name, version, options = {}) {
42
56
  options.onUpgrade(request, event);
43
57
  };
44
58
  }
45
- request.onsuccess = () => settle(() => resolve(request.result));
59
+ request.onsuccess = () => {
60
+ // If we ALREADY timed out (or blocked) and rejected, this is a late
61
+ // success: the native open eventually completed after we gave up. The
62
+ // resulting connection is orphaned — nobody up the stack holds it, so
63
+ // nobody will `.close()` it. A leaked open connection holds an IndexedDB
64
+ // lock that wedges every subsequent open/delete of this DB name (the
65
+ // exact "ablo_databases open/delete hangs forever with no event" failure
66
+ // mode). Close it here so a timed-out attempt can't poison the store.
67
+ if (settled) {
68
+ try {
69
+ request.result.close();
70
+ }
71
+ catch {
72
+ // Best-effort — a half-open connection may already be unusable.
73
+ }
74
+ return;
75
+ }
76
+ const db = request.result;
77
+ // MANDATORY resilience handler (W3C IndexedDB / MDN): close this
78
+ // connection the instant any other context wants to upgrade or delete the
79
+ // DB. Without it, an open connection that ignores `versionchange` blocks
80
+ // the other context's request indefinitely — the root cause of a wedged
81
+ // `ablo_databases` that survives reloads (an interrupted transaction's
82
+ // connection never closes, so every later open/delete hangs with no
83
+ // event). Auto-closing here makes the store self-releasing.
84
+ db.onversionchange = () => {
85
+ try {
86
+ db.close();
87
+ }
88
+ catch {
89
+ // Already closing/closed — nothing to do.
90
+ }
91
+ try {
92
+ options.onVersionChange?.();
93
+ }
94
+ catch {
95
+ // A consumer reaction must never break the close.
96
+ }
97
+ };
98
+ settle(() => resolve(db));
99
+ };
46
100
  request.onerror = () => settle(() => reject(request.error));
47
101
  // The critical handler: another tab is blocking us. Native API leaves
48
102
  // the request pending indefinitely; we fail fast with a clear error so
@@ -61,3 +115,36 @@ export function openIDBWithTimeout(name, version, options = {}) {
61
115
  }, timeoutMs);
62
116
  });
63
117
  }
118
+ /**
119
+ * Bounded `indexedDB.deleteDatabase()` — the delete counterpart of
120
+ * `openIDBWithTimeout`. Used by the meta-DB self-heal: when opening
121
+ * `ablo_databases` times out (a wedged backing store), we attempt to delete it
122
+ * and re-create from scratch. The registry it holds is rebuildable from the
123
+ * server on the next bootstrap, so dropping it is safe.
124
+ *
125
+ * Like `open`, `deleteDatabase` can hang indefinitely: if another live
126
+ * connection holds the DB it fires `onblocked` and waits, and on a truly stuck
127
+ * store it fires *no* event at all. Both become a bounded rejection here so the
128
+ * caller can fall through to surfacing a real error instead of spinning.
129
+ *
130
+ * Resolves `true` on a clean delete, `false` if it was blocked or timed out
131
+ * (caller decides whether to retry the open regardless — a no-op delete still
132
+ * leaves us no worse off).
133
+ */
134
+ export function deleteIDBWithTimeout(name, timeoutMs = 5_000) {
135
+ return new Promise((resolve) => {
136
+ let settled = false;
137
+ const settle = (value) => {
138
+ if (settled)
139
+ return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ resolve(value);
143
+ };
144
+ const request = indexedDB.deleteDatabase(name);
145
+ request.onsuccess = () => settle(true);
146
+ request.onerror = () => settle(false);
147
+ request.onblocked = () => settle(false);
148
+ const timer = setTimeout(() => settle(false), timeoutMs);
149
+ });
150
+ }
@@ -29,6 +29,7 @@
29
29
  * carry no `httpStatus`, exactly as Stripe omits client-side
30
30
  * programmer errors from its published code list.
31
31
  */
32
+ import { z } from 'zod';
32
33
  /**
33
34
  * Version of the error contract — the envelope shape + the set of codes and
34
35
  * their semantics. Date-based, like Stripe's API versions. Bump it (and only
@@ -36,9 +37,48 @@
36
37
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
37
38
  * and on the `Ablo-Version` response header so a consumer can detect drift.
38
39
  */
39
- export declare const ERROR_CONTRACT_VERSION = "2026-05-29";
40
+ export declare const ERROR_CONTRACT_VERSION = "2026-06-02";
40
41
  /** Coarse grouping for metrics dashboards and docs sectioning. */
41
42
  export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'intent' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
43
+ /**
44
+ * The closed taxonomy of *how a failure recovers* — one rung above the raw
45
+ * `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
46
+ * the client should do about it**, which is exactly the discriminant the sync
47
+ * FSM and the network probe need. It collapses what used to be three scattered
48
+ * booleans (`retryable`, `authBlocked`, `sessionValid`) into one exhaustive,
49
+ * Zod-validated enum so the connection layer branches on a single value with
50
+ * compile-time completeness instead of ad-hoc `if (!isRetryableCode(...))`
51
+ * chains.
52
+ *
53
+ * - `access_credential_expiry` — the Stripe-style ephemeral key (`ek_`/`rk_`)
54
+ * the sync-engine presents as its Bearer has expired. The long-lived login
55
+ * is fine; the remedy is to silently RE-MINT a fresh key from the session
56
+ * and retry the same request. This MUST NOT sign the user out (the whole
57
+ * point of the wake-from-sleep fix: a 15-min `ek_` dying after a laptop nap
58
+ * is routine, not a logout).
59
+ * - `session_expiry` — the LONG-LIVED login itself is gone. Terminal:
60
+ * sign out and route to re-authentication.
61
+ * - `auth_blocked` — reachable, but the credential TYPE/config was rejected
62
+ * (wrong key kind, untrusted issuer, no org). Re-auth re-mints the same
63
+ * rejected credential and loops, so STOP — don't reconnect, don't sign out.
64
+ * - `permission` — a 403 authorization denial (scope/role/membership).
65
+ * - `transient` — retry the same request unchanged (5xx, lease contention…).
66
+ * - `none` — not a recoverable-auth condition (validation, not-found, local
67
+ * invariants, and any forward-compat code an older SDK doesn't know).
68
+ */
69
+ export declare const RECOVERY_CLASSES: readonly ["access_credential_expiry", "session_expiry", "auth_blocked", "permission", "transient", "none"];
70
+ /** Zod enum derived from {@link RECOVERY_CLASSES} — the runtime-validatable
71
+ * form of the recovery taxonomy. */
72
+ export declare const recoveryClassSchema: z.ZodEnum<{
73
+ permission: "permission";
74
+ access_credential_expiry: "access_credential_expiry";
75
+ session_expiry: "session_expiry";
76
+ auth_blocked: "auth_blocked";
77
+ transient: "transient";
78
+ none: "none";
79
+ }>;
80
+ /** How a failure recovers. See {@link RECOVERY_CLASSES}. */
81
+ export type RecoveryClass = z.infer<typeof recoveryClassSchema>;
42
82
  /** One registry entry. `httpStatus` is present only for `surface: 'wire'`
43
83
  * codes — status is a property of the wire boundary, never of a
44
84
  * purely-local client invariant. */
@@ -55,6 +95,14 @@ export interface ErrorCodeSpec {
55
95
  readonly retryable: boolean;
56
96
  /** One-line human description — the source text for the `doc_url` page. */
57
97
  readonly message: string;
98
+ /**
99
+ * Explicit recovery class. Set ONLY where it diverges from what `category` /
100
+ * `httpStatus` / `retryable` already imply — i.e. the handful of auth codes
101
+ * whose remedy (`session_expiry` vs `access_credential_expiry`) the bare
102
+ * status can't distinguish. Everything else is derived by
103
+ * {@link classifyRecovery}, so adding a normal code needs no `recovery`.
104
+ */
105
+ readonly recovery?: RecoveryClass;
58
106
  }
59
107
  /**
60
108
  * The closed set of stable error codes. Add a code here BEFORE throwing it
@@ -92,6 +140,7 @@ export declare const ERROR_CODES: {
92
140
  readonly byo_role_cannot_enforce_rls: ErrorCodeSpec;
93
141
  readonly byo_role_unreadable: ErrorCodeSpec;
94
142
  readonly byo_tenant_tables_unforced_rls: ErrorCodeSpec;
143
+ readonly byo_host_not_allowed: ErrorCodeSpec;
95
144
  readonly claim_conflict: ErrorCodeSpec;
96
145
  readonly claim_lost: ErrorCodeSpec;
97
146
  readonly entity_claimed: ErrorCodeSpec;
@@ -167,11 +216,13 @@ export declare const ERROR_CODES: {
167
216
  readonly queue_too_deep: ErrorCodeSpec;
168
217
  readonly flush_timeout: ErrorCodeSpec;
169
218
  readonly wait_for_timeout: ErrorCodeSpec;
219
+ readonly instance_at_capacity: ErrorCodeSpec;
170
220
  readonly fetch_unavailable: ErrorCodeSpec;
171
221
  readonly base_url_missing: ErrorCodeSpec;
172
222
  readonly sync_not_ready: ErrorCodeSpec;
173
223
  readonly ws_not_ready: ErrorCodeSpec;
174
224
  readonly quota_exceeded: ErrorCodeSpec;
225
+ readonly connection_limit_exceeded: ErrorCodeSpec;
175
226
  readonly internal_error: ErrorCodeSpec;
176
227
  readonly quota_lookup_failed: ErrorCodeSpec;
177
228
  readonly turn_open_failed: ErrorCodeSpec;
@@ -211,6 +262,7 @@ export declare const ERROR_CODES: {
211
262
  readonly mutator_registry_unnamed_def: ErrorCodeSpec;
212
263
  readonly mutators_schema_missing: ErrorCodeSpec;
213
264
  readonly undo_scope_schema_missing: ErrorCodeSpec;
265
+ readonly undo_entry_invalid: ErrorCodeSpec;
214
266
  readonly mock_mutation_failed: ErrorCodeSpec;
215
267
  readonly mock_unsupported_operation: ErrorCodeSpec;
216
268
  readonly invalid_body: ErrorCodeSpec;
@@ -284,3 +336,20 @@ export declare function errorCodeSpec(code: string): ErrorCodeSpec | undefined;
284
336
  /** Whether a code's spec marks it retryable. Unknown / dynamic codes
285
337
  * default to non-retryable (safe default — don't auto-retry the unknown). */
286
338
  export declare function isRetryableCode(code: string): boolean;
339
+ /**
340
+ * Classify a `code` into its {@link RecoveryClass} — the single discriminant
341
+ * the connection FSM and the network probe branch on.
342
+ *
343
+ * The registry stays the source of truth: an explicit `spec.recovery` wins
344
+ * (set only on the few auth codes whose remedy the status can't reveal), and
345
+ * everything else is DERIVED from the spec so the registry stays terse:
346
+ * - retryable → `transient`
347
+ * - 403 → `permission`
348
+ * - residual `auth`-category → `auth_blocked` (the 401 credential-type codes)
349
+ * - otherwise / unknown → `none`
350
+ *
351
+ * Unknown / dynamic `policy:*` / forward-compat codes (`spec === undefined`)
352
+ * default to `none`, mirroring {@link isRetryableCode}'s safe default — never
353
+ * silently treat an unrecognised code as a credential expiry or a logout.
354
+ */
355
+ export declare function classifyRecovery(code: string): RecoveryClass;
@@ -29,6 +29,7 @@
29
29
  * carry no `httpStatus`, exactly as Stripe omits client-side
30
30
  * programmer errors from its published code list.
31
31
  */
32
+ import { z } from 'zod';
32
33
  /**
33
34
  * Version of the error contract — the envelope shape + the set of codes and
34
35
  * their semantics. Date-based, like Stripe's API versions. Bump it (and only
@@ -36,8 +37,45 @@
36
37
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
37
38
  * and on the `Ablo-Version` response header so a consumer can detect drift.
38
39
  */
39
- export const ERROR_CONTRACT_VERSION = '2026-05-29';
40
- const wire = (category, httpStatus, retryable, message) => ({ category, surface: 'wire', httpStatus, retryable, message });
40
+ export const ERROR_CONTRACT_VERSION = '2026-06-02';
41
+ /**
42
+ * The closed taxonomy of *how a failure recovers* — one rung above the raw
43
+ * `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
44
+ * the client should do about it**, which is exactly the discriminant the sync
45
+ * FSM and the network probe need. It collapses what used to be three scattered
46
+ * booleans (`retryable`, `authBlocked`, `sessionValid`) into one exhaustive,
47
+ * Zod-validated enum so the connection layer branches on a single value with
48
+ * compile-time completeness instead of ad-hoc `if (!isRetryableCode(...))`
49
+ * chains.
50
+ *
51
+ * - `access_credential_expiry` — the Stripe-style ephemeral key (`ek_`/`rk_`)
52
+ * the sync-engine presents as its Bearer has expired. The long-lived login
53
+ * is fine; the remedy is to silently RE-MINT a fresh key from the session
54
+ * and retry the same request. This MUST NOT sign the user out (the whole
55
+ * point of the wake-from-sleep fix: a 15-min `ek_` dying after a laptop nap
56
+ * is routine, not a logout).
57
+ * - `session_expiry` — the LONG-LIVED login itself is gone. Terminal:
58
+ * sign out and route to re-authentication.
59
+ * - `auth_blocked` — reachable, but the credential TYPE/config was rejected
60
+ * (wrong key kind, untrusted issuer, no org). Re-auth re-mints the same
61
+ * rejected credential and loops, so STOP — don't reconnect, don't sign out.
62
+ * - `permission` — a 403 authorization denial (scope/role/membership).
63
+ * - `transient` — retry the same request unchanged (5xx, lease contention…).
64
+ * - `none` — not a recoverable-auth condition (validation, not-found, local
65
+ * invariants, and any forward-compat code an older SDK doesn't know).
66
+ */
67
+ export const RECOVERY_CLASSES = [
68
+ 'access_credential_expiry',
69
+ 'session_expiry',
70
+ 'auth_blocked',
71
+ 'permission',
72
+ 'transient',
73
+ 'none',
74
+ ];
75
+ /** Zod enum derived from {@link RECOVERY_CLASSES} — the runtime-validatable
76
+ * form of the recovery taxonomy. */
77
+ export const recoveryClassSchema = z.enum(RECOVERY_CLASSES);
78
+ const wire = (category, httpStatus, retryable, message, recovery) => ({ category, surface: 'wire', httpStatus, retryable, message, recovery });
41
79
  const client = (category, message) => ({ category, surface: 'client', retryable: false, message });
42
80
  /**
43
81
  * The closed set of stable error codes. Add a code here BEFORE throwing it
@@ -47,7 +85,13 @@ export const ERROR_CODES = {
47
85
  // ── auth (401) ─────────────────────────────────────────────────────
48
86
  apikey_invalid: wire('auth', 401, false, 'API key is unknown or malformed.'),
49
87
  apikey_revoked: wire('auth', 401, false, 'API key has been revoked.'),
50
- apikey_expired: wire('auth', 401, false, 'API key has expired.'),
88
+ // THE sync-engine access credential the Stripe-style ephemeral key
89
+ // (`ek_` for users, `rk_` for agents) minted server-side from the login and
90
+ // presented as a Bearer. Its expiry is routine and re-mintable: get a fresh
91
+ // key from the still-valid session and retry — NEVER a sign-out. (An agent's
92
+ // expired `rk_` must not log a human out either.) This is the ONLY code on
93
+ // the silent re-mint path; see RecoveryClass `access_credential_expiry`.
94
+ apikey_expired: wire('auth', 401, false, 'API key has expired.', 'access_credential_expiry'),
51
95
  apikey_missing: wire('auth', 401, false, 'No API key was supplied on the request.'),
52
96
  api_key_required: wire('auth', 401, false, 'This operation requires an API key.'),
53
97
  capability_id_missing: wire('auth', 401, false, 'A capability id was expected but not provided.'),
@@ -55,7 +99,8 @@ export const ERROR_CODES = {
55
99
  identity_resolve_failed: wire('auth', 401, false, 'Identity resolution was rejected.'),
56
100
  auth_no_credentials: wire('auth', 401, false, 'No recognized authentication credential was presented — no API key and no bearer JWT. Send `Authorization: Bearer <token>`.'),
57
101
  identity_missing_organization: wire('auth', 401, false, 'Authentication succeeded but resolved to no organization context.'),
58
- session_expired: wire('auth', 401, false, 'The session is invalid or expired; re-authenticate.'),
102
+ // The long-lived login is gone terminal, drives sign-out + re-auth.
103
+ session_expired: wire('auth', 401, false, 'The session is invalid or expired; re-authenticate.', 'session_expiry'),
59
104
  // `jwt_invalid` is the residual fallback; the codes below split out the
60
105
  // specific failure modes so an integrating customer can tell "I registered
61
106
  // the wrong JWKS" from "my token has no org claim" from "wrong audience"
@@ -68,19 +113,25 @@ export const ERROR_CODES = {
68
113
  jwt_audience_mismatch: wire('auth', 401, false, "The bearer JWT's `aud` (audience) claim does not match the audience this issuer is registered with."),
69
114
  jwt_missing_subject: wire('auth', 401, false, 'The bearer JWT has no `sub` (subject) claim to identify the user.'),
70
115
  jwt_missing_organization: wire('auth', 401, false, 'The bearer JWT carries no organization context — neither a fixed org for the issuer nor the configured organization claim.'),
71
- jwt_expired: wire('auth', 401, false, 'The bearer JWT has expired; obtain a fresh token.'),
116
+ // Trusted-issuer / BYO-IdP path only Ablo's own sync-engine no longer
117
+ // authenticates with JWTs (it uses the Stripe-style ephemeral key, below).
118
+ // When a customer DOES present an external-IdP JWT, its expiry means
119
+ // re-authenticate against that IdP, so it classifies as a session expiry
120
+ // (which also keeps `isSessionErrorResponse` behaviour unchanged).
121
+ jwt_expired: wire('auth', 401, false, 'The bearer JWT has expired; obtain a fresh token.', 'session_expiry'),
72
122
  jwt_org_membership_denied: wire('auth', 403, false, "The bearer JWT's subject is not an active member of the organization in its `org_id` claim (removed, suspended, or the claim does not match a membership)."),
73
123
  file_upload_auth_required: wire('auth', 401, false, 'File upload requires an authenticated session.'),
74
124
  browser_apikey_blocked: client('auth', 'Raw API keys must not be used from a browser context.'),
75
125
  browser_database_url_blocked: client('auth', 'A database connection string must not be used from a browser context — it carries DB credentials.'),
76
- datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl as this project data source.'),
126
+ datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl for the direct Postgres connector.'),
77
127
  // ── permission / capability (403) ──────────────────────────────────
78
128
  capability_scope_denied: wire('capability', 403, false, "The connection's resolved scope does not cover the attempted action."),
79
129
  issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
80
130
  capability_invalid: wire('capability', 403, false, 'The capability is unknown, revoked, or expired.'),
81
- byo_role_cannot_enforce_rls: wire('permission', 403, false, 'The bring-your-own DB role cannot enforce row-level security.'),
82
- byo_role_unreadable: wire('permission', 403, false, 'The bring-your-own DB role could not be introspected.'),
83
- byo_tenant_tables_unforced_rls: wire('permission', 403, false, 'Tenant tables do not have RLS forced under the BYO role.'),
131
+ byo_role_cannot_enforce_rls: wire('permission', 403, false, 'The direct Postgres connector role cannot enforce row-level security.'),
132
+ byo_role_unreadable: wire('permission', 403, false, 'The direct Postgres connector role could not be introspected.'),
133
+ byo_tenant_tables_unforced_rls: wire('permission', 403, false, 'Tenant tables do not have RLS forced under the direct Postgres connector role.'),
134
+ byo_host_not_allowed: wire('permission', 403, false, 'The direct Postgres connector host resolves to a private, loopback, or link-local address and cannot be used.'),
84
135
  // ── claim / intent conflict (409) ──────────────────────────────────
85
136
  claim_conflict: wire('claim', 409, true, 'The target entity is claimed by another participant.'),
86
137
  claim_lost: wire('claim', 409, true, 'A previously held claim was lost before the write applied.'),
@@ -172,12 +223,14 @@ export const ERROR_CODES = {
172
223
  queue_too_deep: wire('transport', 503, true, 'The transaction queue exceeded its depth limit.'),
173
224
  flush_timeout: wire('transport', 504, true, 'Timed out flushing the transaction queue.'),
174
225
  wait_for_timeout: wire('transport', 504, true, 'A wait-for condition timed out.'),
226
+ instance_at_capacity: wire('transport', 503, true, 'The server is at connection capacity. Retry shortly — transient and not specific to your credentials.'),
175
227
  fetch_unavailable: client('transport', 'No fetch implementation is available in this environment.'),
176
228
  base_url_missing: client('transport', 'No base URL was configured for the client.'),
177
229
  sync_not_ready: client('transport', 'A sync operation was attempted before the client was ready.'),
178
230
  ws_not_ready: client('transport', 'A frame was sent before the WebSocket was connected.'),
179
231
  // ── quota / rate limit (429) ──────────────────────────────────────
180
232
  quota_exceeded: wire('rate_limit', 429, true, 'The organization exceeded its configured usage quota.'),
233
+ connection_limit_exceeded: wire('rate_limit', 429, true, 'Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain.'),
181
234
  // ── server (5xx) ───────────────────────────────────────────────────
182
235
  internal_error: wire('server', 500, true, 'An unexpected server error occurred.'),
183
236
  quota_lookup_failed: wire('server', 503, true, 'The quota decision could not be loaded.'),
@@ -219,6 +272,7 @@ export const ERROR_CODES = {
219
272
  mutator_registry_unnamed_def: client('client', 'A mutator definition was registered without a name.'),
220
273
  mutators_schema_missing: client('client', 'Mutators were registered without a schema.'),
221
274
  undo_scope_schema_missing: client('client', 'An undo scope was opened without a schema.'),
275
+ undo_entry_invalid: client('client', 'An undo entry failed inverse-op schema validation.'),
222
276
  mock_mutation_failed: client('client', 'A mock mutation adapter was configured to fail.'),
223
277
  mock_unsupported_operation: client('client', 'A mock adapter received an unsupported operation.'),
224
278
  // ── HTTP route edge codes (egress through app.onError) ─────────────
@@ -282,3 +336,48 @@ export function errorCodeSpec(code) {
282
336
  export function isRetryableCode(code) {
283
337
  return errorCodeSpec(code)?.retryable ?? false;
284
338
  }
339
+ /**
340
+ * Classify a `code` into its {@link RecoveryClass} — the single discriminant
341
+ * the connection FSM and the network probe branch on.
342
+ *
343
+ * The registry stays the source of truth: an explicit `spec.recovery` wins
344
+ * (set only on the few auth codes whose remedy the status can't reveal), and
345
+ * everything else is DERIVED from the spec so the registry stays terse:
346
+ * - retryable → `transient`
347
+ * - 403 → `permission`
348
+ * - residual `auth`-category → `auth_blocked` (the 401 credential-type codes)
349
+ * - otherwise / unknown → `none`
350
+ *
351
+ * Unknown / dynamic `policy:*` / forward-compat codes (`spec === undefined`)
352
+ * default to `none`, mirroring {@link isRetryableCode}'s safe default — never
353
+ * silently treat an unrecognised code as a credential expiry or a logout.
354
+ */
355
+ export function classifyRecovery(code) {
356
+ const spec = errorCodeSpec(code);
357
+ if (!spec)
358
+ return 'none';
359
+ if (spec.recovery)
360
+ return spec.recovery;
361
+ if (spec.retryable)
362
+ return 'transient';
363
+ if (spec.httpStatus === 403)
364
+ return 'permission';
365
+ if (spec.category === 'auth')
366
+ return 'auth_blocked';
367
+ return 'none';
368
+ }
369
+ /**
370
+ * Compile-time exhaustiveness guard: forces every {@link RecoveryClass} to be
371
+ * acknowledged here, so adding a class to {@link RECOVERY_CLASSES} without
372
+ * deciding its meaning is a type error rather than a silent gap. (Mirrors the
373
+ * closed-union discipline `ERROR_CODES` itself uses via `satisfies`.)
374
+ */
375
+ const _RECOVERY_CLASS_EXHAUSTIVE = {
376
+ access_credential_expiry: true,
377
+ session_expiry: true,
378
+ auth_blocked: true,
379
+ permission: true,
380
+ transient: true,
381
+ none: true,
382
+ };
383
+ void _RECOVERY_CLASS_EXHAUSTIVE;
package/dist/errors.d.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  * Both work on every subclass.
20
20
  */
21
21
  import type { ErrorCode } from './errorCodes.js';
22
- export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec } from './errorCodes.js';
23
- export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode } from './errorCodes.js';
22
+ export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errorCodes.js';
23
+ export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errorCodes.js';
24
24
  /** Common shape for all errors thrown by this SDK. */
25
25
  export declare class AbloError extends Error {
26
26
  /** Discriminator string — matches the class name. Lets consumers