@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/BaseSyncedStore.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* pull generic methods into this base class.
|
|
13
13
|
*/
|
|
14
14
|
import { makeObservable, observable, computed, runInAction } from 'mobx';
|
|
15
|
-
import { AbloConnectionError, AbloValidationError } from './errors.js';
|
|
15
|
+
import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
|
|
16
16
|
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
17
17
|
import { PropertyType } from './types/index.js';
|
|
18
18
|
import { SyncWebSocket, } from './sync/SyncWebSocket.js';
|
|
@@ -124,6 +124,7 @@ export class BaseSyncedStore {
|
|
|
124
124
|
database;
|
|
125
125
|
objectPool;
|
|
126
126
|
modelRegistry;
|
|
127
|
+
auth;
|
|
127
128
|
/**
|
|
128
129
|
* Schema the store was constructed with. Persisted so the `query`
|
|
129
130
|
* accessor namespace can build typed per-model reader actions lazily
|
|
@@ -199,6 +200,7 @@ export class BaseSyncedStore {
|
|
|
199
200
|
this.database = dependencies.database;
|
|
200
201
|
this.objectPool = dependencies.objectPool;
|
|
201
202
|
this.modelRegistry = dependencies.modelRegistry;
|
|
203
|
+
this.auth = dependencies.auth;
|
|
202
204
|
this.schema = dependencies.schema;
|
|
203
205
|
this._syncServerUrl = dependencies.url;
|
|
204
206
|
// Set this store as the global Model store
|
|
@@ -351,6 +353,24 @@ export class BaseSyncedStore {
|
|
|
351
353
|
* return null.
|
|
352
354
|
*/
|
|
353
355
|
connectionManager = null;
|
|
356
|
+
/**
|
|
357
|
+
* Re-mint hook for the short-lived access credential (the Stripe-style
|
|
358
|
+
* `ek_`/`rk_`). Wired by the React provider from its `getToken`/`authEndpoint`
|
|
359
|
+
* — the engine owns WHEN to refresh (a stale-credential probe / an external
|
|
360
|
+
* nudge), the integrator owns HOW to mint. Mirrors the `getToken` contract:
|
|
361
|
+
* resolves a token string on success, `null` when the long-lived login is
|
|
362
|
+
* gone (terminal), and THROWS on a transient/offline failure. Used by
|
|
363
|
+
* {@link performCredentialRefresh}. Absent ⇒ no silent re-mint (e.g. a static
|
|
364
|
+
* `apiKey` deployment whose credential source refreshes out-of-band).
|
|
365
|
+
*/
|
|
366
|
+
credentialRefresher = null;
|
|
367
|
+
/** Single-flight guard so a wake nudge + an in-flight request + a probe don't
|
|
368
|
+
* all mint at once (the classic "token thrash → random logout" bug). */
|
|
369
|
+
inFlightCredentialRefresh = null;
|
|
370
|
+
/** Teardown for the proactive credential lifecycle (refresh timer + wake/
|
|
371
|
+
* online/focus listeners) installed by {@link startCredentialLifecycle};
|
|
372
|
+
* cleared on {@link disconnect}. Null when no resolver is wired. */
|
|
373
|
+
credentialLifecycleTeardown = null;
|
|
354
374
|
/**
|
|
355
375
|
* Listeners registered via `subscribeSessionError()`. Fired when the
|
|
356
376
|
* WebSocket closes with a session-invalid code (1008/4001/4003) or a
|
|
@@ -447,14 +467,18 @@ export class BaseSyncedStore {
|
|
|
447
467
|
}
|
|
448
468
|
}
|
|
449
469
|
}
|
|
450
|
-
throw lastError
|
|
470
|
+
throw lastError
|
|
471
|
+
? toAbloError(lastError)
|
|
472
|
+
: new AbloConnectionError('Bootstrap failed after all retry attempts', {
|
|
473
|
+
code: 'bootstrap_fetch_timeout',
|
|
474
|
+
});
|
|
451
475
|
}
|
|
452
476
|
/** Create a timeout promise for bootstrap attempts */
|
|
453
477
|
createBootstrapTimeout(attempt) {
|
|
454
478
|
const timeoutMs = BOOTSTRAP_CONFIG.OVERALL_TIMEOUT_MS + (attempt - 1) * 3_000;
|
|
455
479
|
return new Promise((_, reject) => {
|
|
456
480
|
setTimeout(() => {
|
|
457
|
-
reject(new
|
|
481
|
+
reject(new AbloConnectionError(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`, { code: 'bootstrap_fetch_timeout' }));
|
|
458
482
|
}, timeoutMs);
|
|
459
483
|
});
|
|
460
484
|
}
|
|
@@ -527,6 +551,147 @@ export class BaseSyncedStore {
|
|
|
527
551
|
return 'network_error';
|
|
528
552
|
}
|
|
529
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* Register the access-credential re-mint hook. Called by the React provider
|
|
556
|
+
* with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
|
|
557
|
+
* See {@link credentialRefresher}.
|
|
558
|
+
*/
|
|
559
|
+
setCredentialRefresher(refresher) {
|
|
560
|
+
this.credentialRefresher = refresher;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Re-mint the short-lived access credential and push it into the credential
|
|
564
|
+
* source, reporting a tri-state outcome the {@link ConnectionManager} maps to
|
|
565
|
+
* its FSM. The contract mirrors `getToken` (and PowerSync's `fetchCredentials`
|
|
566
|
+
* / Liveblocks' `authEndpoint`, but made explicit instead of overloading
|
|
567
|
+
* return/throw):
|
|
568
|
+
* - token string → `'refreshed'` (fresh key in place; re-probe & reconnect)
|
|
569
|
+
* - `null` → `'session_error'` (login itself is gone → terminal, sign out)
|
|
570
|
+
* - throw → `'network_error'` (couldn't reach the mint endpoint → transient)
|
|
571
|
+
*
|
|
572
|
+
* SINGLE-FLIGHT: concurrent callers (a wake nudge, an in-flight request, the
|
|
573
|
+
* probe) share one in-flight promise so we never double-mint — the canonical
|
|
574
|
+
* fix for the "every 401 mints a token → thrash → spurious logout" anti-pattern.
|
|
575
|
+
*
|
|
576
|
+
* No refresher wired ⇒ `'refreshed'` (a no-op re-probe): a static-`apiKey`
|
|
577
|
+
* deployment has no session to re-mint from; its credential source refreshes
|
|
578
|
+
* out-of-band, so we just re-probe with whatever it currently holds.
|
|
579
|
+
*/
|
|
580
|
+
async performCredentialRefresh() {
|
|
581
|
+
const refresher = this.credentialRefresher;
|
|
582
|
+
if (!refresher)
|
|
583
|
+
return 'refreshed';
|
|
584
|
+
if (this.inFlightCredentialRefresh)
|
|
585
|
+
return this.inFlightCredentialRefresh;
|
|
586
|
+
const run = (async () => {
|
|
587
|
+
try {
|
|
588
|
+
const token = await refresher();
|
|
589
|
+
if (!token) {
|
|
590
|
+
// null = the long-lived login is gone (mint endpoint answered 401/403).
|
|
591
|
+
// Terminal — the FSM routes this to sign-out.
|
|
592
|
+
return 'session_error';
|
|
593
|
+
}
|
|
594
|
+
this.auth?.setAuthToken(token);
|
|
595
|
+
return 'refreshed';
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
// A throw = transient (offline / mint endpoint unreachable / 5xx). The
|
|
599
|
+
// login may be perfectly valid; never sign out for this — back off and
|
|
600
|
+
// retry. Mirrors the `getToken` throw-vs-null contract end-to-end.
|
|
601
|
+
getContext().logger.warn('[BaseSyncedStore] Access-credential re-mint failed (transient)', {
|
|
602
|
+
error: error?.message,
|
|
603
|
+
});
|
|
604
|
+
return 'network_error';
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
this.inFlightCredentialRefresh = run;
|
|
608
|
+
try {
|
|
609
|
+
return await run;
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
this.inFlightCredentialRefresh = null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Nudge the connection FSM to re-probe with the current credential. Idempotent
|
|
617
|
+
* and safe in any state (ignored while `connected`). Call after pushing a
|
|
618
|
+
* freshly-minted token via `setAuthToken`, or on an OS-wake signal, so a
|
|
619
|
+
* connection parked in `offline` / `backoff` / `auth_blocked` picks the new
|
|
620
|
+
* credential up immediately instead of waiting for the 30s watchdog.
|
|
621
|
+
*/
|
|
622
|
+
nudgeReconnect() {
|
|
623
|
+
this.connectionManager?.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Install the access-credential lifecycle the CLIENT owns (this used to live
|
|
627
|
+
* in the React provider — wrong layer). Two parts:
|
|
628
|
+
* 1. REACTIVE — register `getToken` as the re-mint hook the FSM calls when a
|
|
629
|
+
* probe finds the key stale (`credential_stale`) or on a nudge.
|
|
630
|
+
* 2. PROACTIVE — keep the short-lived key fresh ahead of trouble: a refresh
|
|
631
|
+
* timer inside the TTL, plus re-mint on OS wake / network-online / tab
|
|
632
|
+
* focus. Browser-only triggers are env-gated, so Node/agent hosts get
|
|
633
|
+
* only the timer (a no-op there — agents use a static `apiKey`, no
|
|
634
|
+
* resolver, so this is never called for them).
|
|
635
|
+
*
|
|
636
|
+
* Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
|
|
637
|
+
* never call a refresh method. Idempotent (a second call replaces the first);
|
|
638
|
+
* torn down on {@link disconnect}.
|
|
639
|
+
*/
|
|
640
|
+
startCredentialLifecycle(getToken) {
|
|
641
|
+
this.stopCredentialLifecycle();
|
|
642
|
+
this.setCredentialRefresher(getToken);
|
|
643
|
+
// A transient failure is swallowed: the engine keeps its current token and
|
|
644
|
+
// the next trigger — or the reactive `credential_stale` path — retries. We
|
|
645
|
+
// never tear down or sign out on a failed proactive roll.
|
|
646
|
+
const refresh = async () => {
|
|
647
|
+
try {
|
|
648
|
+
const token = await getToken();
|
|
649
|
+
if (token) {
|
|
650
|
+
// Push into the shared credential source (read lazily by bootstrap
|
|
651
|
+
// HTTP, probes, and the WS reconnect URL), then nudge a parked
|
|
652
|
+
// connection to re-probe with the fresh key. Same two steps the
|
|
653
|
+
// engine's `setAuthToken` wrapper performs.
|
|
654
|
+
this.auth?.setAuthToken(token);
|
|
655
|
+
this.nudgeReconnect();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// transient (offline / mint hiccup) — a later trigger retries.
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
// Comfortably inside the 15m `ek_` TTL; a missed (background-throttled) tick
|
|
663
|
+
// is recovered by the next, or by the reactive probe.
|
|
664
|
+
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
665
|
+
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
|
|
666
|
+
const teardowns = [() => clearInterval(timer)];
|
|
667
|
+
if (typeof window !== 'undefined') {
|
|
668
|
+
const onTrigger = () => void refresh();
|
|
669
|
+
window.addEventListener('online', onTrigger);
|
|
670
|
+
// OS-wake: the desktop shell bridges Electron `powerMonitor` 'resume' to
|
|
671
|
+
// this DOM event (visibilitychange does NOT fire on wake-from-sleep, so a
|
|
672
|
+
// nap longer than the TTL would otherwise leave a dead key untouched).
|
|
673
|
+
window.addEventListener('ablo:wake', onTrigger);
|
|
674
|
+
teardowns.push(() => window.removeEventListener('online', onTrigger));
|
|
675
|
+
teardowns.push(() => window.removeEventListener('ablo:wake', onTrigger));
|
|
676
|
+
}
|
|
677
|
+
if (typeof document !== 'undefined') {
|
|
678
|
+
const onVisible = () => {
|
|
679
|
+
if (document.visibilityState === 'visible')
|
|
680
|
+
void refresh();
|
|
681
|
+
};
|
|
682
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
683
|
+
teardowns.push(() => document.removeEventListener('visibilitychange', onVisible));
|
|
684
|
+
}
|
|
685
|
+
this.credentialLifecycleTeardown = () => {
|
|
686
|
+
for (const t of teardowns)
|
|
687
|
+
t();
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
/** Tear down the proactive credential lifecycle (idempotent). */
|
|
691
|
+
stopCredentialLifecycle() {
|
|
692
|
+
this.credentialLifecycleTeardown?.();
|
|
693
|
+
this.credentialLifecycleTeardown = null;
|
|
694
|
+
}
|
|
530
695
|
// ── Sync Group Management ────────────────────────────────────────────────
|
|
531
696
|
/**
|
|
532
697
|
* Handle an actionType 'G' delta.
|
|
@@ -966,10 +1131,14 @@ export class BaseSyncedStore {
|
|
|
966
1131
|
createConnectionManager(kind) {
|
|
967
1132
|
if (kind === 'agent')
|
|
968
1133
|
return null;
|
|
969
|
-
return new ConnectionManager({
|
|
1134
|
+
return new ConnectionManager({
|
|
1135
|
+
baseUrl: this._syncServerUrl,
|
|
1136
|
+
getAuthToken: () => this.auth?.getAuthToken() ?? this.syncWebSocket?.getAuthToken() ?? null,
|
|
1137
|
+
});
|
|
970
1138
|
}
|
|
971
1139
|
/** Disconnect and clean up all resources */
|
|
972
1140
|
async disconnect() {
|
|
1141
|
+
this.stopCredentialLifecycle();
|
|
973
1142
|
if (this.batchTimer) {
|
|
974
1143
|
clearTimeout(this.batchTimer);
|
|
975
1144
|
this.batchTimer = null;
|
|
@@ -1095,6 +1264,7 @@ export class BaseSyncedStore {
|
|
|
1095
1264
|
versions: this.versionVector,
|
|
1096
1265
|
kind: context.kind,
|
|
1097
1266
|
capabilityToken: context.capabilityToken,
|
|
1267
|
+
getAuthToken: this.auth?.getAuthToken,
|
|
1098
1268
|
capabilities: {
|
|
1099
1269
|
partialBootstrap: true,
|
|
1100
1270
|
compressedDeltas: true,
|
|
@@ -1213,6 +1383,7 @@ export class BaseSyncedStore {
|
|
|
1213
1383
|
};
|
|
1214
1384
|
manager.start({
|
|
1215
1385
|
onReconnect: () => this.performReconnect(),
|
|
1386
|
+
onRefreshCredential: () => this.performCredentialRefresh(),
|
|
1216
1387
|
onSessionExpired: () => {
|
|
1217
1388
|
const err = new SyncSessionError('Session expired');
|
|
1218
1389
|
for (const listener of this.sessionErrorListeners) {
|
|
@@ -1249,10 +1420,13 @@ export class BaseSyncedStore {
|
|
|
1249
1420
|
}
|
|
1250
1421
|
break;
|
|
1251
1422
|
case 'probing_network':
|
|
1423
|
+
case 'refreshing_credential':
|
|
1252
1424
|
case 'reconnecting':
|
|
1253
1425
|
case 'backoff':
|
|
1254
1426
|
// Active recovery — the UI should reflect that the FSM
|
|
1255
|
-
// is doing work, not that we've given up.
|
|
1427
|
+
// is doing work, not that we've given up. (Re-minting a stale
|
|
1428
|
+
// access key is just another recovery step, surfaced the same
|
|
1429
|
+
// way; the user never sees a credential-level distinction.)
|
|
1256
1430
|
if (this.syncStatus.state !== 'reconnecting') {
|
|
1257
1431
|
this.updateSyncStatus({ state: 'reconnecting' });
|
|
1258
1432
|
}
|
package/dist/Model.d.ts
CHANGED
|
@@ -212,10 +212,52 @@ export declare abstract class Model {
|
|
|
212
212
|
* Prepare unarchive operation
|
|
213
213
|
*/
|
|
214
214
|
prepareUnarchive(): ModelChanges;
|
|
215
|
+
/**
|
|
216
|
+
* Safely assign each field of `data` onto this instance, skipping `id`,
|
|
217
|
+
* unknown keys, MobX computed accessors, and getter-only (read-only)
|
|
218
|
+
* properties, and coercing date fields. Shared by `updateFromData`
|
|
219
|
+
* (hydration) and `applyChanges` (local user update).
|
|
220
|
+
*
|
|
221
|
+
* Change tracking is EXPLICIT, not magic: for every field actually
|
|
222
|
+
* written, `onWrite(key, oldValue, newValue)` is invoked with the value
|
|
223
|
+
* captured immediately before assignment. `applyChanges` passes a hook
|
|
224
|
+
* that records the change in `modifiedProperties`; `updateFromData`
|
|
225
|
+
* passes none (hydration must not generate outbound mutations). This
|
|
226
|
+
* is the single source of mutation tracking now that the `mobx-setup`
|
|
227
|
+
* `observe()` bridge has been removed (one write path: the SDK proxy).
|
|
228
|
+
*/
|
|
229
|
+
private assignFieldsFromData;
|
|
215
230
|
/**
|
|
216
231
|
* Update from raw data (hydration)
|
|
232
|
+
*
|
|
233
|
+
* Used for inbound server deltas and pool upserts. Change tracking is
|
|
234
|
+
* deliberately suppressed: hydration writes must NOT land in
|
|
235
|
+
* `modifiedProperties`, otherwise applying a server delta would queue a
|
|
236
|
+
* brand-new outbound mutation and the record would echo forever. For a
|
|
237
|
+
* LOCAL user edit, use `applyChanges` instead.
|
|
238
|
+
*
|
|
239
|
+
* Suppression is belt-and-suspenders: we pass no `onWrite` hook AND
|
|
240
|
+
* clear/restore `modifiedProperties` around the assignment, so any
|
|
241
|
+
* remaining `mobx-setup` `observe()` side-channel writes are discarded
|
|
242
|
+
* too. (The clear/restore is a harmless no-op once that bridge is gone.)
|
|
217
243
|
*/
|
|
218
244
|
updateFromData(data: ModelData): void;
|
|
245
|
+
/**
|
|
246
|
+
* Apply a LOCAL user-initiated update from a data object — the write
|
|
247
|
+
* path for `proxy.update({ id, data })`, which is the ONE AND ONLY way
|
|
248
|
+
* application code mutates synced fields.
|
|
249
|
+
*
|
|
250
|
+
* Unlike `updateFromData` (hydration, untracked), this records every
|
|
251
|
+
* written field in `modifiedProperties` via `propertyChanged`, so
|
|
252
|
+
* `getChanges()` / the transaction queue send the edited fields to the
|
|
253
|
+
* server and the undo system gets a correct pre-write baseline.
|
|
254
|
+
* Recording is EXPLICIT here (via the `onWrite` hook) — it does not rely
|
|
255
|
+
* on any MobX `observe()` side-channel.
|
|
256
|
+
*
|
|
257
|
+
* `_originalData` is intentionally NOT reset here: it stays as the
|
|
258
|
+
* last-persisted baseline until `clearChanges()` runs on sync-ack.
|
|
259
|
+
*/
|
|
260
|
+
applyChanges(data: ModelData): void;
|
|
219
261
|
/**
|
|
220
262
|
* Serialize to JSON
|
|
221
263
|
* This method should not trigger MobX reactions since it's used for serialization
|
package/dist/Model.js
CHANGED
|
@@ -357,8 +357,81 @@ export class Model {
|
|
|
357
357
|
timestamp: new Date(),
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Safely assign each field of `data` onto this instance, skipping `id`,
|
|
362
|
+
* unknown keys, MobX computed accessors, and getter-only (read-only)
|
|
363
|
+
* properties, and coercing date fields. Shared by `updateFromData`
|
|
364
|
+
* (hydration) and `applyChanges` (local user update).
|
|
365
|
+
*
|
|
366
|
+
* Change tracking is EXPLICIT, not magic: for every field actually
|
|
367
|
+
* written, `onWrite(key, oldValue, newValue)` is invoked with the value
|
|
368
|
+
* captured immediately before assignment. `applyChanges` passes a hook
|
|
369
|
+
* that records the change in `modifiedProperties`; `updateFromData`
|
|
370
|
+
* passes none (hydration must not generate outbound mutations). This
|
|
371
|
+
* is the single source of mutation tracking now that the `mobx-setup`
|
|
372
|
+
* `observe()` bridge has been removed (one write path: the SDK proxy).
|
|
373
|
+
*/
|
|
374
|
+
assignFieldsFromData(data, onWrite) {
|
|
375
|
+
// Update properties with safety checks for read-only/computed accessors
|
|
376
|
+
for (const [key, raw] of Object.entries(data)) {
|
|
377
|
+
if (key === 'id')
|
|
378
|
+
continue;
|
|
379
|
+
// Only attempt to set if the property exists on instance or prototype
|
|
380
|
+
if (!(this.hasOwnProperty(key) || key in this))
|
|
381
|
+
continue;
|
|
382
|
+
// Never assign to MobX computed properties (they may expose a setter that throws)
|
|
383
|
+
try {
|
|
384
|
+
if (isComputedProp(this, key)) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
390
|
+
}
|
|
391
|
+
// Resolve property descriptor from own or prototype chain
|
|
392
|
+
const ownDesc = Object.getOwnPropertyDescriptor(this, key);
|
|
393
|
+
let desc = ownDesc;
|
|
394
|
+
if (!desc) {
|
|
395
|
+
let proto = Object.getPrototypeOf(this);
|
|
396
|
+
while (proto && proto !== Object.prototype && !desc) {
|
|
397
|
+
desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
398
|
+
proto = Object.getPrototypeOf(proto);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Determine writability: allow if data descriptor writable, or accessor with setter
|
|
402
|
+
const writable = desc
|
|
403
|
+
? ('writable' in desc && !!desc.writable) ||
|
|
404
|
+
('set' in desc && typeof desc.set === 'function')
|
|
405
|
+
: true;
|
|
406
|
+
if (!writable) {
|
|
407
|
+
// Skip read-only accessor properties (getter-only)
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
// Handle date conversions
|
|
411
|
+
const value = (key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt') && raw
|
|
412
|
+
? new Date(raw)
|
|
413
|
+
: raw;
|
|
414
|
+
// Capture the pre-write value BEFORE assignment so trackers
|
|
415
|
+
// (undo inverse, getChanges) see the true previous value.
|
|
416
|
+
const oldValue = onWrite ? this[key] : undefined;
|
|
417
|
+
// Dynamic property assignment - use indexed access
|
|
418
|
+
this[key] = value;
|
|
419
|
+
onWrite?.(key, oldValue, value);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
360
422
|
/**
|
|
361
423
|
* Update from raw data (hydration)
|
|
424
|
+
*
|
|
425
|
+
* Used for inbound server deltas and pool upserts. Change tracking is
|
|
426
|
+
* deliberately suppressed: hydration writes must NOT land in
|
|
427
|
+
* `modifiedProperties`, otherwise applying a server delta would queue a
|
|
428
|
+
* brand-new outbound mutation and the record would echo forever. For a
|
|
429
|
+
* LOCAL user edit, use `applyChanges` instead.
|
|
430
|
+
*
|
|
431
|
+
* Suppression is belt-and-suspenders: we pass no `onWrite` hook AND
|
|
432
|
+
* clear/restore `modifiedProperties` around the assignment, so any
|
|
433
|
+
* remaining `mobx-setup` `observe()` side-channel writes are discarded
|
|
434
|
+
* too. (The clear/restore is a harmless no-op once that bridge is gone.)
|
|
362
435
|
*/
|
|
363
436
|
updateFromData(data) {
|
|
364
437
|
if (this.isDisposed) {
|
|
@@ -367,52 +440,10 @@ export class Model {
|
|
|
367
440
|
});
|
|
368
441
|
}
|
|
369
442
|
runInAction(() => {
|
|
370
|
-
// Temporarily disable change tracking
|
|
371
443
|
const originalTracking = this.modifiedProperties;
|
|
372
444
|
this.modifiedProperties = new Map();
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
if (key === 'id')
|
|
376
|
-
continue;
|
|
377
|
-
// Only attempt to set if the property exists on instance or prototype
|
|
378
|
-
if (!(this.hasOwnProperty(key) || key in this))
|
|
379
|
-
continue;
|
|
380
|
-
// Never assign to MobX computed properties (they may expose a setter that throws)
|
|
381
|
-
try {
|
|
382
|
-
if (isComputedProp(this, key)) {
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
388
|
-
}
|
|
389
|
-
// Resolve property descriptor from own or prototype chain
|
|
390
|
-
const ownDesc = Object.getOwnPropertyDescriptor(this, key);
|
|
391
|
-
let desc = ownDesc;
|
|
392
|
-
if (!desc) {
|
|
393
|
-
let proto = Object.getPrototypeOf(this);
|
|
394
|
-
while (proto && proto !== Object.prototype && !desc) {
|
|
395
|
-
desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
396
|
-
proto = Object.getPrototypeOf(proto);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
// Determine writability: allow if data descriptor writable, or accessor with setter
|
|
400
|
-
const writable = desc
|
|
401
|
-
? ('writable' in desc && !!desc.writable) ||
|
|
402
|
-
('set' in desc && typeof desc.set === 'function')
|
|
403
|
-
: true;
|
|
404
|
-
if (!writable) {
|
|
405
|
-
// Skip read-only accessor properties (getter-only)
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
// Handle date conversions
|
|
409
|
-
const value = (key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt') && raw
|
|
410
|
-
? new Date(raw)
|
|
411
|
-
: raw;
|
|
412
|
-
// Dynamic property assignment for hydration - use indexed access
|
|
413
|
-
this[key] = value;
|
|
414
|
-
}
|
|
415
|
-
// Restore change tracking
|
|
445
|
+
// No `onWrite` → this call records nothing itself.
|
|
446
|
+
this.assignFieldsFromData(data);
|
|
416
447
|
this.modifiedProperties = originalTracking;
|
|
417
448
|
});
|
|
418
449
|
// Mark as persisted if updating existing model
|
|
@@ -421,6 +452,34 @@ export class Model {
|
|
|
421
452
|
}
|
|
422
453
|
this.didUpdate();
|
|
423
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Apply a LOCAL user-initiated update from a data object — the write
|
|
457
|
+
* path for `proxy.update({ id, data })`, which is the ONE AND ONLY way
|
|
458
|
+
* application code mutates synced fields.
|
|
459
|
+
*
|
|
460
|
+
* Unlike `updateFromData` (hydration, untracked), this records every
|
|
461
|
+
* written field in `modifiedProperties` via `propertyChanged`, so
|
|
462
|
+
* `getChanges()` / the transaction queue send the edited fields to the
|
|
463
|
+
* server and the undo system gets a correct pre-write baseline.
|
|
464
|
+
* Recording is EXPLICIT here (via the `onWrite` hook) — it does not rely
|
|
465
|
+
* on any MobX `observe()` side-channel.
|
|
466
|
+
*
|
|
467
|
+
* `_originalData` is intentionally NOT reset here: it stays as the
|
|
468
|
+
* last-persisted baseline until `clearChanges()` runs on sync-ack.
|
|
469
|
+
*/
|
|
470
|
+
applyChanges(data) {
|
|
471
|
+
if (this.isDisposed) {
|
|
472
|
+
throw new AbloValidationError('Cannot update disposed model', {
|
|
473
|
+
code: 'model_disposed',
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
runInAction(() => {
|
|
477
|
+
this.assignFieldsFromData(data, (key, oldValue, newValue) => {
|
|
478
|
+
this.propertyChanged(key, oldValue, newValue);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
this.didUpdate();
|
|
482
|
+
}
|
|
424
483
|
/**
|
|
425
484
|
* Serialize to JSON
|
|
426
485
|
* This method should not trigger MobX reactions since it's used for serialization
|
|
@@ -33,7 +33,8 @@ export declare const noopObservability: SyncObservabilityProvider;
|
|
|
33
33
|
export declare const noopAnalytics: SyncAnalytics;
|
|
34
34
|
/** Browser-native online status provider */
|
|
35
35
|
export declare const browserOnlineStatus: OnlineStatusProvider;
|
|
36
|
-
/**
|
|
36
|
+
/** Session error detector — delegates to SyncSessionError so detection is
|
|
37
|
+
* code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
|
|
37
38
|
export declare const defaultSessionErrorDetector: SessionErrorDetector;
|
|
38
39
|
/**
|
|
39
40
|
* Fallback config used when the context is read before
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* All SDK classes receive this context at construction time.
|
|
5
5
|
* It bundles every injectable dependency so constructors stay clean.
|
|
6
6
|
*/
|
|
7
|
+
import { SyncSessionError } from './errors.js';
|
|
7
8
|
// ─────────────────────────────────────────────
|
|
8
9
|
// No-op defaults for optional dependencies
|
|
9
10
|
// ─────────────────────────────────────────────
|
|
@@ -45,7 +46,8 @@ export const browserOnlineStatus = {
|
|
|
45
46
|
return typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
|
-
/**
|
|
49
|
+
/** Session error detector — delegates to SyncSessionError so detection is
|
|
50
|
+
* code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
|
|
49
51
|
export const defaultSessionErrorDetector = {
|
|
50
52
|
isSessionError(error) {
|
|
51
53
|
if (error && typeof error === 'object' && 'isSessionError' in error) {
|
|
@@ -53,8 +55,8 @@ export const defaultSessionErrorDetector = {
|
|
|
53
55
|
}
|
|
54
56
|
return false;
|
|
55
57
|
},
|
|
56
|
-
isSessionErrorResponse(status) {
|
|
57
|
-
return status
|
|
58
|
+
isSessionErrorResponse(status, body) {
|
|
59
|
+
return SyncSessionError.isSessionErrorResponse(status, body);
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* The helper itself imports nothing app-specific. Open-source-clean.
|
|
21
21
|
*/
|
|
22
22
|
import { Ablo } from '../client/Ablo.js';
|
|
23
|
+
import { AbloConnectionError } from '../errors.js';
|
|
23
24
|
/**
|
|
24
25
|
* Returns a session whose `getAgent` method handles cache, mint,
|
|
25
26
|
* sync_groups alignment, and lifecycle. Call `disposeAll()` from
|
|
@@ -68,9 +69,9 @@ export function createAgentSession(options) {
|
|
|
68
69
|
// `AbloOptions` exposes the URL as `baseURL` (resolved by
|
|
69
70
|
// `resolveBaseURL`). Earlier code passed `url:` here — `Ablo()`
|
|
70
71
|
// silently dropped the unknown field (the cast below masked the
|
|
71
|
-
// type error) and `resolveBaseURL` fell through to the
|
|
72
|
-
// default `wss://api.
|
|
73
|
-
//
|
|
72
|
+
// type error) and `resolveBaseURL` fell through to the hosted
|
|
73
|
+
// default `wss://api.abloatai.com`. Staging surfaced the bug
|
|
74
|
+
// 2026-05-07 — DNS lookup hit the wrong
|
|
74
75
|
// host even though the caller threaded `syncServerUrl` through
|
|
75
76
|
// correctly. Forward as `baseURL` so the caller's URL is the only
|
|
76
77
|
// source of truth and the package default never silently applies.
|
|
@@ -113,8 +114,8 @@ export function createAgentSession(options) {
|
|
|
113
114
|
causeMsg,
|
|
114
115
|
err,
|
|
115
116
|
});
|
|
116
|
-
throw new
|
|
117
|
-
(code ? ` (${code})` : ''));
|
|
117
|
+
throw new AbloConnectionError(`ws bootstrap ${wsUrl} failed: ${e.message ?? 'bootstrap failed'}` +
|
|
118
|
+
(code ? ` (${code})` : ''), { code: 'bootstrap_fetch_timeout', cause: err });
|
|
118
119
|
}
|
|
119
120
|
cacheByKey.set(key, { agent, expiresAtMs: minted.expiresAtMs });
|
|
120
121
|
return agent;
|
|
@@ -80,17 +80,21 @@ function formatCoordinationNote(claims, target) {
|
|
|
80
80
|
const entityLabel = target.entityType.toLowerCase();
|
|
81
81
|
if (claims.length === 1) {
|
|
82
82
|
const c = claims[0];
|
|
83
|
+
const details = c.description ? `Declared work: ${c.description}. ` : '';
|
|
83
84
|
return (`<multiplayer_context>\n` +
|
|
84
85
|
`Another participant is currently editing this ${entityLabel}. ` +
|
|
85
86
|
`Action declared: ${c.reason}. ` +
|
|
87
|
+
details +
|
|
86
88
|
`Defer to their concurrent changes when reasonable, or note your work as complementary to theirs. ` +
|
|
87
89
|
`Avoid stomping their in-flight edits.\n` +
|
|
88
90
|
`</multiplayer_context>`);
|
|
89
91
|
}
|
|
90
92
|
const actions = Array.from(new Set(claims.map((c) => c.reason))).join(', ');
|
|
93
|
+
const descriptions = Array.from(new Set(claims.map((c) => c.description).filter(Boolean))).join('; ');
|
|
91
94
|
return (`<multiplayer_context>\n` +
|
|
92
95
|
`${claims.length} other participants are currently editing this ${entityLabel}. ` +
|
|
93
96
|
`Active actions: ${actions}. ` +
|
|
97
|
+
(descriptions ? `Declared work: ${descriptions}. ` : '') +
|
|
94
98
|
`Coordinate with their in-flight work — defer where reasonable, ` +
|
|
95
99
|
`or describe your work as complementary.\n` +
|
|
96
100
|
`</multiplayer_context>`);
|