@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.
- package/CHANGELOG.md +32 -0
- package/README.md +54 -45
- package/dist/BaseSyncedStore.js +7 -3
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +111 -3
- package/dist/client/Ablo.js +143 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +107 -63
- package/dist/client/createModelProxy.js +65 -33
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/errorCodes.d.ts +23 -1
- package/dist/errorCodes.js +34 -1
- package/dist/errors.d.ts +52 -1
- package/dist/errors.js +140 -42
- package/dist/index.d.ts +9 -5
- package/dist/index.js +9 -5
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +25 -0
- package/dist/react/AbloProvider.js +97 -2
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/serialize.d.ts +3 -3
- package/dist/schema/serialize.js +2 -2
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.js +3 -2
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/docs/api-keys.md +5 -5
- package/docs/api.md +101 -44
- package/docs/audit.md +16 -9
- package/docs/cli.md +27 -17
- package/docs/client-behavior.md +34 -20
- package/docs/coordination.md +40 -51
- package/docs/data-sources.md +21 -19
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +27 -16
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +42 -27
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +26 -17
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +52 -52
- package/docs/interaction-model.md +38 -26
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +15 -11
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +6 -1
package/dist/query/client.js
CHANGED
|
@@ -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
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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.
|
|
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>.
|
|
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
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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>.
|
|
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
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -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
|
|
48
|
+
* const doc = await ablo.documents.retrieve(id); // async server read
|
|
49
49
|
*
|
|
50
|
-
* // Reactive selector:
|
|
51
|
-
* const doc = useAblo((ablo) => ablo.documents.
|
|
52
|
-
* const active = useAblo((ablo) => ablo.documents.
|
|
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']>();
|
package/dist/react/useAblo.js
CHANGED
|
@@ -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.
|
|
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 ||
|
|
49
|
+
if (!engine || id === undefined)
|
|
44
50
|
return;
|
|
45
51
|
return engine.intents.onChange(() => setClaimVersion((version) => version + 1));
|
|
46
|
-
}, [engine,
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/schema/serialize.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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);
|