@abloatai/ablo 0.11.1 → 0.11.2

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 (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +10 -2
  3. package/dist/Model.d.ts +39 -0
  4. package/dist/Model.js +68 -0
  5. package/dist/auth/credentialPolicy.d.ts +145 -0
  6. package/dist/auth/credentialPolicy.js +130 -0
  7. package/dist/cli.cjs +39 -6
  8. package/dist/client/Ablo.d.ts +39 -88
  9. package/dist/client/Ablo.js +38 -98
  10. package/dist/client/ApiClient.d.ts +10 -1
  11. package/dist/client/ApiClient.js +19 -11
  12. package/dist/client/auth.d.ts +12 -5
  13. package/dist/client/auth.js +2 -1
  14. package/dist/client/createModelProxy.d.ts +49 -10
  15. package/dist/client/createModelProxy.js +6 -0
  16. package/dist/client/httpClient.d.ts +17 -3
  17. package/dist/client/httpClient.js +1 -0
  18. package/dist/client/identity.js +134 -122
  19. package/dist/client/index.d.ts +1 -1
  20. package/dist/client/sessionMint.d.ts +15 -0
  21. package/dist/client/sessionMint.js +86 -0
  22. package/dist/errorCodes.d.ts +2 -0
  23. package/dist/errorCodes.js +2 -0
  24. package/dist/errors.d.ts +3 -2
  25. package/dist/errors.js +3 -2
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +4 -7
  28. package/dist/mutators/RecordingTransaction.js +14 -42
  29. package/dist/react/AbloProvider.d.ts +1 -6
  30. package/dist/react/AbloProvider.js +1 -5
  31. package/dist/react/context.d.ts +1 -31
  32. package/dist/react/context.js +2 -2
  33. package/dist/react/index.d.ts +0 -6
  34. package/dist/react/index.js +0 -7
  35. package/dist/react/useSyncStatus.d.ts +1 -1
  36. package/dist/realtime/index.d.ts +1 -1
  37. package/dist/schema/generate.js +1 -2
  38. package/dist/schema/schema.d.ts +13 -2
  39. package/dist/schema/schema.js +26 -0
  40. package/dist/surface.d.ts +29 -0
  41. package/dist/surface.js +60 -0
  42. package/dist/sync/ConnectionManager.d.ts +16 -5
  43. package/dist/sync/ConnectionManager.js +42 -7
  44. package/dist/transactions/TransactionQueue.d.ts +0 -11
  45. package/dist/transactions/TransactionQueue.js +12 -56
  46. package/dist/types/global.d.ts +3 -0
  47. package/dist/types/streams.d.ts +0 -22
  48. package/dist/utils/mobx-setup.js +1 -0
  49. package/docs/api-keys.md +49 -0
  50. package/docs/api.md +3 -2
  51. package/docs/client-behavior.md +1 -0
  52. package/docs/coordination.md +75 -21
  53. package/docs/examples/existing-python-backend.md +9 -5
  54. package/docs/examples/scoped-agent.md +1 -1
  55. package/docs/guarantees.md +4 -3
  56. package/docs/identity.md +89 -82
  57. package/docs/integration-guide.md +19 -10
  58. package/docs/migration.md +9 -2
  59. package/docs/quickstart.md +6 -2
  60. package/docs/react.md +3 -3
  61. package/docs/schema-contract.md +23 -5
  62. package/llms-full.txt +18 -16
  63. package/llms.txt +6 -6
  64. package/package.json +1 -1
  65. package/dist/api/index.d.ts +0 -10
  66. package/dist/api/index.js +0 -9
  67. package/dist/principal.d.ts +0 -44
  68. package/dist/principal.js +0 -49
  69. package/dist/react/SyncGroupProvider.d.ts +0 -19
  70. package/dist/react/SyncGroupProvider.js +0 -44
  71. package/dist/react/useClaim.d.ts +0 -29
  72. package/dist/react/useClaim.js +0 -42
  73. package/dist/react/usePresence.d.ts +0 -32
  74. package/dist/react/usePresence.js +0 -41
@@ -20,18 +20,29 @@
20
20
  * Designed to be embedded by `BaseSyncedStore`: one instance per store,
21
21
  * started on first successful connect, disposed on teardown.
22
22
  *
23
- * CONNECTED ──► OFFLINE ──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
- *
25
- *
26
- * WAITING_FOR_NETWORK SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
23
+ * CONNECTED ──(socket drop)──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
+ *
25
+ * (network lost)
26
+ *SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
27
+ * OFFLINE ──(online)──► PROBING_NETWORK
28
+ * │
29
+ * ▼
30
+ * WAITING_FOR_NETWORK
27
31
  *
28
- * Includes two fixes over the original app-side FSM:
32
+ * Includes three fixes over the original app-side FSM:
29
33
  * 1. `backoff` accepts `NETWORK_ONLINE` / `TAB_VISIBLE` — jumps to
30
34
  * probing immediately when the network comes back, without
31
35
  * waiting for the backoff timer to elapse.
32
36
  * 2. `scheduleBackoff` parks in `waiting_for_network` (resetting
33
37
  * `attempt`) when `navigator.onLine === false` at max retries,
34
38
  * instead of hard-reloading an already-offline browser.
39
+ * 3. A socket drop (`WS_DISCONNECTED`, typically code 1006) goes
40
+ * STRAIGHT to `probing_network`, not the passive `offline` state.
41
+ * 1006 is browser-local and carries no connectivity signal, so on a
42
+ * healthy machine no `online`/`offline` event ever fires — parking in
43
+ * `offline` stranded recovery until the 30s watchdog, long enough for
44
+ * queued commits to roll back. Only a genuine OS-level `NETWORK_LOST`
45
+ * parks in `offline` and waits for the `online` event.
35
46
  */
36
47
  import { makeAutoObservable, runInAction } from 'mobx';
37
48
  import { getContext } from '../context.js';
@@ -142,8 +153,19 @@ export class ConnectionManager {
142
153
  case 'connected':
143
154
  switch (event.type) {
144
155
  case 'NETWORK_LOST':
145
- case 'WS_DISCONNECTED':
156
+ // The OS reported the NIC down — park passively in `offline` and
157
+ // wait for the `online` event. Probing a downed adapter is wasted
158
+ // work.
146
159
  return 'offline';
160
+ case 'WS_DISCONNECTED':
161
+ // The socket died (typically code 1006) but the OS network is
162
+ // almost certainly fine — 1006 is generated locally when the TCP
163
+ // conn vanishes and carries NO connectivity signal, so the browser
164
+ // fires no online/offline event. Probe IMMEDIATELY rather than
165
+ // landing in the passive `offline` dead-end (which only escaped via
166
+ // the 30s watchdog, long after queued commits rolled back). The
167
+ // probe fast-fails if we genuinely ARE offline → waiting_for_network.
168
+ return 'probing_network';
147
169
  case 'WS_SESSION_ERROR':
148
170
  case 'BOOTSTRAP_FAILED_SESSION':
149
171
  return 'session_expired';
@@ -301,7 +323,7 @@ export class ConnectionManager {
301
323
  }
302
324
  }
303
325
  // ── Side effects per state ───────────────────────────────────────────
304
- onEnterState(state, _event) {
326
+ onEnterState(state, event) {
305
327
  switch (state) {
306
328
  case 'connected':
307
329
  this.clearBackoffTimer();
@@ -314,6 +336,19 @@ export class ConnectionManager {
314
336
  this.callbacks?.onDisconnectWebSocket();
315
337
  break;
316
338
  case 'probing_network':
339
+ // A socket drop (`WS_DISCONNECTED`) now lands here directly so recovery
340
+ // starts immediately. Tear the dead socket down FIRST — this is what
341
+ // sets SyncWebSocket's `isManualClose=true` and suppresses its own
342
+ // scheduleReconnect, keeping the FSM the single reconnect authority on
343
+ // the human path. The teardown runs synchronously inside the
344
+ // `disconnected` emit, before `SyncWebSocket.onclose` checks the flag,
345
+ // so the timing matches the previous `offline`-entry teardown. We gate
346
+ // on the drop event specifically: the other paths into `probing_network`
347
+ // (TAB_VISIBLE re-validation, handshake retry, backoff elapse) must NOT
348
+ // tear down a socket that may still be live.
349
+ if (event.type === 'WS_DISCONNECTED') {
350
+ this.callbacks?.onDisconnectWebSocket();
351
+ }
317
352
  this.runProbe();
318
353
  break;
319
354
  case 'waiting_for_network':
@@ -427,17 +427,6 @@ export declare class TransactionQueue extends EventEmitter {
427
427
  private extractUpdateData;
428
428
  private buildUpdateInput;
429
429
  private extractPreviousData;
430
- /**
431
- * Re-baseline `modifiedProperties` for the fields a freshly-staged update just
432
- * committed. Called right after {@link extractPreviousData} freezes their
433
- * `.old` into the transaction, so the NEXT update to the same field sees this
434
- * update's result as its baseline rather than the stale pre-session `.old`
435
- * preserved by `Model.propertyChanged`'s first-old-wins policy. Only consumes
436
- * keys present in this update — untouched fields keep their baselines. Safe
437
- * because the wire payload lives on `transaction.data` and rollback restores
438
- * from `transaction.previousData`; neither re-reads `modifiedProperties`.
439
- */
440
- private consumeModifiedFields;
441
430
  /**
442
431
  * Public API
443
432
  */
@@ -780,8 +780,8 @@ export class TransactionQueue extends EventEmitter {
780
780
  // instead of THIS update's result — corrupting the stream-recorded undo
781
781
  // inverse (the second move's "before" would point all the way back). The
782
782
  // wire payload is already frozen in `transaction.data`, so dropping the
783
- // consumed entries is safe. Mirrors `RecordingTransaction.consumeModifiedFields`.
784
- this.consumeModifiedFields(model, updateInput);
783
+ // consumed entries is safe.
784
+ model.consumeModifiedFields(Object.keys(updateInput));
785
785
  const modelKey = normalizeModelKey(actualModelName);
786
786
  const priorityScore = this.computePriorityScore('update', actualModelName);
787
787
  const transaction = {
@@ -1999,63 +1999,19 @@ export class TransactionQueue extends EventEmitter {
1999
1999
  // model ever needs to surface previous-state outside `modifiedProperties`,
2000
2000
  // expose a typed `getPreviousData()` accessor on Model and call that.
2001
2001
  extractPreviousData(model, updateInput) {
2002
- const prev = { id: model.id };
2003
- const modified = model.modifiedProperties instanceof Map ? model.modifiedProperties : null;
2004
2002
  // When the update's written keys are known, capture a before-image for
2005
2003
  // EXACTLY those keys so the recorded undo inverse can revert them and only
2006
2004
  // them (a full-row inverse would clobber concurrent edits to unrelated
2007
- // fields). Resolution order mirrors `RecordingTransaction.snapshotFields`:
2008
- // 1. `modifiedProperties.old` first-old-wins pre-session baseline, set
2009
- // whenever the caller mutated the field in place before committing.
2010
- // 2. `getOriginalSnapshot()` the last loaded/acked row, the correct
2011
- // before-image for a key written WITHOUT a prior in-place mutation
2012
- // (e.g. a `precomputedChanges` write).
2013
- // Without (2) such a key yields an empty `previousData`, and `buildUndoOps`
2014
- // nulls the inverse entirely — making updates silently un-undoable where a
2015
- // create's `delete(id)` inverse never is. This closes that asymmetry.
2016
- if (updateInput) {
2017
- const original = model.getOriginalSnapshot();
2018
- for (const key of Object.keys(updateInput)) {
2019
- if (key === 'id')
2020
- continue;
2021
- const mod = modified?.get(key);
2022
- if (mod) {
2023
- prev[key] = mod.old;
2024
- }
2025
- else if (original && key in original) {
2026
- prev[key] = original[key];
2027
- }
2028
- }
2029
- return prev;
2030
- }
2031
- if (modified && modified.size > 0) {
2032
- for (const [key, change] of modified) {
2033
- prev[key] = change.old;
2034
- }
2035
- }
2036
- return prev;
2037
- }
2038
- /**
2039
- * Re-baseline `modifiedProperties` for the fields a freshly-staged update just
2040
- * committed. Called right after {@link extractPreviousData} freezes their
2041
- * `.old` into the transaction, so the NEXT update to the same field sees this
2042
- * update's result as its baseline rather than the stale pre-session `.old`
2043
- * preserved by `Model.propertyChanged`'s first-old-wins policy. Only consumes
2044
- * keys present in this update — untouched fields keep their baselines. Safe
2045
- * because the wire payload lives on `transaction.data` and rollback restores
2046
- * from `transaction.previousData`; neither re-reads `modifiedProperties`.
2047
- */
2048
- consumeModifiedFields(model, updateInput) {
2049
- if (!(model.modifiedProperties instanceof Map) || model.modifiedProperties.size === 0) {
2050
- return;
2051
- }
2052
- for (const key of [...model.modifiedProperties.keys()]) {
2053
- if (key === 'id')
2054
- continue;
2055
- if (updateInput && !(key in updateInput))
2056
- continue;
2057
- model.modifiedProperties.delete(key);
2058
- }
2005
+ // fields). `fallbackToLive: false` makes `Model.capturePreviousValues` OMIT
2006
+ // any key it can't resolve from `modifiedProperties.old` / the original
2007
+ // snapshot `buildUndoOps` then drops an un-revertible inverse rather than
2008
+ // inventing one. With no `updateInput` (full extract) fall back to every
2009
+ // tracked field. `Model.capturePreviousValues` is the single before-image
2010
+ // source shared with `RecordingTransaction.snapshotFields`.
2011
+ const keys = updateInput
2012
+ ? Object.keys(updateInput)
2013
+ : [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
2014
+ return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
2059
2015
  }
2060
2016
  /**
2061
2017
  * Public API
@@ -60,6 +60,9 @@ export interface DefaultSyncShape {
60
60
  * Empty by default — every SDK resolver falls back to {@link DefaultSyncShape}
61
61
  * when an expected key is absent. Exported from the package root so the module
62
62
  * augmentation merges into this declaration.
63
+ *
64
+ * The `Schema` augmentation key holds the type produced by `defineSchema`, so
65
+ * the same noun reads consistently here and in {@link ResolveSchema}.
63
66
  */
64
67
  export interface Register {
65
68
  }
@@ -57,28 +57,6 @@ export interface AgentDelta {
57
57
  causedByTaskId?: string | null;
58
58
  createdAt: string;
59
59
  }
60
- /**
61
- * A reference to whoever's authority bounds a joined participant.
62
- * The spawned participant can never see or do more than this principal.
63
- * Enforced server-side: the spawned agent gets its own restricted
64
- * (`rk_`) key whose scope is a subset of the parent's.
65
- *
66
- * • `SessionRef` — human is joining an agent (chat assistant flow)
67
- * • `AgentRef` — agent spawning a sub-agent (attenuation chain)
68
- * • omitted — the API key on the Ablo client is the ceiling
69
- */
70
- export type Principal = SessionRef | AgentRef;
71
- export interface SessionRef {
72
- readonly kind: 'session';
73
- readonly id: string;
74
- readonly userId: string;
75
- readonly organizationId: string;
76
- }
77
- export interface AgentRef {
78
- readonly kind: 'agent';
79
- readonly id: string;
80
- readonly capabilityToken: string;
81
- }
82
60
  /**
83
61
  * Flat snapshot view returned from `participant.snapshot(...)`.
84
62
  *
@@ -145,6 +145,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
145
145
  'propertyChanged',
146
146
  'markAsPersisted',
147
147
  'clearChanges',
148
+ 'consumeModifiedFields',
148
149
  'updateFromData',
149
150
  'applyChanges',
150
151
  ];
package/docs/api-keys.md CHANGED
@@ -12,6 +12,55 @@ The key identifies the Ablo account. Application code does not pass an organizat
12
12
 
13
13
  "Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently — they never carry a secret key.
14
14
 
15
+ ## Which credential to use
16
+
17
+ There's **one field — `apiKey`** — and what you pass depends on **where the code runs**.
18
+ Pick your row:
19
+
20
+ | Where your code runs | What to pass | Example |
21
+ |---|---|---|
22
+ | **Server / worker / CLI** (can hold a secret) | your secret `sk_` — it defaults to `ABLO_API_KEY`, so usually pass **nothing** | `Ablo({ schema })` |
23
+ | **Browser — read-only** | a publishable `pk_` (safe to ship, like a Stripe `pk_`) | `Ablo({ schema, apiKey: process.env.NEXT_PUBLIC_ABLO_PUBLISHABLE_KEY })` |
24
+ | **Browser — writing as the signed-in user** | a function that fetches a short-lived per-user token from your own backend | `Ablo({ schema, apiKey: () => fetch('/api/ablo-session').then((r) => r.text()) })` |
25
+
26
+ That's the whole story: one knob, filled by audience.
27
+
28
+ **Coming from Stripe? It's the same key model, same prefixes:**
29
+
30
+ | Stripe | Ablo | Where it goes |
31
+ |---|---|---|
32
+ | publishable `pk_` (client-safe) | `pk_` | browser — read-only |
33
+ | secret `sk_` (server, full) | `sk_` | server — full authority |
34
+ | restricted `rk_` (granular) | `rk_` | scoped agent sessions (`sessions.create({ agent, can })`) |
35
+ | ephemeral key (client, customer-scoped) | `ek_` | per-user browser sessions (`sessions.create({ user })`) |
36
+
37
+ Mode lives in the prefix too — `sk_test_` / `sk_live_` — exactly like Stripe. The
38
+ `apiKey` resolver fetching an `ek_` is Ablo's ephemeral-key flow: server mints, client holds.
39
+
40
+ **Why a function for browser writes?** Anything you ship to a browser must be public, and a
41
+ public `pk_` is **read-only** — it can't carry one specific user's write authority. So when
42
+ the browser writes *as the logged-in user*, your backend (which holds the secret `sk_` and
43
+ knows who's signed in) mints a short-lived per-user token with `sessions.create({ user })`,
44
+ and the browser's `apiKey` function fetches it. You don't manage refresh — the SDK calls the
45
+ function once before connecting and then keeps the token fresh (re-mint before expiry, and on
46
+ tab-focus / network-online / device-wake). This is the Stripe ephemeral-key / Supabase
47
+ session model. For a read-only app you don't need any of this — just the `pk_` above.
48
+
49
+ Server-side, because `apiKey` defaults to `process.env.ABLO_API_KEY`, most backend and agent
50
+ code passes nothing. The secret `sk_` (and `databaseUrl`) are **server-only** — never in a
51
+ browser bundle. There is no `getToken`, `authEndpoint`, or `as` option — `apiKey` (a string,
52
+ or a function for the browser-write case) is the single credential knob.
53
+
54
+ ### Minting per-user / agent tokens (server-side, with your `sk_`)
55
+
56
+ | Mint | Call | Result |
57
+ |---|---|---|
58
+ | Human end-user session | `await server.sessions.create({ user: { id } })` | `ek_` (full user authority) |
59
+ | Scoped delegated agent | `await server.sessions.create({ agent: { id }, can: { Task: ['update'] } })` | `rk_` (scoped to `can`) |
60
+ | Connect Ablo to your own Postgres | `Ablo({ schema, apiKey, databaseUrl })` (server-only) | dedicated tenant |
61
+
62
+ The principal kind comes from *which* shape you pass — `{ user }` → `user`, `{ agent, can }` → `agent`.
63
+
15
64
  ## Server-Side API Keys
16
65
 
17
66
  Use API keys from trusted (server-side) runtimes:
package/docs/api.md CHANGED
@@ -167,8 +167,9 @@ side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if s
167
167
  already holds the row, `claim` waits for them to finish, re-reads the fresh row,
168
168
  then hands it to you, so you always proceed from current state. Default reads
169
169
  return the row even while someone is mid-edit; if a server read should not
170
- return a row while it's claimed, pass `ifClaimed: 'wait'` to wait for the claim
171
- to clear, or `ifClaimed: 'fail'` to error out instead.
170
+ return a row while it's claimed, pass `ifClaimed: 'fail'` to error out instead.
171
+ Reads never block on a claim — to wait for a row to free up, `claim({ id })` it
172
+ (the claim queues fairly behind the holder).
172
173
 
173
174
  ```ts
174
175
  const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
@@ -32,6 +32,7 @@ Common options:
32
32
  | `databaseUrl` | Optional, server-only. Registers your Postgres directly (the connection-string path). Pass it explicitly — it is **not** auto-read from the environment. Omit it for a signed Data Source endpoint or the hosted sandbox. The SDK throws if it sees this in a browser. |
33
33
  | `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
34
34
  | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
35
+ | `transport` | `'websocket'` (default) is the live, stateful client — a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (`get`/`getAll`, `onChange`, `watch`) are compile errors rather than runtime gaps. |
35
36
  | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
36
37
  | `defaultHeaders` | Extra headers attached to every HTTP request. |
37
38
  | `defaultQuery` | Extra query parameters attached to every HTTP request. |
@@ -1,9 +1,10 @@
1
1
  # Coordination Reference
2
2
 
3
3
  Coordinate long-running work on a row so humans and agents don't clobber each
4
- other. Most writes need none of this — `ablo.<model>.update({ id, data })` is optimistic
5
- and the server rejects it if the row moved. Reach for `claim` only when you'll
6
- **hold a row across a slow gap** (read LLM call write).
4
+ other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
5
+ is **last-write-wins** by default. For lost-update detection, take a claim or pass
6
+ `readAt` / `onStale` yourself. Reach for `claim` only when you'll **hold a row
7
+ across a slow gap** (read → LLM call → write).
7
8
 
8
9
  Claims don't lock. If another writer holds the row, `claim` waits for them,
9
10
  re-reads the fresh row, then hands it to you — so two writers serialize instead
@@ -36,10 +37,12 @@ make:
36
37
  **The one decision: do you hold the row across a slow gap (read → LLM call →
37
38
  write)?**
38
39
 
39
- - **No** (the common case — a single quick `update`): do nothing. `ablo.<model>.update`
40
- is optimistically guarded by stale-context already; it rejects with
41
- `AbloStaleContextError` if the row moved under you. This is the default and
42
- needs no ceremony.
40
+ - **No** (the common case — a single quick `update`): a plain `ablo.<model>.update`
41
+ is **last-write-wins** it carries no `readAt`, so the server skips the stale
42
+ check and the write simply lands. That's fine for most fields. If you need
43
+ lost-update detection on a no-claim write, pass `readAt` + `onStale: 'reject'`
44
+ yourself and it rejects with `AbloStaleContextError` when the row moved under
45
+ you.
43
46
  - **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
44
47
  excludes other participants for the duration, queues contenders fairly, and —
45
48
  see below — your own writes under it stay stale-guarded too.
@@ -48,12 +51,13 @@ write)?**
48
51
  non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
49
52
  `readAt`. If you do hold it, your own writes are still stale-checked — a row that
50
53
  moved between your snapshot and your write still rejects with
51
- `AbloStaleContextError`. With no claim held, the stale check is the only
52
- protection, and it's automatic, which is why the no-claim path is safe by default.
53
- Presence (`claim.state`) never decides anything — read it to render, act on the
54
- errors. The two checks are independent: one rejects writes from people who don't
55
- hold the claim, the other rejects writes based on a stale snapshot, and the SDK
56
- adds the stale-check for you when you write under a claim, so you don't pass
54
+ `AbloStaleContextError`. With no claim held and no `readAt`, there is **no**
55
+ stale protection the plain write is last-write-wins; opt into lost-update
56
+ detection by passing `readAt` + `onStale` yourself. Presence (`claim.state`)
57
+ never decides anything read it to render, act on the errors. The two checks are
58
+ independent: one rejects writes from people who don't hold the claim, the other
59
+ rejects writes based on a stale snapshot, and the SDK adds the stale-check for you
60
+ when you write under a claim **you took on this client**, so there you don't pass
57
61
  anything extra.
58
62
 
59
63
  ---
@@ -92,6 +96,17 @@ a model row. It's what `claim.state()` returns and what observers render.
92
96
 
93
97
  ## Methods
94
98
 
99
+ One word — "claim" — names four distinct things; keep them separate as you read:
100
+
101
+ - **the lease (claim handle)** — the *object* returned by `ablo.<model>.claim({ id })`
102
+ (`ClaimHandle`, an `AsyncDisposable` with `.data` and `.release()`).
103
+ - **acquiring a claim/lease** — the *verb* `ablo.<model>.claim({ id })`, the call
104
+ that takes the lease.
105
+ - **`claim.state` / `claim.queue`** — the *inspection namespace* hanging off the
106
+ model, for reading who holds the row and who's lined up.
107
+ - **the write's `claim` param** — `update({ id, data, claim })`, where you pass a
108
+ lease the proxy didn't take itself.
109
+
95
110
  Each method below follows one fixed shape: **signature · what it does ·
96
111
  parameters · returns · example**.
97
112
 
@@ -149,15 +164,19 @@ held. Server/model reads can choose a claimed policy:
149
164
  ```ts
150
165
  await ablo.weatherReports.retrieve({
151
166
  id: 'report_stockholm',
152
- ifClaimed: 'wait',
153
- claimedTimeout: 30_000,
167
+ ifClaimed: 'fail',
154
168
  });
155
169
  ```
156
170
 
157
- - `ifClaimed: 'return'` reads now and includes active work metadata.
158
- - `ifClaimed: 'wait'` waits for the active claim to clear before reading.
171
+ - `ifClaimed: 'return'` (the default) reads now and includes active work metadata.
159
172
  - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
160
173
 
174
+ Reads never block on a claim — there is no `ifClaimed: 'wait'`. Waiting for a row
175
+ to free up is a **claim-side** concern: take `ablo.<model>.claim({ id })` (it
176
+ queues fairly behind the current holder and re-reads the fresh row once it's
177
+ yours). Use `ifClaimed: 'fail'` when a read should simply refuse to proceed
178
+ against a row someone else is mid-editing.
179
+
161
180
  ### `claim.state`
162
181
 
163
182
  ```ts
@@ -272,19 +291,54 @@ try {
272
291
  }
273
292
  ```
274
293
 
294
+ ### `watch` — presence for a set of rows
295
+
296
+ Reading or claiming a row auto-enrolls you in its sync group, which is enough for
297
+ `claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
298
+ presence on a known set of rows — a deck's slides, a board's cards — and react to
299
+ who joins or leaves, use `watch`:
300
+
301
+ ```ts
302
+ await using room = await ablo.slides.watch(slideIds, { ttl: '5m' });
303
+ room.peers; // who else is here, live
304
+ ```
305
+
306
+ `watch(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
307
+ a participant handle (`.peers`, the scoped claim stream, `.leave()` / `await using`
308
+ disposal). It is the model-scoped successor to the old top-level
309
+ `ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
310
+ socket, so `watch` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
311
+ throws on any non-ws construction.
312
+
275
313
  ### Writing under a claim
276
314
 
277
315
  There is no separate "write" method on a claim — use the normal
278
- `ablo.<model>.update({ id, data })`. While you hold a claim on `id`, that `update` is
279
- automatically stale-guarded against the snapshot the claim took (`readAt` =
280
- snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
281
- it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
316
+ `ablo.<model>.update({ id, data })`. The auto-guarding holds **only when this same
317
+ client took the claim** via `ablo.<model>.claim({ id })` (the proxy remembers the
318
+ lease in-process): that `update` is then stale-guarded against the snapshot the
319
+ claim took (`readAt` = snapshot watermark, `onStale: 'reject'`) and attributed to
320
+ the claim's lease, so it rejects with [`AbloStaleContextError`](#errors) if the
321
+ row changed under you.
282
322
 
283
323
  ```ts
284
324
  await using claim = await ablo.weatherReports.claim({ id });
285
325
  await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
286
326
  ```
287
327
 
328
+ A claim handle minted by **another client** (or returned over HTTP) is not known
329
+ to this proxy, so a plain `update` won't pick it up. Pass it explicitly:
330
+
331
+ ```ts
332
+ await ablo.weatherReports.update({ id, data: { status: 'ready' }, claim: handle });
333
+ ```
334
+
335
+ **Self-stale on a second write.** The claim's watermark is fixed at claim time
336
+ and is **not** re-baselined as you write. So a *second* `update` under one held
337
+ claim is stale-checked against the snapshot the claim took — which your *first*
338
+ write already moved past — and rejects with `AbloStaleContextError` against your
339
+ own earlier write. Re-read (and re-claim) between writes if you need to write the
340
+ same row more than once under one claim.
341
+
288
342
  Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
289
343
  participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
290
344
  'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
@@ -57,8 +57,9 @@ export const ablo = Ablo({
57
57
  ```
58
58
 
59
59
  Mount the React provider near the app root. Build the browser client first —
60
- with an `authEndpoint` so it mints a short-lived session token instead of
61
- carrying the secret key then pass it to the provider via `client`.
60
+ with an `apiKey` resolver (an async `() => Promise<string | null>`) that fetches
61
+ the short-lived session token your backend minted, instead of carrying the
62
+ secret key — then pass it to the provider via `client`.
62
63
 
63
64
  ```tsx
64
65
  // web/app/providers.tsx
@@ -68,9 +69,12 @@ import Ablo from '@abloatai/ablo';
68
69
  import { AbloProvider } from '@abloatai/ablo/react';
69
70
  import { schema } from '@/ablo/schema';
70
71
 
71
- // Browser client: no secret key — `authEndpoint` mints the session token
72
- // server-side (see the session route below).
73
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
72
+ // Browser client: no secret key — the `apiKey` resolver fetches the session
73
+ // token your server route mints (see the session route below).
74
+ const ablo = Ablo({
75
+ schema,
76
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
77
+ });
74
78
 
75
79
  export function Providers({ children }: { children: React.ReactNode }) {
76
80
  return <AbloProvider client={ablo}>{children}</AbloProvider>;
@@ -81,7 +81,7 @@ import { schema } from './schema';
81
81
 
82
82
  const ablo = Ablo({
83
83
  schema,
84
- getToken: async () => mintDeckAgentSession(deckId, agentId),
84
+ apiKey: async () => mintDeckAgentSession(deckId, agentId),
85
85
  });
86
86
 
87
87
  // The agent run is mounted on behalf of its triggering user.
@@ -82,9 +82,10 @@ Claims are live coordination signals. They are not database locks.
82
82
  already holds the row, the claim waits for them to finish, then re-reads the row
83
83
  before handing it back, so you proceed from fresh state. Reads stay open while a
84
84
  claim is held — `ablo.<model>.claim.state({ id })` returns the current claim state
85
- (or `null`) without ever blocking. A server read can pass `ifClaimed: 'wait'` to
86
- wait for the claim to clear, or `ifClaimed: 'fail'` to error out, when it should
87
- not return a row while someone else is mid-edit.
85
+ (or `null`) without ever blocking. A server read can pass `ifClaimed: 'fail'` to
86
+ error out, when it should not return a row while someone else is mid-edit. Reads
87
+ never block on a claim — to wait for a row to free up, `claim({ id })` it (the
88
+ claim queues fairly behind the holder).
88
89
 
89
90
  A claim does not reject or block other writers; it announces work so peers
90
91
  serialize behind it rather than racing. While you hold a claim, the matching