@abloatai/ablo 0.24.0 → 0.26.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/AGENTS.md +5 -3
- package/CHANGELOG.md +33 -0
- package/README.md +45 -36
- package/dist/BaseSyncedStore.d.ts +72 -195
- package/dist/BaseSyncedStore.js +240 -625
- package/dist/Database.d.ts +8 -9
- package/dist/Database.js +9 -14
- package/dist/LazyReferenceCollection.js +5 -0
- package/dist/Model.js +6 -8
- package/dist/ModelRegistry.d.ts +25 -6
- package/dist/ModelRegistry.js +12 -6
- package/dist/NetworkMonitor.js +1 -1
- package/dist/ObjectPool.js +9 -3
- package/dist/SyncClient.d.ts +15 -5
- package/dist/SyncClient.js +152 -66
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- package/dist/agent/Agent.d.ts +21 -8
- package/dist/agent/Agent.js +26 -13
- package/dist/ai-sdk/coordination-context.js +9 -3
- package/dist/ai-sdk/wrap.d.ts +1 -1
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +10 -2
- package/dist/auth/credentialSource.d.ts +1 -13
- package/dist/auth/credentialSource.js +6 -13
- package/dist/auth/index.js +3 -3
- package/dist/cli.cjs +583 -287
- package/dist/client/Ablo.d.ts +35 -764
- package/dist/client/Ablo.js +64 -688
- package/dist/client/ApiClient.d.ts +14 -1
- package/dist/client/ApiClient.js +63 -9
- package/dist/client/auth.d.ts +8 -17
- package/dist/client/auth.js +42 -6
- package/dist/client/consoleLogger.d.ts +36 -0
- package/dist/client/consoleLogger.js +45 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.js +10 -6
- package/dist/client/credentialEndpoint.d.ts +63 -0
- package/dist/client/credentialEndpoint.js +87 -0
- package/dist/client/hostedEndpoints.d.ts +24 -0
- package/dist/client/hostedEndpoints.js +24 -0
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/identity.js +2 -1
- package/dist/client/modelRegistration.d.ts +14 -0
- package/dist/client/modelRegistration.js +321 -0
- package/dist/client/options.d.ts +396 -0
- package/dist/client/options.js +10 -0
- package/dist/client/resourceTypes.d.ts +344 -0
- package/dist/client/resourceTypes.js +13 -0
- package/dist/client/schemaConfig.d.ts +56 -0
- package/dist/client/schemaConfig.js +188 -0
- package/dist/client/sessionMint.d.ts +1 -1
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/client/wsMutationExecutor.d.ts +33 -0
- package/dist/client/wsMutationExecutor.js +77 -0
- package/dist/context.js +0 -3
- package/dist/coordination/index.d.ts +8 -1
- package/dist/coordination/index.js +24 -1
- package/dist/coordination/trace.d.ts +0 -1
- package/dist/coordination/trace.js +0 -1
- package/dist/core/DatabaseManager.d.ts +0 -1
- package/dist/core/DatabaseManager.js +26 -24
- package/dist/core/QueryProcessor.js +7 -8
- package/dist/core/QueryView.d.ts +14 -2
- package/dist/core/QueryView.js +4 -5
- package/dist/core/StoreManager.d.ts +2 -2
- package/dist/core/StoreManager.js +5 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/openIDBWithTimeout.js +15 -11
- package/dist/core/query-utils.js +1 -1
- package/dist/core/storeContract.d.ts +143 -0
- package/dist/core/storeContract.js +16 -0
- package/dist/errorCodes.d.ts +11 -2
- package/dist/errorCodes.js +155 -137
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +14 -1
- package/dist/index.js +5 -0
- package/dist/interfaces/index.d.ts +6 -20
- package/dist/keys/index.js +18 -9
- package/dist/mutators/UndoManager.js +6 -1
- package/dist/mutators/defineMutators.d.ts +1 -3
- package/dist/policy/types.d.ts +1 -1
- package/dist/query/client.d.ts +11 -0
- package/dist/query/client.js +81 -52
- package/dist/react/AbloProvider.d.ts +6 -10
- package/dist/react/AbloProvider.js +22 -15
- package/dist/react/context.d.ts +2 -118
- package/dist/react/useAblo.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useMutators.js +7 -1
- package/dist/react/useReactive.js +0 -1
- package/dist/react/useUndoScope.js +2 -2
- package/dist/schema/ddlLock.d.ts +39 -0
- package/dist/schema/ddlLock.js +51 -0
- package/dist/schema/field.js +1 -1
- package/dist/schema/index.d.ts +4 -3
- package/dist/schema/index.js +15 -5
- package/dist/schema/model.d.ts +3 -3
- package/dist/schema/model.js +2 -2
- package/dist/schema/openapi.js +5 -1
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -0
- package/dist/schema/residency.d.ts +35 -0
- package/dist/schema/residency.js +27 -0
- package/dist/schema/roles.d.ts +25 -0
- package/dist/schema/roles.js +37 -0
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/schema.js +11 -7
- package/dist/schema/serialize.d.ts +8 -8
- package/dist/schema/sync-delta-row.d.ts +2 -2
- package/dist/schema/sync-delta-row.js +2 -2
- package/dist/server/storage-mode.d.ts +8 -1
- package/dist/server/storage-mode.js +14 -4
- package/dist/source/adapter.d.ts +1 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -3
- package/dist/source/adapters/memory.js +1 -1
- package/dist/source/adapters/prisma.js +5 -4
- package/dist/source/conformance.js +10 -8
- package/dist/source/connector.js +4 -2
- package/dist/source/factory.d.ts +106 -0
- package/dist/source/factory.js +273 -0
- package/dist/source/index.d.ts +11 -461
- package/dist/source/index.js +14 -416
- package/dist/source/next.d.ts +1 -1
- package/dist/source/next.js +1 -1
- package/dist/source/pushQueue.d.ts +1 -1
- package/dist/source/pushQueue.js +6 -3
- package/dist/source/signing.d.ts +59 -0
- package/dist/source/signing.js +142 -0
- package/dist/source/types.d.ts +334 -0
- package/dist/source/types.js +44 -0
- package/dist/stores/ObjectStore.d.ts +1 -1
- package/dist/stores/ObjectStore.js +23 -23
- package/dist/stores/SyncActionStore.d.ts +1 -1
- package/dist/stores/SyncActionStore.js +70 -35
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/dist/sync/AreaOfInterestManager.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.js +1 -1
- package/dist/sync/BootstrapHelper.d.ts +1 -3
- package/dist/sync/BootstrapHelper.js +29 -8
- package/dist/sync/ConnectionManager.d.ts +8 -0
- package/dist/sync/ConnectionManager.js +19 -4
- package/dist/sync/HydrationCoordinator.d.ts +12 -0
- package/dist/sync/HydrationCoordinator.js +25 -5
- package/dist/sync/NetworkProbe.d.ts +3 -2
- package/dist/sync/NetworkProbe.js +7 -6
- package/dist/sync/SyncWebSocket.d.ts +71 -120
- package/dist/sync/SyncWebSocket.js +324 -630
- package/dist/sync/awaitClaimGrant.js +27 -19
- package/dist/sync/bootstrapApply.d.ts +60 -0
- package/dist/sync/bootstrapApply.js +65 -0
- package/dist/sync/commitFrames.d.ts +43 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.js +3 -2
- package/dist/sync/createSnapshot.d.ts +1 -3
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +329 -0
- package/dist/sync/deltaPipeline.d.ts +111 -0
- package/dist/sync/deltaPipeline.js +262 -0
- package/dist/sync/groupChange.d.ts +111 -0
- package/dist/sync/groupChange.js +246 -0
- package/dist/sync/heartbeat.d.ts +62 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +8 -8
- package/dist/sync/syncCursor.d.ts +44 -0
- package/dist/sync/syncCursor.js +59 -0
- package/dist/sync/syncPlan.d.ts +62 -0
- package/dist/sync/syncPlan.js +51 -0
- package/dist/sync/wsFrameHandlers.d.ts +117 -0
- package/dist/sync/wsFrameHandlers.js +378 -0
- package/dist/testing/fixtures/bootstrap.d.ts +9 -11
- package/dist/testing/fixtures/deltas.d.ts +1 -1
- package/dist/testing/fixtures/models.js +1 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
- package/dist/testing/helpers/sync-engine-harness.js +2 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
- package/dist/testing/mocks/MockSyncContext.js +1 -32
- package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
- package/dist/transactions/TransactionQueue.d.ts +20 -56
- package/dist/transactions/TransactionQueue.js +101 -487
- package/dist/transactions/TransactionStore.d.ts +18 -0
- package/dist/transactions/TransactionStore.js +51 -0
- package/dist/transactions/coalesceRules.d.ts +34 -0
- package/dist/transactions/coalesceRules.js +117 -0
- package/dist/transactions/commitPayload.d.ts +134 -0
- package/dist/transactions/commitPayload.js +152 -0
- package/dist/transactions/deltaConfirmation.d.ts +60 -0
- package/dist/transactions/deltaConfirmation.js +223 -0
- package/dist/transactions/optimistic.d.ts +24 -0
- package/dist/transactions/optimistic.js +45 -0
- package/dist/transactions/persistedReplay.d.ts +93 -0
- package/dist/transactions/persistedReplay.js +105 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.js +0 -5
- package/dist/types/modelData.d.ts +12 -0
- package/dist/types/modelData.js +11 -0
- package/dist/types/participant.d.ts +21 -0
- package/dist/types/participant.js +12 -0
- package/dist/types/streams.d.ts +8 -14
- package/dist/utils/mobx-setup.d.ts +1 -1
- package/dist/utils/mobx-setup.js +2 -2
- package/dist/wire/errorEnvelope.d.ts +11 -2
- package/dist/wire/errorEnvelope.js +14 -2
- package/dist/wire/frames.d.ts +90 -0
- package/dist/wire/frames.js +55 -1
- package/dist/wire/index.d.ts +4 -1
- package/dist/wire/index.js +20 -1
- package/dist/wire/protocol.d.ts +45 -0
- package/dist/wire/protocol.js +45 -0
- package/dist/wire/protocolVersion.d.ts +56 -0
- package/dist/wire/protocolVersion.js +63 -0
- package/docs/api-keys.md +4 -3
- package/docs/data-sources.md +12 -5
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/llms.txt +4 -2
- package/package.json +15 -13
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/index.d.ts +0 -16
- package/dist/transactions/index.js +0 -7
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentialLifecycle — reconnect-credential re-mint + proactive pre-roll.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from BaseSyncedStore.ts as a cohesive leaf: owns the re-mint
|
|
5
|
+
* hook, the single-flight guard, and the browser-only proactive refresh
|
|
6
|
+
* (timer + OS-wake listener). The store keeps thin public delegates
|
|
7
|
+
* (`setCredentialRefresher` / `performCredentialRefresh` /
|
|
8
|
+
* `startCredentialLifecycle`) so its published surface is unchanged, and
|
|
9
|
+
* talks back through the minimal {@link CredentialLifecycleContext} —
|
|
10
|
+
* never the store's class type — so no module cycle forms.
|
|
11
|
+
*/
|
|
12
|
+
import { getContext } from '../context.js';
|
|
13
|
+
/** Fallback pre-roll cadence AND ceiling — the historical 10-minute value,
|
|
14
|
+
* comfortably inside the server's 15m default `ek_` TTL. Used verbatim when
|
|
15
|
+
* the refresher reports no expiry. */
|
|
16
|
+
export const DEFAULT_PREROLL_INTERVAL_MS = 10 * 60 * 1000;
|
|
17
|
+
/** Floor so a very short (or already-elapsed) TTL can't hot-loop the mint. */
|
|
18
|
+
export const MIN_PREROLL_DELAY_MS = 30 * 1000;
|
|
19
|
+
/**
|
|
20
|
+
* When to pre-roll a credential that expires at `expiresAtMs`: after ~2/3 of
|
|
21
|
+
* the REMAINING lifetime (15m TTL → 10m, exactly the historical cadence),
|
|
22
|
+
* clamped to [{@link MIN_PREROLL_DELAY_MS}, {@link DEFAULT_PREROLL_INTERVAL_MS}].
|
|
23
|
+
* Unknown expiry (`null`) → the 10-minute fallback. Exported for unit tests.
|
|
24
|
+
*/
|
|
25
|
+
export function computePrerollDelayMs(expiresAtMs, nowMs) {
|
|
26
|
+
if (expiresAtMs === null || !Number.isFinite(expiresAtMs)) {
|
|
27
|
+
return DEFAULT_PREROLL_INTERVAL_MS;
|
|
28
|
+
}
|
|
29
|
+
const remaining = expiresAtMs - nowMs;
|
|
30
|
+
if (remaining <= 0)
|
|
31
|
+
return MIN_PREROLL_DELAY_MS;
|
|
32
|
+
const twoThirds = Math.floor((remaining * 2) / 3);
|
|
33
|
+
return Math.min(Math.max(twoThirds, MIN_PREROLL_DELAY_MS), DEFAULT_PREROLL_INTERVAL_MS);
|
|
34
|
+
}
|
|
35
|
+
/** Narrow a refresher-supplied `expiresAt` to epoch ms, or `null` when
|
|
36
|
+
* absent/unparseable (→ fallback cadence, never a crash). */
|
|
37
|
+
function normalizeExpiresAtMs(expiresAt) {
|
|
38
|
+
if (expiresAt === undefined)
|
|
39
|
+
return null;
|
|
40
|
+
const ms = expiresAt instanceof Date
|
|
41
|
+
? expiresAt.getTime()
|
|
42
|
+
: typeof expiresAt === 'number'
|
|
43
|
+
? expiresAt
|
|
44
|
+
: Date.parse(expiresAt);
|
|
45
|
+
return Number.isFinite(ms) ? ms : null;
|
|
46
|
+
}
|
|
47
|
+
export class CredentialLifecycle {
|
|
48
|
+
ctx;
|
|
49
|
+
/**
|
|
50
|
+
* Re-mint hook for the short-lived access credential (the Stripe-style
|
|
51
|
+
* `ek_`/`rk_`). Wired by the React provider from its `getToken`/`authEndpoint`
|
|
52
|
+
* — the engine owns WHEN to refresh (a stale-credential probe / an external
|
|
53
|
+
* nudge), the integrator owns HOW to mint. Mirrors the `getToken` contract:
|
|
54
|
+
* resolves a token string on success, `null` when the long-lived login is
|
|
55
|
+
* gone (terminal), and THROWS on a transient/offline failure. Used by
|
|
56
|
+
* {@link refresh}. Absent ⇒ no silent re-mint (e.g. a static
|
|
57
|
+
* `apiKey` deployment whose credential source refreshes out-of-band).
|
|
58
|
+
*/
|
|
59
|
+
credentialRefresher = null;
|
|
60
|
+
/** Single-flight guard so a wake nudge + an in-flight request + a probe don't
|
|
61
|
+
* all mint at once (the classic "token thrash → random logout" bug). */
|
|
62
|
+
inFlightCredentialRefresh = null;
|
|
63
|
+
/** Teardown for the proactive credential lifecycle (refresh timer + wake/
|
|
64
|
+
* online/focus listeners) installed by {@link start};
|
|
65
|
+
* cleared on `BaseSyncedStore.disconnect`. Null when no resolver is wired. */
|
|
66
|
+
credentialLifecycleTeardown = null;
|
|
67
|
+
/** Epoch ms the CURRENT credential expires, when the refresher reports it
|
|
68
|
+
* (object-form {@link CredentialRefreshResult}); `null` for the classic
|
|
69
|
+
* string-form resolver → the pre-roll falls back to its fixed cadence. */
|
|
70
|
+
credentialExpiresAtMs = null;
|
|
71
|
+
/** Re-arm hook for the proactive pre-roll timer (set by {@link start}).
|
|
72
|
+
* Invoked after every successful mint so a REACTIVE refresh (probe, wake,
|
|
73
|
+
* the initial `ready()` mint) immediately re-anchors the schedule to the
|
|
74
|
+
* fresh credential's real expiry instead of a stale fixed delay. */
|
|
75
|
+
prerollReschedule = null;
|
|
76
|
+
constructor(ctx) {
|
|
77
|
+
this.ctx = ctx;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Register the access-credential re-mint hook. Called by the React provider
|
|
81
|
+
* with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
|
|
82
|
+
* See {@link credentialRefresher}.
|
|
83
|
+
*/
|
|
84
|
+
setRefresher(refresher) {
|
|
85
|
+
this.credentialRefresher = refresher;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Re-mint the short-lived access credential and push it into the credential
|
|
89
|
+
* source, reporting a tri-state outcome the `ConnectionManager` maps to
|
|
90
|
+
* its FSM. The contract mirrors `getToken` (and PowerSync's `fetchCredentials`
|
|
91
|
+
* / Liveblocks' `authEndpoint`, but made explicit instead of overloading
|
|
92
|
+
* return/throw):
|
|
93
|
+
* - token string → `'refreshed'` (fresh key in place; re-probe & reconnect)
|
|
94
|
+
* - `null` → `'session_error'` (login itself is gone → terminal, sign out)
|
|
95
|
+
* - throw → `'network_error'` (couldn't reach the mint endpoint → transient)
|
|
96
|
+
*
|
|
97
|
+
* SINGLE-FLIGHT: concurrent callers (a wake nudge, an in-flight request, the
|
|
98
|
+
* probe) share one in-flight promise so we never double-mint — the canonical
|
|
99
|
+
* fix for the "every 401 mints a token → thrash → spurious logout" anti-pattern.
|
|
100
|
+
*
|
|
101
|
+
* No refresher wired ⇒ `'refreshed'` (a no-op re-probe): a static-`apiKey`
|
|
102
|
+
* deployment has no session to re-mint from; its credential source refreshes
|
|
103
|
+
* out-of-band, so we just re-probe with whatever it currently holds.
|
|
104
|
+
*/
|
|
105
|
+
async refresh() {
|
|
106
|
+
const refresher = this.credentialRefresher;
|
|
107
|
+
if (!refresher)
|
|
108
|
+
return 'refreshed';
|
|
109
|
+
if (this.inFlightCredentialRefresh)
|
|
110
|
+
return this.inFlightCredentialRefresh;
|
|
111
|
+
const run = (async () => {
|
|
112
|
+
try {
|
|
113
|
+
const result = await refresher();
|
|
114
|
+
const token = typeof result === 'string' ? result : result?.token;
|
|
115
|
+
if (!token) {
|
|
116
|
+
// null = the long-lived login is gone (mint endpoint answered 401/403).
|
|
117
|
+
// Terminal — the FSM routes this to sign-out.
|
|
118
|
+
return 'session_error';
|
|
119
|
+
}
|
|
120
|
+
// Object-form resolvers carry the mint's actual expiry; remember it so
|
|
121
|
+
// the proactive pre-roll schedules off the real TTL (string-form
|
|
122
|
+
// resolvers leave it null → fixed fallback cadence).
|
|
123
|
+
this.credentialExpiresAtMs =
|
|
124
|
+
typeof result === 'object' && result !== null
|
|
125
|
+
? normalizeExpiresAtMs(result.expiresAt)
|
|
126
|
+
: null;
|
|
127
|
+
this.ctx.setAuthToken(token);
|
|
128
|
+
// Re-anchor the proactive pre-roll to the fresh credential's expiry.
|
|
129
|
+
this.prerollReschedule?.();
|
|
130
|
+
return 'refreshed';
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// A throw = transient (offline / mint endpoint unreachable / 5xx). The
|
|
134
|
+
// login may be perfectly valid; never sign out for this — back off and
|
|
135
|
+
// retry. Mirrors the `getToken` throw-vs-null contract end-to-end.
|
|
136
|
+
const message = error?.message ?? String(error);
|
|
137
|
+
// A relative-URL resolver invoked server-side (Node fetch has no origin
|
|
138
|
+
// to resolve against) emits the opaque "Failed to parse URL" / "Only
|
|
139
|
+
// absolute URLs are supported". Translate it into something actionable
|
|
140
|
+
// instead of a mystery transient blip — the proactive refresh is now
|
|
141
|
+
// browser-only, so hitting this means the resolver fired from SSR/RSC or
|
|
142
|
+
// a server route.
|
|
143
|
+
if (typeof window === 'undefined' && /parse URL|absolute URLs?/i.test(message)) {
|
|
144
|
+
getContext().logger.warn('credential resolver ran on the server with a relative URL — Node fetch needs an absolute URL. ' +
|
|
145
|
+
'Refresh the Ablo client in the browser, or build an absolute URL server-side ' +
|
|
146
|
+
"(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
getContext().logger.debug('access-credential re-mint failed (transient)', { error: message });
|
|
150
|
+
}
|
|
151
|
+
return 'network_error';
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
this.inFlightCredentialRefresh = run;
|
|
155
|
+
try {
|
|
156
|
+
return await run;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
this.inFlightCredentialRefresh = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Route a refresh outcome into the FSM — the ONE place the tri-state is
|
|
164
|
+
* interpreted, shared by the proactive pre-roll, the wake nudge, and the
|
|
165
|
+
* HTTP auth-recovery path so every trigger converges on identical behavior.
|
|
166
|
+
*/
|
|
167
|
+
routeRefreshOutcome(outcome) {
|
|
168
|
+
if (outcome === 'refreshed') {
|
|
169
|
+
// Fresh key already pushed into the credential source by `refresh`;
|
|
170
|
+
// nudge a parked connection to re-probe with it.
|
|
171
|
+
this.ctx.nudgeReconnect();
|
|
172
|
+
return 'retry';
|
|
173
|
+
}
|
|
174
|
+
if (outcome === 'session_error') {
|
|
175
|
+
// The long-lived login is gone (mint answered 401/403). Surface it —
|
|
176
|
+
// a no-op in FSM states that don't accept the event (the probe
|
|
177
|
+
// converges on sign-out there anyway); `session_expired`'s onEnter
|
|
178
|
+
// owns the authoritative log.
|
|
179
|
+
this.ctx.reportSessionExpired();
|
|
180
|
+
return 'stop';
|
|
181
|
+
}
|
|
182
|
+
// 'network_error' → transient (offline / mint hiccup); the next proactive
|
|
183
|
+
// tick or the FSM's own probe retries. Never sign out, never replay now.
|
|
184
|
+
return 'stop';
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* THE recovery backbone for auth-rejected requests, whatever the transport.
|
|
188
|
+
* The WS probe already routes 401s through the FSM; HTTP consumers (the
|
|
189
|
+
* lazy-query lane, bootstrap helpers) call THIS instead of inventing their
|
|
190
|
+
* own handling — one single-flight mint, one outcome routing, one taxonomy.
|
|
191
|
+
*
|
|
192
|
+
* Mirrors `NetworkProbe`'s classification of the same codes:
|
|
193
|
+
* - `access_credential_expiry` — the routine case (an expired `ek_`/`rk_`):
|
|
194
|
+
* silently re-mint via the single-flight {@link refresh} and tell the
|
|
195
|
+
* caller to replay ONCE on success. Never a sign-out by itself — the
|
|
196
|
+
* only terminal path is the mint itself resolving `null`.
|
|
197
|
+
* - `session_expiry` — the login is gone. Report to the FSM (sign-out
|
|
198
|
+
* path) and stop; replaying is pointless.
|
|
199
|
+
* - `auth_blocked` / `permission` / everything else — re-minting produces
|
|
200
|
+
* the same rejected credential; stop without touching the FSM.
|
|
201
|
+
*/
|
|
202
|
+
async recoverFromAuthRejection(recovery) {
|
|
203
|
+
switch (recovery) {
|
|
204
|
+
case 'access_credential_expiry':
|
|
205
|
+
return this.routeRefreshOutcome(await this.refresh());
|
|
206
|
+
case 'session_expiry':
|
|
207
|
+
this.ctx.reportSessionExpired();
|
|
208
|
+
return 'stop';
|
|
209
|
+
default:
|
|
210
|
+
return 'stop';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Install the access-credential lifecycle the CLIENT owns (this used to live
|
|
215
|
+
* in the React provider — wrong layer). Two parts:
|
|
216
|
+
* 1. REACTIVE — register `getToken` as the re-mint hook the FSM calls when a
|
|
217
|
+
* probe finds the key stale (`credential_stale`) or on a nudge.
|
|
218
|
+
* 2. PROACTIVE — keep the short-lived key fresh ahead of trouble: a refresh
|
|
219
|
+
* timer inside the TTL, plus re-mint on OS wake. The ENTIRE proactive
|
|
220
|
+
* block is browser-gated (`typeof window`): server/SSR has no socket to
|
|
221
|
+
* keep warm and the resolver is browser-oriented, so arming it in Node
|
|
222
|
+
* would fire a relative-URL fetch and throw. (Agents pass a static
|
|
223
|
+
* `apiKey` with no resolver, so this method is never called for them.)
|
|
224
|
+
*
|
|
225
|
+
* Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
|
|
226
|
+
* never call a refresh method. Idempotent (a second call replaces the first);
|
|
227
|
+
* torn down on `BaseSyncedStore.disconnect`.
|
|
228
|
+
*
|
|
229
|
+
* `opts.proactiveInNode` arms the refresh TIMER on a windowless host too —
|
|
230
|
+
* set for agent/system participants (long-lived server sockets whose
|
|
231
|
+
* `rk_`/`ek_` must renew BEFORE the hub's keepalive reaper closes them; the
|
|
232
|
+
* PowerSync/Ably "SDK renews everywhere" model). Node timers are `unref`ed
|
|
233
|
+
* so a finishing script is never kept alive by the pre-roll. The OS-wake
|
|
234
|
+
* listener remains browser-only regardless (no `window` to listen on).
|
|
235
|
+
*/
|
|
236
|
+
start(getToken, opts) {
|
|
237
|
+
this.stop();
|
|
238
|
+
this.setRefresher(getToken);
|
|
239
|
+
// Re-mint through the SAME single-flight path the FSM's reactive probe uses
|
|
240
|
+
// (`refresh`) rather than calling `getToken()` directly. Two
|
|
241
|
+
// wins over the old direct call:
|
|
242
|
+
// - SINGLE-FLIGHT: a wake nudge, an in-flight probe, and this proactive
|
|
243
|
+
// roll share one in-flight promise — no double-mint thrash.
|
|
244
|
+
// - The tri-state is HONOURED. The old code did `if (token) {…}` and
|
|
245
|
+
// dropped a `null` on the floor — a zombie session that re-minted on
|
|
246
|
+
// every tab focus and logged "signing out" forever without ever signing
|
|
247
|
+
// out. `session_error` now drives the FSM to actually expire.
|
|
248
|
+
const refresh = async () => {
|
|
249
|
+
// Same outcome routing as every other trigger (see routeRefreshOutcome):
|
|
250
|
+
// refreshed → nudge, session_error → report, network_error → wait for
|
|
251
|
+
// the next tick / the FSM's own probe. Never sign out for a transient.
|
|
252
|
+
this.routeRefreshOutcome(await this.refresh());
|
|
253
|
+
};
|
|
254
|
+
const teardowns = [];
|
|
255
|
+
// The proactive pre-roll arms in the browser, or in Node when the host
|
|
256
|
+
// OPTED IN (`proactiveInNode` — agent/system participants). The default
|
|
257
|
+
// Node posture stays reactive-only because a Next.js SSR/RSC eval of the
|
|
258
|
+
// `providers` module constructs user-kind clients whose scaffolded
|
|
259
|
+
// resolver is browser-oriented (a relative-URL `fetch('/api/ablo-session')`)
|
|
260
|
+
// — arming a timer there fires that resolver in Node, where fetch has no
|
|
261
|
+
// origin to resolve a relative URL against → "Failed to parse URL" on
|
|
262
|
+
// every tick. Long-lived server participants are the opposite case: their
|
|
263
|
+
// socket MUST outlive the credential TTL, so they get the timer (the
|
|
264
|
+
// PowerSync/Ably model — "the SDK invokes fetchCredentials when a token is
|
|
265
|
+
// nearing expiry", on every platform). The reactive re-mint hook
|
|
266
|
+
// (`setRefresher` above) stays UNCONDITIONAL: it only fires on a
|
|
267
|
+
// real connection probe, which can't happen during a bare SSR module eval.
|
|
268
|
+
if (typeof window !== 'undefined' || opts?.proactiveInNode === true) {
|
|
269
|
+
// A missed (background-throttled) tick is recovered by the next, or by
|
|
270
|
+
// the reactive probe. The timer is the sole proactive PRE-ROLL — it keeps
|
|
271
|
+
// the key warm ahead of expiry even while the socket sits healthy-
|
|
272
|
+
// `connected` (a state the FSM never probes). The delay is derived from
|
|
273
|
+
// the minted credential's ACTUAL `expiresAt` when the refresher reports
|
|
274
|
+
// one (~2/3 of remaining TTL), with the historical 10-minute value as
|
|
275
|
+
// ceiling AND fallback — a deployment minting shorter-TTL keys no longer
|
|
276
|
+
// outlives a fixed pre-roll and drops to reactive-only recovery.
|
|
277
|
+
let timer = null;
|
|
278
|
+
const scheduleNext = () => {
|
|
279
|
+
if (timer !== null)
|
|
280
|
+
clearTimeout(timer); // idempotent re-arm
|
|
281
|
+
const delay = computePrerollDelayMs(this.credentialExpiresAtMs, Date.now());
|
|
282
|
+
timer = setTimeout(() => {
|
|
283
|
+
// `.finally` keeps the chain alive on transient failures; a
|
|
284
|
+
// SUCCESSFUL mint already re-armed via `prerollReschedule` (no-op
|
|
285
|
+
// double-arm thanks to the clearTimeout above).
|
|
286
|
+
void refresh().finally(scheduleNext);
|
|
287
|
+
}, delay);
|
|
288
|
+
// Node: never keep a finishing process alive just for the pre-roll
|
|
289
|
+
// (browser timers have no unref — optional call is a no-op there).
|
|
290
|
+
timer.unref?.();
|
|
291
|
+
};
|
|
292
|
+
scheduleNext();
|
|
293
|
+
this.prerollReschedule = scheduleNext;
|
|
294
|
+
teardowns.push(() => {
|
|
295
|
+
if (timer !== null)
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
timer = null;
|
|
298
|
+
this.prerollReschedule = null;
|
|
299
|
+
});
|
|
300
|
+
// OS-wake (desktop only): the Electron shell bridges `powerMonitor`
|
|
301
|
+
// 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
|
|
302
|
+
// still owns, because `visibilitychange` does NOT fire on wake-from-sleep
|
|
303
|
+
// and — unlike `online`/`visibilitychange` — the ConnectionManager's own
|
|
304
|
+
// browser listeners (`setupBrowserListeners`) don't cover wake.
|
|
305
|
+
// Browser-gated separately from the timer: a `proactiveInNode` host has
|
|
306
|
+
// no `window` to listen on (and no OS sleep to wake from).
|
|
307
|
+
//
|
|
308
|
+
// The `online` and `visibilitychange` listeners that used to live here
|
|
309
|
+
// were REMOVED: the FSM already re-probes on NETWORK_ONLINE / TAB_VISIBLE
|
|
310
|
+
// through this exact credential path, so registering them here too only
|
|
311
|
+
// fired a second, null-swallowing mint per focus — the "session-key
|
|
312
|
+
// POSTed on every tab focus" spam in the console.
|
|
313
|
+
if (typeof window !== 'undefined') {
|
|
314
|
+
const onWake = () => void refresh();
|
|
315
|
+
window.addEventListener('ablo:wake', onWake);
|
|
316
|
+
teardowns.push(() => { window.removeEventListener('ablo:wake', onWake); });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.credentialLifecycleTeardown = () => {
|
|
320
|
+
for (const t of teardowns)
|
|
321
|
+
t();
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Tear down the proactive credential lifecycle (idempotent). */
|
|
325
|
+
stop() {
|
|
326
|
+
this.credentialLifecycleTeardown?.();
|
|
327
|
+
this.credentialLifecycleTeardown = null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deltaPipeline — the incoming-delta dedup/batch/flush pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from BaseSyncedStore.ts as a cohesive leaf: state-signature
|
|
5
|
+
* dedup, per-delta bookkeeping + enqueue, the live-traffic debounce, and
|
|
6
|
+
* the IDB→pool flush. The store keeps thin protected delegates with
|
|
7
|
+
* unchanged signatures, and the leaf routes every call to a protected
|
|
8
|
+
* override point (getStateFields, isCustomEntity, deduplicateDeltas,
|
|
9
|
+
* flushPendingDeltas, the G/S handlers, …) back through the minimal
|
|
10
|
+
* {@link DeltaPipelineContext} so subclass dynamic dispatch is preserved.
|
|
11
|
+
* `applyDeltaFrame` — the authoritative-apply correctness seam — stays in
|
|
12
|
+
* BaseSyncedStore and drives this pipeline via `enqueueDelta` +
|
|
13
|
+
* `flushPendingDeltas`.
|
|
14
|
+
*/
|
|
15
|
+
import { ModelScope } from '../ObjectPool.js';
|
|
16
|
+
import type { Model } from '../Model.js';
|
|
17
|
+
import type { ModelData } from '../types/modelData.js';
|
|
18
|
+
import type { SyncDelta } from './SyncWebSocket.js';
|
|
19
|
+
/** One applied-delta result out of `Database.processDeltaBatch`, forwarded to the pool. */
|
|
20
|
+
interface DeltaDbResult {
|
|
21
|
+
action: 'add' | 'update' | 'remove' | 'archive' | 'verify';
|
|
22
|
+
modelName: string;
|
|
23
|
+
modelId: string;
|
|
24
|
+
data?: ModelData | null;
|
|
25
|
+
transactionId?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* What the pipeline needs back from its host store: the shared mutable
|
|
29
|
+
* pipeline state (backed by host fields via get/set accessors), narrow
|
|
30
|
+
* persistence/pool facades, and the host's own protected hooks so subclass
|
|
31
|
+
* overrides keep taking effect.
|
|
32
|
+
*/
|
|
33
|
+
export interface DeltaPipelineContext {
|
|
34
|
+
pendingDeltas: SyncDelta[];
|
|
35
|
+
batchTimer: ReturnType<typeof setTimeout> | null;
|
|
36
|
+
/** Queue for deltas arriving during an active bootstrap; null when none. */
|
|
37
|
+
readonly bootstrapDeltaQueue: SyncDelta[] | null;
|
|
38
|
+
readonly smartSyncOptions: {
|
|
39
|
+
readonly batchingDelay: number;
|
|
40
|
+
readonly maxBatchSize: number;
|
|
41
|
+
};
|
|
42
|
+
/** Pool-applied cursor (`syncClient.position.applied`). */
|
|
43
|
+
readonly highestProcessedSyncId: number;
|
|
44
|
+
/** Resume/ack cursor (`syncClient.position.persisted`). */
|
|
45
|
+
readonly lastAckedId: number;
|
|
46
|
+
onDeltaReceived(syncId: number): void;
|
|
47
|
+
advanceApplied(syncId: number): void;
|
|
48
|
+
advancePersisted(syncId: number): void;
|
|
49
|
+
processDeltaBatch(deltas: {
|
|
50
|
+
syncId?: number;
|
|
51
|
+
actionType: SyncDelta['actionType'];
|
|
52
|
+
modelName: string;
|
|
53
|
+
modelId: string;
|
|
54
|
+
data: ModelData | null;
|
|
55
|
+
transactionId?: string;
|
|
56
|
+
}[]): Promise<{
|
|
57
|
+
results: DeltaDbResult[];
|
|
58
|
+
persistedSyncId: number;
|
|
59
|
+
}>;
|
|
60
|
+
/** `SyncClient.applyDeltaBatchToPool` with the host's `enrichRelations` bound. */
|
|
61
|
+
applyDeltaBatchToPool(results: DeltaDbResult[]): void;
|
|
62
|
+
/** `syncWebSocket?.acknowledge?.(syncId)` — no-op when the socket is down. */
|
|
63
|
+
acknowledge(syncId: number): void;
|
|
64
|
+
readonly objectPool: {
|
|
65
|
+
get(id: string): Model | undefined;
|
|
66
|
+
add(model: Model, scope: ModelScope): void;
|
|
67
|
+
remove(id: string): boolean;
|
|
68
|
+
/** Full in-memory clear — the revocation-failure fallback (see
|
|
69
|
+
* {@link handleGroupHandlerFailure}). */
|
|
70
|
+
clear(): void;
|
|
71
|
+
};
|
|
72
|
+
getStateFields(modelName: string): string[];
|
|
73
|
+
isCustomEntity(modelName: string): boolean;
|
|
74
|
+
createCustomEntity(modelName: string, modelId: string, data: Record<string, unknown>): Model | null;
|
|
75
|
+
deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
|
|
76
|
+
flushPendingDeltas(): Promise<void>;
|
|
77
|
+
handleFlushError(error: unknown): void;
|
|
78
|
+
handleSyncGroupChange(delta: SyncDelta): Promise<void>;
|
|
79
|
+
handleGroupRemoved(delta: SyncDelta): Promise<void>;
|
|
80
|
+
/** Host's re-bootstrap trigger — the revocation-failure fallback. */
|
|
81
|
+
forceFullRebootstrap(): void;
|
|
82
|
+
cascadeCancelTransactionsForDeletedParent(parentModelName: string, parentId: string): void;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A 'G'/'S' handler rejected AFTER the applied watermark already advanced —
|
|
86
|
+
* the delta will never be re-delivered, so a failed SECURITY clear (revoked
|
|
87
|
+
* data must not persist) would otherwise be permanently silent. Fall back to
|
|
88
|
+
* the bluntest safe response: drop the whole in-memory pool and force a full
|
|
89
|
+
* re-bootstrap so local state is rebuilt from server truth. Never throws —
|
|
90
|
+
* this runs inside the delta pipeline's fire-and-forget seam.
|
|
91
|
+
*/
|
|
92
|
+
export declare function handleGroupHandlerFailure(ctx: DeltaPipelineContext, delta: SyncDelta, error: unknown): void;
|
|
93
|
+
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
94
|
+
export declare function deduplicateDeltas(ctx: DeltaPipelineContext, deltas: SyncDelta[]): SyncDelta[];
|
|
95
|
+
/**
|
|
96
|
+
* Per-delta bookkeeping + enqueue. Returns `true` when the delta was
|
|
97
|
+
* pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
|
|
98
|
+
* subsequent flush must drain), `false` when it was skipped (dedup),
|
|
99
|
+
* deferred (bootstrap queue), or handled immediately out-of-band (G/S
|
|
100
|
+
* sync-group mutations). Does NOT schedule a flush — callers decide
|
|
101
|
+
* whether to debounce (live) or flush atomically (catch-up frame).
|
|
102
|
+
*/
|
|
103
|
+
export declare function enqueueDelta(ctx: DeltaPipelineContext, delta: SyncDelta, options?: {
|
|
104
|
+
authoritative?: boolean;
|
|
105
|
+
}): boolean;
|
|
106
|
+
/** Debounce a flush for live single-delta traffic. */
|
|
107
|
+
export declare function scheduleDeltaFlush(ctx: DeltaPipelineContext): void;
|
|
108
|
+
/** Flush pending deltas with deduplication and batched ObjectPool mutations */
|
|
109
|
+
/** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
|
|
110
|
+
export declare function flushPendingDeltas(ctx: DeltaPipelineContext): Promise<void>;
|
|
111
|
+
export {};
|