@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ A single options object for every model verb, and a disposable `claim` handle.
6
+
7
+ ### Breaking Changes
8
+
9
+ - **One options object per verb.** `create`, `update`, `delete`, and the async
10
+ server `retrieve` each take a single options object instead of positional
11
+ arguments, so the id, the data, and every modifier live as named siblings:
12
+ `create({ data, id? })`, `update({ id, data, ...options })`,
13
+ `delete({ id, ...options })`, `retrieve({ id, ...options })`. Reactive local
14
+ reads stay on `get(id)` (synchronous) —
15
+ `useAblo((ablo) => ablo.tasks.get(id))`.
16
+
17
+ ```diff
18
+ - await ablo.tasks.update(id, { status: 'done' }, { wait: 'confirmed' })
19
+ + await ablo.tasks.update({ id, data: { status: 'done' }, wait: 'confirmed' })
20
+
21
+ - await ablo.tasks.retrieve(id)
22
+ + await ablo.tasks.retrieve({ id })
23
+
24
+ - useAblo((ablo) => ablo.tasks.retrieve(id)) ?? serverTask
25
+ + useAblo((ablo) => ablo.tasks.get(id)) ?? serverTask
26
+ ```
27
+
28
+ - **`claim` returns a disposable handle** instead of taking a callback. The
29
+ handle exposes the fresh row on `.data` and is released on scope exit
30
+ (`await using`) or explicitly via `.release()`. `claim.state`, `claim.queue`,
31
+ `claim.release`, and `claim.reorder` also take the options object.
32
+
33
+ ```diff
34
+ - await ablo.tasks.claim(id, async (task) => {
35
+ - await ablo.tasks.update(task.id, { status: 'in_review' })
36
+ - })
37
+ + await using claim = await ablo.tasks.claim({ id })
38
+ + const task = claim.data
39
+ + await ablo.tasks.update({ id: task.id, data: { status: 'in_review' } })
40
+ ```
41
+
3
42
  ## 0.8.0
4
43
 
5
44
  A callable `claim` coordination namespace and bring-your-own-database support
@@ -325,7 +364,7 @@ The SDK covers exactly three integration shapes. Each has a canonical example in
325
364
  ### Env / config
326
365
 
327
366
  - `ABLO_API_KEY` — required for server-side use.
328
- - `ABLO_BASE_URL` — optional override for staging / local-dev (defaults to `https://mesh.ablo.finance`). Not a customer-facing self-hosting path.
367
+ - `baseURL` — optional override for private deployments / local-dev (defaults to `wss://api.abloatai.com`).
329
368
  - `organizationId` — **no longer required** in `createMesh`. The API key or session binds the caller to one org; the capability mint response echoes it back.
330
369
  - `createMeshFromEnv` — removed. `new Ablo({ schema })` auto-reads env.
331
370
 
package/README.md CHANGED
@@ -84,17 +84,19 @@ const ablo = Ablo({
84
84
  await ablo.ready();
85
85
 
86
86
  const created = await ablo.weatherReports.create({
87
- location: 'Stockholm',
88
- status: 'pending',
87
+ data: {
88
+ location: 'Stockholm',
89
+ status: 'pending',
90
+ },
89
91
  });
90
92
 
91
93
  // An agent claims the row, does its slow work, then writes back. While the
92
94
  // claim is held nobody else can overwrite it; anyone else who tries waits in
93
95
  // line and re-reads the result. This is the whole point of Ablo.
94
- await ablo.weatherReports.claim(created.id, async (report) => {
95
- const forecast = await fetchForecast(report.location); // slow: API or LLM call
96
- await ablo.weatherReports.update(report.id, { status: 'ready', forecast });
97
- });
96
+ await using claim = await ablo.weatherReports.claim({ id: created.id });
97
+ const report = claim.data;
98
+ const forecast = await fetchForecast(report.location); // slow: API or LLM call
99
+ await ablo.weatherReports.update({ id: report.id, data: { status: 'ready', forecast } });
98
100
 
99
101
  const ready = ablo.weatherReports.get(created.id);
100
102
  console.log({ id: ready.id, status: ready.status });
@@ -145,7 +147,7 @@ matter day to day:
145
147
  | `idempotencyKey` | `string` | Auto-generated per call. Override only when you own the retry boundary (e.g. a job id) so a re-run dedupes server-side. |
146
148
 
147
149
  ```ts
148
- await ablo.weatherReports.update(id, { status: 'ready' }, { wait: 'confirmed' });
150
+ await ablo.weatherReports.update({ id, data: { status: 'ready' }, wait: 'confirmed' });
149
151
  ```
150
152
 
151
153
  To guard a write against a row that changed under you, pass `readAt` + `onStale`
@@ -157,30 +159,32 @@ An agent reads a row, thinks for 30s, writes back — and clobbers whatever chan
157
159
  meanwhile, or worse, acts on stale state. `claim` holds the row across that gap:
158
160
 
159
161
  ```ts
160
- await ablo.weatherReports.claim('report_stockholm', async (report) => {
161
- const forecast = await weatherAgent.getWeather(report.location);
162
- await ablo.weatherReports.update(report.id, { forecast, status: 'ready' });
163
- });
162
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
163
+ const report = claim.data;
164
+ const forecast = await weatherAgent.getWeather(report.location);
165
+ await ablo.weatherReports.update({ id: report.id, data: { forecast, status: 'ready' } });
164
166
  ```
165
167
 
166
168
  If someone else holds the row, `claim()` waits in a fair queue, then re-reads —
167
169
  so `report` is the current row, never a stale snapshot. Reads stay open by
168
- default; only acting on the row serializes. The claim releases when the callback
169
- returns or throws.
170
+ default; only acting on the row serializes. The claim releases when the `await
171
+ using` scope exits.
170
172
 
171
173
  See who's mid-edit before you act — decide to wait, or skip:
172
174
 
173
175
  ```ts
174
- ablo.weatherReports.claim.state('report_stockholm');
175
- ablo.weatherReports.claim.queue('report_stockholm');
176
+ ablo.weatherReports.claim.state({ id: 'report_stockholm' });
177
+ ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
176
178
 
177
- await ablo.weatherReports.claim(id, async (report) => {
179
+ {
180
+ await using claim = await ablo.weatherReports.claim({ id, wait: false });
178
181
  /* do the held work */
179
- }, { wait: false });
182
+ }
180
183
 
181
- await ablo.weatherReports.claim(id, async (report) => {
184
+ {
185
+ await using claim = await ablo.weatherReports.claim({ id, maxQueueDepth: 2 });
182
186
  /* do the held work */
183
- }, { maxQueueDepth: 2 });
187
+ }
184
188
  ```
185
189
 
186
190
  `claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
@@ -194,14 +198,15 @@ Even an unclaimed write can't land on stale reasoning — the commit is guarded:
194
198
 
195
199
  ```ts
196
200
  try {
197
- await ablo.weatherReports.update(id, { status: 'ready' }, { readAt, onStale: 'reject' });
201
+ await ablo.weatherReports.update({ id, data: { status: 'ready' }, readAt, onStale: 'reject' });
198
202
  } catch (e) {
199
203
  if (e instanceof AbloStaleContextError) { /* row moved under you — re-read, retry */ }
200
204
  }
201
205
  ```
202
206
 
203
- > Prefer the callback form for ordinary held work. Manual scoped claims are
204
- > available for wider lifetimes, but callback claims are the docs default.
207
+ > Use `await using` for ordinary held work the claim releases when the scope
208
+ > exits. Call `claim.release({ id })` only to give a manually held claim back
209
+ > early.
205
210
 
206
211
  See [Coordination](./docs/coordination.md) for the full `claim` / `claim.state` /
207
212
  `claim.queue` / `claim.release` reference.
@@ -231,7 +236,7 @@ function Report({ id }: { id: string }) {
231
236
  if (!report) return null;
232
237
 
233
238
  return (
234
- <button onClick={() => ablo?.weatherReports.update(id, { status: 'ready' })}>
239
+ <button onClick={() => ablo?.weatherReports.update({ id, data: { status: 'ready' } })}>
235
240
  {report.status}
236
241
  </button>
237
242
  );
@@ -285,7 +290,7 @@ each other's changes in real time — that's the default, not a feature you turn
285
290
 
286
291
  - `ablo.<model>.create/update/delete` fan out confirmed deltas to subscribers.
287
292
  - `useAblo(...)` gives React clients the live row, kept current automatically.
288
- - `ablo.<model>.claim(id)` / `claim.state(id)` / `queue(id)` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
293
+ - `ablo.<model>.claim({ id })` / `claim.state({ id })` / `claim.queue({ id })` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
289
294
 
290
295
  Always write through Ablo — either the SDK model methods
291
296
  (`ablo.<model>.create/update/delete`) or the HTTP write endpoint below. If you
@@ -314,7 +319,7 @@ curl https://api.abloatai.com/v1/commits \
314
319
  ## Connect Your Database
315
320
 
316
321
  Every schema model has a backing store. By default, Ablo stores rows for the
317
- models you declare, so `ablo.weatherReports.create(...)` and `ablo.weatherReports.update(...)`
322
+ models you declare, so `ablo.weatherReports.create({ data })` and `ablo.weatherReports.update({ id, data })`
318
323
  write to Ablo-managed state.
319
324
 
320
325
  If your existing database stays the source of truth, connect it as a Data
@@ -332,7 +337,7 @@ See [Connect Your Database](./docs/data-sources.md) for the integration shape.
332
337
  | --- | --- | --- | --- |
333
338
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
334
339
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
335
- | `baseURL` | `string` | `wss://mesh.ablo.finance` | Point at a self-hosted or staging mesh |
340
+ | `baseURL` | `string` | `wss://api.abloatai.com` | Point at a self-hosted or private API |
336
341
 
337
342
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
338
343
  authenticates with the signed-in user's session; the raw-key path is gated
@@ -348,7 +353,7 @@ survives worker / `postMessage` boundaries, where `instanceof` does not:
348
353
 
349
354
  ```ts
350
355
  try {
351
- await ablo.weatherReports.update(id, { status: 'ready' }, { readAt, onStale: 'reject' });
356
+ await ablo.weatherReports.update({ id, data: { status: 'ready' }, readAt, onStale: 'reject' });
352
357
  } catch (e) {
353
358
  if (e instanceof AbloStaleContextError) { /* row moved under you — re-read, retry */ }
354
359
  if ((e as AbloError).type === 'AbloClaimedError') { /* another participant holds it */ }
@@ -22,6 +22,7 @@ import { Model } from './Model.js';
22
22
  import { ModelScope } from './ObjectPool.js';
23
23
  import type { Schema } from './schema/schema.js';
24
24
  import { type ReaderActions } from './mutators/readerActions.js';
25
+ import type { AuthCredentialSource } from './auth/credentialSource.js';
25
26
  /** Constructor type for Model subclasses (accepts abstract classes) */
26
27
  export type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
27
28
  /** Concrete constructor type for instantiation */
@@ -240,6 +241,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
240
241
  protected readonly database: Database;
241
242
  protected readonly objectPool: ObjectPool;
242
243
  protected readonly modelRegistry: ModelRegistry;
244
+ protected readonly auth?: AuthCredentialSource;
243
245
  /**
244
246
  * Schema the store was constructed with. Persisted so the `query`
245
247
  * accessor namespace can build typed per-model reader actions lazily
@@ -308,6 +310,8 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
308
310
  schema?: TSchema;
309
311
  /** Sync server URL for WebSocket connection. Converted to wss:// automatically. */
310
312
  url?: string;
313
+ /** Shared bearer credential source for every auth-aware transport. */
314
+ auth?: AuthCredentialSource;
311
315
  }, config?: SyncedStoreConfig);
312
316
  /**
313
317
  * Register foreign key indexes for O(1) lookups.
@@ -355,6 +359,24 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
355
359
  * return null.
356
360
  */
357
361
  protected connectionManager: import('./sync/ConnectionManager.js').ConnectionManager | null;
362
+ /**
363
+ * Re-mint hook for the short-lived access credential (the Stripe-style
364
+ * `ek_`/`rk_`). Wired by the React provider from its `getToken`/`authEndpoint`
365
+ * — the engine owns WHEN to refresh (a stale-credential probe / an external
366
+ * nudge), the integrator owns HOW to mint. Mirrors the `getToken` contract:
367
+ * resolves a token string on success, `null` when the long-lived login is
368
+ * gone (terminal), and THROWS on a transient/offline failure. Used by
369
+ * {@link performCredentialRefresh}. Absent ⇒ no silent re-mint (e.g. a static
370
+ * `apiKey` deployment whose credential source refreshes out-of-band).
371
+ */
372
+ private credentialRefresher;
373
+ /** Single-flight guard so a wake nudge + an in-flight request + a probe don't
374
+ * all mint at once (the classic "token thrash → random logout" bug). */
375
+ private inFlightCredentialRefresh;
376
+ /** Teardown for the proactive credential lifecycle (refresh timer + wake/
377
+ * online/focus listeners) installed by {@link startCredentialLifecycle};
378
+ * cleared on {@link disconnect}. Null when no resolver is wired. */
379
+ private credentialLifecycleTeardown;
358
380
  /**
359
381
  * Listeners registered via `subscribeSessionError()`. Fired when the
360
382
  * WebSocket closes with a session-invalid code (1008/4001/4003) or a
@@ -404,6 +426,57 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
404
426
  protected resetBootstrapState(): void;
405
427
  /** Perform reconnect: bootstrap + WS reconnect. Returns outcome for state machine. */
406
428
  performReconnect(): Promise<'success' | 'session_error' | 'network_error'>;
429
+ /**
430
+ * Register the access-credential re-mint hook. Called by the React provider
431
+ * with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
432
+ * See {@link credentialRefresher}.
433
+ */
434
+ setCredentialRefresher(refresher: (() => Promise<string | null>) | null): void;
435
+ /**
436
+ * Re-mint the short-lived access credential and push it into the credential
437
+ * source, reporting a tri-state outcome the {@link ConnectionManager} maps to
438
+ * its FSM. The contract mirrors `getToken` (and PowerSync's `fetchCredentials`
439
+ * / Liveblocks' `authEndpoint`, but made explicit instead of overloading
440
+ * return/throw):
441
+ * - token string → `'refreshed'` (fresh key in place; re-probe & reconnect)
442
+ * - `null` → `'session_error'` (login itself is gone → terminal, sign out)
443
+ * - throw → `'network_error'` (couldn't reach the mint endpoint → transient)
444
+ *
445
+ * SINGLE-FLIGHT: concurrent callers (a wake nudge, an in-flight request, the
446
+ * probe) share one in-flight promise so we never double-mint — the canonical
447
+ * fix for the "every 401 mints a token → thrash → spurious logout" anti-pattern.
448
+ *
449
+ * No refresher wired ⇒ `'refreshed'` (a no-op re-probe): a static-`apiKey`
450
+ * deployment has no session to re-mint from; its credential source refreshes
451
+ * out-of-band, so we just re-probe with whatever it currently holds.
452
+ */
453
+ performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
454
+ /**
455
+ * Nudge the connection FSM to re-probe with the current credential. Idempotent
456
+ * and safe in any state (ignored while `connected`). Call after pushing a
457
+ * freshly-minted token via `setAuthToken`, or on an OS-wake signal, so a
458
+ * connection parked in `offline` / `backoff` / `auth_blocked` picks the new
459
+ * credential up immediately instead of waiting for the 30s watchdog.
460
+ */
461
+ nudgeReconnect(): void;
462
+ /**
463
+ * Install the access-credential lifecycle the CLIENT owns (this used to live
464
+ * in the React provider — wrong layer). Two parts:
465
+ * 1. REACTIVE — register `getToken` as the re-mint hook the FSM calls when a
466
+ * probe finds the key stale (`credential_stale`) or on a nudge.
467
+ * 2. PROACTIVE — keep the short-lived key fresh ahead of trouble: a refresh
468
+ * timer inside the TTL, plus re-mint on OS wake / network-online / tab
469
+ * focus. Browser-only triggers are env-gated, so Node/agent hosts get
470
+ * only the timer (a no-op there — agents use a static `apiKey`, no
471
+ * resolver, so this is never called for them).
472
+ *
473
+ * Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
474
+ * never call a refresh method. Idempotent (a second call replaces the first);
475
+ * torn down on {@link disconnect}.
476
+ */
477
+ startCredentialLifecycle(getToken: () => Promise<string | null>): void;
478
+ /** Tear down the proactive credential lifecycle (idempotent). */
479
+ private stopCredentialLifecycle;
407
480
  /**
408
481
  * Handle an actionType 'G' delta.
409
482
  *
@@ -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
@@ -531,6 +551,147 @@ export class BaseSyncedStore {
531
551
  return 'network_error';
532
552
  }
533
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
+ }
534
695
  // ── Sync Group Management ────────────────────────────────────────────────
535
696
  /**
536
697
  * Handle an actionType 'G' delta.
@@ -970,10 +1131,14 @@ export class BaseSyncedStore {
970
1131
  createConnectionManager(kind) {
971
1132
  if (kind === 'agent')
972
1133
  return null;
973
- return new ConnectionManager({ baseUrl: this._syncServerUrl });
1134
+ return new ConnectionManager({
1135
+ baseUrl: this._syncServerUrl,
1136
+ getAuthToken: () => this.auth?.getAuthToken() ?? this.syncWebSocket?.getAuthToken() ?? null,
1137
+ });
974
1138
  }
975
1139
  /** Disconnect and clean up all resources */
976
1140
  async disconnect() {
1141
+ this.stopCredentialLifecycle();
977
1142
  if (this.batchTimer) {
978
1143
  clearTimeout(this.batchTimer);
979
1144
  this.batchTimer = null;
@@ -1099,6 +1264,7 @@ export class BaseSyncedStore {
1099
1264
  versions: this.versionVector,
1100
1265
  kind: context.kind,
1101
1266
  capabilityToken: context.capabilityToken,
1267
+ getAuthToken: this.auth?.getAuthToken,
1102
1268
  capabilities: {
1103
1269
  partialBootstrap: true,
1104
1270
  compressedDeltas: true,
@@ -1217,6 +1383,7 @@ export class BaseSyncedStore {
1217
1383
  };
1218
1384
  manager.start({
1219
1385
  onReconnect: () => this.performReconnect(),
1386
+ onRefreshCredential: () => this.performCredentialRefresh(),
1220
1387
  onSessionExpired: () => {
1221
1388
  const err = new SyncSessionError('Session expired');
1222
1389
  for (const listener of this.sessionErrorListeners) {
@@ -1253,10 +1420,13 @@ export class BaseSyncedStore {
1253
1420
  }
1254
1421
  break;
1255
1422
  case 'probing_network':
1423
+ case 'refreshing_credential':
1256
1424
  case 'reconnecting':
1257
1425
  case 'backoff':
1258
1426
  // Active recovery — the UI should reflect that the FSM
1259
- // 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.)
1260
1430
  if (this.syncStatus.state !== 'reconnecting') {
1261
1431
  this.updateSyncStatus({ state: 'reconnecting' });
1262
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