@abloatai/ablo 0.8.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +86 -50
  52. package/dist/mutators/UndoManager.js +129 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Next.js App Router adapter for Data Source. The core `dataSource()` already
3
+ * returns a Web-standard `(Request) => Promise<Response>`, which Next App Router
4
+ * accepts directly — so this is pure ergonomics: wire an ORM `adapter` in via the
5
+ * bridge and hand back a named `POST` so the customer's route file is the minimum:
6
+ *
7
+ * // app/api/ablo/source/route.ts
8
+ * import { dataSourceNext } from '@abloatai/ablo/source/next';
9
+ * import { prismaDataSource } from '@abloatai/ablo/source';
10
+ * import { schema } from '@/ablo/schema';
11
+ * import { prisma } from '@/lib/prisma';
12
+ *
13
+ * export const { POST } = dataSourceNext({
14
+ * schema,
15
+ * apiKey: process.env.ABLO_API_KEY!,
16
+ * adapter: prismaDataSource(prisma, schema),
17
+ * });
18
+ *
19
+ * Day-one scope: Next + the adapter form only. Hand-written handlers use the core
20
+ * `dataSource()` directly; Hono/Express are the same one-liner and land on demand
21
+ * — not pre-built.
22
+ */
23
+ import { dataSource } from './index.js';
24
+ export function dataSourceNext(options) {
25
+ return { POST: dataSource(options) };
26
+ }
@@ -61,7 +61,13 @@ export interface BootstrapOptions {
61
61
  * old clients that don't send a models param).
62
62
  */
63
63
  instantModels?: string[];
64
+ /**
65
+ * Shared SDK credential getter. Preferred over `setAuthToken`; read at
66
+ * request time so token refreshes apply without recreating BootstrapHelper.
67
+ */
68
+ getAuthToken?: AuthTokenGetter;
64
69
  }
70
+ import { type AuthTokenGetter } from '../auth/credentialSource.js';
65
71
  import { type ValidatedServerDelta } from './schemas.js';
66
72
  export declare class BootstrapHelper {
67
73
  private options;
@@ -74,6 +80,10 @@ export declare class BootstrapHelper {
74
80
  */
75
81
  setCacheScope(cacheScope: string): void;
76
82
  setSyncGroups(syncGroups: readonly string[] | undefined): void;
83
+ /**
84
+ * Compatibility setter for direct BootstrapHelper users. The SDK-owned
85
+ * `Ablo()` path passes `getAuthToken` and does not mutate this helper.
86
+ */
77
87
  setAuthToken(authToken: string | undefined): void;
78
88
  /**
79
89
  * Create a promise that rejects after a timeout
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { getContext } from '../context.js';
6
6
  import { SyncSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '../errors.js';
7
+ import { withAuthHeaders } from '../auth/credentialSource.js';
7
8
  // SyncObservability replaced by getContext().observability
8
9
  import { parseBootstrapResponse } from './schemas.js';
9
10
  export class BootstrapHelper {
@@ -46,6 +47,10 @@ export class BootstrapHelper {
46
47
  setSyncGroups(syncGroups) {
47
48
  this.options.syncGroups = [...(syncGroups ?? [])];
48
49
  }
50
+ /**
51
+ * Compatibility setter for direct BootstrapHelper users. The SDK-owned
52
+ * `Ablo()` path passes `getAuthToken` and does not mutate this helper.
53
+ */
49
54
  setAuthToken(authToken) {
50
55
  if (!authToken) {
51
56
  delete this.options.authToken;
@@ -197,15 +202,11 @@ export class BootstrapHelper {
197
202
  // conditional revalidation (If-None-Match) implement it at their own
198
203
  // level where they own the cache-key namespace. The 304 branch below
199
204
  // remains defensively in place for when a caller enables revalidation.
200
- const headers = { 'Content-Type': 'application/json' };
201
- if (this.options.authToken) {
202
- headers.Authorization = `Bearer ${this.options.authToken}`;
203
- }
205
+ const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
204
206
  this.abortController = new AbortController();
205
207
  const res = await fetch(url, {
206
208
  method: 'GET',
207
209
  headers,
208
- credentials: 'include',
209
210
  signal: this.abortController.signal,
210
211
  });
211
212
  const etag = res.headers.get('ETag');
@@ -272,15 +273,11 @@ export class BootstrapHelper {
272
273
  try {
273
274
  response = await fetch(url, {
274
275
  method: 'GET',
275
- headers: {
276
+ headers: withAuthHeaders(this.options.getAuthToken, {
276
277
  'Content-Type': 'application/json',
277
278
  'Cache-Control': 'no-cache, no-store, must-revalidate',
278
279
  Pragma: 'no-cache',
279
- ...(this.options.authToken
280
- ? { Authorization: `Bearer ${this.options.authToken}` }
281
- : {}),
282
- },
283
- credentials: 'include',
280
+ }, this.options.authToken),
284
281
  signal: this.abortController.signal,
285
282
  cache: 'no-store', // Force browser to not cache
286
283
  });
@@ -337,10 +334,9 @@ export class BootstrapHelper {
337
334
  const url = `${this.options.baseUrl}/sync/entity/${modelName}/${id}`;
338
335
  const response = await fetch(url, {
339
336
  method: 'GET',
340
- headers: {
337
+ headers: withAuthHeaders(this.options.getAuthToken, {
341
338
  'Content-Type': 'application/json',
342
- },
343
- credentials: 'include',
339
+ }, this.options.authToken),
344
340
  });
345
341
  if (response.status === 404) {
346
342
  return null;
@@ -436,7 +432,6 @@ export class BootstrapHelper {
436
432
  try {
437
433
  const response = await fetch(`${this.options.baseUrl}/health`, {
438
434
  method: 'GET',
439
- credentials: 'include',
440
435
  signal: AbortSignal.timeout(5000),
441
436
  cache: 'no-store',
442
437
  });
@@ -34,7 +34,8 @@
34
34
  * instead of hard-reloading an already-offline browser.
35
35
  */
36
36
  import { type ProbeResult } from './NetworkProbe.js';
37
- export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'auth_blocked' | 'session_expired';
37
+ import type { AuthTokenGetter } from '../auth/credentialSource.js';
38
+ export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'refreshing_credential' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'auth_blocked' | 'session_expired';
38
39
  export type ConnectionEvent = {
39
40
  type: 'NETWORK_LOST';
40
41
  } | {
@@ -54,8 +55,18 @@ export type ConnectionEvent = {
54
55
  sessionValid: boolean;
55
56
  } | {
56
57
  type: 'PROBE_AUTH_BLOCKED';
58
+ }
59
+ /** The probe saw an expired ephemeral access key (`access_credential_expiry`).
60
+ * Recoverable: re-mint a fresh `ek_`/`rk_` and re-probe — never a sign-out. */
61
+ | {
62
+ type: 'PROBE_CREDENTIAL_STALE';
57
63
  } | {
58
64
  type: 'PROBE_FAILED';
65
+ }
66
+ /** A fresh access credential is available (the re-mint succeeded, or one was
67
+ * pushed in via `setAuthToken`). Re-probe so a parked connection picks it up. */
68
+ | {
69
+ type: 'CREDENTIAL_REFRESHED';
59
70
  } | {
60
71
  type: 'RECONNECT_SUCCESS';
61
72
  } | {
@@ -70,6 +81,20 @@ export type ConnectionEvent = {
70
81
  export interface ConnectionCallbacks {
71
82
  /** Run bootstrap + WebSocket reconnect. Returns the outcome. */
72
83
  onReconnect: () => Promise<'success' | 'session_error' | 'network_error'>;
84
+ /**
85
+ * Re-mint the short-lived access credential (the Stripe-style `ek_`/`rk_`)
86
+ * and push it into the credential source, then report the outcome. Invoked
87
+ * on `refreshing_credential` — i.e. when a probe found the access key stale
88
+ * (`PROBE_CREDENTIAL_STALE`). Mirrors the `getToken` contract:
89
+ * - `'refreshed'` → a fresh credential is in place; re-probe & reconnect.
90
+ * - `'session_error'` → the LONG-LIVED login is gone (mint returned null →
91
+ * 401/403); terminal → sign out.
92
+ * - `'network_error'` → couldn't reach the mint endpoint (offline/5xx/throw);
93
+ * transient → back off and retry, never sign out.
94
+ * Optional: a deployment with no re-mint path (e.g. a static `apiKey`) omits
95
+ * it, and the FSM falls back to a plain re-probe.
96
+ */
97
+ onRefreshCredential?: () => Promise<'refreshed' | 'session_error' | 'network_error'>;
73
98
  /** Called when the session is confirmed expired — route to signin. */
74
99
  onSessionExpired: () => void;
75
100
  /** Called to tear down the WebSocket when entering a dead state. */
@@ -90,6 +115,12 @@ export interface ConnectionManagerOptions {
90
115
  * default of `probeNetwork`.
91
116
  */
92
117
  baseUrl?: string;
118
+ /**
119
+ * Current bearer credential for authenticated probes. Read lazily so token
120
+ * refreshes pushed through `Ablo.setAuthToken()` are used by the next probe
121
+ * without recreating the manager.
122
+ */
123
+ getAuthToken?: AuthTokenGetter;
93
124
  /** Override retry ceilings / jitter. Production should leave defaults. */
94
125
  backoff?: Partial<typeof DEFAULT_BACKOFF>;
95
126
  }
@@ -109,8 +140,12 @@ export declare class ConnectionManager {
109
140
  private debounceTimer;
110
141
  private watchdogTimer;
111
142
  private stuckCycles;
143
+ /** Consecutive access-key re-mints in the current recovery cycle; reset on
144
+ * reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
145
+ private credentialRefreshAttempts;
112
146
  private disposed;
113
147
  private readonly baseUrl?;
148
+ private readonly getAuthToken?;
114
149
  private readonly backoff;
115
150
  private handleBrowserOnline;
116
151
  private handleBrowserOffline;
@@ -122,6 +157,25 @@ export declare class ConnectionManager {
122
157
  private transition;
123
158
  private onEnterState;
124
159
  private runProbe;
160
+ /**
161
+ * Re-mint the short-lived access key on `refreshing_credential`. Delegates to
162
+ * the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
163
+ * still-valid login and pushes it into the credential source) and maps its
164
+ * tri-state outcome onto the FSM:
165
+ * - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
166
+ * - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
167
+ * - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
168
+ *
169
+ * A bounded attempt counter guards against a hot loop where the server keeps
170
+ * reporting the key stale even after a "successful" re-mint (e.g. a clock skew
171
+ * or a mint that returns an already-rejected key): after
172
+ * `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
173
+ * no sign-out) rather than spin. The counter resets once we reach `connected`.
174
+ *
175
+ * When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
176
+ * directly — the credential source's own scheduler owns refresh there.
177
+ */
178
+ private runRefreshCredential;
125
179
  private runReconnect;
126
180
  private scheduleBackoff;
127
181
  private setupBrowserListeners;
@@ -46,6 +46,10 @@ const DEFAULT_BACKOFF = {
46
46
  const ONLINE_DEBOUNCE_MS = 500;
47
47
  const WATCHDOG_INTERVAL_MS = 30_000;
48
48
  const MAX_STUCK_CYCLES_BEFORE_RELOAD = 6;
49
+ /** Cap on consecutive access-key re-mints before giving up to `auth_blocked`.
50
+ * Stops a hot loop if the server keeps reporting the key stale even after a
51
+ * "successful" re-mint (clock skew, a mint returning an already-rejected key). */
52
+ const MAX_CREDENTIAL_REFRESH_ATTEMPTS = 3;
49
53
  // ─── ConnectionManager ────────────────────────────────────────────────────
50
54
  export class ConnectionManager {
51
55
  // Observable state
@@ -59,14 +63,19 @@ export class ConnectionManager {
59
63
  debounceTimer = null;
60
64
  watchdogTimer = null;
61
65
  stuckCycles = 0;
66
+ /** Consecutive access-key re-mints in the current recovery cycle; reset on
67
+ * reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
68
+ credentialRefreshAttempts = 0;
62
69
  disposed = false;
63
70
  baseUrl;
71
+ getAuthToken;
64
72
  backoff;
65
73
  handleBrowserOnline = null;
66
74
  handleBrowserOffline = null;
67
75
  handleVisibilityChange = null;
68
76
  constructor(options = {}) {
69
77
  this.baseUrl = options.baseUrl;
78
+ this.getAuthToken = options.getAuthToken;
70
79
  this.backoff = { ...DEFAULT_BACKOFF, ...(options.backoff ?? {}) };
71
80
  makeAutoObservable(this, {}, { autoBind: true });
72
81
  }
@@ -151,6 +160,7 @@ export class ConnectionManager {
151
160
  case 'MANUAL_RETRY':
152
161
  case 'TAB_VISIBLE':
153
162
  case 'WS_HANDSHAKE_FAILED':
163
+ case 'CREDENTIAL_REFRESHED':
154
164
  return 'probing_network';
155
165
  case 'WS_SESSION_ERROR':
156
166
  case 'BOOTSTRAP_FAILED_SESSION':
@@ -162,6 +172,9 @@ export class ConnectionManager {
162
172
  switch (event.type) {
163
173
  case 'PROBE_SUCCESS':
164
174
  return event.sessionValid ? 'reconnecting' : 'session_expired';
175
+ case 'PROBE_CREDENTIAL_STALE':
176
+ // Access key expired but the login is fine — re-mint, don't sign out.
177
+ return 'refreshing_credential';
165
178
  case 'PROBE_AUTH_BLOCKED':
166
179
  return 'auth_blocked';
167
180
  case 'PROBE_FAILED':
@@ -177,6 +190,7 @@ export class ConnectionManager {
177
190
  case 'TAB_VISIBLE':
178
191
  case 'MANUAL_RETRY':
179
192
  case 'BACKOFF_ELAPSED':
193
+ case 'CREDENTIAL_REFRESHED':
180
194
  return 'probing_network';
181
195
  case 'NETWORK_LOST':
182
196
  return 'offline';
@@ -187,6 +201,8 @@ export class ConnectionManager {
187
201
  switch (event.type) {
188
202
  case 'PROBE_SUCCESS':
189
203
  return event.sessionValid ? 'reconnecting' : 'session_expired';
204
+ case 'PROBE_CREDENTIAL_STALE':
205
+ return 'refreshing_credential';
190
206
  case 'PROBE_AUTH_BLOCKED':
191
207
  return 'auth_blocked';
192
208
  case 'NETWORK_LOST':
@@ -194,6 +210,31 @@ export class ConnectionManager {
194
210
  default:
195
211
  return null;
196
212
  }
213
+ case 'refreshing_credential':
214
+ // Re-minting the short-lived access key (the Stripe-style `ek_`/`rk_`).
215
+ // The login is presumed valid; this is NOT a sign-out state.
216
+ switch (event.type) {
217
+ case 'CREDENTIAL_REFRESHED':
218
+ // Fresh key in hand — re-probe so we reconnect with it.
219
+ return 'probing_network';
220
+ case 'BOOTSTRAP_FAILED_SESSION':
221
+ // The re-mint hit a genuine 401/403: the long-lived login itself is
222
+ // gone. THIS is the only path from here to sign-out.
223
+ return 'session_expired';
224
+ case 'RECONNECT_FAILED':
225
+ // Couldn't reach the mint endpoint (offline/5xx/throw) — transient.
226
+ // Back off and retry; never sign out for a network failure.
227
+ return 'backoff';
228
+ case 'PROBE_AUTH_BLOCKED':
229
+ // Bounded-attempt fallback: the key keeps coming back stale even
230
+ // after re-mint (see runRefreshCredential's attempt guard). Stop
231
+ // looping without signing out.
232
+ return 'auth_blocked';
233
+ case 'NETWORK_LOST':
234
+ return 'offline';
235
+ default:
236
+ return null;
237
+ }
197
238
  case 'reconnecting':
198
239
  switch (event.type) {
199
240
  case 'RECONNECT_SUCCESS':
@@ -218,10 +259,11 @@ export class ConnectionManager {
218
259
  return 'probing_network';
219
260
  case 'NETWORK_ONLINE':
220
261
  case 'TAB_VISIBLE':
221
- // Network came back while we were waiting out a backoff
222
- // delay jump straight to probing instead of waiting the
223
- // full exponential interval. Fixes the "doesn't retrigger
224
- // when internet comes back" bug.
262
+ case 'CREDENTIAL_REFRESHED':
263
+ // Network came back (or a fresh credential arrived) while we were
264
+ // waiting out a backoff delay jump straight to probing instead of
265
+ // waiting the full exponential interval. Fixes the "doesn't
266
+ // retrigger when internet comes back" bug.
225
267
  return 'probing_network';
226
268
  case 'NETWORK_LOST':
227
269
  return 'offline';
@@ -235,12 +277,14 @@ export class ConnectionManager {
235
277
  // Reachable, but the data-plane rejected the credential (non-retryable,
236
278
  // non-expiry — e.g. api_key_required, jwt_issuer_untrusted). Don't
237
279
  // auto-reconnect and don't sign out. Allow a manual retry or a
238
- // tab-focus / network-return re-probe (e.g. after a server deploy);
239
- // a network drop parks offline; a genuine session error still expires.
280
+ // tab-focus / network-return / fresh-credential re-probe (e.g. after a
281
+ // server deploy or an out-of-band re-mint); a network drop parks
282
+ // offline; a genuine session error still expires.
240
283
  switch (event.type) {
241
284
  case 'MANUAL_RETRY':
242
285
  case 'TAB_VISIBLE':
243
286
  case 'NETWORK_ONLINE':
287
+ case 'CREDENTIAL_REFRESHED':
244
288
  return 'probing_network';
245
289
  case 'NETWORK_LOST':
246
290
  return 'offline';
@@ -261,6 +305,9 @@ export class ConnectionManager {
261
305
  switch (state) {
262
306
  case 'connected':
263
307
  this.clearBackoffTimer();
308
+ runInAction(() => {
309
+ this.credentialRefreshAttempts = 0;
310
+ });
264
311
  break;
265
312
  case 'offline':
266
313
  this.clearBackoffTimer();
@@ -275,6 +322,9 @@ export class ConnectionManager {
275
322
  case 'reconnecting':
276
323
  this.runReconnect();
277
324
  break;
325
+ case 'refreshing_credential':
326
+ this.runRefreshCredential();
327
+ break;
278
328
  case 'backoff':
279
329
  this.scheduleBackoff();
280
330
  break;
@@ -299,24 +349,108 @@ export class ConnectionManager {
299
349
  // ── Async operations ─────────────────────────────────────────────────
300
350
  async runProbe() {
301
351
  try {
302
- const result = await probeNetwork(this.baseUrl);
352
+ const result = await probeNetwork({
353
+ baseUrl: this.baseUrl,
354
+ getAuthToken: this.getAuthToken,
355
+ });
303
356
  runInAction(() => {
304
357
  this.lastProbeResult = result;
305
358
  });
306
- if (result.authBlocked) {
307
- this.send({ type: 'PROBE_AUTH_BLOCKED' });
308
- }
309
- else if (result.reachable) {
310
- this.send({ type: 'PROBE_SUCCESS', sessionValid: result.sessionValid ?? true });
311
- }
312
- else {
313
- this.send({ type: 'PROBE_FAILED' });
359
+ // One probe outcome → one event. Exhaustive over ProbeOutcome so a new
360
+ // outcome can't be silently dropped.
361
+ switch (result.outcome) {
362
+ case 'reachable':
363
+ this.send({ type: 'PROBE_SUCCESS', sessionValid: true });
364
+ break;
365
+ case 'session_expired':
366
+ // Genuine login expiry — terminal. (PROBE_SUCCESS with
367
+ // sessionValid:false routes to session_expired in the FSM.)
368
+ this.send({ type: 'PROBE_SUCCESS', sessionValid: false });
369
+ break;
370
+ case 'credential_stale':
371
+ // Access key expired but the login is fine — re-mint, don't sign out.
372
+ this.send({ type: 'PROBE_CREDENTIAL_STALE' });
373
+ break;
374
+ case 'auth_blocked':
375
+ this.send({ type: 'PROBE_AUTH_BLOCKED' });
376
+ break;
377
+ case 'unreachable':
378
+ this.send({ type: 'PROBE_FAILED' });
379
+ break;
380
+ default: {
381
+ const _exhaustive = result.outcome;
382
+ void _exhaustive;
383
+ this.send({ type: 'PROBE_FAILED' });
384
+ }
314
385
  }
315
386
  }
316
387
  catch {
317
388
  this.send({ type: 'PROBE_FAILED' });
318
389
  }
319
390
  }
391
+ /**
392
+ * Re-mint the short-lived access key on `refreshing_credential`. Delegates to
393
+ * the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
394
+ * still-valid login and pushes it into the credential source) and maps its
395
+ * tri-state outcome onto the FSM:
396
+ * - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
397
+ * - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
398
+ * - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
399
+ *
400
+ * A bounded attempt counter guards against a hot loop where the server keeps
401
+ * reporting the key stale even after a "successful" re-mint (e.g. a clock skew
402
+ * or a mint that returns an already-rejected key): after
403
+ * `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
404
+ * no sign-out) rather than spin. The counter resets once we reach `connected`.
405
+ *
406
+ * When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
407
+ * directly — the credential source's own scheduler owns refresh there.
408
+ */
409
+ async runRefreshCredential() {
410
+ if (this.credentialRefreshAttempts >= MAX_CREDENTIAL_REFRESH_ATTEMPTS) {
411
+ getContext().logger.warn('[ConnectionManager] Access key still stale after repeated re-mints — stopping', { attempts: this.credentialRefreshAttempts });
412
+ runInAction(() => {
413
+ this.credentialRefreshAttempts = 0;
414
+ });
415
+ this.send({ type: 'PROBE_AUTH_BLOCKED' });
416
+ return;
417
+ }
418
+ runInAction(() => {
419
+ this.credentialRefreshAttempts += 1;
420
+ });
421
+ const refresher = this.callbacks?.onRefreshCredential;
422
+ if (!refresher) {
423
+ // No re-mint path wired — re-probe with whatever the credential source
424
+ // holds (a static-key deployment refreshes out-of-band, if at all).
425
+ this.send({ type: 'CREDENTIAL_REFRESHED' });
426
+ return;
427
+ }
428
+ try {
429
+ const result = await refresher();
430
+ switch (result) {
431
+ case 'refreshed':
432
+ this.send({ type: 'CREDENTIAL_REFRESHED' });
433
+ break;
434
+ case 'session_error':
435
+ this.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
436
+ break;
437
+ case 'network_error':
438
+ this.send({ type: 'RECONNECT_FAILED' });
439
+ break;
440
+ default: {
441
+ const _exhaustive = result;
442
+ void _exhaustive;
443
+ this.send({ type: 'RECONNECT_FAILED' });
444
+ }
445
+ }
446
+ }
447
+ catch (error) {
448
+ // A thrown refresher is transient by contract (offline / mint endpoint
449
+ // unreachable) — back off and retry, never sign out.
450
+ getContext().logger.warn('[ConnectionManager] Credential re-mint threw (transient)', { error });
451
+ this.send({ type: 'RECONNECT_FAILED' });
452
+ }
453
+ }
320
454
  async runReconnect() {
321
455
  if (!this.callbacks)
322
456
  return;
@@ -431,6 +565,7 @@ export class ConnectionManager {
431
565
  const isStuck = this.state !== 'connected' &&
432
566
  this.state !== 'session_expired' &&
433
567
  this.state !== 'probing_network' &&
568
+ this.state !== 'refreshing_credential' &&
434
569
  this.state !== 'reconnecting';
435
570
  if (isStuck) {
436
571
  this.stuckCycles++;
@@ -467,7 +602,11 @@ export class ConnectionManager {
467
602
  get isConnected() { return this.state === 'connected'; }
468
603
  get isOffline() { return this.state === 'offline' || this.state === 'waiting_for_network'; }
469
604
  get isReconnecting() {
470
- return this.state === 'probing_network' || this.state === 'reconnecting' || this.state === 'backoff';
605
+ return (this.state === 'probing_network' ||
606
+ this.state === 'validating_session' ||
607
+ this.state === 'refreshing_credential' ||
608
+ this.state === 'reconnecting' ||
609
+ this.state === 'backoff');
471
610
  }
472
611
  get isSessionExpired() { return this.state === 'session_expired'; }
473
612
  get offlineDuration() {
@@ -33,12 +33,11 @@ export interface HydrationCoordinatorOptions {
33
33
  /** Bootstrap base URL (without trailing slash), e.g. `https://api.example.com/api`. */
34
34
  readonly baseUrl: string;
35
35
  /**
36
- * Lazy getter for the active capability token. Resolved per-request
37
- * so cap refreshes propagate without re-instantiating the coordinator.
38
- * Optional: browser consumers ride session cookies and can omit this;
39
- * Node consumers (agent-worker) must wire it through or HTTP queries
40
- * fail with 401 because cookies aren't available.
36
+ * Lazy getter for the active bearer token. Resolved per request so refreshes
37
+ * propagate without re-instantiating the coordinator.
41
38
  */
39
+ readonly getAuthToken?: () => string | null;
40
+ /** @deprecated Use `getAuthToken`. */
42
41
  readonly getCapabilityToken?: () => string | null;
43
42
  }
44
43
  export interface FetchOptions<T> {
@@ -55,10 +54,14 @@ export interface FetchOptions<T> {
55
54
  };
56
55
  readonly limit?: number;
57
56
  /**
58
- * `'complete'` (default): wait for network round-trip even if local
59
- * data exists, so the caller observes server-confirmed state.
60
- * `'unknown'`: return whatever's in the pool/IDB immediately and
61
- * fire the network in the background.
57
+ * Freshness mode. When omitted, the default is derived from the model's
58
+ * load strategy: `lazy` models default to `'unknown'` (local-first), while
59
+ * `instant`/`partial` models default to `'complete'`.
60
+ *
61
+ * `'complete'`: wait for the network round-trip even if local data exists,
62
+ * so the caller observes server-confirmed state (read-after-write).
63
+ * `'unknown'`: return whatever's in the pool/IDB immediately and fire the
64
+ * network refresh in the background (stale-while-revalidate).
62
65
  */
63
66
  readonly type?: 'complete' | 'unknown';
64
67
  /**
@@ -71,18 +74,35 @@ export interface FetchOptions<T> {
71
74
  export declare class HydrationCoordinator {
72
75
  private readonly opts;
73
76
  private readonly inFlight;
74
- private capabilityTokenProvider;
77
+ /**
78
+ * Query keys with a background confirm currently in flight. Distinct from
79
+ * {@link inFlight} (which dedupes *blocking* callers awaiting the same
80
+ * fetch): this set dedupes the fire-and-forget network confirm kicked off
81
+ * after a local-first read returns cached data, so a burst of mounts that
82
+ * all hit the warm pool/IDB don't each spawn their own redundant fetch.
83
+ */
84
+ private readonly revalidating;
85
+ /**
86
+ * Query keys that have been satisfied from the server at least once this
87
+ * session. Once a key is here, repeat reads serve purely from the pool with
88
+ * NO network: the WebSocket delta stream keeps those pool rows fresh, so
89
+ * re-running the HTTP query would be redundant polling. This is the ledger
90
+ * that stops an already-open deck from re-querying on every navigation.
91
+ *
92
+ * Cleared on reconnect (see {@link invalidate}) so that, after a connection
93
+ * drop where deltas may have been missed, the next read re-confirms once.
94
+ */
95
+ private readonly hydratedKeys;
96
+ private authTokenProvider;
75
97
  constructor(opts: HydrationCoordinatorOptions);
76
98
  /**
77
- * Late-bind the capability token getter. Used by `Ablo.ts` to wire
78
- * the token closure after the coordinator is constructed (the token
79
- * isn't known until auth resolves, which happens after component
80
- * construction). Browser consumers ride session cookies and don't
81
- * need this; Node consumers (agent-worker) MUST call it or HTTP
82
- * queries fail with 401 because cookies aren't available.
99
+ * Late-bind the auth token getter. Browser cookie consumers can omit this;
100
+ * bearer consumers need it so lazy HTTP queries use the same credential as
101
+ * bootstrap and the WebSocket.
83
102
  */
103
+ setAuthTokenProvider(provider: () => string | null): void;
104
+ /** @deprecated Use `setAuthTokenProvider`. */
84
105
  setCapabilityTokenProvider(provider: () => string | null): void;
85
- private resolveToken;
86
106
  /**
87
107
  * Fetch matching rows for a model, hydrating the pool from IDB or
88
108
  * network if not already present. Idempotent and single-flight
@@ -90,6 +110,62 @@ export declare class HydrationCoordinator {
90
110
  */
91
111
  fetch<T>(modelName: string, options?: FetchOptions<T>): Promise<Model[]>;
92
112
  private runFetch;
113
+ /**
114
+ * Read a query's rows from local storage only — pool first, then IndexedDB
115
+ * on a pool miss (cold start after reload, or LRU eviction), hydrating the
116
+ * pool from IDB as a side effect. Resolves requested `expand` relations from
117
+ * their own local stores too. Never touches the network.
118
+ */
119
+ private readLocal;
120
+ /**
121
+ * Drop the hydration ledger so the next read of each query re-confirms with
122
+ * the server. Called on reconnect — after a connection drop, deltas may have
123
+ * been missed, so the "WS keeps the pool fresh" assumption no longer holds
124
+ * until a fresh fetch (or the engine's delta catch-up) reconciles.
125
+ */
126
+ invalidate(): void;
127
+ /**
128
+ * Run the network leg of a fetch: query the server, hydrate primary rows
129
+ * (and any expanded relations) into the pool, and persist them to IDB.
130
+ * Shared by the blocking path (`runFetch` step 3) and the background
131
+ * revalidation kicked off after an `'unknown'` local hit.
132
+ */
133
+ private fetchFromNetwork;
134
+ /**
135
+ * Fire-and-forget the ONE server confirm for a query that was just served
136
+ * from local cache but isn't hydrated yet. On success the key is marked
137
+ * hydrated, so every later read serves pure-local with no network until a
138
+ * reconnect invalidates the ledger. Deduped per query key so a render burst
139
+ * doesn't stampede. Errors are swallowed — the caller already has a usable
140
+ * local snapshot, and a failed confirm leaves the key un-hydrated so the
141
+ * next read simply tries again.
142
+ */
143
+ private scheduleHydratingFetch;
144
+ /**
145
+ * Hydrate a parent's `hasMany`/`hasOne` relations from their OWN local
146
+ * stores (pool first, then IndexedDB by the FK secondary index) into the
147
+ * pool. The mirror of {@link hydrateExpanded} for the local read path:
148
+ * `hydrateExpanded` walks server-JOINed nested rows, this walks the child
149
+ * model's own store keyed by the relation's foreign key.
150
+ *
151
+ * Fully schema-driven via the relation's `target` + `foreignKey` — no
152
+ * per-model special-casing. `belongsTo` relations are skipped: those point
153
+ * at a single parent (the inverse direction), already covered by the
154
+ * primary scan when that parent is itself the fetched model.
155
+ */
156
+ private hydrateExpandedFromLocal;
157
+ /**
158
+ * Read a child model's rows from local storage by foreign key.
159
+ *
160
+ * Uses the FK secondary index (O(matches) per parent) only when the schema
161
+ * declares one — `getAllFromIndex` resolves `[]` for a missing index rather
162
+ * than throwing, so the decision is made up front from the registry, not by
163
+ * catching. Unindexed FKs — and in-memory stores, which carry no secondary
164
+ * indexes at all — fall back to a single full-store scan filtered in JS.
165
+ */
166
+ private readChildrenLocal;
167
+ /** Typed accessor for a model's schema definition (typename + relations). */
168
+ private getModelDef;
93
169
  private hydrateOne;
94
170
  /**
95
171
  * Stamp `__typename` onto a row when it's known (from the schema's