@abloatai/ablo 0.8.0 → 0.9.1
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 +46 -1
- package/README.md +33 -28
- package/dist/BaseSyncedStore.d.ts +83 -0
- package/dist/BaseSyncedStore.js +194 -2
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/agent/session.js +3 -3
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +4 -42
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +160 -42
- package/dist/client/Ablo.js +145 -75
- package/dist/client/ApiClient.d.ts +20 -4
- package/dist/client/ApiClient.js +166 -28
- package/dist/client/auth.d.ts +14 -5
- package/dist/client/auth.js +60 -7
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +130 -66
- package/dist/client/createModelProxy.js +152 -49
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +49 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +3 -3
- package/dist/client/registerDataSource.js +11 -9
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +70 -1
- package/dist/errorCodes.js +108 -9
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +72 -22
- package/dist/index.d.ts +17 -8
- package/dist/index.js +15 -6
- package/dist/keys/index.d.ts +16 -1
- package/dist/keys/index.js +26 -6
- package/dist/mutators/UndoManager.d.ts +158 -50
- package/dist/mutators/UndoManager.js +345 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +3 -6
- package/dist/react/AbloProvider.d.ts +23 -126
- package/dist/react/AbloProvider.js +62 -199
- package/dist/react/context.d.ts +31 -0
- package/dist/react/useAblo.d.ts +2 -2
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/schema/ddl.d.ts +34 -3
- package/dist/schema/ddl.js +162 -4
- package/dist/schema/index.d.ts +5 -1
- package/dist/schema/index.js +13 -1
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +4 -0
- package/dist/schema/serialize.js +4 -0
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +65 -0
- package/dist/source/adapter.js +20 -0
- package/dist/source/adapters/drizzle.d.ts +43 -0
- package/dist/source/adapters/drizzle.js +185 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +176 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +144 -0
- package/dist/source/contract.js +99 -0
- package/dist/source/index.d.ts +62 -10
- package/dist/source/index.js +99 -0
- package/dist/source/migrations.d.ts +14 -0
- package/dist/source/migrations.js +39 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +10 -15
- package/dist/sync/ConnectionManager.d.ts +55 -1
- package/dist/sync/ConnectionManager.js +155 -16
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +238 -39
- package/dist/sync/NetworkProbe.d.ts +58 -24
- package/dist/sync/NetworkProbe.js +118 -42
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +70 -36
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api.md +47 -44
- package/docs/cli.md +44 -44
- package/docs/client-behavior.md +30 -30
- package/docs/coordination.md +33 -36
- package/docs/data-sources.md +35 -15
- package/docs/examples/agent-human.md +45 -43
- package/docs/examples/ai-sdk-tool.md +20 -16
- package/docs/examples/existing-python-backend.md +16 -12
- package/docs/examples/nextjs.md +14 -12
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/examples/server-agent.md +24 -21
- package/docs/guarantees.md +15 -13
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +30 -30
- package/docs/interaction-model.md +19 -23
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +2 -2
- package/docs/mcp.md +6 -6
- package/docs/quickstart.md +41 -31
- package/docs/react.md +13 -9
- package/docs/schema-contract.md +12 -10
- package/docs/the-loop.md +21 -0
- package/examples/data-source/README.md +4 -5
- package/examples/data-source/customer-server.ts +27 -25
- package/llms.txt +28 -5
- package/package.json +43 -3
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* After laptop sleep/wake, it may report true before WiFi/DNS are functional.
|
|
7
7
|
*
|
|
8
8
|
* This module provides an authenticated probe against the sync server to verify
|
|
9
|
-
* real connectivity +
|
|
10
|
-
* `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
|
|
11
|
-
* upgrade path
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* real connectivity + credential validity in a single round-trip. The probe
|
|
10
|
+
* hits `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
|
|
11
|
+
* upgrade path, and classifies the response into a single {@link ProbeOutcome}
|
|
12
|
+
* via the closed recovery taxonomy ({@link classifyRecovery}):
|
|
13
|
+
* 204 No Content → `reachable` (credential valid)
|
|
14
|
+
* 401 `apikey_expired` (ephemeral key) → `credential_stale` (re-mint & retry, NO sign-out)
|
|
15
|
+
* 401 `session_expired` / bare 401 → `session_expired` (sign out)
|
|
16
|
+
* 401/403 credential-type/config/perm → `auth_blocked` (stop, no loop, no sign-out)
|
|
17
|
+
* network fail / offline → `unreachable`
|
|
15
18
|
*
|
|
16
19
|
* This closes a real gap: the browser's WebSocket API hides HTTP status from
|
|
17
20
|
* the handshake, so a 401 on the WS upgrade surfaces only as `close code
|
|
@@ -21,8 +24,39 @@
|
|
|
21
24
|
*
|
|
22
25
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
|
|
23
26
|
*/
|
|
27
|
+
import { z } from 'zod';
|
|
24
28
|
import { getContext } from '../context.js';
|
|
25
|
-
import {
|
|
29
|
+
import { classifyRecovery } from '../errors.js';
|
|
30
|
+
import { withAuthHeaders } from '../auth/credentialSource.js';
|
|
31
|
+
/**
|
|
32
|
+
* The closed set of probe outcomes — one value carrying both reachability and
|
|
33
|
+
* credential disposition, so the {@link ConnectionManager} branches on a single
|
|
34
|
+
* exhaustive discriminant instead of reconstructing intent from a trio of
|
|
35
|
+
* booleans. Mirrors the {@link RecoveryClass} taxonomy at the connectivity tier.
|
|
36
|
+
*/
|
|
37
|
+
export const PROBE_OUTCOMES = [
|
|
38
|
+
/** Server reachable and the access credential is currently valid. */
|
|
39
|
+
'reachable',
|
|
40
|
+
/** Could not reach the server (offline / DNS / TLS / timeout). */
|
|
41
|
+
'unreachable',
|
|
42
|
+
/** Reachable, but the long-lived login is gone → terminal, sign out. */
|
|
43
|
+
'session_expired',
|
|
44
|
+
/** Reachable, but the ephemeral access key (`ek_`/`rk_`) expired → silently
|
|
45
|
+
* re-mint a fresh key from the still-valid login and retry. NOT a sign-out. */
|
|
46
|
+
'credential_stale',
|
|
47
|
+
/** Reachable, but the credential TYPE/config was rejected (wrong key kind,
|
|
48
|
+
* untrusted issuer, no org, a 403) → stop; neither reconnecting nor re-auth
|
|
49
|
+
* helps. Distinct from a sign-out. */
|
|
50
|
+
'auth_blocked',
|
|
51
|
+
];
|
|
52
|
+
/** Zod enum derived from {@link PROBE_OUTCOMES}. */
|
|
53
|
+
export const probeOutcomeSchema = z.enum(PROBE_OUTCOMES);
|
|
54
|
+
/** Result of a network probe: a single {@link ProbeOutcome} plus round-trip
|
|
55
|
+
* latency (null when the probe never completed). */
|
|
56
|
+
export const probeResultSchema = z.object({
|
|
57
|
+
outcome: probeOutcomeSchema,
|
|
58
|
+
latencyMs: z.number().nullable(),
|
|
59
|
+
});
|
|
26
60
|
const PROBE_TIMEOUT_MS = 4000;
|
|
27
61
|
/**
|
|
28
62
|
* Derive the probe URL from a sync-server base URL. Accepts `ws://`,
|
|
@@ -46,11 +80,14 @@ function resolveProbeUrl(baseUrl) {
|
|
|
46
80
|
* Returns reachability AND session status in a single call, so the
|
|
47
81
|
* ConnectionStore can make the right state transition without guessing.
|
|
48
82
|
*
|
|
49
|
-
* @param
|
|
50
|
-
*
|
|
51
|
-
*
|
|
83
|
+
* @param input The sync-server base URL (HTTP or WS scheme accepted), or an
|
|
84
|
+
* options bag with `authToken`. A bare string is still accepted
|
|
85
|
+
* for backwards compatibility.
|
|
52
86
|
*/
|
|
53
|
-
export async function probeNetwork(
|
|
87
|
+
export async function probeNetwork(input) {
|
|
88
|
+
const baseUrl = typeof input === 'string' ? input : input?.baseUrl;
|
|
89
|
+
const getAuthToken = typeof input === 'string' ? undefined : input?.getAuthToken;
|
|
90
|
+
const authToken = typeof input === 'string' ? undefined : input?.authToken;
|
|
54
91
|
const url = resolveProbeUrl(baseUrl);
|
|
55
92
|
// Fast-fail: if navigator.onLine is false, skip the probe entirely.
|
|
56
93
|
// This is the ONE case where navigator.onLine is reliable (MDN: "false
|
|
@@ -58,51 +95,90 @@ export async function probeNetwork(baseUrl) {
|
|
|
58
95
|
// because Node 22+ exposes `navigator` with `onLine === undefined`,
|
|
59
96
|
// and `!undefined === true` would short-circuit the probe server-side.
|
|
60
97
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
61
|
-
return {
|
|
98
|
+
return { outcome: 'unreachable', latencyMs: null };
|
|
62
99
|
}
|
|
63
100
|
const controller = new AbortController();
|
|
64
101
|
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
65
102
|
const start = performance.now();
|
|
66
103
|
try {
|
|
104
|
+
const headers = withAuthHeaders(getAuthToken, { 'Cache-Control': 'no-cache' }, authToken);
|
|
67
105
|
const response = await fetch(url, {
|
|
68
106
|
method: 'HEAD',
|
|
69
|
-
credentials: 'include', // Send cookies for session check
|
|
70
107
|
signal: controller.signal,
|
|
71
108
|
// Cache-bust to avoid stale responses
|
|
72
|
-
headers
|
|
109
|
+
headers,
|
|
73
110
|
});
|
|
74
111
|
const latencyMs = Math.round(performance.now() - start);
|
|
75
112
|
// The probe is a HEAD (no body), but the sync-server sets `X-Auth-Failure:
|
|
76
|
-
// <code>` on every auth rejection
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// a
|
|
113
|
+
// <code>` on every auth rejection. Route the code through the closed
|
|
114
|
+
// recovery taxonomy so each failure mode gets its correct outcome — the
|
|
115
|
+
// whole reason this taxonomy exists: an expired ephemeral key
|
|
116
|
+
// (`access_credential_expiry`) must re-mint, NOT sign the user out the way
|
|
117
|
+
// a genuine login expiry (`session_expiry`) does, and NOT wedge the way a
|
|
118
|
+
// credential-type/config rejection (`auth_blocked`) does.
|
|
81
119
|
const authFailure = response.headers.get('x-auth-failure');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
if (authFailure) {
|
|
121
|
+
const recovery = classifyRecovery(authFailure);
|
|
122
|
+
switch (recovery) {
|
|
123
|
+
case 'session_expiry':
|
|
124
|
+
getContext().logger.info('[NetworkProbe] Server reachable, login expired', {
|
|
125
|
+
status: response.status,
|
|
126
|
+
code: authFailure,
|
|
127
|
+
latencyMs,
|
|
128
|
+
});
|
|
129
|
+
return { outcome: 'session_expired', latencyMs };
|
|
130
|
+
case 'access_credential_expiry':
|
|
131
|
+
getContext().logger.info('[NetworkProbe] Server reachable, access key stale — will re-mint', {
|
|
132
|
+
status: response.status,
|
|
133
|
+
code: authFailure,
|
|
134
|
+
latencyMs,
|
|
135
|
+
});
|
|
136
|
+
return { outcome: 'credential_stale', latencyMs };
|
|
137
|
+
case 'auth_blocked':
|
|
138
|
+
case 'permission':
|
|
139
|
+
case 'none':
|
|
140
|
+
// A non-expiry auth rejection — wrong credential type/config, a 403,
|
|
141
|
+
// or an auth-tagged code this SDK doesn't recognise. Re-auth re-mints
|
|
142
|
+
// the same rejected credential and retrying won't help, so STOP
|
|
143
|
+
// rather than reconnect-loop or sign the user out.
|
|
144
|
+
getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
|
|
145
|
+
status: response.status,
|
|
146
|
+
code: authFailure,
|
|
147
|
+
recovery,
|
|
148
|
+
latencyMs,
|
|
149
|
+
});
|
|
150
|
+
return { outcome: 'auth_blocked', latencyMs };
|
|
151
|
+
case 'transient':
|
|
152
|
+
// Retryable auth-tagged response — connectivity is proven; fall
|
|
153
|
+
// through to `reachable` and let the normal retry path handle it.
|
|
154
|
+
break;
|
|
155
|
+
default: {
|
|
156
|
+
const _exhaustive = recovery;
|
|
157
|
+
void _exhaustive;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
92
160
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
161
|
+
else if (response.status === 401) {
|
|
162
|
+
// Bare 401 with no READABLE structured code. This is AMBIGUOUS and must
|
|
163
|
+
// NOT sign the user out on its own — two common causes are both
|
|
164
|
+
// recoverable, and only one is a real logout:
|
|
165
|
+
// 1. The server DID send `X-Auth-Failure: apikey_expired`, but it's a
|
|
166
|
+
// custom header on a cross-origin response and the server didn't list
|
|
167
|
+
// it in `Access-Control-Expose-Headers`, so the browser stripped it to
|
|
168
|
+
// null (the network-change logout bug). The access key just needs a
|
|
169
|
+
// re-mint.
|
|
170
|
+
// 2. A genuinely expired access key on a non-Ablo proxy / cookie path.
|
|
171
|
+
// So route to `credential_stale`: the FSM attempts a re-mint, and the ONLY
|
|
172
|
+
// way to actually sign out is the re-mint resolving `null` (login truly
|
|
173
|
+
// gone). If no refresher is wired, the bounded attempt counter falls
|
|
174
|
+
// through to `auth_blocked` (stop) — still never a spurious logout. This
|
|
175
|
+
// upholds the invariant: null is the only terminal path, never a bare 401.
|
|
176
|
+
getContext().logger.info('[NetworkProbe] Server reachable, bare 401 — re-mint (not sign-out)', {
|
|
101
177
|
latencyMs,
|
|
102
178
|
});
|
|
103
|
-
return {
|
|
179
|
+
return { outcome: 'credential_stale', latencyMs };
|
|
104
180
|
}
|
|
105
|
-
// 2xx (including 204) means reachable +
|
|
181
|
+
// 2xx (including 204) means reachable + credential valid.
|
|
106
182
|
// 3xx/4xx (non-auth) still prove connectivity even though the probe
|
|
107
183
|
// expected 204; log a warning so misconfigurations surface instead of
|
|
108
184
|
// silently passing.
|
|
@@ -114,12 +190,12 @@ export async function probeNetwork(baseUrl) {
|
|
|
114
190
|
});
|
|
115
191
|
}
|
|
116
192
|
else {
|
|
117
|
-
getContext().logger.debug('[NetworkProbe] Server reachable,
|
|
193
|
+
getContext().logger.debug('[NetworkProbe] Server reachable, credential valid', {
|
|
118
194
|
status: response.status,
|
|
119
195
|
latencyMs,
|
|
120
196
|
});
|
|
121
197
|
}
|
|
122
|
-
return {
|
|
198
|
+
return { outcome: 'reachable', latencyMs };
|
|
123
199
|
}
|
|
124
200
|
catch (error) {
|
|
125
201
|
clearTimeout(timeout);
|
|
@@ -127,7 +203,7 @@ export async function probeNetwork(baseUrl) {
|
|
|
127
203
|
getContext().logger.info('[NetworkProbe] Probe failed', {
|
|
128
204
|
reason: isAbort ? 'timeout' : error.message,
|
|
129
205
|
});
|
|
130
|
-
return {
|
|
206
|
+
return { outcome: 'unreachable', latencyMs: null };
|
|
131
207
|
}
|
|
132
208
|
finally {
|
|
133
209
|
clearTimeout(timeout);
|
|
@@ -8,40 +8,18 @@
|
|
|
8
8
|
* - Automatic reconnection with exponential backoff
|
|
9
9
|
*/
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
|
-
|
|
12
|
-
type
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* V — Unarchive (reVive)
|
|
24
|
-
*
|
|
25
|
-
* Permission / access control:
|
|
26
|
-
* C — Covering: client gained permission to see an existing entity
|
|
27
|
-
* (treated as insert by the client — see handleCovering path).
|
|
28
|
-
* G — GroupAdded: recipient was added to a sync group. Paired with
|
|
29
|
-
* subsequent 'C' deltas for each newly-visible entity.
|
|
30
|
-
* S — GroupRemoved: recipient lost access to a sync group. Client
|
|
31
|
-
* purges affected entities from its local store.
|
|
32
|
-
*/
|
|
33
|
-
actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S';
|
|
34
|
-
modelName: string;
|
|
35
|
-
modelId: string;
|
|
36
|
-
data: SyncDeltaPayload;
|
|
37
|
-
previousData?: SyncDeltaPayload;
|
|
38
|
-
metadata?: SyncDeltaPayload;
|
|
39
|
-
syncGroups: string[];
|
|
40
|
-
createdBy?: string;
|
|
41
|
-
transactionId?: string;
|
|
42
|
-
clientMutationId?: string;
|
|
43
|
-
createdAt: string;
|
|
44
|
-
}
|
|
11
|
+
import type { MutationOperation } from '../interfaces/index.js';
|
|
12
|
+
import type { ClientSyncDelta } from '../schema/sync-delta-wire.js';
|
|
13
|
+
import { type AuthTokenGetter } from '../auth/credentialSource.js';
|
|
14
|
+
/**
|
|
15
|
+
* The wire delta the client receives. Derived from the canonical
|
|
16
|
+
* `clientSyncDeltaSchema` (`@abloatai/ablo/schema`) via `z.infer` so the
|
|
17
|
+
* SDK and the sync-server share ONE contract instead of two hand-maintained
|
|
18
|
+
* interfaces. The action vocabulary (`I`/`U`/`D`/`A`/`V`/`C`/`G`/`S`) and the
|
|
19
|
+
* client-only extras (`metadata`, `clientMutationId`, deprecated flat
|
|
20
|
+
* `createdBy`) live in that schema; see its doc for the full field reference.
|
|
21
|
+
*/
|
|
22
|
+
export type SyncDelta = ClientSyncDelta;
|
|
45
23
|
/**
|
|
46
24
|
* Payload for legacy actionType 'G' deltas emitted by EmitGroupChange.
|
|
47
25
|
* Carries both added and removed groups in one delta, forces full re-bootstrap.
|
|
@@ -121,6 +99,15 @@ export interface SyncWebSocketOptions {
|
|
|
121
99
|
* the Biscuit→opaque-key migration.)
|
|
122
100
|
*/
|
|
123
101
|
capabilityToken?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Shared credential getter. When provided, WebSocket URL auth reads this
|
|
104
|
+
* instead of a copied `capabilityToken`, so reconnects use refreshed tokens
|
|
105
|
+
* from the SDK's single auth source.
|
|
106
|
+
*/
|
|
107
|
+
/** Shared SDK auth getter. Preferred internal name. */
|
|
108
|
+
getAuthToken?: AuthTokenGetter;
|
|
109
|
+
/** @deprecated Use `getAuthToken`. Kept for direct low-level callers. */
|
|
110
|
+
getCapabilityToken?: AuthTokenGetter;
|
|
124
111
|
}
|
|
125
112
|
/**
|
|
126
113
|
* Bootstrap hint from server indicating full or partial bootstrap is needed.
|
|
@@ -271,7 +258,7 @@ export interface CoreSyncEventMap {
|
|
|
271
258
|
/**
|
|
272
259
|
* Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
|
|
273
260
|
* entry `status: 'queued'` + `position`. Broadcast to entity peers on every
|
|
274
|
-
* queue mutation — powers the reactive `ablo.<model>.claim.queue(id)` read.
|
|
261
|
+
* queue mutation — powers the reactive `ablo.<model>.claim.queue({ id })` read.
|
|
275
262
|
*/
|
|
276
263
|
intent_queue: [Record<string, unknown>];
|
|
277
264
|
intent_acquired: [Record<string, unknown>];
|
|
@@ -422,6 +409,16 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
422
409
|
* Send message to server
|
|
423
410
|
*/
|
|
424
411
|
send(message: any): void;
|
|
412
|
+
/**
|
|
413
|
+
* Project the SDK's `MutationOperation[]` onto the canonical wire
|
|
414
|
+
* `CommitMessage`. This is the single serialize boundary between the SDK op
|
|
415
|
+
* type (loose `type: string`, plus an SDK-internal `options` the server never
|
|
416
|
+
* reads) and the strict wire contract. The per-field map gives compile-time
|
|
417
|
+
* drift detection (a `CommitOperation` shape change breaks here) and the lone
|
|
418
|
+
* `as` narrows the validated op `type` to the wire union — the only
|
|
419
|
+
* loosening, localized to this boundary.
|
|
420
|
+
*/
|
|
421
|
+
private buildCommitFrame;
|
|
425
422
|
/**
|
|
426
423
|
* Send a `commit` mutation request over the existing WebSocket and
|
|
427
424
|
* resolve when the server's `mutation_result` frame comes back with
|
|
@@ -441,24 +438,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
441
438
|
* NOT auto-retry here — the caller's TransactionQueue owns retry +
|
|
442
439
|
* offline replay semantics and the SDK shouldn't duplicate that logic.
|
|
443
440
|
*/
|
|
444
|
-
sendCommit(operations: ReadonlyArray<{
|
|
445
|
-
type: string;
|
|
446
|
-
model: string;
|
|
447
|
-
id: string;
|
|
448
|
-
input?: Record<string, unknown>;
|
|
449
|
-
/**
|
|
450
|
-
* Per-op client transaction id. The server stamps this onto
|
|
451
|
-
* `sync_deltas.transaction_id` so the originating client
|
|
452
|
-
* recognizes the broadcast as an echo of its own optimistic
|
|
453
|
-
* mutation (echo detection in `SyncClient.applyDeltaBatchToPool`).
|
|
454
|
-
* Distinct from the batch-level `clientTxId` argument below
|
|
455
|
-
* (which keys `mutation_log` for retry idempotency). See
|
|
456
|
-
* `apps/sync-server/docs/OPTIMISTIC_RECONCILIATION.md`.
|
|
457
|
-
*/
|
|
458
|
-
transactionId?: string;
|
|
459
|
-
readAt?: number | null;
|
|
460
|
-
onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
|
|
461
|
-
}>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null): Promise<{
|
|
441
|
+
sendCommit(operations: ReadonlyArray<MutationOperation>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null): Promise<{
|
|
462
442
|
lastSyncId: number;
|
|
463
443
|
}>;
|
|
464
444
|
/**
|
|
@@ -469,15 +449,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
469
449
|
* eventual `mutation_result` frame is intentionally ignored by this
|
|
470
450
|
* instance because no pending resolver is registered.
|
|
471
451
|
*/
|
|
472
|
-
sendCommitQueued(operations: ReadonlyArray<
|
|
473
|
-
type: string;
|
|
474
|
-
model: string;
|
|
475
|
-
id: string;
|
|
476
|
-
input?: Record<string, unknown>;
|
|
477
|
-
transactionId?: string;
|
|
478
|
-
readAt?: number | null;
|
|
479
|
-
onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
|
|
480
|
-
}>, clientTxId: string, causedByTaskId?: string | null): void;
|
|
452
|
+
sendCommitQueued(operations: ReadonlyArray<MutationOperation>, clientTxId: string, causedByTaskId?: string | null): void;
|
|
481
453
|
/**
|
|
482
454
|
* Activate a participant claim on this connection. Multiplexed
|
|
483
455
|
* subscription pattern (Phoenix Channels / Pusher) — the same
|
|
@@ -514,15 +486,19 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
514
486
|
*/
|
|
515
487
|
sendRelease(claimId: string): void;
|
|
516
488
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* connections alive past cap expiry until they decide to close, and
|
|
521
|
-
* a forced reconnect would interrupt in-flight deltas. The cap-mint
|
|
522
|
-
* scheduler in `Ablo.ts` calls this on each successful refresh so
|
|
523
|
-
* reconnects after server-initiated close pick up the fresh token.
|
|
489
|
+
* Compatibility setter for direct SyncWebSocket users. The SDK-owned
|
|
490
|
+
* `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
|
|
491
|
+
* shared credential source instead of this copied value.
|
|
524
492
|
*/
|
|
525
493
|
setCapabilityToken(token: string): void;
|
|
494
|
+
getAuthToken(): string | undefined;
|
|
495
|
+
/**
|
|
496
|
+
* Return the credential that will be used by the next WebSocket upgrade.
|
|
497
|
+
* ConnectionManager reads this for HTTP auth probes so visibility/network
|
|
498
|
+
* checks authenticate the same way reconnects do.
|
|
499
|
+
*/
|
|
500
|
+
getCapabilityToken(): string | undefined;
|
|
501
|
+
private resolveAuthToken;
|
|
526
502
|
/**
|
|
527
503
|
* Send spreadsheet selection presence
|
|
528
504
|
*/
|
|
@@ -692,4 +668,3 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
692
668
|
*/
|
|
693
669
|
private handlePresenceUpdate;
|
|
694
670
|
}
|
|
695
|
-
export {};
|
|
@@ -11,6 +11,7 @@ import { EventEmitter } from 'events';
|
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
13
|
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
|
+
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
16
17
|
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
@@ -170,16 +171,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
170
171
|
}
|
|
171
172
|
this.isConnecting = true;
|
|
172
173
|
this.isManualClose = false;
|
|
173
|
-
// Pattern: one credential, server-resolved identity. The
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// agentTokenProvider → betterAuthProvider`) resolves identity
|
|
178
|
-
// from the verified credential — userId/organizationId are
|
|
179
|
-
// NEVER read from URL params in production. See
|
|
180
|
-
// `apps/sync-server/src/auth/provider.ts:148` (betterAuthProvider
|
|
181
|
-
// calls `auth.api.getSession({headers})`) and `agentTokenProvider`
|
|
182
|
-
// for the cap-token path.
|
|
174
|
+
// Pattern: one credential, server-resolved identity. The bearer travels
|
|
175
|
+
// in a `Sec-WebSocket-Protocol` value (built below), NOT the URL. The
|
|
176
|
+
// server is bearer-only (`apiKeyProvider`) and resolves identity from the
|
|
177
|
+
// verified token — userId/organizationId are NEVER read from URL params.
|
|
183
178
|
const params = new URLSearchParams({
|
|
184
179
|
// Intentionally omit lastSyncId, versions, capabilities from URL; these are sent in sync_request
|
|
185
180
|
// and ack messages to avoid stale baselines on reconnect.
|
|
@@ -191,22 +186,28 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
191
186
|
if (this.options.kind && this.options.kind !== 'user') {
|
|
192
187
|
params.set('kind', this.options.kind);
|
|
193
188
|
}
|
|
194
|
-
// Capability bearer (query-param form so it works in both Node's
|
|
195
|
-
// global WebSocket — which can't set headers — and browsers).
|
|
196
|
-
if (this.options.capabilityToken) {
|
|
197
|
-
params.set('authorization', `Bearer ${this.options.capabilityToken}`);
|
|
198
|
-
}
|
|
199
189
|
// Add sync groups if provided
|
|
200
190
|
this.options.syncGroups.forEach((group) => {
|
|
201
191
|
params.append('syncGroups', group);
|
|
202
192
|
});
|
|
203
193
|
const wsUrl = `${this.options.url}?${params.toString()}`;
|
|
194
|
+
// Carry the bearer in a `Sec-WebSocket-Protocol` value, NOT the URL. A
|
|
195
|
+
// browser can't set an Authorization header on a WS, but it CAN offer
|
|
196
|
+
// subprotocols — and unlike the query string, those don't land in ALB
|
|
197
|
+
// access logs, proxies, or browser history. The server reads
|
|
198
|
+
// `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol,
|
|
199
|
+
// never echoing the token-bearing value back. (Token is the raw ek_/rk_,
|
|
200
|
+
// which is subprotocol-token-safe — alphanumerics + `_`.)
|
|
201
|
+
const authToken = this.resolveAuthToken();
|
|
202
|
+
const protocols = authToken
|
|
203
|
+
? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
|
|
204
|
+
: [WS_SYNC_SUBPROTOCOL];
|
|
204
205
|
try {
|
|
205
206
|
// Reset the handshake flag before wiring the new socket. Each connect()
|
|
206
207
|
// gets its own lifecycle — a prior successful open on a previous socket
|
|
207
208
|
// must not mask a handshake failure on the new one.
|
|
208
209
|
this._everOpened = false;
|
|
209
|
-
this.ws = new WebSocket(wsUrl);
|
|
210
|
+
this.ws = new WebSocket(wsUrl, protocols);
|
|
210
211
|
this.setupEventHandlers();
|
|
211
212
|
}
|
|
212
213
|
catch (error) {
|
|
@@ -303,11 +304,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
303
304
|
this.handlePresenceUpdate(message);
|
|
304
305
|
break;
|
|
305
306
|
case 'mutation_result': {
|
|
306
|
-
// Ack for a prior `commit` we sent.
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// lastSyncId?, error? } }
|
|
307
|
+
// Ack for a prior `commit` we sent. Canonical shape is
|
|
308
|
+
// `MutationResultMessage` in `@abloatai/ablo/wire`. This stays a
|
|
309
|
+
// DEFENSIVE parse (not a typed cast) because the payload is
|
|
310
|
+
// untrusted wire data that may be malformed or from an older server.
|
|
311
311
|
const p = message.payload ?? message;
|
|
312
312
|
const { clientTxId, success, lastSyncId, error } = p ?? {};
|
|
313
313
|
const pending = typeof clientTxId === 'string'
|
|
@@ -720,6 +720,32 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
720
720
|
}
|
|
721
721
|
}
|
|
722
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Project the SDK's `MutationOperation[]` onto the canonical wire
|
|
725
|
+
* `CommitMessage`. This is the single serialize boundary between the SDK op
|
|
726
|
+
* type (loose `type: string`, plus an SDK-internal `options` the server never
|
|
727
|
+
* reads) and the strict wire contract. The per-field map gives compile-time
|
|
728
|
+
* drift detection (a `CommitOperation` shape change breaks here) and the lone
|
|
729
|
+
* `as` narrows the validated op `type` to the wire union — the only
|
|
730
|
+
* loosening, localized to this boundary.
|
|
731
|
+
*/
|
|
732
|
+
buildCommitFrame(operations, clientTxId, causedByTaskId) {
|
|
733
|
+
const payload = {
|
|
734
|
+
operations: operations.map((op) => ({
|
|
735
|
+
type: op.type,
|
|
736
|
+
model: op.model,
|
|
737
|
+
id: op.id,
|
|
738
|
+
input: op.input,
|
|
739
|
+
transactionId: op.transactionId,
|
|
740
|
+
readAt: op.readAt,
|
|
741
|
+
onStale: op.onStale,
|
|
742
|
+
})),
|
|
743
|
+
clientTxId,
|
|
744
|
+
};
|
|
745
|
+
if (causedByTaskId)
|
|
746
|
+
payload.causedByTaskId = causedByTaskId;
|
|
747
|
+
return { type: 'commit', payload };
|
|
748
|
+
}
|
|
723
749
|
/**
|
|
724
750
|
* Send a `commit` mutation request over the existing WebSocket and
|
|
725
751
|
* resolve when the server's `mutation_result` frame comes back with
|
|
@@ -754,10 +780,8 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
754
780
|
// an open turn — keeps the wire shape stable for sessions
|
|
755
781
|
// that don't use turns. Servers that don't know the field
|
|
756
782
|
// ignore it; newer servers stamp it onto every delta.
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
payload.causedByTaskId = causedByTaskId;
|
|
760
|
-
this.ws.send(JSON.stringify({ type: 'commit', payload }));
|
|
783
|
+
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
|
|
784
|
+
this.ws.send(JSON.stringify(frame));
|
|
761
785
|
}
|
|
762
786
|
catch (error) {
|
|
763
787
|
clearTimeout(timeout);
|
|
@@ -778,10 +802,8 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
778
802
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
779
803
|
throw this.notConnectedError('commit');
|
|
780
804
|
}
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
payload.causedByTaskId = causedByTaskId;
|
|
784
|
-
this.ws.send(JSON.stringify({ type: 'commit', payload }));
|
|
805
|
+
const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
|
|
806
|
+
this.ws.send(JSON.stringify(frame));
|
|
785
807
|
}
|
|
786
808
|
/**
|
|
787
809
|
* Activate a participant claim on this connection. Multiplexed
|
|
@@ -863,17 +885,29 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
863
885
|
}
|
|
864
886
|
}
|
|
865
887
|
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
* connections alive past cap expiry until they decide to close, and
|
|
870
|
-
* a forced reconnect would interrupt in-flight deltas. The cap-mint
|
|
871
|
-
* scheduler in `Ablo.ts` calls this on each successful refresh so
|
|
872
|
-
* reconnects after server-initiated close pick up the fresh token.
|
|
888
|
+
* Compatibility setter for direct SyncWebSocket users. The SDK-owned
|
|
889
|
+
* `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
|
|
890
|
+
* shared credential source instead of this copied value.
|
|
873
891
|
*/
|
|
874
892
|
setCapabilityToken(token) {
|
|
875
893
|
this.options.capabilityToken = token;
|
|
876
894
|
}
|
|
895
|
+
getAuthToken() {
|
|
896
|
+
return this.resolveAuthToken();
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Return the credential that will be used by the next WebSocket upgrade.
|
|
900
|
+
* ConnectionManager reads this for HTTP auth probes so visibility/network
|
|
901
|
+
* checks authenticate the same way reconnects do.
|
|
902
|
+
*/
|
|
903
|
+
getCapabilityToken() {
|
|
904
|
+
return this.resolveAuthToken();
|
|
905
|
+
}
|
|
906
|
+
resolveAuthToken = () => {
|
|
907
|
+
return this.options.getAuthToken?.()
|
|
908
|
+
?? this.options.getCapabilityToken?.()
|
|
909
|
+
?? this.options.capabilityToken;
|
|
910
|
+
};
|
|
877
911
|
/**
|
|
878
912
|
* Send spreadsheet selection presence
|
|
879
913
|
*/
|
|
@@ -93,6 +93,9 @@ export function createIntentStream(config, transport = null) {
|
|
|
93
93
|
// `settled()`. Absent status means active (wire back-compat).
|
|
94
94
|
if (claim.status && claim.status !== 'active')
|
|
95
95
|
continue;
|
|
96
|
+
const description = typeof claim.meta?.description === 'string'
|
|
97
|
+
? claim.meta.description
|
|
98
|
+
: undefined;
|
|
96
99
|
activeByIntentId.set(claim.intentId, {
|
|
97
100
|
id: claim.intentId,
|
|
98
101
|
heldBy: event.userId,
|
|
@@ -106,6 +109,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
106
109
|
meta: claim.meta,
|
|
107
110
|
},
|
|
108
111
|
reason: claim.action,
|
|
112
|
+
...(description ? { description } : {}),
|
|
109
113
|
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
110
114
|
announcedAt: new Date(claim.declaredAt).toISOString(),
|
|
111
115
|
expiresAt: new Date(claim.expiresAt).toISOString(),
|
|
@@ -227,6 +231,11 @@ export function createIntentStream(config, transport = null) {
|
|
|
227
231
|
},
|
|
228
232
|
});
|
|
229
233
|
}
|
|
234
|
+
function withDescription(meta, description) {
|
|
235
|
+
if (!description)
|
|
236
|
+
return meta;
|
|
237
|
+
return { ...(meta ?? {}), description };
|
|
238
|
+
}
|
|
230
239
|
function mintHandle(args) {
|
|
231
240
|
const intentId = crypto.randomUUID();
|
|
232
241
|
const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
|
|
@@ -273,7 +282,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
273
282
|
path: resolved.path,
|
|
274
283
|
range: resolved.range,
|
|
275
284
|
field: resolved.field,
|
|
276
|
-
meta: resolved.meta,
|
|
285
|
+
meta: withDescription(resolved.meta, opts?.description),
|
|
277
286
|
action: opts?.reason ?? 'editing',
|
|
278
287
|
ttl: opts?.ttl,
|
|
279
288
|
queue: opts?.queue,
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -340,6 +340,12 @@ export interface ClaimOptions extends IntentOptions {
|
|
|
340
340
|
* app-specific phases.
|
|
341
341
|
*/
|
|
342
342
|
readonly reason?: string;
|
|
343
|
+
/**
|
|
344
|
+
* Peer-visible explanation of the exact work being performed. This is more
|
|
345
|
+
* specific than `reason`: `reason` is the phase (`'renaming'`), while
|
|
346
|
+
* `description` is the instruction other agents should see.
|
|
347
|
+
*/
|
|
348
|
+
readonly description?: string;
|
|
343
349
|
/**
|
|
344
350
|
* Join the server's fair FIFO queue on contention instead of being
|
|
345
351
|
* rejected. The grant arrives asynchronously (`intent_acquired` if the
|
|
@@ -528,6 +534,7 @@ export interface ActiveIntent extends IntentDeclaration {
|
|
|
528
534
|
* from "user editing X" without string-parsing `heldBy`.
|
|
529
535
|
*/
|
|
530
536
|
readonly participantKind: 'human' | 'agent';
|
|
537
|
+
readonly description?: string;
|
|
531
538
|
readonly announcedAt: string;
|
|
532
539
|
readonly expiresAt: string;
|
|
533
540
|
}
|
|
@@ -565,6 +572,8 @@ export interface Intent {
|
|
|
565
572
|
readonly target: EntityRef;
|
|
566
573
|
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. */
|
|
567
574
|
readonly action: string;
|
|
575
|
+
/** Peer-visible explanation of the work being performed. */
|
|
576
|
+
readonly description?: string;
|
|
568
577
|
/** Participant holding it. */
|
|
569
578
|
readonly heldBy: string;
|
|
570
579
|
readonly participantKind: 'human' | 'agent';
|
package/dist/utils/mobx-setup.js
CHANGED
|
@@ -146,6 +146,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
|
|
|
146
146
|
'markAsPersisted',
|
|
147
147
|
'clearChanges',
|
|
148
148
|
'updateFromData',
|
|
149
|
+
'applyChanges',
|
|
149
150
|
];
|
|
150
151
|
for (const methodName of actionMethods) {
|
|
151
152
|
if (typeof Reflect.get(target, methodName) === 'function') {
|