@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.
- package/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- 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 +112 -3
- package/dist/client/Ablo.js +144 -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 +120 -53
- package/dist/client/createModelProxy.js +66 -31
- 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/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- 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/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- 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.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- 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/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- 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 +18 -14
- package/docs/roadmap.md +15 -3
- 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 +13 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Removed problematic caching that was serving stale data
|
|
4
4
|
*/
|
|
5
5
|
import { getContext } from '../context.js';
|
|
6
|
-
import { SyncSessionError, AbloConnectionError, translateHttpError } from '../errors.js';
|
|
6
|
+
import { SyncSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '../errors.js';
|
|
7
7
|
// SyncObservability replaced by getContext().observability
|
|
8
8
|
import { parseBootstrapResponse } from './schemas.js';
|
|
9
9
|
export class BootstrapHelper {
|
|
@@ -60,7 +60,9 @@ export class BootstrapHelper {
|
|
|
60
60
|
createTimeoutPromise(ms, operation) {
|
|
61
61
|
return new Promise((_, reject) => {
|
|
62
62
|
setTimeout(() => {
|
|
63
|
-
reject(new
|
|
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 {
|
|
@@ -113,6 +113,8 @@ export declare class HydrationCoordinator {
|
|
|
113
113
|
private hydrateExpanded;
|
|
114
114
|
private persistToIdb;
|
|
115
115
|
private resolveTypename;
|
|
116
|
+
private columnizeField;
|
|
117
|
+
private columnizeClause;
|
|
116
118
|
}
|
|
117
119
|
/**
|
|
118
120
|
* Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* models accessed by id/where after the engine is ready.
|
|
21
21
|
*/
|
|
22
22
|
import { ModelScope } from '../ObjectPool.js';
|
|
23
|
+
import { AbloValidationError } from '../errors.js';
|
|
23
24
|
import { postQuery } from '../query/client.js';
|
|
24
25
|
export class HydrationCoordinator {
|
|
25
26
|
opts;
|
|
@@ -53,8 +54,8 @@ export class HydrationCoordinator {
|
|
|
53
54
|
const ModelClass = this.opts.registry.getModelByName(typename)
|
|
54
55
|
?? this.opts.registry.getModelByName(modelName);
|
|
55
56
|
if (!ModelClass) {
|
|
56
|
-
throw new
|
|
57
|
-
`not registered in the schema
|
|
57
|
+
throw new AbloValidationError(`HydrationCoordinator.fetch: unknown model "${modelName}" — ` +
|
|
58
|
+
`not registered in the schema.`, { code: 'model_not_registered' });
|
|
58
59
|
}
|
|
59
60
|
const clauses = normalizeWhere(options?.where);
|
|
60
61
|
const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit);
|
|
@@ -161,10 +162,10 @@ export class HydrationCoordinator {
|
|
|
161
162
|
const firstOrder = orderEntries[0];
|
|
162
163
|
const query = {
|
|
163
164
|
model: typename,
|
|
164
|
-
where: clauses.map((c) => columnizeClause(c)),
|
|
165
|
+
where: clauses.map((c) => this.columnizeClause(modelName, c)),
|
|
165
166
|
...(firstOrder
|
|
166
167
|
? {
|
|
167
|
-
orderBy:
|
|
168
|
+
orderBy: this.columnizeField(modelName, firstOrder[0]),
|
|
168
169
|
order: firstOrder[1] ?? 'asc',
|
|
169
170
|
}
|
|
170
171
|
: {}),
|
|
@@ -256,6 +257,27 @@ export class HydrationCoordinator {
|
|
|
256
257
|
.models?.[modelName];
|
|
257
258
|
return def?.typename ?? modelName;
|
|
258
259
|
}
|
|
260
|
+
columnizeField(modelName, field) {
|
|
261
|
+
const fields = this.opts.schema.models?.[modelName]?.fields;
|
|
262
|
+
if (fields) {
|
|
263
|
+
const direct = fields[field]?.column;
|
|
264
|
+
if (direct)
|
|
265
|
+
return direct;
|
|
266
|
+
for (const [fieldName, meta] of Object.entries(fields)) {
|
|
267
|
+
const conventional = columnize(fieldName);
|
|
268
|
+
if (field === fieldName || field === conventional || field === meta.column) {
|
|
269
|
+
return meta.column ?? conventional;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return /[A-Z]/.test(field) ? columnize(field) : field;
|
|
274
|
+
}
|
|
275
|
+
columnizeClause(modelName, clause) {
|
|
276
|
+
const finalCol = this.columnizeField(modelName, clause[0]);
|
|
277
|
+
if (clause.length === 2)
|
|
278
|
+
return [finalCol, clause[1]];
|
|
279
|
+
return [finalCol, clause[1], clause[2]];
|
|
280
|
+
}
|
|
259
281
|
}
|
|
260
282
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
261
283
|
function stableKey(modelName, clauses, orderBy, limit) {
|
|
@@ -350,21 +372,6 @@ export function normalizeWhere(where) {
|
|
|
350
372
|
}
|
|
351
373
|
return [];
|
|
352
374
|
}
|
|
353
|
-
/**
|
|
354
|
-
* Apply `columnize` to the column name of a wire-bound clause so the
|
|
355
|
-
* server sees `slide_id` instead of `slideId`. Tuple-form clauses from
|
|
356
|
-
* callers are passed through unchanged — they already supply the wire
|
|
357
|
-
* column name (matches what existing `postQuery` consumers do).
|
|
358
|
-
*/
|
|
359
|
-
function columnizeClause(clause) {
|
|
360
|
-
const col = clause[0];
|
|
361
|
-
// If the column already looks snake_case (no uppercase letters), assume
|
|
362
|
-
// the caller is already using server-side naming. Otherwise camelize→snake.
|
|
363
|
-
const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
|
|
364
|
-
if (clause.length === 2)
|
|
365
|
-
return [finalCol, clause[1]];
|
|
366
|
-
return [finalCol, clause[1], clause[2]];
|
|
367
|
-
}
|
|
368
375
|
/** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
|
|
369
376
|
function extractEqClauses(clauses) {
|
|
370
377
|
const out = {};
|
|
@@ -27,6 +27,14 @@ export interface ProbeResult {
|
|
|
27
27
|
reachable: boolean;
|
|
28
28
|
/** Whether the session cookie is still valid (null if server unreachable) */
|
|
29
29
|
sessionValid: boolean | null;
|
|
30
|
+
/**
|
|
31
|
+
* Reachable, but a NON-retryable auth/config failure that is NOT a session
|
|
32
|
+
* expiry (e.g. `api_key_required`, `jwt_issuer_untrusted`). The session is
|
|
33
|
+
* fine — the data-plane rejected the credential TYPE — so neither
|
|
34
|
+
* reconnecting nor re-authenticating will help. The manager stops instead of
|
|
35
|
+
* looping. Distinct from `sessionValid: false` (genuine expiry → sign in).
|
|
36
|
+
*/
|
|
37
|
+
authBlocked?: boolean;
|
|
30
38
|
/** Round-trip time in ms (null if failed) */
|
|
31
39
|
latencyMs: number | null;
|
|
32
40
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
|
|
23
23
|
*/
|
|
24
24
|
import { getContext } from '../context.js';
|
|
25
|
-
import { SyncSessionError } from '../errors.js';
|
|
25
|
+
import { SyncSessionError, isRetryableCode } from '../errors.js';
|
|
26
26
|
const PROBE_TIMEOUT_MS = 4000;
|
|
27
27
|
/**
|
|
28
28
|
* Derive the probe URL from a sync-server base URL. Accepts `ws://`,
|
|
@@ -72,7 +72,17 @@ export async function probeNetwork(baseUrl) {
|
|
|
72
72
|
headers: { 'Cache-Control': 'no-cache' },
|
|
73
73
|
});
|
|
74
74
|
const latencyMs = Math.round(performance.now() - start);
|
|
75
|
-
|
|
75
|
+
// The probe is a HEAD (no body), but the sync-server sets `X-Auth-Failure:
|
|
76
|
+
// <code>` on every auth rejection — feed that to the code-aware detector so
|
|
77
|
+
// only a genuine session/JWT EXPIRY marks the session invalid. A non-expiry
|
|
78
|
+
// auth failure (e.g. api_key_required, jwt_issuer_untrusted) leaves
|
|
79
|
+
// sessionValid alone — the user IS logged in; signing them out wouldn't fix
|
|
80
|
+
// a credential-type/config problem and just bounces them to /signin.
|
|
81
|
+
const authFailure = response.headers.get('x-auth-failure');
|
|
82
|
+
const failureBody = authFailure
|
|
83
|
+
? JSON.stringify({ code: authFailure })
|
|
84
|
+
: undefined;
|
|
85
|
+
if (SyncSessionError.isSessionErrorResponse(response.status, failureBody)) {
|
|
76
86
|
// Server reachable but session expired/invalid
|
|
77
87
|
getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
|
|
78
88
|
status: response.status,
|
|
@@ -80,6 +90,18 @@ export async function probeNetwork(baseUrl) {
|
|
|
80
90
|
});
|
|
81
91
|
return { reachable: true, sessionValid: false, latencyMs };
|
|
82
92
|
}
|
|
93
|
+
// Reachable, but a NON-retryable auth/config failure that is NOT a session
|
|
94
|
+
// expiry (api_key_required, jwt_issuer_untrusted, …). Re-auth won't fix it
|
|
95
|
+
// and retrying won't either — signal authBlocked so the manager STOPS
|
|
96
|
+
// rather than reconnect-looping or signing the user out.
|
|
97
|
+
if (authFailure && !isRetryableCode(authFailure)) {
|
|
98
|
+
getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
|
|
99
|
+
status: response.status,
|
|
100
|
+
code: authFailure,
|
|
101
|
+
latencyMs,
|
|
102
|
+
});
|
|
103
|
+
return { reachable: true, sessionValid: true, authBlocked: true, latencyMs };
|
|
104
|
+
}
|
|
83
105
|
// 2xx (including 204) means reachable + session valid.
|
|
84
106
|
// 3xx/4xx (non-auth) still prove connectivity even though the probe
|
|
85
107
|
// expected 204; log a warning so misconfigurations surface instead of
|
|
@@ -271,7 +271,7 @@ export interface CoreSyncEventMap {
|
|
|
271
271
|
/**
|
|
272
272
|
* Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
|
|
273
273
|
* entry `status: 'queued'` + `position`. Broadcast to entity peers on every
|
|
274
|
-
* queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
|
|
274
|
+
* queue mutation — powers the reactive `ablo.<model>.claim.queue(id)` read.
|
|
275
275
|
*/
|
|
276
276
|
intent_queue: [Record<string, unknown>];
|
|
277
277
|
intent_acquired: [Record<string, unknown>];
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
|
-
import {
|
|
13
|
+
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
16
16
|
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
@@ -214,7 +214,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
214
214
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
|
|
215
215
|
getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
|
|
216
216
|
this.isConnecting = false;
|
|
217
|
-
this.emit('error', new
|
|
217
|
+
this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
|
|
218
218
|
this.scheduleReconnect();
|
|
219
219
|
}
|
|
220
220
|
}
|
|
@@ -360,38 +360,19 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
360
360
|
else {
|
|
361
361
|
errorMessage = 'mutation failed on server';
|
|
362
362
|
}
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
errorCode
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// pre-commit lease check (`intent_conflict`, the code that
|
|
377
|
-
// reaches clients in practice) and `executeCommit`'s deeper
|
|
378
|
-
// guard (`entity_claimed`). Both mean "claimed", so both route
|
|
379
|
-
// through the typed AbloClaimedError, letting callers
|
|
380
|
-
// `instanceof AbloClaimedError` (or read `e.type` across worker
|
|
381
|
-
// boundaries) and wait/bypass — symmetric with the
|
|
382
|
-
// CapabilityError branch above, and with the HTTP commit path
|
|
383
|
-
// (`translateHttpError`).
|
|
384
|
-
pending.reject(new AbloClaimedError(errorMessage, {
|
|
385
|
-
code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
|
|
386
|
-
httpStatus: 409,
|
|
387
|
-
}));
|
|
388
|
-
}
|
|
389
|
-
else {
|
|
390
|
-
const rejection = new Error(errorMessage);
|
|
391
|
-
if (errorCode)
|
|
392
|
-
rejection.code = errorCode;
|
|
393
|
-
pending.reject(rejection);
|
|
394
|
-
}
|
|
363
|
+
// Build the proper typed AbloError from the wire code via the
|
|
364
|
+
// shared factory — the same code→class mapping the HTTP commit
|
|
365
|
+
// path uses (`translateHttpError`). This keeps rejected commits
|
|
366
|
+
// inside the typed hierarchy (capability denials →
|
|
367
|
+
// CapabilityError with `.requiredCapability`; foreign-claim
|
|
368
|
+
// conflicts → AbloClaimedError; everything else → the subclass
|
|
369
|
+
// its registry `httpStatus` implies) instead of a hand-rolled
|
|
370
|
+
// `new Error`, so callers can `instanceof`/`e.type` it and
|
|
371
|
+
// downstream retry logic can read the contract's retryability.
|
|
372
|
+
pending.reject(errorFromWire(errorMessage, {
|
|
373
|
+
code: errorCode,
|
|
374
|
+
requiredCapability,
|
|
375
|
+
}));
|
|
395
376
|
}
|
|
396
377
|
break;
|
|
397
378
|
}
|
|
@@ -438,11 +419,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
438
419
|
pending.reject(new CapabilityError(code, msg, requiredCapability));
|
|
439
420
|
}
|
|
440
421
|
else {
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
pending.reject(rejection);
|
|
422
|
+
// Route through the shared factory so a failed claim_ack is a
|
|
423
|
+
// typed AbloError (registry code → right subclass), symmetric
|
|
424
|
+
// with the commit `mutation_result` path — never a bare Error.
|
|
425
|
+
pending.reject(errorFromWire(msg, { code }));
|
|
446
426
|
}
|
|
447
427
|
}
|
|
448
428
|
break;
|
|
@@ -539,12 +519,12 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
539
519
|
// Check if we're offline first
|
|
540
520
|
if (!getContext().onlineStatus.isOnline()) {
|
|
541
521
|
getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
|
|
542
|
-
this.emit('error', new
|
|
522
|
+
this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
|
|
543
523
|
return;
|
|
544
524
|
}
|
|
545
525
|
// After session error, suppress Sentry capture — the root cause is already reported.
|
|
546
526
|
// Still emit so SyncedStore can update UI state.
|
|
547
|
-
const error = new
|
|
527
|
+
const error = new AbloConnectionError(`WebSocket connection failed`);
|
|
548
528
|
if (!this._sessionErrorDetected) {
|
|
549
529
|
getContext().observability.captureWebSocketError({
|
|
550
530
|
context: 'connection-error',
|
|
@@ -578,12 +558,16 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
578
558
|
if (this.pendingMutations.size > 0) {
|
|
579
559
|
for (const pending of this.pendingMutations.values()) {
|
|
580
560
|
clearTimeout(pending.timeout);
|
|
581
|
-
|
|
561
|
+
// AbloConnectionError → `isPermanentError` treats it as transient,
|
|
562
|
+
// so TransactionQueue retries the commit on reconnect rather than
|
|
563
|
+
// rolling it back. `diagnostics` is preserved as a property (the
|
|
564
|
+
// queue's failure log walks the cause chain for it).
|
|
565
|
+
pending.reject(Object.assign(new AbloConnectionError(`WebSocket closed while commit was in flight (code=${event.code}` +
|
|
582
566
|
(event.reason ? ` reason=${event.reason}` : '') +
|
|
583
567
|
(this.lastForceCloseReason
|
|
584
568
|
? ` forceCloseReason=${this.lastForceCloseReason}`
|
|
585
569
|
: '') +
|
|
586
|
-
')'), { diagnostics: this.getConnectionDiagnostics() }));
|
|
570
|
+
')', { code: 'commit_no_result' }), { diagnostics: this.getConnectionDiagnostics() }));
|
|
587
571
|
}
|
|
588
572
|
this.pendingMutations.clear();
|
|
589
573
|
}
|
|
@@ -594,7 +578,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
594
578
|
if (this.pendingClaims.size > 0) {
|
|
595
579
|
for (const pending of this.pendingClaims.values()) {
|
|
596
580
|
clearTimeout(pending.timeout);
|
|
597
|
-
pending.reject(new
|
|
581
|
+
pending.reject(new AbloConnectionError(`WebSocket closed while claim was in flight (code=${event.code})`));
|
|
598
582
|
}
|
|
599
583
|
this.pendingClaims.clear();
|
|
600
584
|
}
|
|
@@ -762,7 +746,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
762
746
|
return new Promise((resolve, reject) => {
|
|
763
747
|
const timeout = setTimeout(() => {
|
|
764
748
|
this.pendingMutations.delete(clientTxId);
|
|
765
|
-
reject(new
|
|
749
|
+
reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
|
|
766
750
|
}, timeoutMs);
|
|
767
751
|
this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
|
|
768
752
|
try {
|
|
@@ -778,9 +762,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
778
762
|
catch (error) {
|
|
779
763
|
clearTimeout(timeout);
|
|
780
764
|
this.pendingMutations.delete(clientTxId);
|
|
781
|
-
reject(error
|
|
782
|
-
? error
|
|
783
|
-
: new Error(String(error)));
|
|
765
|
+
reject(toAbloError(error));
|
|
784
766
|
}
|
|
785
767
|
});
|
|
786
768
|
}
|
|
@@ -826,7 +808,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
826
808
|
return new Promise((resolve, reject) => {
|
|
827
809
|
const timeout = setTimeout(() => {
|
|
828
810
|
this.pendingClaims.delete(claimId);
|
|
829
|
-
reject(new
|
|
811
|
+
reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
|
|
812
|
+
code: 'wait_for_timeout',
|
|
813
|
+
}));
|
|
830
814
|
}, timeoutMs);
|
|
831
815
|
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
832
816
|
try {
|
|
@@ -843,7 +827,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
843
827
|
catch (error) {
|
|
844
828
|
clearTimeout(timeout);
|
|
845
829
|
this.pendingClaims.delete(claimId);
|
|
846
|
-
reject(
|
|
830
|
+
reject(toAbloError(error));
|
|
847
831
|
}
|
|
848
832
|
});
|
|
849
833
|
}
|
|
@@ -864,7 +848,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
864
848
|
if (pending) {
|
|
865
849
|
clearTimeout(pending.timeout);
|
|
866
850
|
this.pendingClaims.delete(claimId);
|
|
867
|
-
pending.reject(new
|
|
851
|
+
pending.reject(new AbloError(`claim ${claimId} released before ack`, {
|
|
852
|
+
code: 'intent_wait_aborted',
|
|
853
|
+
httpStatus: 409,
|
|
854
|
+
}));
|
|
868
855
|
}
|
|
869
856
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
870
857
|
return;
|
|
@@ -1172,8 +1159,11 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1172
1159
|
else {
|
|
1173
1160
|
detail = 'never_connected';
|
|
1174
1161
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1162
|
+
// Typed so it lands in the AbloError hierarchy AND `isPermanentError`
|
|
1163
|
+
// sees a transient transport failure (retry on reconnect, don't roll
|
|
1164
|
+
// back). `diagnostics` stays a property — the queue's failure log walks
|
|
1165
|
+
// the cause chain for it.
|
|
1166
|
+
const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
|
|
1177
1167
|
return err;
|
|
1178
1168
|
}
|
|
1179
1169
|
/** Returns the sync groups this connection is subscribed to. */
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'intent_begin', payload: { intentId,
|
|
13
13
|
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
-
* • Outbound: `{ type: 'intent_abandon', payload: { intentId
|
|
14
|
+
* • Outbound: `{ type: 'intent_abandon', payload: { intentId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
15
16
|
* • Inbound (via presence): `event.activeIntents: IntentClaim[]`
|
|
16
17
|
* stamped with `declaredAt`, `expiresAt`.
|
|
17
18
|
* • Inbound: `intent_rejected` event with conflict metadata.
|