@abloatai/ablo 0.7.0 → 0.9.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 +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- 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 +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- 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 +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- 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 +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -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 +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- 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/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- 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 +7 -3
- package/dist/schema/serialize.js +6 -2
- 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 +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -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 +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -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 +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -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-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
package/dist/client/Ablo.js
CHANGED
|
@@ -11,17 +11,22 @@
|
|
|
11
11
|
* const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
12
12
|
*
|
|
13
13
|
* const reports = sync.reports.list({ where: { status: 'todo' } });
|
|
14
|
-
* await sync.reports.create({ title: 'Fix bug' });
|
|
15
|
-
* await sync.reports.update(
|
|
16
|
-
*
|
|
14
|
+
* await sync.reports.create({ data: { title: 'Fix bug' } });
|
|
15
|
+
* await sync.reports.update({
|
|
16
|
+
* id: reportId,
|
|
17
|
+
* data: { status: 'ready' },
|
|
18
|
+
* });
|
|
19
|
+
* await sync.reports.delete({ id: reportId });
|
|
17
20
|
*/
|
|
18
21
|
import { z } from 'zod';
|
|
19
|
-
import { AbloClaimedError, AbloError, AbloConnectionError, AbloValidationError, translateHttpError } from '../errors.js';
|
|
22
|
+
import { AbloClaimedError, AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, hasWireCode, toAbloError } from '../errors.js';
|
|
20
23
|
import { LoadStrategy, PropertyType } from '../types/index.js';
|
|
21
24
|
import { initSyncEngine } from '../context.js';
|
|
22
25
|
import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
|
|
23
26
|
import { alwaysOnline } from '../adapters/alwaysOnline.js';
|
|
24
27
|
import { validateAbloOptions } from './validateAbloOptions.js';
|
|
28
|
+
import { exchangeApiKey } from '../auth/index.js';
|
|
29
|
+
import { createAuthCredentialSource } from '../auth/credentialSource.js';
|
|
25
30
|
import { createInternalComponents } from './createInternalComponents.js';
|
|
26
31
|
import { resolveParticipantIdentity } from './identity.js';
|
|
27
32
|
import { Model } from '../Model.js';
|
|
@@ -32,7 +37,8 @@ import { awaitIntentGrant } from '../sync/awaitIntentGrant.js';
|
|
|
32
37
|
import { createSnapshot } from '../sync/createSnapshot.js';
|
|
33
38
|
import { createParticipantManager } from '../sync/participants.js';
|
|
34
39
|
import { createProtocolClient, } from './ApiClient.js';
|
|
35
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveAuthToken, resolveBaseURL, } from './auth.js';
|
|
40
|
+
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
|
|
41
|
+
import { registerDataSource } from './registerDataSource.js';
|
|
36
42
|
import { shouldUseInMemoryPersistence, } from './persistence.js';
|
|
37
43
|
import { createModelProxy } from './createModelProxy.js';
|
|
38
44
|
// ── Config derivation from schema ─────────────────────────────────────────
|
|
@@ -644,6 +650,37 @@ function createDefaultMutationDispatcher(executor) {
|
|
|
644
650
|
},
|
|
645
651
|
};
|
|
646
652
|
}
|
|
653
|
+
// ── Auth normalization ─────────────────────────────────────────────────────
|
|
654
|
+
/**
|
|
655
|
+
* The one resolver the credential lifecycle needs: an async `() => token | null`,
|
|
656
|
+
* or `null` when auth is static (a plain long-lived `apiKey` with no refresh —
|
|
657
|
+
* the common case). Only the short-lived per-user path sets this, via `getToken`
|
|
658
|
+
* (the primitive) or `authEndpoint` (sugar that POSTs for `{ token }`).
|
|
659
|
+
*/
|
|
660
|
+
function resolveCredentialResolver(options) {
|
|
661
|
+
if (options.getToken)
|
|
662
|
+
return options.getToken;
|
|
663
|
+
if (options.authEndpoint) {
|
|
664
|
+
const endpoint = options.authEndpoint;
|
|
665
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
666
|
+
return async () => {
|
|
667
|
+
// The endpoint lives on the consumer's OWN backend and is authed by the
|
|
668
|
+
// user's session cookie (hence `credentials: 'include'`); it returns the
|
|
669
|
+
// `ek_` to carry to the sync-server. A non-OK response is terminal
|
|
670
|
+
// (`null` → sign out), matching the `getToken` contract.
|
|
671
|
+
const res = await fetchImpl(endpoint, {
|
|
672
|
+
method: 'POST',
|
|
673
|
+
credentials: 'include',
|
|
674
|
+
headers: { 'Content-Type': 'application/json' },
|
|
675
|
+
});
|
|
676
|
+
if (!res.ok)
|
|
677
|
+
return null;
|
|
678
|
+
const body = (await res.json());
|
|
679
|
+
return body.token ?? null;
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
647
684
|
export function Ablo(options) {
|
|
648
685
|
if (options.schema == null) {
|
|
649
686
|
return createProtocolClient(options);
|
|
@@ -653,8 +690,16 @@ export function Ablo(options) {
|
|
|
653
690
|
const authInput = { options, env };
|
|
654
691
|
const configuredApiKey = resolveApiKey(authInput);
|
|
655
692
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
693
|
+
// The client OWNS its credential lifecycle (not the React layer): this resolver
|
|
694
|
+
// drives both the reactive re-mint (FSM `credential_stale`) and the proactive
|
|
695
|
+
// refresh timer + wake/online/focus triggers. Null for the common static
|
|
696
|
+
// `apiKey` path — no refresh needed.
|
|
697
|
+
const credentialResolver = resolveCredentialResolver(options);
|
|
698
|
+
const authCredentials = createAuthCredentialSource(internalOptions.capabilityToken ?? configuredAuthToken);
|
|
699
|
+
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
656
700
|
assertBrowserSafety({
|
|
657
701
|
apiKey: configuredApiKey,
|
|
702
|
+
databaseUrl: configuredDatabaseUrl,
|
|
658
703
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
659
704
|
});
|
|
660
705
|
const { logger = consoleLogger } = internalOptions;
|
|
@@ -720,7 +765,12 @@ export function Ablo(options) {
|
|
|
720
765
|
// the schema-to-Model-class translation depends on private
|
|
721
766
|
// helpers (`createDynamicModelClass`, `unwrapZodType`, etc.)
|
|
722
767
|
// that aren't worth pulling into the components module.
|
|
723
|
-
const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({
|
|
768
|
+
const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({
|
|
769
|
+
schema,
|
|
770
|
+
url,
|
|
771
|
+
options: internalOptions,
|
|
772
|
+
auth: authCredentials,
|
|
773
|
+
});
|
|
724
774
|
registerModelsFromSchema(schema, modelRegistry);
|
|
725
775
|
// 5. BaseSyncedStore handles the initialization orchestration
|
|
726
776
|
// (open DB → hydrate IDB → connect WS → fetch bootstrap → hydrate again →
|
|
@@ -738,7 +788,15 @@ export function Ablo(options) {
|
|
|
738
788
|
modelRegistry,
|
|
739
789
|
schema,
|
|
740
790
|
url,
|
|
791
|
+
auth: authCredentials,
|
|
741
792
|
});
|
|
793
|
+
// Hand the credential lifecycle to the client (refresher + proactive refresh
|
|
794
|
+
// timer + wake/online/focus re-mint). Installed once here so refresh works for
|
|
795
|
+
// ANY consumer of `Ablo({ auth })` — not only those who render `<AbloProvider>`.
|
|
796
|
+
// The first mint happens in `ready()` so the first connection carries a token.
|
|
797
|
+
if (credentialResolver) {
|
|
798
|
+
store.startCredentialLifecycle(credentialResolver);
|
|
799
|
+
}
|
|
742
800
|
// Wire the store back into the default executor's lazy getter (see
|
|
743
801
|
// `storeHolder` above). The executor was constructed before the store
|
|
744
802
|
// existed; this late binding closes the loop so commits dispatch over
|
|
@@ -808,16 +866,6 @@ export function Ablo(options) {
|
|
|
808
866
|
// source of truth. No duplicate closure variables.
|
|
809
867
|
let _readyPromise = null;
|
|
810
868
|
let _refreshScheduler = null;
|
|
811
|
-
let currentCapabilityToken = internalOptions.capabilityToken ?? configuredAuthToken ?? undefined;
|
|
812
|
-
// Wire the cap token into HydrationCoordinator's HTTP path. Without
|
|
813
|
-
// this, `ablo.<model>.load(...)` / `ablo.<model>.retrieve(...)` go
|
|
814
|
-
// through `postQuery` with `credentials: 'include'` only — fine in
|
|
815
|
-
// browsers (session cookies), but Node consumers (agent-worker)
|
|
816
|
-
// have no cookies and the request lands with no credential at all.
|
|
817
|
-
// The WS path was already wired (token rides the upgrade URL); this
|
|
818
|
-
// closes the gap on HTTP. Closure-over-binding so cap rotation
|
|
819
|
-
// (`applyRotatedToken` in the refresh scheduler below) propagates.
|
|
820
|
-
hydration.setCapabilityTokenProvider(() => currentCapabilityToken ?? null);
|
|
821
869
|
async function ready() {
|
|
822
870
|
if (_readyPromise)
|
|
823
871
|
return _readyPromise;
|
|
@@ -827,6 +875,33 @@ export function Ablo(options) {
|
|
|
827
875
|
}
|
|
828
876
|
_readyPromise = (async () => {
|
|
829
877
|
try {
|
|
878
|
+
// Mint the FIRST access credential before we connect, so the initial
|
|
879
|
+
// WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
|
|
880
|
+
// connect that has to self-heal). Only when a refreshing resolver is
|
|
881
|
+
// wired AND no static credential is already present. Contract mirrors
|
|
882
|
+
// `getToken`: `null` ⇒ the login is gone (terminal — fail ready so the
|
|
883
|
+
// app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
|
|
884
|
+
// and the lifecycle's online/wake triggers retry).
|
|
885
|
+
if (credentialResolver && !authCredentials.getAuthToken()) {
|
|
886
|
+
const token = await credentialResolver();
|
|
887
|
+
if (!token) {
|
|
888
|
+
throw new AbloAuthenticationError('Auth resolver returned null before connect — the user is not signed in.', { code: 'auth_no_credentials' });
|
|
889
|
+
}
|
|
890
|
+
authCredentials.setAuthToken(token);
|
|
891
|
+
}
|
|
892
|
+
// Register the caller's own database for write-back BEFORE bootstrap, so
|
|
893
|
+
// the server resolves this org's data plane to the customer's DB rather
|
|
894
|
+
// than serving an empty/wrong store. The org is derived server-side from
|
|
895
|
+
// the API key. Idempotent server-side (register-or-update). Skipped when
|
|
896
|
+
// no `databaseUrl` was configured (Ablo-managed storage).
|
|
897
|
+
if (configuredDatabaseUrl) {
|
|
898
|
+
await registerDataSource({
|
|
899
|
+
baseUrl: resolveBootstrapBaseUrl({ url }),
|
|
900
|
+
apiKey: await resolveApiKeyValue(configuredApiKey),
|
|
901
|
+
databaseUrl: configuredDatabaseUrl,
|
|
902
|
+
...(internalOptions.fetch ? { fetchImpl: internalOptions.fetch } : {}),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
830
905
|
// Resolve participant identity + scope. Three branches —
|
|
831
906
|
// hosted-cloud apiKey exchange, self-derived from capability
|
|
832
907
|
// token, or legacy explicit options. See `./identity.ts`.
|
|
@@ -836,15 +911,18 @@ export function Ablo(options) {
|
|
|
836
911
|
url,
|
|
837
912
|
kind,
|
|
838
913
|
configuredApiKey,
|
|
839
|
-
|
|
914
|
+
// Resolve identity against the LIVE token, not the construction-time
|
|
915
|
+
// `configuredAuthToken`. Consumers using `getToken` (apps/web) never
|
|
916
|
+
// pass `authToken` at construction — they call `setAuthToken()` before
|
|
917
|
+
// `ready()`, which updates the shared credential source. Reading the frozen
|
|
918
|
+
// `configuredAuthToken` here made `/auth/identity` fire with no Bearer
|
|
919
|
+
// (→ `no_matching_provider` / `session_expired`) even though the JWT
|
|
920
|
+
// was present. Mirrors every other transport by reading the shared
|
|
921
|
+
// credential source.
|
|
922
|
+
configuredAuthToken: authCredentials.getAuthToken() ?? configuredAuthToken,
|
|
840
923
|
bootstrapHelper,
|
|
924
|
+
auth: authCredentials,
|
|
841
925
|
logger,
|
|
842
|
-
applyRotatedToken: (token) => {
|
|
843
|
-
currentCapabilityToken = token;
|
|
844
|
-
bootstrapHelper.setAuthToken(token);
|
|
845
|
-
const ws = store.getSyncWebSocket();
|
|
846
|
-
ws?.setCapabilityToken(token);
|
|
847
|
-
},
|
|
848
926
|
});
|
|
849
927
|
const { userId, accountScope, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
|
|
850
928
|
// Fail-loud guard: detect the degenerate "no real sync groups
|
|
@@ -870,8 +948,6 @@ export function Ablo(options) {
|
|
|
870
948
|
'`["org:${orgId}", "user:${userId}"]`) or verify your auth ' +
|
|
871
949
|
'provider populates them. See packages/sync-engine/src/client/identity.ts.', { participantKind, resolvedSyncGroups });
|
|
872
950
|
}
|
|
873
|
-
currentCapabilityToken = capabilityToken;
|
|
874
|
-
bootstrapHelper.setAuthToken(capabilityToken);
|
|
875
951
|
if (resolved.refreshScheduler) {
|
|
876
952
|
_refreshScheduler = resolved.refreshScheduler;
|
|
877
953
|
}
|
|
@@ -902,7 +978,11 @@ export function Ablo(options) {
|
|
|
902
978
|
}
|
|
903
979
|
const result = current.value;
|
|
904
980
|
if (!result.success) {
|
|
905
|
-
throw result.error
|
|
981
|
+
throw result.error
|
|
982
|
+
? toAbloError(result.error)
|
|
983
|
+
: new AbloConnectionError('Sync engine initialization failed', {
|
|
984
|
+
code: 'bootstrap_fetch_timeout',
|
|
985
|
+
});
|
|
906
986
|
}
|
|
907
987
|
// Wire presence + intents to the now-open transport.
|
|
908
988
|
// `getSyncWebSocket()` returns non-null after a successful
|
|
@@ -916,11 +996,33 @@ export function Ablo(options) {
|
|
|
916
996
|
logger.info('Sync engine ready', { models: Object.keys(schema.models).length });
|
|
917
997
|
}
|
|
918
998
|
catch (err) {
|
|
919
|
-
|
|
999
|
+
// Coerce so the rejection a consumer awaiting `ready()` catches is
|
|
1000
|
+
// always an AbloError — connection setup is held to the same
|
|
1001
|
+
// never-leak-untagged contract as the model operations.
|
|
1002
|
+
const error = toAbloError(err);
|
|
920
1003
|
// Make sure syncStatus reflects the failure for observer() components
|
|
921
1004
|
store.syncStatus.state = 'error';
|
|
922
1005
|
store.syncStatus.error = error;
|
|
923
|
-
|
|
1006
|
+
// Log the typed envelope (type + code + status), not just the bare
|
|
1007
|
+
// message — so the console line names it as an Ablo error and carries
|
|
1008
|
+
// the code (e.g. AbloAuthenticationError/identity_resolve_failed on a
|
|
1009
|
+
// 401) instead of reading like an untagged failure.
|
|
1010
|
+
logger.error('Sync engine failed to initialize', {
|
|
1011
|
+
type: error.type,
|
|
1012
|
+
code: error.code,
|
|
1013
|
+
httpStatus: error.httpStatus,
|
|
1014
|
+
error: error.message,
|
|
1015
|
+
});
|
|
1016
|
+
// Clear the memo so a FUTURE `ready()` re-attempts bootstrap instead of
|
|
1017
|
+
// replaying this rejection forever. Bootstrap failures here are
|
|
1018
|
+
// transient by nature — offline, an IndexedDB open timeout, a bootstrap
|
|
1019
|
+
// fetch hiccup — and used to brick the engine until a full page reload
|
|
1020
|
+
// because line ~2013 (`if (_readyPromise) return _readyPromise`) handed
|
|
1021
|
+
// every later caller this same dead promise. Nulling it lets the
|
|
1022
|
+
// provider's online/wake/retry triggers drive a clean re-bootstrap.
|
|
1023
|
+
// (The terminal `_validationError` branch above intentionally stays
|
|
1024
|
+
// cached — config can't change without recreating the engine.)
|
|
1025
|
+
_readyPromise = null;
|
|
924
1026
|
throw error;
|
|
925
1027
|
}
|
|
926
1028
|
})();
|
|
@@ -951,14 +1053,7 @@ export function Ablo(options) {
|
|
|
951
1053
|
}
|
|
952
1054
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
953
1055
|
function authHeaders() {
|
|
954
|
-
|
|
955
|
-
if (currentCapabilityToken) {
|
|
956
|
-
headers.Authorization = `Bearer ${currentCapabilityToken}`;
|
|
957
|
-
}
|
|
958
|
-
else if (configuredAuthToken) {
|
|
959
|
-
headers.Authorization = `Bearer ${configuredAuthToken}`;
|
|
960
|
-
}
|
|
961
|
-
return headers;
|
|
1056
|
+
return authCredentials.withAuthHeaders({ 'Content-Type': 'application/json' });
|
|
962
1057
|
}
|
|
963
1058
|
function createClientTxId(idempotencyKey) {
|
|
964
1059
|
if (idempotencyKey && idempotencyKey.length > 0)
|
|
@@ -1007,11 +1102,15 @@ export function Ablo(options) {
|
|
|
1007
1102
|
return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
|
|
1008
1103
|
}
|
|
1009
1104
|
function modelClaimFromActive(intent) {
|
|
1105
|
+
const description = typeof intent.target.meta?.description === 'string'
|
|
1106
|
+
? intent.target.meta.description
|
|
1107
|
+
: undefined;
|
|
1010
1108
|
return {
|
|
1011
1109
|
id: intent.id,
|
|
1012
1110
|
actor: intent.heldBy,
|
|
1013
1111
|
participantKind: intent.participantKind,
|
|
1014
1112
|
action: intent.reason,
|
|
1113
|
+
...(description ? { description } : {}),
|
|
1015
1114
|
field: intent.target.field,
|
|
1016
1115
|
status: 'active',
|
|
1017
1116
|
expiresAt: intent.expiresAt,
|
|
@@ -1031,6 +1130,7 @@ export function Ablo(options) {
|
|
|
1031
1130
|
actor: intent.heldBy,
|
|
1032
1131
|
participantKind: intent.participantKind,
|
|
1033
1132
|
action: intent.action,
|
|
1133
|
+
...(intent.description ? { description: intent.description } : {}),
|
|
1034
1134
|
field: intent.target.field,
|
|
1035
1135
|
status: 'queued',
|
|
1036
1136
|
position: intent.position,
|
|
@@ -1279,7 +1379,6 @@ export function Ablo(options) {
|
|
|
1279
1379
|
const res = await fetchImpl(`${bootstrapHelper.baseUrl}/sync/query`, {
|
|
1280
1380
|
method: 'POST',
|
|
1281
1381
|
headers: authHeaders(),
|
|
1282
|
-
credentials: 'include',
|
|
1283
1382
|
body: JSON.stringify({
|
|
1284
1383
|
queries: [
|
|
1285
1384
|
{
|
|
@@ -1321,59 +1420,59 @@ export function Ablo(options) {
|
|
|
1321
1420
|
}
|
|
1322
1421
|
function model(name) {
|
|
1323
1422
|
return {
|
|
1324
|
-
retrieve(
|
|
1325
|
-
return retrieveModel(name, id,
|
|
1423
|
+
retrieve(params) {
|
|
1424
|
+
return retrieveModel(name, params.id, params);
|
|
1326
1425
|
},
|
|
1327
|
-
async create(
|
|
1328
|
-
const id =
|
|
1329
|
-
await applyClaimedPolicy({ model: name, id },
|
|
1426
|
+
async create(params) {
|
|
1427
|
+
const id = params.id ?? createModelId();
|
|
1428
|
+
await applyClaimedPolicy({ model: name, id }, params);
|
|
1330
1429
|
return commits.create({
|
|
1331
|
-
intent:
|
|
1332
|
-
idempotencyKey:
|
|
1333
|
-
readAt:
|
|
1334
|
-
onStale:
|
|
1335
|
-
wait:
|
|
1430
|
+
intent: params.intent,
|
|
1431
|
+
idempotencyKey: params.idempotencyKey,
|
|
1432
|
+
readAt: params.readAt,
|
|
1433
|
+
onStale: params.onStale,
|
|
1434
|
+
wait: params.wait,
|
|
1336
1435
|
operations: [
|
|
1337
1436
|
{
|
|
1338
1437
|
action: 'create',
|
|
1339
1438
|
model: name,
|
|
1340
1439
|
id,
|
|
1341
|
-
data,
|
|
1440
|
+
data: params.data,
|
|
1342
1441
|
},
|
|
1343
1442
|
],
|
|
1344
1443
|
});
|
|
1345
1444
|
},
|
|
1346
|
-
async update(
|
|
1347
|
-
await applyClaimedPolicy({ model: name, id },
|
|
1445
|
+
async update(params) {
|
|
1446
|
+
await applyClaimedPolicy({ model: name, id: params.id }, params);
|
|
1348
1447
|
return commits.create({
|
|
1349
|
-
intent:
|
|
1350
|
-
idempotencyKey:
|
|
1351
|
-
readAt:
|
|
1352
|
-
onStale:
|
|
1353
|
-
wait:
|
|
1448
|
+
intent: params.intent,
|
|
1449
|
+
idempotencyKey: params.idempotencyKey,
|
|
1450
|
+
readAt: params.readAt,
|
|
1451
|
+
onStale: params.onStale,
|
|
1452
|
+
wait: params.wait,
|
|
1354
1453
|
operations: [
|
|
1355
1454
|
{
|
|
1356
1455
|
action: 'update',
|
|
1357
1456
|
model: name,
|
|
1358
|
-
id,
|
|
1359
|
-
data,
|
|
1457
|
+
id: params.id,
|
|
1458
|
+
data: params.data,
|
|
1360
1459
|
},
|
|
1361
1460
|
],
|
|
1362
1461
|
});
|
|
1363
1462
|
},
|
|
1364
|
-
async delete(
|
|
1365
|
-
await applyClaimedPolicy({ model: name, id },
|
|
1463
|
+
async delete(params) {
|
|
1464
|
+
await applyClaimedPolicy({ model: name, id: params.id }, params);
|
|
1366
1465
|
return commits.create({
|
|
1367
|
-
intent:
|
|
1368
|
-
idempotencyKey:
|
|
1369
|
-
readAt:
|
|
1370
|
-
onStale:
|
|
1371
|
-
wait:
|
|
1466
|
+
intent: params.intent,
|
|
1467
|
+
idempotencyKey: params.idempotencyKey,
|
|
1468
|
+
readAt: params.readAt,
|
|
1469
|
+
onStale: params.onStale,
|
|
1470
|
+
wait: params.wait,
|
|
1372
1471
|
operations: [
|
|
1373
1472
|
{
|
|
1374
1473
|
action: 'delete',
|
|
1375
1474
|
model: name,
|
|
1376
|
-
id,
|
|
1475
|
+
id: params.id,
|
|
1377
1476
|
},
|
|
1378
1477
|
],
|
|
1379
1478
|
});
|
|
@@ -1384,6 +1483,83 @@ export function Ablo(options) {
|
|
|
1384
1483
|
...modelProxies,
|
|
1385
1484
|
ready,
|
|
1386
1485
|
waitForFlush,
|
|
1486
|
+
setAuthToken(token) {
|
|
1487
|
+
// The single credential source is read lazily by bootstrap HTTP,
|
|
1488
|
+
// lazy query HTTP, network probes, and WebSocket reconnect URL auth.
|
|
1489
|
+
// Updating it here is enough for the next request/connect to use the
|
|
1490
|
+
// refreshed token; no per-transport patching.
|
|
1491
|
+
authCredentials.setAuthToken(token);
|
|
1492
|
+
// A fresh credential is useless to a connection parked in offline /
|
|
1493
|
+
// backoff / auth_blocked until the next probe trigger — so kick one now.
|
|
1494
|
+
// Harmless while connected (the FSM ignores the nudge there).
|
|
1495
|
+
store.nudgeReconnect();
|
|
1496
|
+
},
|
|
1497
|
+
async getAuthToken() {
|
|
1498
|
+
// The live short-lived bearer (set via `setAuthToken`/`getToken` refresh)
|
|
1499
|
+
// is the canonical credential; fall back to a configured API key.
|
|
1500
|
+
return (authCredentials.getAuthToken() ??
|
|
1501
|
+
(await resolveApiKeyValue(configuredApiKey)) ??
|
|
1502
|
+
configuredAuthToken ??
|
|
1503
|
+
null);
|
|
1504
|
+
},
|
|
1505
|
+
setCredentialRefresher(refresher) {
|
|
1506
|
+
store.setCredentialRefresher(refresher);
|
|
1507
|
+
},
|
|
1508
|
+
nudgeReconnect() {
|
|
1509
|
+
store.nudgeReconnect();
|
|
1510
|
+
},
|
|
1511
|
+
sessions: {
|
|
1512
|
+
// Stripe `ephemeralKeys.create` shape: a BACKEND (holding `sk_`) mints a
|
|
1513
|
+
// short-lived scoped token for one end user OR one agent. Thin wrapper over
|
|
1514
|
+
// the `/auth/capability` exchange, reshaped to a Stripe-style resource.
|
|
1515
|
+
async create(params) {
|
|
1516
|
+
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
1517
|
+
if (!apiKey) {
|
|
1518
|
+
throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
|
|
1519
|
+
}
|
|
1520
|
+
const baseUrl = resolveBootstrapBaseUrl({
|
|
1521
|
+
url,
|
|
1522
|
+
bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
|
|
1523
|
+
});
|
|
1524
|
+
// Discriminate the union: `{ user }` → full-authority `ek_` (no op
|
|
1525
|
+
// allowlist); `{ agent, can }` → scoped `rk_`. `can: { Task: ['update'] }`
|
|
1526
|
+
// serializes to the wire allowlist `['task.update']` — the Hub matches
|
|
1527
|
+
// `${model.toLowerCase()}.${op}` (Hub.ts handleCommit).
|
|
1528
|
+
let participantKind;
|
|
1529
|
+
let participantId;
|
|
1530
|
+
let operations;
|
|
1531
|
+
if (params.user) {
|
|
1532
|
+
participantKind = 'user';
|
|
1533
|
+
participantId = params.user.id;
|
|
1534
|
+
operations = undefined;
|
|
1535
|
+
}
|
|
1536
|
+
else {
|
|
1537
|
+
participantKind = 'agent';
|
|
1538
|
+
participantId = params.agent.id;
|
|
1539
|
+
operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
|
|
1540
|
+
}
|
|
1541
|
+
const res = await exchangeApiKey({
|
|
1542
|
+
apiKey,
|
|
1543
|
+
baseUrl,
|
|
1544
|
+
participantKind,
|
|
1545
|
+
participantId,
|
|
1546
|
+
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
1547
|
+
...(operations ? { operations } : {}),
|
|
1548
|
+
ttlSeconds: params.ttlSeconds ?? 900,
|
|
1549
|
+
...(params.userMeta ? { userMeta: params.userMeta } : {}),
|
|
1550
|
+
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
1551
|
+
});
|
|
1552
|
+
return {
|
|
1553
|
+
object: 'session',
|
|
1554
|
+
id: res.capabilityId,
|
|
1555
|
+
token: res.token,
|
|
1556
|
+
expiresAt: res.expiresAt,
|
|
1557
|
+
organizationId: res.organizationId,
|
|
1558
|
+
scope: res.scope,
|
|
1559
|
+
userMeta: res.userMeta,
|
|
1560
|
+
};
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1387
1563
|
async dispose() {
|
|
1388
1564
|
_refreshScheduler?.dispose();
|
|
1389
1565
|
_refreshScheduler = null;
|
|
@@ -1476,10 +1652,7 @@ export function Ablo(options) {
|
|
|
1476
1652
|
async beginTurn(beginOptions) {
|
|
1477
1653
|
const baseUrl = url.replace(/\/+$/, '');
|
|
1478
1654
|
const turnUrl = `${baseUrl.replace(/^ws/, 'http')}/api/agent/turn`;
|
|
1479
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
1480
|
-
if (currentCapabilityToken) {
|
|
1481
|
-
headers.Authorization = `Bearer ${currentCapabilityToken}`;
|
|
1482
|
-
}
|
|
1655
|
+
const headers = authCredentials.withAuthHeaders({ 'Content-Type': 'application/json' });
|
|
1483
1656
|
const res = await fetch(turnUrl, {
|
|
1484
1657
|
method: 'POST',
|
|
1485
1658
|
headers,
|
|
@@ -1491,8 +1664,24 @@ export function Ablo(options) {
|
|
|
1491
1664
|
}),
|
|
1492
1665
|
});
|
|
1493
1666
|
if (!res.ok) {
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1667
|
+
const text = await res.text().catch(() => '');
|
|
1668
|
+
let parsed = text;
|
|
1669
|
+
if (text) {
|
|
1670
|
+
try {
|
|
1671
|
+
parsed = JSON.parse(text);
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
/* keep raw text */
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// Preserve the server's structured envelope (code/message/doc_url) when
|
|
1678
|
+
// present; fall back to turn_open_failed for a bare/non-Ablo body.
|
|
1679
|
+
throw hasWireCode(parsed)
|
|
1680
|
+
? translateHttpError(res.status, parsed, res.headers.get('x-request-id') ?? undefined)
|
|
1681
|
+
: new AbloError(`beginTurn failed: ${res.status} ${text}`, {
|
|
1682
|
+
code: 'turn_open_failed',
|
|
1683
|
+
httpStatus: res.status,
|
|
1684
|
+
});
|
|
1496
1685
|
}
|
|
1497
1686
|
const json = (await res.json());
|
|
1498
1687
|
const turnId = json.turnId;
|
|
@@ -1515,8 +1704,22 @@ export function Ablo(options) {
|
|
|
1515
1704
|
}),
|
|
1516
1705
|
});
|
|
1517
1706
|
if (!closeRes.ok) {
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1707
|
+
const text = await closeRes.text().catch(() => '');
|
|
1708
|
+
let parsed = text;
|
|
1709
|
+
if (text) {
|
|
1710
|
+
try {
|
|
1711
|
+
parsed = JSON.parse(text);
|
|
1712
|
+
}
|
|
1713
|
+
catch {
|
|
1714
|
+
/* keep raw text */
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
throw hasWireCode(parsed)
|
|
1718
|
+
? translateHttpError(closeRes.status, parsed, closeRes.headers.get('x-request-id') ?? undefined)
|
|
1719
|
+
: new AbloError(`closeTurn failed: ${closeRes.status} ${text}`, {
|
|
1720
|
+
code: 'turn_close_failed',
|
|
1721
|
+
httpStatus: closeRes.status,
|
|
1722
|
+
});
|
|
1520
1723
|
}
|
|
1521
1724
|
};
|
|
1522
1725
|
const dispose = () => {
|
|
@@ -73,10 +73,42 @@ export interface CapabilityRevocation {
|
|
|
73
73
|
readonly deleted: boolean;
|
|
74
74
|
readonly activeSessionsClosed?: number;
|
|
75
75
|
}
|
|
76
|
+
export interface CapabilityRotateOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Overlap window — the OLD token keeps authenticating for this long after
|
|
79
|
+
* rotation, so you can deploy the replacement with zero downtime. Default
|
|
80
|
+
* 24h server-side.
|
|
81
|
+
*/
|
|
82
|
+
readonly grace?: Duration;
|
|
83
|
+
readonly graceSeconds?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Lifetime of the REPLACEMENT capability. Omit to inherit the original's
|
|
86
|
+
* lifetime.
|
|
87
|
+
*/
|
|
88
|
+
readonly lease?: Duration;
|
|
89
|
+
readonly leaseSeconds?: number;
|
|
90
|
+
}
|
|
91
|
+
/** The fresh capability returned by `rotate`, plus a pointer to the old one. */
|
|
92
|
+
export interface RotatedCapability extends Capability {
|
|
93
|
+
/**
|
|
94
|
+
* The capability that was rotated out. Its token keeps working until
|
|
95
|
+
* `expiresAt` (the end of the grace window), then expires.
|
|
96
|
+
*/
|
|
97
|
+
readonly rotatedFrom: {
|
|
98
|
+
readonly id: string;
|
|
99
|
+
readonly expiresAt: string;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
76
102
|
export interface CapabilityResource {
|
|
77
103
|
create(options: CapabilityCreateOptions): Promise<Capability>;
|
|
78
104
|
retrieve(id: string): Promise<CapabilityRecord>;
|
|
79
105
|
revoke(id: string): Promise<CapabilityRevocation>;
|
|
106
|
+
/**
|
|
107
|
+
* Rotate with overlap (Stripe's "roll" model): mint a fresh capability
|
|
108
|
+
* carrying the SAME scope, and keep the old token working for a grace
|
|
109
|
+
* window so you can roll out the replacement without downtime.
|
|
110
|
+
*/
|
|
111
|
+
rotate(id: string, options?: CapabilityRotateOptions): Promise<RotatedCapability>;
|
|
80
112
|
/**
|
|
81
113
|
* Alias for `create`. Kept because "mint" is common capability-token
|
|
82
114
|
* language, but `create` is the canonical SDK verb.
|
|
@@ -168,12 +200,20 @@ export interface AgentModelMutationOptions extends Omit<ModelMutationOptions, 'i
|
|
|
168
200
|
} | null;
|
|
169
201
|
}
|
|
170
202
|
export interface AgentModelClient<T = Record<string, unknown>> {
|
|
171
|
-
retrieve(
|
|
172
|
-
|
|
203
|
+
retrieve(params: AgentModelReadOptions & {
|
|
204
|
+
readonly id: string;
|
|
205
|
+
}): Promise<ModelRead<T>>;
|
|
206
|
+
create(params: AgentModelMutationOptions & {
|
|
207
|
+
readonly data: Record<string, unknown>;
|
|
173
208
|
readonly id?: string | null;
|
|
174
209
|
}): Promise<CommitReceipt>;
|
|
175
|
-
update(
|
|
176
|
-
|
|
210
|
+
update(params: AgentModelMutationOptions & {
|
|
211
|
+
readonly id: string;
|
|
212
|
+
readonly data: Record<string, unknown>;
|
|
213
|
+
}): Promise<CommitReceipt>;
|
|
214
|
+
delete(params: AgentModelMutationOptions & {
|
|
215
|
+
readonly id: string;
|
|
216
|
+
}): Promise<CommitReceipt>;
|
|
177
217
|
}
|
|
178
218
|
export interface AgentRunContext {
|
|
179
219
|
readonly task: Task;
|
|
@@ -196,5 +236,13 @@ export interface AbloApi {
|
|
|
196
236
|
agent(id: string, options: AgentOptions): Agent;
|
|
197
237
|
model<T = Record<string, unknown>>(name: string): ModelClient<T>;
|
|
198
238
|
beginTurn(options: TaskCreateOptions): Promise<Turn>;
|
|
239
|
+
/**
|
|
240
|
+
* Resolve the active bearer credential this client authenticates with — the
|
|
241
|
+
* same token its own requests carry in `Authorization`. Returns `null` when
|
|
242
|
+
* no credential is configured. Async because the API key may be supplied as
|
|
243
|
+
* an async setter. Use it to authenticate side-band requests to the same
|
|
244
|
+
* sync-server (e.g. the S3 presign endpoint) without re-minting.
|
|
245
|
+
*/
|
|
246
|
+
getAuthToken(): Promise<string | null>;
|
|
199
247
|
}
|
|
200
248
|
export declare function createProtocolClient(options: AbloApiClientOptions): AbloApi;
|