@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
package/dist/errors.d.ts CHANGED
@@ -18,27 +18,71 @@
18
18
  *
19
19
  * Both work on every subclass.
20
20
  */
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';
21
24
  /** Common shape for all errors thrown by this SDK. */
22
25
  export declare class AbloError extends Error {
23
26
  /** Discriminator string — matches the class name. Lets consumers
24
27
  * switch on `e.type` without `instanceof` checks across package
25
28
  * boundaries (matches Stripe's `err.type` pattern). */
26
29
  readonly type: string;
27
- /** Stable short identifier for logs + metrics.
28
- * E.g. `'apikey_invalid'`, `'capability_scope_denied'`. */
30
+ /** Stable short identifier for logs + metrics, drawn from the closed
31
+ * {@link ErrorCode} registry — e.g. `'apikey_invalid'`,
32
+ * `'capability_scope_denied'`. Stored as a plain `string` (not
33
+ * `ErrorCode`) so an older SDK still surfaces a newer server's code it
34
+ * doesn't recognise yet; producers are constrained at the constructor
35
+ * param instead. */
29
36
  readonly code?: string;
30
37
  /** HTTP status code when the error originated from an HTTP response. */
31
38
  readonly httpStatus?: number;
32
39
  /** Correlation id for ops — present when the server sent one on
33
40
  * `x-request-id`. Include in support tickets. */
34
41
  readonly requestId?: string;
42
+ /** Which input caused the error — a model/field path like
43
+ * `'dataroomMember.grants.subject'`. Mirrors Stripe's `error.param`;
44
+ * lets tooling point at the exact offending declaration. */
45
+ readonly param?: string;
46
+ /** Link to the docs for this `code`. Mirrors Stripe's `error.doc_url`.
47
+ * Defaults from `code` via {@link docUrlForCode} when omitted. */
48
+ readonly docUrl?: string;
49
+ /** Domain-specific structured payload merged into the wire envelope —
50
+ * e.g. a schema push's `{ warnings, unexecutable }`, a stale write's
51
+ * conflicting rows. Mirrors how Stripe attaches type-specific fields
52
+ * (`decline_code`, `payment_intent`) alongside the standard ones, so a
53
+ * structured error keeps its detail through `toJSON` instead of being
54
+ * flattened to a bare message. */
55
+ readonly details?: Readonly<Record<string, unknown>>;
35
56
  constructor(message: string, options?: {
36
- code?: string;
57
+ code?: ErrorCode;
37
58
  httpStatus?: number;
38
59
  requestId?: string;
39
60
  cause?: unknown;
61
+ param?: string;
62
+ docUrl?: string;
63
+ details?: Readonly<Record<string, unknown>>;
40
64
  });
65
+ /**
66
+ * Serialize to Stripe's error-object shape: `{ type, code, param, message,
67
+ * doc_url, request_id }`. One JSON shape across HTTP bodies, WS frames, and
68
+ * logs — so consumers parse Ablo errors the way they already parse Stripe's.
69
+ */
70
+ toJSON(): {
71
+ type: string;
72
+ code?: string;
73
+ param?: string;
74
+ message: string;
75
+ doc_url?: string;
76
+ request_id?: string;
77
+ [key: string]: unknown;
78
+ };
41
79
  }
80
+ /**
81
+ * Map a stable error `code` to its docs URL — the one place the convention
82
+ * lives, so every error carrying a code gets a `doc_url` for free (Stripe
83
+ * ships a link on every error).
84
+ */
85
+ export declare function docUrlForCode(code: ErrorCode): string;
42
86
  /** 401 — invalid/missing/expired credentials. */
43
87
  export declare class AbloAuthenticationError extends AbloError {
44
88
  readonly type: "AbloAuthenticationError";
@@ -53,11 +97,12 @@ export declare class AbloRateLimitError extends AbloError {
53
97
  readonly type: "AbloRateLimitError";
54
98
  readonly retryAfterSeconds?: number;
55
99
  constructor(message: string, options?: {
56
- code?: string;
100
+ code?: ErrorCode;
57
101
  httpStatus?: number;
58
102
  requestId?: string;
59
103
  cause?: unknown;
60
104
  retryAfterSeconds?: number;
105
+ details?: Readonly<Record<string, unknown>>;
61
106
  });
62
107
  }
63
108
  /** 409 — same `Idempotency-Key` reused with a different request body. */
@@ -98,7 +143,7 @@ export declare class AbloStaleContextError extends AbloError {
98
143
  readonly observedSyncId: number;
99
144
  }>;
100
145
  constructor(message: string, options?: {
101
- code?: string;
146
+ code?: ErrorCode;
102
147
  httpStatus?: number;
103
148
  requestId?: string;
104
149
  cause?: unknown;
@@ -121,7 +166,7 @@ export declare class AbloClaimedError extends AbloError {
121
166
  readonly type: "AbloClaimedError";
122
167
  readonly claims?: ReadonlyArray<unknown>;
123
168
  constructor(message: string, options?: {
124
- code?: string;
169
+ code?: ErrorCode;
125
170
  httpStatus?: number;
126
171
  requestId?: string;
127
172
  cause?: unknown;
@@ -227,11 +272,62 @@ export declare class SyncSessionError extends AbloAuthenticationError {
227
272
  */
228
273
  static isSessionErrorResponse(status: number, body?: string): boolean;
229
274
  }
275
+ /**
276
+ * Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
277
+ * that an SDK consumer never catches an untagged error. An already-typed
278
+ * AbloError passes through untouched (so `code`/`httpStatus`/subclass survive);
279
+ * a bare `Error` keeps its message and is preserved as `cause` (carrying any
280
+ * `.code` someone attached); a non-Error is stringified.
281
+ *
282
+ * This is the client mirror of the server's `normalizeError` — applied at the
283
+ * SDK's public async boundaries so `instanceof AbloError` / `e.type` always
284
+ * hold for whatever a consumer catches, regardless of which internal layer
285
+ * (transport, IndexedDB, bootstrap, a third-party throw) produced it.
286
+ */
287
+ export declare function toAbloError(err: unknown): AbloError;
288
+ /**
289
+ * Build the appropriate typed {@link AbloError} from a wire error — the
290
+ * single code→class mapping shared by every transport that can reject a
291
+ * request (HTTP responses via {@link translateHttpError}, WebSocket
292
+ * `mutation_result`/`claim_ack` frames, agent-job receipts).
293
+ *
294
+ * Code-first, then status-driven. A known {@link ErrorCode} carries its own
295
+ * canonical `httpStatus` in the registry, so frame transports that don't have
296
+ * an HTTP status (the WebSocket commit path) still produce the right subclass
297
+ * — instead of a hand-rolled `new Error(message)` that drops out of the typed
298
+ * hierarchy and loses `code`/`httpStatus`/retryability.
299
+ */
300
+ export declare function errorFromWire(message: string, opts?: {
301
+ code?: string;
302
+ /** Explicit transport status (HTTP). When omitted, derived from the
303
+ * registry spec for `code` so frame transports map correctly too. */
304
+ httpStatus?: number;
305
+ requestId?: string;
306
+ requiredCapability?: RequiredCapability;
307
+ }): AbloError;
230
308
  /**
231
309
  * Translate an HTTP response into the appropriate typed error.
232
310
  *
233
311
  * Single source of truth for status-code → class mapping — every SDK
234
312
  * fetch path that sees a non-2xx response should route through here
235
- * so the customer-visible error is always the right subclass.
313
+ * so the customer-visible error is always the right subclass. Delegates
314
+ * the actual class selection to {@link errorFromWire} (shared with the
315
+ * frame transports) after extracting code/message from the HTTP body.
236
316
  */
237
317
  export declare function translateHttpError(status: number, body: unknown, requestId?: string): AbloError;
318
+ /**
319
+ * Whether an HTTP error body carries a code {@link translateHttpError} can read
320
+ * — a top-level `code`, a nested `error.code`, or a string `error`. Callers that
321
+ * own a meaningful fallback code (e.g. `turn_open_failed`) use this to decide
322
+ * between routing through `translateHttpError` (structured envelope present) and
323
+ * throwing their own typed error with the fallback (bare/non-Ablo body), instead
324
+ * of emitting a code-less error.
325
+ */
326
+ export declare function hasWireCode(body: unknown): boolean;
327
+ /**
328
+ * Extract the canonical error `code` from a raw HTTP error body STRING — the
329
+ * top-level `code` or a nested `error.code`. Returns undefined for non-JSON or
330
+ * code-less bodies. Used by session-error detection to tell a genuine expiry
331
+ * (`session_expired`/`jwt_expired`) apart from other auth failures.
332
+ */
333
+ export declare function extractWireCode(body?: string): string | undefined;
package/dist/errors.js CHANGED
@@ -18,6 +18,8 @@
18
18
  *
19
19
  * Both work on every subclass.
20
20
  */
21
+ import { errorCodeSpec } from './errorCodes.js';
22
+ export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode } from './errorCodes.js';
21
23
  // ── AbloError hierarchy — the typed error surface ────────────────────
22
24
  /** Common shape for all errors thrown by this SDK. */
23
25
  export class AbloError extends Error {
@@ -25,14 +27,32 @@ export class AbloError extends Error {
25
27
  * switch on `e.type` without `instanceof` checks across package
26
28
  * boundaries (matches Stripe's `err.type` pattern). */
27
29
  type = 'AbloError';
28
- /** Stable short identifier for logs + metrics.
29
- * E.g. `'apikey_invalid'`, `'capability_scope_denied'`. */
30
+ /** Stable short identifier for logs + metrics, drawn from the closed
31
+ * {@link ErrorCode} registry — e.g. `'apikey_invalid'`,
32
+ * `'capability_scope_denied'`. Stored as a plain `string` (not
33
+ * `ErrorCode`) so an older SDK still surfaces a newer server's code it
34
+ * doesn't recognise yet; producers are constrained at the constructor
35
+ * param instead. */
30
36
  code;
31
37
  /** HTTP status code when the error originated from an HTTP response. */
32
38
  httpStatus;
33
39
  /** Correlation id for ops — present when the server sent one on
34
40
  * `x-request-id`. Include in support tickets. */
35
41
  requestId;
42
+ /** Which input caused the error — a model/field path like
43
+ * `'dataroomMember.grants.subject'`. Mirrors Stripe's `error.param`;
44
+ * lets tooling point at the exact offending declaration. */
45
+ param;
46
+ /** Link to the docs for this `code`. Mirrors Stripe's `error.doc_url`.
47
+ * Defaults from `code` via {@link docUrlForCode} when omitted. */
48
+ docUrl;
49
+ /** Domain-specific structured payload merged into the wire envelope —
50
+ * e.g. a schema push's `{ warnings, unexecutable }`, a stale write's
51
+ * conflicting rows. Mirrors how Stripe attaches type-specific fields
52
+ * (`decline_code`, `payment_intent`) alongside the standard ones, so a
53
+ * structured error keeps its detail through `toJSON` instead of being
54
+ * flattened to a bare message. */
55
+ details;
36
56
  constructor(message, options) {
37
57
  super(message);
38
58
  this.name = this.constructor.name;
@@ -42,10 +62,41 @@ export class AbloError extends Error {
42
62
  this.httpStatus = options.httpStatus;
43
63
  if (options?.requestId !== undefined)
44
64
  this.requestId = options.requestId;
65
+ if (options?.param !== undefined)
66
+ this.param = options.param;
67
+ if (options?.details !== undefined)
68
+ this.details = options.details;
69
+ const docUrl = options?.docUrl ?? (options?.code ? docUrlForCode(options.code) : undefined);
70
+ if (docUrl !== undefined)
71
+ this.docUrl = docUrl;
45
72
  if (options?.cause !== undefined) {
46
73
  Object.defineProperty(this, 'cause', { value: options.cause, enumerable: false });
47
74
  }
48
75
  }
76
+ /**
77
+ * Serialize to Stripe's error-object shape: `{ type, code, param, message,
78
+ * doc_url, request_id }`. One JSON shape across HTTP bodies, WS frames, and
79
+ * logs — so consumers parse Ablo errors the way they already parse Stripe's.
80
+ */
81
+ toJSON() {
82
+ return {
83
+ type: this.type,
84
+ ...(this.code !== undefined ? { code: this.code } : {}),
85
+ ...(this.param !== undefined ? { param: this.param } : {}),
86
+ message: this.message,
87
+ ...(this.docUrl !== undefined ? { doc_url: this.docUrl } : {}),
88
+ ...(this.requestId !== undefined ? { request_id: this.requestId } : {}),
89
+ ...(this.details ?? {}),
90
+ };
91
+ }
92
+ }
93
+ /**
94
+ * Map a stable error `code` to its docs URL — the one place the convention
95
+ * lives, so every error carrying a code gets a `doc_url` for free (Stripe
96
+ * ships a link on every error).
97
+ */
98
+ export function docUrlForCode(code) {
99
+ return `https://docs.abloatai.com/errors#${code}`;
49
100
  }
50
101
  /** 401 — invalid/missing/expired credentials. */
51
102
  export class AbloAuthenticationError extends AbloError {
@@ -187,29 +238,106 @@ export class SyncSessionError extends AbloAuthenticationError {
187
238
  * Check if an HTTP response status indicates a session error
188
239
  */
189
240
  static isSessionErrorResponse(status, body) {
190
- if (status === 401)
191
- return true;
192
- if (status === 403) {
193
- if (body) {
194
- const lowerBody = body.toLowerCase();
195
- if (lowerBody.includes('session') ||
196
- lowerBody.includes('unauthorized') ||
197
- lowerBody.includes('not authenticated') ||
198
- lowerBody.includes('token')) {
199
- return true;
200
- }
201
- }
202
- return true;
241
+ // Prefer the structured error code: ONLY a genuine session / JWT EXPIRY
242
+ // should drive sign-out + re-auth. A non-expiry auth failure
243
+ // (api_key_required, jwt_issuer_untrusted, a 403 permission denial, …) must
244
+ // NOT — re-authenticating re-mints the same credential and loops, which is
245
+ // exactly the "flash then bounce to /signin" symptom. This mirrors the
246
+ // canonical wire mapping (401 → Authentication, 403 → Permission).
247
+ const code = extractWireCode(body);
248
+ if (code) {
249
+ return code === 'session_expired' || code === 'jwt_expired';
203
250
  }
204
- return false;
251
+ // No structured code (bare body, non-Ablo proxy response): a 401 is taken as
252
+ // expiry — the historical default that drives re-auth — while a 403 is a
253
+ // permission failure, not a session error.
254
+ return status === 401;
255
+ }
256
+ }
257
+ /**
258
+ * Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
259
+ * that an SDK consumer never catches an untagged error. An already-typed
260
+ * AbloError passes through untouched (so `code`/`httpStatus`/subclass survive);
261
+ * a bare `Error` keeps its message and is preserved as `cause` (carrying any
262
+ * `.code` someone attached); a non-Error is stringified.
263
+ *
264
+ * This is the client mirror of the server's `normalizeError` — applied at the
265
+ * SDK's public async boundaries so `instanceof AbloError` / `e.type` always
266
+ * hold for whatever a consumer catches, regardless of which internal layer
267
+ * (transport, IndexedDB, bootstrap, a third-party throw) produced it.
268
+ */
269
+ export function toAbloError(err) {
270
+ if (err instanceof AbloError)
271
+ return err;
272
+ if (err instanceof Error) {
273
+ const rawCode = err.code;
274
+ const code = typeof rawCode === 'string' ? rawCode : undefined;
275
+ return new AbloError(err.message, { code, cause: err });
276
+ }
277
+ return new AbloError(String(err), { cause: err });
278
+ }
279
+ /**
280
+ * Build the appropriate typed {@link AbloError} from a wire error — the
281
+ * single code→class mapping shared by every transport that can reject a
282
+ * request (HTTP responses via {@link translateHttpError}, WebSocket
283
+ * `mutation_result`/`claim_ack` frames, agent-job receipts).
284
+ *
285
+ * Code-first, then status-driven. A known {@link ErrorCode} carries its own
286
+ * canonical `httpStatus` in the registry, so frame transports that don't have
287
+ * an HTTP status (the WebSocket commit path) still produce the right subclass
288
+ * — instead of a hand-rolled `new Error(message)` that drops out of the typed
289
+ * hierarchy and loses `code`/`httpStatus`/retryability.
290
+ */
291
+ export function errorFromWire(message, opts = {}) {
292
+ const { code, requestId, requiredCapability } = opts;
293
+ // Effective status: an explicit HTTP status wins; otherwise fall back to
294
+ // the code's canonical status from the registry (undefined for unknown /
295
+ // forward-compat codes, which then map to the base AbloError).
296
+ const httpStatus = opts.httpStatus ?? (code ? errorCodeSpec(code)?.httpStatus : undefined);
297
+ // Wire boundary: an incoming code is an arbitrary string (a newer server
298
+ // may send a code this SDK predates). Cast to ErrorCode here — the one
299
+ // sanctioned crossing — so internal producers stay statically checked.
300
+ const publicCode = (code === 'intent_conflict' ? 'claim_conflict' : code);
301
+ const baseOpts = { code: publicCode, httpStatus, requestId };
302
+ // ── Code-first specials (transport-independent) ──────────────────────
303
+ // A scoped credential was denied — route through CapabilityError so callers
304
+ // can read `.requiredCapability` to attenuate-and-retry.
305
+ if (code === 'capability_scope_denied' || code === 'capability_invalid') {
306
+ return new CapabilityError(code, message, requiredCapability);
307
+ }
308
+ // Claim enforcement (rides 409): the target entity is held by another
309
+ // participant. Discriminate on code BEFORE the generic 409→idempotency
310
+ // mapping so a claim rejection surfaces as AbloClaimedError.
311
+ if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
312
+ return new AbloClaimedError(message, baseOpts);
313
+ }
314
+ // A write whose `readAt` watermark went stale — callers re-read and retry.
315
+ if (code === 'stale_context') {
316
+ return new AbloStaleContextError(message, baseOpts);
205
317
  }
318
+ // ── Status-driven dispatch (HTTP parity) ─────────────────────────────
319
+ if (httpStatus === 401)
320
+ return new AbloAuthenticationError(message, baseOpts);
321
+ if (httpStatus === 403)
322
+ return new AbloPermissionError(message, baseOpts);
323
+ if (httpStatus === 409)
324
+ return new AbloIdempotencyError(message, baseOpts);
325
+ if (httpStatus === 422 || httpStatus === 400)
326
+ return new AbloValidationError(message, baseOpts);
327
+ if (httpStatus === 429)
328
+ return new AbloRateLimitError(message, baseOpts);
329
+ if (httpStatus !== undefined && httpStatus >= 500)
330
+ return new AbloServerError(message, baseOpts);
331
+ return new AbloError(message, baseOpts);
206
332
  }
207
333
  /**
208
334
  * Translate an HTTP response into the appropriate typed error.
209
335
  *
210
336
  * Single source of truth for status-code → class mapping — every SDK
211
337
  * fetch path that sees a non-2xx response should route through here
212
- * so the customer-visible error is always the right subclass.
338
+ * so the customer-visible error is always the right subclass. Delegates
339
+ * the actual class selection to {@link errorFromWire} (shared with the
340
+ * frame transports) after extracting code/message from the HTTP body.
213
341
  */
214
342
  export function translateHttpError(status, body, requestId) {
215
343
  const parsed = typeof body === 'object' && body !== null ? body : {};
@@ -224,30 +352,53 @@ export function translateHttpError(status, body, requestId) {
224
352
  flatError ??
225
353
  (typeof body === 'string' ? body : `HTTP ${status}`);
226
354
  const requiredCapability = nested?.requiredCapability ?? parsed.requiredCapability;
227
- const publicCode = code === 'intent_conflict' ? 'claim_conflict' : code;
228
- const baseOpts = { code: publicCode, httpStatus: status, requestId };
229
- if (status === 401)
230
- return new AbloAuthenticationError(message, baseOpts);
231
- if (status === 403 || code === 'capability_scope_denied' || code === 'capability_invalid') {
232
- if (code === 'capability_scope_denied' || code === 'capability_invalid') {
233
- return new CapabilityError(code, message, requiredCapability);
234
- }
235
- return new AbloPermissionError(message, baseOpts);
355
+ return errorFromWire(message, { code, httpStatus: status, requestId, requiredCapability });
356
+ }
357
+ /**
358
+ * Whether an HTTP error body carries a code {@link translateHttpError} can read
359
+ * a top-level `code`, a nested `error.code`, or a string `error`. Callers that
360
+ * own a meaningful fallback code (e.g. `turn_open_failed`) use this to decide
361
+ * between routing through `translateHttpError` (structured envelope present) and
362
+ * throwing their own typed error with the fallback (bare/non-Ablo body), instead
363
+ * of emitting a code-less error.
364
+ */
365
+ export function hasWireCode(body) {
366
+ if (typeof body !== 'object' || body === null)
367
+ return false;
368
+ const b = body;
369
+ if (typeof b.code === 'string')
370
+ return true;
371
+ if (typeof b.error === 'string')
372
+ return true;
373
+ return (typeof b.error === 'object' &&
374
+ b.error !== null &&
375
+ typeof b.error.code === 'string');
376
+ }
377
+ /**
378
+ * Extract the canonical error `code` from a raw HTTP error body STRING — the
379
+ * top-level `code` or a nested `error.code`. Returns undefined for non-JSON or
380
+ * code-less bodies. Used by session-error detection to tell a genuine expiry
381
+ * (`session_expired`/`jwt_expired`) apart from other auth failures.
382
+ */
383
+ export function extractWireCode(body) {
384
+ if (!body)
385
+ return undefined;
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(body);
236
389
  }
237
- // Claim enforcement also rides 409 (a commit blocked by a foreign claim).
238
- // Discriminate on the code BEFORE the generic idempotency mapping so a
239
- // claim rejection surfaces as AbloClaimedError, not AbloIdempotencyError —
240
- // same typed error the WebSocket commit path yields for these codes.
241
- if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
242
- return new AbloClaimedError(message, baseOpts);
390
+ catch {
391
+ return undefined;
243
392
  }
244
- if (status === 409)
245
- return new AbloIdempotencyError(message, baseOpts);
246
- if (status === 422 || status === 400)
247
- return new AbloValidationError(message, baseOpts);
248
- if (status === 429)
249
- return new AbloRateLimitError(message, baseOpts);
250
- if (status >= 500)
251
- return new AbloServerError(message, baseOpts);
252
- return new AbloError(message, baseOpts);
393
+ if (typeof parsed !== 'object' || parsed === null)
394
+ return undefined;
395
+ const b = parsed;
396
+ if (typeof b.code === 'string')
397
+ return b.code;
398
+ if (typeof b.error === 'object' &&
399
+ b.error !== null &&
400
+ typeof b.error.code === 'string') {
401
+ return b.error.code;
402
+ }
403
+ return undefined;
253
404
  }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * import Ablo from '@abloatai/ablo';
6
6
  *
7
7
  * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
8
- * await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
8
+ * const report = await ablo.weatherReports.retrieve('report_stockholm');
9
9
  * await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
10
10
  *
11
11
  * type Entry = Ablo.Peer;
@@ -23,9 +23,13 @@
23
23
  * @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
24
24
  * @abloatai/ablo/testing — test harnesses + mocks
25
25
  *
26
- * Consumer code should converge on `ablo.<model>.load(...)`, which routes
27
- * through the engine's `HydrationCoordinator` and dedupes single-flight
28
- * hydrations.
26
+ * Reads split by where the data comes from. `ablo.<model>.retrieve(id)` and
27
+ * `.list({ where })` are the async **server** reads (pool → IDB → network via
28
+ * the `HydrationCoordinator`, single-flight deduped); they're the default and
29
+ * what hosted/stateless callers want, since their local graph starts empty.
30
+ * `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
31
+ * **local-graph** snapshots with no network round-trip — for reactive React
32
+ * selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
29
33
  *
30
34
  * ── What to import (read this first) ────────────────────────────────
31
35
  * Default path — this is all most apps and agents ever need:
@@ -48,9 +52,10 @@ export { session, agent } from './principal.js';
48
52
  import { Ablo } from './client/Ablo.js';
49
53
  export default Ablo;
50
54
  export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
51
- export { defaultPolicy } from './policy/index.js';
52
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, } from './errors.js';
55
+ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
56
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
53
57
  export type { CommitReceipt, RequiredCapability } from './errors.js';
58
+ export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec } from './errors.js';
54
59
  export type { Register, DefaultSyncShape } from './types/global.js';
55
60
  export { defineMutators } from './mutators/defineMutators.js';
56
61
  export { createTransaction, type Transaction } from './mutators/Transaction.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * import Ablo from '@abloatai/ablo';
6
6
  *
7
7
  * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
8
- * await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
8
+ * const report = await ablo.weatherReports.retrieve('report_stockholm');
9
9
  * await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
10
10
  *
11
11
  * type Entry = Ablo.Peer;
@@ -23,9 +23,13 @@
23
23
  * @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
24
24
  * @abloatai/ablo/testing — test harnesses + mocks
25
25
  *
26
- * Consumer code should converge on `ablo.<model>.load(...)`, which routes
27
- * through the engine's `HydrationCoordinator` and dedupes single-flight
28
- * hydrations.
26
+ * Reads split by where the data comes from. `ablo.<model>.retrieve(id)` and
27
+ * `.list({ where })` are the async **server** reads (pool → IDB → network via
28
+ * the `HydrationCoordinator`, single-flight deduped); they're the default and
29
+ * what hosted/stateless callers want, since their local graph starts empty.
30
+ * `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
31
+ * **local-graph** snapshots with no network round-trip — for reactive React
32
+ * selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
29
33
  *
30
34
  * ── What to import (read this first) ────────────────────────────────
31
35
  * Default path — this is all most apps and agents ever need:
@@ -74,11 +78,11 @@ export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest,
74
78
  // (reject-on-stale) is already applied server-side, so you only import it
75
79
  // to COMPOSE a custom policy. Leave it alone and stale writes are rejected
76
80
  // safely by default. Type counterparts live under `Ablo.Conflict.*`.
77
- export { defaultPolicy } from './policy/index.js';
81
+ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
78
82
  // Typed error hierarchy — Stripe-style. One import gets every class
79
83
  // consumers need to discriminate failures (`e instanceof AbloX` or
80
84
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
81
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, } from './errors.js';
85
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
82
86
  // Advanced — most apps never import this. Custom (Zero-style) mutators:
83
87
  // `ablo.<model>.create/update/delete` already covers normal writes. Reach
84
88
  // for `defineMutators` only when you need a named, multi-step mutation with
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Canonical Ablo API-key format — the single source of truth for how keys
3
+ * are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
4
+ * and the web control-plane (`generate-key.ts`) consume THIS module, so the
5
+ * format can no longer drift between the two mint sites (it used to live as
6
+ * a hand-copied twin kept in sync by a comment).
7
+ *
8
+ * Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
9
+ * subpath and NEVER re-exported from the browser-facing `.` entry, so the
10
+ * client bundle never pulls in `node:crypto`.
11
+ *
12
+ * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
+ * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
14
+ * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
+ * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
+ * still validate by hash — they parse here as `checksummed: false`.
17
+ */
18
+ import { z } from 'zod';
19
+ export declare const API_KEY_KINDS: readonly ["secret", "restricted", "ephemeral"];
20
+ export type ApiKeyKind = (typeof API_KEY_KINDS)[number];
21
+ export declare const API_KEY_ENVS: readonly ["live", "test"];
22
+ export type ApiKeyEnv = (typeof API_KEY_ENVS)[number];
23
+ /** A structurally-valid Ablo API key, parsed into its parts. */
24
+ export interface ParsedApiKey {
25
+ /** The original plaintext. */
26
+ raw: string;
27
+ kind: ApiKeyKind;
28
+ env: ApiKeyEnv;
29
+ /** The chars after `<prefix>_<env>_` (body + checksum for new keys). */
30
+ body: string;
31
+ /** True when this is the new checksummed format (36-char base62 body). */
32
+ checksummed: boolean;
33
+ }
34
+ /**
35
+ * Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
36
+ * {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
37
+ * rejected (the offline-reject), while a legacy key parses as
38
+ * `checksummed: false` and passes (the server still hash-validates it).
39
+ */
40
+ export declare const apiKeySchema: z.ZodPipe<z.ZodString, z.ZodTransform<ParsedApiKey, string>>;
41
+ /** Parse + fully validate (incl. checksum). Returns null when invalid. */
42
+ export declare function parseApiKey(raw: string): ParsedApiKey | null;
43
+ /** True when the key uses the new checksummed format (regardless of validity). */
44
+ export declare function isChecksummedKey(raw: string): boolean;
45
+ /** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
46
+ export declare function keyChecksumMatches(raw: string): boolean;
47
+ /**
48
+ * Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
49
+ * once), its SHA-256 hash (persisted), and the 12-char display prefix.
50
+ */
51
+ export declare function generateApiKey(env?: ApiKeyEnv, kind?: ApiKeyKind): {
52
+ plaintext: string;
53
+ hash: string;
54
+ prefix: string;
55
+ };
56
+ /**
57
+ * Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
58
+ * bcrypt) — API keys are high-entropy random, so there's no dictionary to
59
+ * defend against. Used at both write (mint) and lookup.
60
+ */
61
+ export declare function hashApiKey(plaintext: string): string;