@abloatai/ablo 0.7.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 (83) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +54 -45
  3. package/dist/BaseSyncedStore.js +7 -3
  4. package/dist/SyncEngineContext.d.ts +2 -1
  5. package/dist/SyncEngineContext.js +5 -3
  6. package/dist/agent/session.js +3 -2
  7. package/dist/auth/index.js +39 -11
  8. package/dist/client/Ablo.d.ts +111 -3
  9. package/dist/client/Ablo.js +143 -10
  10. package/dist/client/ApiClient.d.ts +32 -0
  11. package/dist/client/ApiClient.js +76 -44
  12. package/dist/client/auth.d.ts +11 -1
  13. package/dist/client/auth.js +21 -2
  14. package/dist/client/createModelProxy.d.ts +107 -63
  15. package/dist/client/createModelProxy.js +65 -33
  16. package/dist/client/identity.js +14 -0
  17. package/dist/client/registerDataSource.d.ts +19 -0
  18. package/dist/client/registerDataSource.js +57 -0
  19. package/dist/client/validateAbloOptions.d.ts +2 -1
  20. package/dist/client/validateAbloOptions.js +8 -7
  21. package/dist/errorCodes.d.ts +23 -1
  22. package/dist/errorCodes.js +34 -1
  23. package/dist/errors.d.ts +52 -1
  24. package/dist/errors.js +140 -42
  25. package/dist/index.d.ts +9 -5
  26. package/dist/index.js +9 -5
  27. package/dist/keys/index.d.ts +61 -0
  28. package/dist/keys/index.js +151 -0
  29. package/dist/query/client.js +19 -8
  30. package/dist/react/AbloProvider.d.ts +25 -0
  31. package/dist/react/AbloProvider.js +97 -2
  32. package/dist/react/ClientSideSuspense.d.ts +1 -1
  33. package/dist/react/DefaultFallback.d.ts +1 -1
  34. package/dist/react/SyncGroupProvider.d.ts +1 -1
  35. package/dist/react/index.d.ts +3 -2
  36. package/dist/react/index.js +3 -2
  37. package/dist/react/useAblo.d.ts +4 -4
  38. package/dist/react/useAblo.js +10 -5
  39. package/dist/react/useReactive.js +16 -3
  40. package/dist/schema/serialize.d.ts +3 -3
  41. package/dist/schema/serialize.js +2 -2
  42. package/dist/sync/BootstrapHelper.js +46 -27
  43. package/dist/sync/ConnectionManager.d.ts +3 -1
  44. package/dist/sync/ConnectionManager.js +37 -1
  45. package/dist/sync/HydrationCoordinator.js +3 -2
  46. package/dist/sync/NetworkProbe.d.ts +8 -0
  47. package/dist/sync/NetworkProbe.js +24 -2
  48. package/dist/sync/SyncWebSocket.d.ts +1 -1
  49. package/dist/sync/SyncWebSocket.js +43 -53
  50. package/dist/sync/participants.js +5 -2
  51. package/dist/transactions/TransactionQueue.js +13 -1
  52. package/docs/api-keys.md +5 -5
  53. package/docs/api.md +101 -44
  54. package/docs/audit.md +16 -9
  55. package/docs/cli.md +27 -17
  56. package/docs/client-behavior.md +34 -20
  57. package/docs/coordination.md +40 -51
  58. package/docs/data-sources.md +21 -19
  59. package/docs/examples/agent-human.md +72 -28
  60. package/docs/examples/ai-sdk-tool.md +14 -11
  61. package/docs/examples/existing-python-backend.md +27 -16
  62. package/docs/examples/nextjs.md +21 -8
  63. package/docs/examples/scoped-agent.md +42 -27
  64. package/docs/examples/server-agent.md +27 -5
  65. package/docs/guarantees.md +26 -17
  66. package/docs/identity.md +65 -59
  67. package/docs/index.md +30 -19
  68. package/docs/integration-guide.md +52 -52
  69. package/docs/interaction-model.md +38 -26
  70. package/docs/mcp/claude-code.md +9 -17
  71. package/docs/mcp/cursor.md +6 -24
  72. package/docs/mcp/windsurf.md +6 -19
  73. package/docs/mcp.md +103 -26
  74. package/docs/quickstart.md +31 -39
  75. package/docs/react.md +15 -11
  76. package/docs/roadmap.md +13 -13
  77. package/docs/schema-contract.md +109 -0
  78. package/examples/README.md +8 -4
  79. package/examples/data-source/README.md +6 -2
  80. package/examples/data-source/run.ts +4 -3
  81. package/examples/quickstart.ts +1 -1
  82. package/llms.txt +27 -16
  83. package/package.json +6 -1
@@ -12,6 +12,7 @@
12
12
  * without duplicating the fetch boilerplate.
13
13
  */
14
14
  import { z } from 'zod';
15
+ import { translateHttpError } from '../errors.js';
15
16
  // ── Response validation ─────────────────────────────────────────────────
16
17
  //
17
18
  // Each result slot is an array of rows (or an object for bundled
@@ -60,14 +61,24 @@ export async function postQuery(options, batch) {
60
61
  signal: controller.signal,
61
62
  });
62
63
  if (!response.ok) {
63
- // Direct console.error is INTENTIONAL operators alert on the
64
- // `[postQuery.error]` prefix in browser console. Routing through
65
- // an injected logger here would require a coordinated change to
66
- // the alerting pipeline. Tracked as future work; the dual-channel
67
- // alternative (logger + observability.captureException) is the
68
- // production target. Never throw fire-and-forget callers would
69
- // kill Next.js router on unhandled rejection.
70
- console.error(`[postQuery.error] ${response.status} ${response.statusText} for ${batch.queries.map((q) => q.model).join(',')}`);
64
+ // Build the typed AbloError for this HTTP failure (same code→class
65
+ // map the throwing paths use) so the log is tagged + carries a
66
+ // registry `code` (e.g. AbloAuthenticationError/session_expired on a
67
+ // 401) instead of a bare status. We deliberately DON'T throw
68
+ // fire-and-forget callers would kill the Next.js router on an
69
+ // unhandled rejection — and still return empty slots, but the failure
70
+ // is now legible as an Ablo error. Direct console.error is
71
+ // INTENTIONAL: operators alert on the `[postQuery.error]` prefix.
72
+ let body = null;
73
+ try {
74
+ body = await response.clone().json();
75
+ }
76
+ catch {
77
+ // non-JSON error page — translateHttpError falls back to status text
78
+ }
79
+ const err = translateHttpError(response.status, body);
80
+ console.error(`[postQuery.error] ${err.type} ${err.code ?? response.status} for ` +
81
+ `${batch.queries.map((q) => q.model).join(',')}: ${err.message}`);
71
82
  return { results: batch.queries.map(() => []) };
72
83
  }
73
84
  const raw = await response.json();
@@ -72,6 +72,31 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
72
72
  * same-origin session cookies.
73
73
  */
74
74
  apiKey?: string;
75
+ /**
76
+ * Static bearer auth token, sent as `Authorization: Bearer <token>` on the
77
+ * WebSocket upgrade + HTTP. For a token that must be refreshed (e.g. a
78
+ * short-lived JWT), prefer {@link getToken}.
79
+ */
80
+ authToken?: string | null;
81
+ /**
82
+ * Async resolver for a short-lived bearer token. Called once before the
83
+ * engine connects (so the first connection carries a fresh token) and then on
84
+ * a refresh interval ahead of expiry — each result is pushed via
85
+ * `engine.setAuthToken` without tearing down the connection. Wire this to
86
+ * a resolver that mints a short-lived session token (`ek_`/`rk_`) — e.g.
87
+ * `getSyncCapabilityToken` — or your own. Takes precedence over
88
+ * {@link authEndpoint}.
89
+ */
90
+ getToken?: () => Promise<string | null>;
91
+ /**
92
+ * Liveblocks/Stripe-style auth endpoint: a URL on YOUR backend that returns
93
+ * `{ token }` — the `ek_` ephemeral key your server minted for the logged-in
94
+ * user with `ablo.sessions.create({ user: { id } })`. The provider POSTs to it
95
+ * (with cookies) to fetch + refresh the bearer, so the browser carries no
96
+ * secret. Shorthand for a {@link getToken} that does the fetch; ignored when
97
+ * `getToken` is set.
98
+ */
99
+ authEndpoint?: string;
75
100
  /** Optional Zero-style custom mutators. */
76
101
  mutators?: MutatorDefs<Schema<R>>;
77
102
  /** Options forwarded to the internal `useMutators` call (e.g., `undoScope`). */
@@ -5,7 +5,7 @@ import { Ablo } from '../client/Ablo.js';
5
5
  import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../sync/participants.js';
6
6
  import { SyncContext } from './context.js';
7
7
  import { AbloInternalContext } from './internalContext.js';
8
- import { AbloValidationError } from '../errors.js';
8
+ import { AbloValidationError, AbloAuthenticationError } from '../errors.js';
9
9
  import { useSyncStatus } from './useSyncStatus.js';
10
10
  import { DefaultFallback } from './DefaultFallback.js';
11
11
  // ── Implementation ───────────────────────────────────────────────────
@@ -32,7 +32,7 @@ function createErrorEmitter() {
32
32
  };
33
33
  }
34
34
  export function AbloProvider(props) {
35
- const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, scope, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
35
+ const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, authToken, getToken, authEndpoint, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, scope, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
36
36
  // Account scope is no longer accepted from props. The engine learns
37
37
  // it from auth (capability token) at bootstrap and we read it back
38
38
  // out of `_store.orgId` once `engine.ready()` resolves.
@@ -54,6 +54,25 @@ export function AbloProvider(props) {
54
54
  useEffect(() => {
55
55
  return errorEmitter.subscribe((err) => onErrorRef.current?.(err));
56
56
  }, [errorEmitter]);
57
+ // Stash the token resolver in a ref so a new function identity each render
58
+ // does not re-key (tear down) the engine. Read at fire time in the connect +
59
+ // refresh paths below — same `useEventCallback` idiom as `onError`. `getToken`
60
+ // wins; otherwise `authEndpoint` is fetched for `{ token }`.
61
+ const tokenFromEndpoint = authEndpoint
62
+ ? async () => {
63
+ const res = await fetch(authEndpoint, {
64
+ method: 'POST',
65
+ credentials: 'include',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ });
68
+ if (!res.ok)
69
+ return null;
70
+ const body = (await res.json());
71
+ return body.token ?? null;
72
+ }
73
+ : undefined;
74
+ const getTokenRef = useRef(getToken ?? tokenFromEndpoint);
75
+ getTokenRef.current = getToken ?? tokenFromEndpoint;
57
76
  // ── Engine lifecycle keyed on (userId, url) ─────────────────────
58
77
  //
59
78
  // The engine rotates when either of these change. For everything
@@ -79,6 +98,7 @@ export function AbloProvider(props) {
79
98
  schema,
80
99
  ...(userId ? { user: { id: userId, teamIds } } : {}),
81
100
  apiKey,
101
+ ...(authToken ? { authToken } : {}),
82
102
  logger,
83
103
  observability,
84
104
  sessionErrorDetector,
@@ -121,6 +141,52 @@ export function AbloProvider(props) {
121
141
  // saves once `useAblo` exists.
122
142
  (async () => {
123
143
  try {
144
+ // Resolve a fresh bearer token BEFORE `ready()` (which connects —
145
+ // the engine is built with `autoStart: false`), so the first WS
146
+ // upgrade + bootstrap carry it. No race: nothing has connected yet.
147
+ const fetchToken = getTokenRef.current;
148
+ if (fetchToken) {
149
+ const token = await fetchToken();
150
+ if (isStale || abort.signal.aborted)
151
+ return;
152
+ if (token) {
153
+ engine.setAuthToken(token);
154
+ }
155
+ else {
156
+ // A configured `getToken` that resolves to falsy means "no active
157
+ // session" — the session-token resolver (e.g. `getSyncCapabilityToken`)
158
+ // returns null when logged out, the session hasn't hydrated, or the
159
+ // mint endpoint rejects. Its contract is "the provider then surfaces
160
+ // the usual unauthenticated path rather than connecting with a bad token."
161
+ //
162
+ // We MUST short-circuit here rather than fall through to
163
+ // `engine.ready()`. On apps/web's identity path (org + user.id are
164
+ // both supplied) `resolveParticipantIdentity` takes the
165
+ // trust-the-caller Branch 3, which does NOT validate the (absent)
166
+ // token — so init would proceed into `store.initialize()` and open
167
+ // IndexedDB *before* any auth-bearing bootstrap/WS call. If storage
168
+ // is at all wedged the no-session condition then surfaces only as an
169
+ // opaque `IndexedDB "ablo_databases" open did not resolve within
170
+ // 10000ms` stall, never the real auth error. Surface `session_expired`
171
+ // explicitly and let the app redirect to sign-in.
172
+ //
173
+ // Unlike the server-detected `onSessionError` handler above, we do
174
+ // NOT purge: nothing connected or wrote this mount, so any cached
175
+ // IndexedDB belongs to a prior session and is reconciled by the next
176
+ // valid bootstrap — purging here would drop a still-valid offline
177
+ // queue on a merely transient null token.
178
+ const authErr = new AbloAuthenticationError('No session token available — getToken() resolved to null. The ' +
179
+ 'session is missing or expired; sign in again.', { code: 'session_expired' });
180
+ errorEmitter.emit(authErr);
181
+ try {
182
+ await onSessionExpired?.();
183
+ }
184
+ catch (hookErr) {
185
+ errorEmitter.emit(hookErr);
186
+ }
187
+ return;
188
+ }
189
+ }
124
190
  await engine.ready();
125
191
  if (isStale || abort.signal.aborted)
126
192
  return;
@@ -146,6 +212,35 @@ export function AbloProvider(props) {
146
212
  // `mutationExecutor` identity change would destroy the WebSocket.
147
213
  // eslint-disable-next-line react-hooks/exhaustive-deps
148
214
  }, [engineKey]);
215
+ // ── Bearer-token refresh ─────────────────────────────────────────
216
+ //
217
+ // Short-lived tokens (the sync-server JWT defaults to 15m) must be rolled
218
+ // before they expire or the next reconnect/HTTP call fails auth. Re-resolve
219
+ // ahead of expiry and push via `engine.setAuthToken`, which swaps the token
220
+ // on the live WebSocket + bootstrap header without tearing down the engine.
221
+ // Only runs when a `getToken` resolver is wired (cookie deployments skip it).
222
+ useEffect(() => {
223
+ const engine = engineState.engine;
224
+ if (!engine || !getTokenRef.current)
225
+ return;
226
+ // Comfortably inside the 15m JWT lifetime; a missed tick is recovered by
227
+ // the next one well before expiry.
228
+ const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
229
+ const id = setInterval(() => {
230
+ void (async () => {
231
+ try {
232
+ const token = await getTokenRef.current?.();
233
+ if (token)
234
+ engine.setAuthToken(token);
235
+ }
236
+ catch {
237
+ // Transient (offline / session check). The next tick retries; the
238
+ // engine keeps using the still-valid current token until then.
239
+ }
240
+ })();
241
+ }, REFRESH_INTERVAL_MS);
242
+ return () => clearInterval(id);
243
+ }, [engineState.engine]);
149
244
  // ── beforeunload + preventUnsavedChanges ─────────────────────────
150
245
  useEffect(() => {
151
246
  if (typeof window === 'undefined')
@@ -33,4 +33,4 @@ export interface ClientSideSuspenseProps {
33
33
  /** What to render once the subtree is cleared to render. */
34
34
  children: ReactNode;
35
35
  }
36
- export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react/jsx-runtime").JSX.Element;
36
+ export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
@@ -21,4 +21,4 @@
21
21
  * pass `fallback={null}`. Consumers who want to skip the gate entirely
22
22
  * pass `fallback="passthrough"`.
23
23
  */
24
- export declare function DefaultFallback(): import("react/jsx-runtime").JSX.Element;
24
+ export declare function DefaultFallback(): import("react").JSX.Element;
@@ -4,7 +4,7 @@ export interface SyncGroupProviderProps {
4
4
  id: string;
5
5
  children: ReactNode;
6
6
  }
7
- export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react").JSX.Element;
8
8
  /**
9
9
  * Returns the ID of the nearest `<SyncGroupProvider>`. Throws if
10
10
  * called outside one — sync-group awareness is mandatory by design,
@@ -13,9 +13,10 @@
13
13
  * immediately. The provider-level `fallback` is the default path.
14
14
  *
15
15
  * Data hooks:
16
- * useAblo((ablo) => ablo.tasks.retrieve(id)) — primary React read API
16
+ * useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
17
17
  * useAblo() — typed client for callbacks/effects
18
- * (reads: ablo.<model>.retrieve/list;
18
+ * (sync local reads: ablo.<model>.get/getAll;
19
+ * async server reads: ablo.<model>.retrieve/list;
19
20
  * writes: ablo.<model>.create/update/delete)
20
21
  * useMutators(defs, opts?) — Zero-style custom mutators
21
22
  * useUndoScope(name) — per-surface undo/redo
@@ -13,9 +13,10 @@
13
13
  * immediately. The provider-level `fallback` is the default path.
14
14
  *
15
15
  * Data hooks:
16
- * useAblo((ablo) => ablo.tasks.retrieve(id)) — primary React read API
16
+ * useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
17
17
  * useAblo() — typed client for callbacks/effects
18
- * (reads: ablo.<model>.retrieve/list;
18
+ * (sync local reads: ablo.<model>.get/getAll;
19
+ * async server reads: ablo.<model>.retrieve/list;
19
20
  * writes: ablo.<model>.create/update/delete)
20
21
  * useMutators(defs, opts?) — Zero-style custom mutators
21
22
  * useUndoScope(name) — per-surface undo/redo
@@ -45,11 +45,11 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
45
45
  * // With Register augmentation (recommended):
46
46
  * const ablo = useAblo();
47
47
  * if (!ablo) return <Loading />;
48
- * const docs = await ablo.documents.load({ where: { id } });
48
+ * const doc = await ablo.documents.retrieve(id); // async server read
49
49
  *
50
- * // Reactive selector:
51
- * const doc = useAblo((ablo) => ablo.documents.retrieve(id)) ?? serverDoc;
52
- * const active = useAblo((ablo) => ablo.documents.claimState(id));
50
+ * // Reactive selector (sync local-graph snapshot):
51
+ * const doc = useAblo((ablo) => ablo.documents.get(id)) ?? serverDoc;
52
+ * const active = useAblo((ablo) => ablo.documents.claim.state(id));
53
53
  *
54
54
  * // Without augmentation, pass the schema generic:
55
55
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -9,7 +9,7 @@ function readModelResult(engine, modelClient, id, initial) {
9
9
  if (!modelClient || id === undefined) {
10
10
  return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
11
11
  }
12
- const data = snapshotValue(modelClient.retrieve(id) ?? initial);
12
+ const data = snapshotValue(modelClient.get(id) ?? initial);
13
13
  const meta = getModelClientMeta(modelClient);
14
14
  const claims = meta && engine
15
15
  ? engine.intents.list({ model: meta.key, id })
@@ -29,7 +29,6 @@ export function useAblo(modelOrSelect, id, options) {
29
29
  const ctx = useContext(AbloInternalContext);
30
30
  const engine = ctx?.engine ?? null;
31
31
  const initial = options?.initial;
32
- const hasSelection = modelOrSelect !== undefined;
33
32
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
34
33
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
35
34
  ? engine
@@ -38,14 +37,20 @@ export function useAblo(modelOrSelect, id, options) {
38
37
  : typeof modelOrSelect === 'function'
39
38
  ? undefined
40
39
  : modelOrSelect;
40
+ // Claims live on a non-MobX event emitter (engine.intents), so the useReactive
41
+ // reactions below cannot track them — we bridge changes through a setState bump.
42
+ // ONLY the model-row form (`id !== undefined`) actually reads claims, so gate the
43
+ // subscription on `id`. The selector-only form (`useAblo((a) => a.x.get/getAll)`)
44
+ // never reads claims; subscribing it to the workspace-global intent stream would
45
+ // re-render + double-compute it on every intent/presence delta anywhere (a real
46
+ // storm during AI editing / live collaboration) for a value that can't change.
41
47
  const [claimVersion, setClaimVersion] = useState(0);
42
48
  useEffect(() => {
43
- if (!engine || !hasSelection)
49
+ if (!engine || id === undefined)
44
50
  return;
45
51
  return engine.intents.onChange(() => setClaimVersion((version) => version + 1));
46
- }, [engine, hasSelection]);
52
+ }, [engine, id]);
47
53
  const selected = useReactive(() => {
48
- void claimVersion;
49
54
  if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
50
55
  return undefined;
51
56
  }
@@ -66,16 +66,29 @@ export function useReactive(compute, equals = defaultEquals) {
66
66
  const subscribeVersionRef = useRef(0);
67
67
  if (snapshotRef.current === null) {
68
68
  snapshotRef.current = { value: compute() };
69
- computeRef.current = compute;
70
69
  }
71
70
  else if (computeRef.current !== compute) {
71
+ // `compute` is a fresh inline arrow at virtually every call site, so this
72
+ // branch runs on essentially every render. Reconcile the snapshot against
73
+ // the latest closure, but only force a re-subscription when the value
74
+ // ACTUALLY changed. For the dominant case (same observable source, new
75
+ // arrow identity, unchanged value) this avoids tearing down + recreating
76
+ // the MobX reaction — and its double-compute — on every render. A genuine
77
+ // source swap (a memoized compute closing over a new observable source)
78
+ // changes the value, which both updates the snapshot and bumps
79
+ // `subscribeVersion` so the reaction below re-subscribes and re-tracks the
80
+ // new source's observables.
72
81
  const next = compute();
73
82
  if (!equals(snapshotRef.current.value, next)) {
74
83
  snapshotRef.current = { value: next };
84
+ subscribeVersionRef.current++;
75
85
  }
76
- computeRef.current = compute;
77
- subscribeVersionRef.current++;
78
86
  }
87
+ // Point the long-lived reaction at the latest closure every render. The
88
+ // reaction expression reads `computeRef.current` at fire time, so it always
89
+ // runs the newest compute (and re-tracks its observables) even when we did
90
+ // not re-subscribe above.
91
+ computeRef.current = compute;
79
92
  const subscribeVersion = subscribeVersionRef.current;
80
93
  const subscribe = useCallback((onChange) => {
81
94
  return reaction(() => computeRef.current(), (next) => {
@@ -8,7 +8,7 @@
8
8
  * This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
9
9
  * type, two representations. A hosted multi-tenant server obtains a tenant's
10
10
  * `Schema` by `parseSchema(json)` instead of an in-process import — the JSON
11
- * is what travels over the control plane (`ablo schema push`) and is stored
11
+ * is what travels over the control plane (`ablo push`) and is stored
12
12
  * per `(tenant, version)`.
13
13
  *
14
14
  * What round-trips:
@@ -62,7 +62,7 @@ export interface ModelJSON {
62
62
  readonly autoFill?: readonly AutoFillRule[];
63
63
  readonly requiredFields?: readonly string[];
64
64
  }
65
- /** The JSON form of a {@link Schema}. The `@ablo schema push` payload. */
65
+ /** The JSON form of a {@link Schema}. The `ablo push` payload. */
66
66
  export interface SchemaJSON {
67
67
  readonly v: typeof SCHEMA_JSON_VERSION;
68
68
  readonly models: Record<string, ModelJSON>;
@@ -74,7 +74,7 @@ export interface SchemaJSON {
74
74
  * rebuild need. The result is plain data — `JSON.stringify`-safe.
75
75
  */
76
76
  export declare function toSchemaJSON(schema: Schema<SchemaRecord>): SchemaJSON;
77
- /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
77
+ /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
78
78
  export declare function serializeSchema(schema: Schema<SchemaRecord>): string;
79
79
  /**
80
80
  * Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
@@ -8,7 +8,7 @@
8
8
  * This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
9
9
  * type, two representations. A hosted multi-tenant server obtains a tenant's
10
10
  * `Schema` by `parseSchema(json)` instead of an in-process import — the JSON
11
- * is what travels over the control plane (`ablo schema push`) and is stored
11
+ * is what travels over the control plane (`ablo push`) and is stored
12
12
  * per `(tenant, version)`.
13
13
  *
14
14
  * What round-trips:
@@ -88,7 +88,7 @@ export function toSchemaJSON(schema) {
88
88
  }
89
89
  return { v: SCHEMA_JSON_VERSION, models, identityRoles: schema.identityRoles };
90
90
  }
91
- /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
91
+ /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
92
92
  export function serializeSchema(schema) {
93
93
  return JSON.stringify(toSchemaJSON(schema));
94
94
  }
@@ -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 {
@@ -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);