@abloatai/ablo 0.13.0 → 0.15.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 (45) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/BaseSyncedStore.js +39 -32
  3. package/dist/Database.d.ts +1 -1
  4. package/dist/auth/index.d.ts +4 -0
  5. package/dist/auth/index.js +1 -0
  6. package/dist/batching/index.d.ts +57 -0
  7. package/dist/batching/index.js +150 -0
  8. package/dist/cli.cjs +63 -1
  9. package/dist/client/Ablo.d.ts +43 -28
  10. package/dist/client/Ablo.js +12 -5
  11. package/dist/client/auth.js +11 -0
  12. package/dist/client/createModelProxy.d.ts +33 -8
  13. package/dist/client/createModelProxy.js +4 -4
  14. package/dist/client/sessionMint.js +1 -0
  15. package/dist/client/writeOptionsSchema.d.ts +4 -6
  16. package/dist/client/writeOptionsSchema.js +1 -1
  17. package/dist/coordination/schema.d.ts +90 -12
  18. package/dist/coordination/schema.js +99 -4
  19. package/dist/errorCodes.d.ts +3 -1
  20. package/dist/errorCodes.js +10 -1
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +9 -0
  23. package/dist/interfaces/index.d.ts +18 -2
  24. package/dist/policy/types.d.ts +35 -3
  25. package/dist/policy/types.js +20 -7
  26. package/dist/server/commit.d.ts +17 -0
  27. package/dist/source/connector-protocol.d.ts +159 -0
  28. package/dist/source/connector-protocol.js +161 -0
  29. package/dist/source/connector.d.ts +96 -0
  30. package/dist/source/connector.js +264 -0
  31. package/dist/source/contract.d.ts +4 -6
  32. package/dist/source/contract.js +1 -1
  33. package/dist/source/index.d.ts +3 -1
  34. package/dist/source/index.js +6 -0
  35. package/dist/sync/SyncWebSocket.d.ts +32 -5
  36. package/dist/sync/SyncWebSocket.js +40 -6
  37. package/dist/transactions/TransactionQueue.d.ts +7 -1
  38. package/dist/transactions/TransactionQueue.js +43 -2
  39. package/dist/wire/frames.d.ts +21 -4
  40. package/docs/api.md +6 -5
  41. package/docs/concurrency-convention.md +222 -0
  42. package/docs/coordination.md +16 -11
  43. package/docs/data-sources.md +41 -0
  44. package/docs/react.md +69 -0
  45. package/package.json +11 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Notify-instead-of-abort: non-coercive conflict handling + read-set (the "did anything I looked at change?" layer).**
8
+
9
+ The principle: on a stale-context conflict the engine now **surfaces the current state and lets the actor — agent or human — resolve it**, instead of forcing an outcome. See `docs/concurrency-convention.md`.
10
+
11
+ **`onStale` redesigned — Stripe-aligned values (BREAKING).**
12
+
13
+ The mode set is now `'reject' | 'overwrite' | 'notify'`. Each value names its outcome:
14
+ - **`notify` (new, non-coercive)** — the conflicting write is **held** (not applied) and the commit returns a `StaleNotification` carrying the conflicting field's *current* value, so the actor reconciles and re-commits rather than losing work. The rest of the batch still commits.
15
+ - **`overwrite`** (was `force`) — blind last-writer-wins, no signal.
16
+ - **`reject`** (default, unchanged) — throws `AbloStaleContextError`.
17
+
18
+ Migration:
19
+ - `onStale: 'force'` → `onStale: 'overwrite'`.
20
+ - `onStale: 'flag'` / `onStale: 'merge'` → `onStale: 'notify'` (both removed; `notify` is the single hold-and-surface mode).
21
+
22
+ **`StaleNotification` — the new advisory signal.** New public type + `staleNotificationSchema`:
23
+ `{ object: 'stale_notification', model, id, readAt, observedSyncId, conflictingFields, currentValues, writtenBy, group? }`. Delivered two ways:
24
+ - on the receipt — `CommitReceipt.notifications` (and `CommitResult.notifications`);
25
+ - on a new SDK event — **`conflict:notified`** `{ clientTxId, notifications }` (mirrors `reconciliation:needed` / `sync:rollback`).
26
+
27
+ **Read-set (`reads[]`) — declare what you looked at, not just what you write (new).** A commit may carry batch-level read dependencies; a moved premise fires that entry's `onStale` over the whole batch (`notify` holds every write + notifies, `reject` aborts, `overwrite` proceeds). Two granularities:
28
+ - **Row** — `{ model, id, readAt, fields? }`: did this row (optionally these fields) change?
29
+ - **Group** — `{ group, readAt }`: did anything in this sync group (`deck:abc`, `org:X`) change? — the same unit a participant watches and claims.
30
+
31
+ New public type `ReadDependency` + `readDependencySchema`; available on `ablo.commits.create({ operations, reads })` and the lower-level write options. This closes the gap the write-target check alone could not: a premise that changed without the written row changing.
32
+
33
+ **Conflict policy.** `ConflictDecision` gains `{ action: 'notify' }`; `defaultPolicy` maps `onStale: 'notify'` → notify-and-hold, everything else → reject. `StaleContextConflict.requestedMode` is added so custom policies can honor the caller's declared intent.
34
+
35
+ - **Data Source reverse-channel connector (new).** A customer Data Source can now **dial out** to the engine over a single outbound WebSocket (`ablo.source.v1` subprotocol) instead of exposing an inbound HTTP endpoint — the deployment shape private/VPC stores need.
36
+
37
+ - **`createSourceConnector({ apiKey, handler, baseURL? })`** (new public API, exported from the root and `/source`) — opens one outbound socket (Node global `WebSocket`, no new dependency), with reconnect/backoff, and serves the customer's existing Data Source `handler`.
38
+ - Server side: a connector registry + `/v1/source/listen` upgrade route bridge requests down / responses up, teed into `SourceClient` through the storage resolver.
39
+ - **Trust model unchanged:** the Standard-Webhooks HMAC is signed *above* the transport, so the socket carries the signed envelope byte-for-byte and the customer's `verifyAbloSourceRequest` is untouched. Transport changes, trust model doesn't.
40
+ - Opt-in per source via `reverse_channel_prod` (migration `20260622150000`); gated in `authorizeUpgrade`.
41
+
42
+ ## 0.14.0
43
+
44
+ ### Minor Changes
45
+
46
+ - Claim API consistency + coordination docs
47
+ - **React:** document `useWatch` (scoped presence + read-interest, with `claim`/`hydrate`/`paused` options) and `usePeers` (read-only presence) — previously exported but undocumented.
48
+ - **HTTP claim surface:** `HttpClaimApi` is now a mechanically derived async projection of the reactive `ClaimApi` (`AwaitedClaimMethod`), so the two transports can never drift. No behavior change — the only difference remains the `Promise` wrapper that statelessness forces on `state`/`queue`/`reorder`.
49
+ - **Naming:** unified the claim read verb to `state` across every layer (the internal `ModelCollaboration.observe` is now `state`, matching the public `ablo.<model>.claim.state({ id })`).
50
+ - **Docs:** corrected the `Claim` object reference — the field is `reason` (serialized on the wire as `action`), and `createdAt`/`expiresAt` are `number` (epoch-ms), not strings; corrected the claim options to `reason` and `queue`.
51
+
3
52
  ## 0.13.0
4
53
 
5
54
  ### Minor Changes
@@ -781,47 +781,54 @@ export class BaseSyncedStore {
781
781
  startCredentialLifecycle(getToken) {
782
782
  this.stopCredentialLifecycle();
783
783
  this.setCredentialRefresher(getToken);
784
- // A transient failure is swallowed: the engine keeps its current token and
785
- // the next trigger or the reactive `credential_stale` path — retries. We
786
- // never tear down or sign out on a failed proactive roll.
784
+ // Re-mint through the SAME single-flight path the FSM's reactive probe uses
785
+ // (`performCredentialRefresh`) rather than calling `getToken()` directly. Two
786
+ // wins over the old direct call:
787
+ // - SINGLE-FLIGHT: a wake nudge, an in-flight probe, and this proactive
788
+ // roll share one in-flight promise — no double-mint thrash.
789
+ // - The tri-state is HONOURED. The old code did `if (token) {…}` and
790
+ // dropped a `null` on the floor — a zombie session that re-minted on
791
+ // every tab focus and logged "signing out" forever without ever signing
792
+ // out. `session_error` now drives the FSM to actually expire.
787
793
  const refresh = async () => {
788
- try {
789
- const token = await getToken();
790
- if (token) {
791
- // Push into the shared credential source (read lazily by bootstrap
792
- // HTTP, probes, and the WS reconnect URL), then nudge a parked
793
- // connection to re-probe with the fresh key. Same two steps the
794
- // engine's `setAuthToken` wrapper performs.
795
- this.auth?.setAuthToken(token);
796
- this.nudgeReconnect();
797
- }
794
+ const outcome = await this.performCredentialRefresh();
795
+ if (outcome === 'refreshed') {
796
+ // Fresh key already pushed into the credential source by
797
+ // `performCredentialRefresh`; nudge a parked connection to re-probe.
798
+ this.nudgeReconnect();
798
799
  }
799
- catch {
800
- // transient (offline / mint hiccup) a later trigger retries.
800
+ else if (outcome === 'session_error') {
801
+ // The long-lived login is gone (mint answered 401/403). Surface it
802
+ // the proactive path's job is to report this, not hide it. A no-op in
803
+ // FSM states that don't accept the event (the probe converges on
804
+ // sign-out there anyway); `session_expired`'s onEnter owns the log.
805
+ this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
801
806
  }
807
+ // 'network_error' → transient (offline / mint hiccup); the next timer tick
808
+ // or the FSM's own probe retries. Never sign out for it.
802
809
  };
803
810
  // Comfortably inside the 15m `ek_` TTL; a missed (background-throttled) tick
804
- // is recovered by the next, or by the reactive probe.
811
+ // is recovered by the next, or by the reactive probe. The timer is the sole
812
+ // proactive PRE-ROLL — it keeps the key warm ahead of expiry even while the
813
+ // socket sits healthy-`connected` (a state the FSM never probes unprompted).
805
814
  const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
806
815
  const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
807
816
  const teardowns = [() => clearInterval(timer)];
808
817
  if (typeof window !== 'undefined') {
809
- const onTrigger = () => void refresh();
810
- window.addEventListener('online', onTrigger);
811
- // OS-wake: the desktop shell bridges Electron `powerMonitor` 'resume' to
812
- // this DOM event (visibilitychange does NOT fire on wake-from-sleep, so a
813
- // nap longer than the TTL would otherwise leave a dead key untouched).
814
- window.addEventListener('ablo:wake', onTrigger);
815
- teardowns.push(() => window.removeEventListener('online', onTrigger));
816
- teardowns.push(() => window.removeEventListener('ablo:wake', onTrigger));
817
- }
818
- if (typeof document !== 'undefined') {
819
- const onVisible = () => {
820
- if (document.visibilityState === 'visible')
821
- void refresh();
822
- };
823
- document.addEventListener('visibilitychange', onVisible);
824
- teardowns.push(() => document.removeEventListener('visibilitychange', onVisible));
818
+ // OS-wake (desktop only): the Electron shell bridges `powerMonitor`
819
+ // 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
820
+ // still owns, because `visibilitychange` does NOT fire on wake-from-sleep
821
+ // and unlike `online`/`visibilitychange` the ConnectionManager's own
822
+ // browser listeners (`setupBrowserListeners`) don't cover wake.
823
+ //
824
+ // The `online` and `visibilitychange` listeners that used to live here
825
+ // were REMOVED: the FSM already re-probes on NETWORK_ONLINE / TAB_VISIBLE
826
+ // through this exact credential path, so registering them here too only
827
+ // fired a second, null-swallowing mint per focus — the "session-key
828
+ // POSTed on every tab focus" spam in the console.
829
+ const onWake = () => void refresh();
830
+ window.addEventListener('ablo:wake', onWake);
831
+ teardowns.push(() => window.removeEventListener('ablo:wake', onWake));
825
832
  }
826
833
  this.credentialLifecycleTeardown = () => {
827
834
  for (const t of teardowns)
@@ -17,7 +17,7 @@ interface PersistedMutation {
17
17
  timestamp: string;
18
18
  writeOptions?: {
19
19
  readAt?: number | null;
20
- onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
20
+ onStale?: 'reject' | 'overwrite' | 'notify' | null;
21
21
  };
22
22
  }
23
23
  /** Persisted transaction for offline/retry support.
@@ -34,6 +34,10 @@ export interface MintUserSessionRequest {
34
34
  readonly baseUrl: string;
35
35
  /** The end user's external IdP id — becomes the session's `participantId`. */
36
36
  readonly userId: string;
37
+ /** Target org for a cross-org (platform) mint — the Stripe-Connect
38
+ * `Stripe-Account` analogue. Requires the `sk_` to carry
39
+ * `ephemeral:mint-any-org`; omit to mint into the key's own org. */
40
+ readonly organizationId?: string;
37
41
  readonly syncGroups?: readonly string[];
38
42
  readonly ttlSeconds: number;
39
43
  readonly label?: string;
@@ -107,6 +107,7 @@ export async function mintUserSessionKey(options) {
107
107
  },
108
108
  body: JSON.stringify({
109
109
  user: { id: options.userId },
110
+ ...(options.organizationId ? { organizationId: options.organizationId } : {}),
110
111
  ...(options.syncGroups ? { syncGroups: options.syncGroups } : {}),
111
112
  ttlSeconds: options.ttlSeconds,
112
113
  ...(options.label ? { label: options.label } : {}),
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `@abloatai/ablo/batching` — a dependency-free batch-coalescing primitive.
3
+ *
4
+ * Accumulate items issued close together (the canonical case: a synchronous
5
+ * burst, e.g. `Promise.all([ a(), b(), c() ])` in one event-loop tick) and
6
+ * dispatch them as ONE atomic batch instead of one call each. This is the
7
+ * scheduling essence of Ablo's `TransactionQueue` (and Linear's sync engine) —
8
+ * microtask same-tick staging, size/cost/delay flush triggers, and in-flight
9
+ * backpressure — distilled to a pure state machine with NO dependency on
10
+ * models, MobX, IndexedDB, or the wire. Consumers inject the actual dispatch.
11
+ *
12
+ * Guarantees:
13
+ * - a batch is ONE `dispatchBatch(items)` call → **atomic** (all-or-nothing).
14
+ * - on dispatch failure, **every** enqueued promise in that batch rejects
15
+ * with the same error.
16
+ * - items dispatch in enqueue order (optionally reordered by `compare` just
17
+ * before a batch is cut); batches run FIFO under a `maxInFlight` cap.
18
+ *
19
+ * The slides-sdk wraps this to coalesce `commits.create` calls; the stateful
20
+ * `TransactionQueue` MAY adopt it later (it would supply `compare` for FK
21
+ * ordering and keep its merge/confirm/retry logic in its own hooks).
22
+ */
23
+ export interface BatchSchedulerOptions<T> {
24
+ /** Master switch. When false, every `enqueue` dispatches solo immediately. Default true. */
25
+ readonly enabled?: boolean;
26
+ /** Coalescing window in ms. `0` (default) → flush on the next microtask (zero added latency). */
27
+ readonly windowMs?: number;
28
+ /** Max items per batch before a forced flush. Default 256. */
29
+ readonly maxBatchSize?: number;
30
+ /** Max accumulated `costOf` per batch before a forced flush. Default `Infinity` (disabled). */
31
+ readonly maxBatchCost?: number;
32
+ /** Per-item cost used by `maxBatchCost` (e.g. serialized bytes). Default `() => 0`. */
33
+ readonly costOf?: (item: T) => number;
34
+ /** Max dispatches in flight at once (backpressure). Default 1 → strictly ordered. */
35
+ readonly maxInFlight?: number;
36
+ }
37
+ export interface BatchSchedulerHooks<T, R> {
38
+ /** The single dispatch for one batch. One call → atomic at this layer. */
39
+ dispatchBatch(items: T[]): Promise<R>;
40
+ /**
41
+ * Optional ordering applied to the staged items immediately before a batch
42
+ * is cut (e.g. FK-priority). Omit for FIFO. Does not affect which items share
43
+ * a batch — only their order within the dispatched array.
44
+ */
45
+ compare?(a: T, b: T): number;
46
+ }
47
+ export interface BatchScheduler<T, R> {
48
+ /** Stage one item; resolves with its batch's dispatch result, or rejects with the batch error. */
49
+ enqueue(item: T): Promise<R>;
50
+ /** Stage an item that must dispatch in its OWN batch (e.g. it carries an explicit idempotency key). */
51
+ enqueueSolo(item: T): Promise<R>;
52
+ /** Force-flush the pending batch and resolve once everything in flight has settled. */
53
+ flush(): Promise<void>;
54
+ /** Stop scheduling and clear timers. Pending/in-flight promises still settle. */
55
+ dispose(): void;
56
+ }
57
+ export declare function createBatchScheduler<T, R>(hooks: BatchSchedulerHooks<T, R>, options?: BatchSchedulerOptions<T>): BatchScheduler<T, R>;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `@abloatai/ablo/batching` — a dependency-free batch-coalescing primitive.
3
+ *
4
+ * Accumulate items issued close together (the canonical case: a synchronous
5
+ * burst, e.g. `Promise.all([ a(), b(), c() ])` in one event-loop tick) and
6
+ * dispatch them as ONE atomic batch instead of one call each. This is the
7
+ * scheduling essence of Ablo's `TransactionQueue` (and Linear's sync engine) —
8
+ * microtask same-tick staging, size/cost/delay flush triggers, and in-flight
9
+ * backpressure — distilled to a pure state machine with NO dependency on
10
+ * models, MobX, IndexedDB, or the wire. Consumers inject the actual dispatch.
11
+ *
12
+ * Guarantees:
13
+ * - a batch is ONE `dispatchBatch(items)` call → **atomic** (all-or-nothing).
14
+ * - on dispatch failure, **every** enqueued promise in that batch rejects
15
+ * with the same error.
16
+ * - items dispatch in enqueue order (optionally reordered by `compare` just
17
+ * before a batch is cut); batches run FIFO under a `maxInFlight` cap.
18
+ *
19
+ * The slides-sdk wraps this to coalesce `commits.create` calls; the stateful
20
+ * `TransactionQueue` MAY adopt it later (it would supply `compare` for FK
21
+ * ordering and keep its merge/confirm/retry logic in its own hooks).
22
+ */
23
+ export function createBatchScheduler(hooks, options) {
24
+ const enabled = options?.enabled ?? true;
25
+ const windowMs = options?.windowMs ?? 0;
26
+ const maxBatchSize = options?.maxBatchSize ?? 256;
27
+ const maxBatchCost = options?.maxBatchCost ?? Infinity;
28
+ const costOf = options?.costOf ?? (() => 0);
29
+ const maxInFlight = options?.maxInFlight ?? 1;
30
+ let pending = null;
31
+ let timer = null;
32
+ let microtaskScheduled = false;
33
+ const ready = [];
34
+ let inFlight = 0;
35
+ let idleWaiters = [];
36
+ let disposed = false;
37
+ function scheduleFlush() {
38
+ if (microtaskScheduled || timer)
39
+ return;
40
+ if (windowMs > 0) {
41
+ timer = setTimeout(() => {
42
+ timer = null;
43
+ flushPending();
44
+ }, windowMs);
45
+ }
46
+ else {
47
+ microtaskScheduled = true;
48
+ queueMicrotask(() => {
49
+ microtaskScheduled = false;
50
+ flushPending();
51
+ });
52
+ }
53
+ }
54
+ function flushPending() {
55
+ if (timer) {
56
+ clearTimeout(timer);
57
+ timer = null;
58
+ }
59
+ microtaskScheduled = false;
60
+ if (!pending)
61
+ return;
62
+ if (hooks.compare)
63
+ pending.items.sort(hooks.compare);
64
+ ready.push(pending);
65
+ pending = null;
66
+ pump();
67
+ }
68
+ function pump() {
69
+ while (inFlight < maxInFlight && ready.length > 0) {
70
+ const batch = ready.shift();
71
+ if (!batch)
72
+ break;
73
+ inFlight++;
74
+ let dispatched;
75
+ try {
76
+ dispatched = hooks.dispatchBatch(batch.items);
77
+ }
78
+ catch (error) {
79
+ dispatched = Promise.reject(error);
80
+ }
81
+ dispatched
82
+ .then((result) => {
83
+ for (const d of batch.deferreds)
84
+ d.resolve(result);
85
+ }, (error) => {
86
+ for (const d of batch.deferreds)
87
+ d.reject(error);
88
+ })
89
+ .finally(() => {
90
+ inFlight--;
91
+ pump();
92
+ notifyIdleIfDrained();
93
+ });
94
+ }
95
+ }
96
+ function notifyIdleIfDrained() {
97
+ if (inFlight === 0 && ready.length === 0 && idleWaiters.length > 0) {
98
+ const waiters = idleWaiters;
99
+ idleWaiters = [];
100
+ for (const w of waiters)
101
+ w();
102
+ }
103
+ }
104
+ function enqueueSolo(item) {
105
+ return new Promise((resolve, reject) => {
106
+ ready.push({ items: [item], deferreds: [{ resolve, reject }], cost: costOf(item) });
107
+ pump();
108
+ });
109
+ }
110
+ function enqueue(item) {
111
+ if (disposed)
112
+ return Promise.reject(new Error('batch scheduler disposed'));
113
+ if (!enabled)
114
+ return enqueueSolo(item);
115
+ const cost = costOf(item);
116
+ return new Promise((resolve, reject) => {
117
+ const deferred = { resolve, reject };
118
+ // Rollover: if appending would blow a cap, flush the current batch first.
119
+ if (pending && (pending.items.length + 1 > maxBatchSize || pending.cost + cost > maxBatchCost)) {
120
+ flushPending();
121
+ }
122
+ if (!pending)
123
+ pending = { items: [], deferreds: [], cost: 0 };
124
+ pending.items.push(item);
125
+ pending.deferreds.push(deferred);
126
+ pending.cost += cost;
127
+ if (pending.items.length >= maxBatchSize || pending.cost >= maxBatchCost) {
128
+ flushPending();
129
+ }
130
+ else {
131
+ scheduleFlush();
132
+ }
133
+ });
134
+ }
135
+ async function flush() {
136
+ flushPending();
137
+ while (ready.length > 0 || inFlight > 0) {
138
+ await new Promise((resolve) => idleWaiters.push(resolve));
139
+ }
140
+ }
141
+ function dispose() {
142
+ disposed = true;
143
+ if (timer) {
144
+ clearTimeout(timer);
145
+ timer = null;
146
+ }
147
+ microtaskScheduled = false;
148
+ }
149
+ return { enqueue, enqueueSolo, flush, dispose };
150
+ }
package/dist/cli.cjs CHANGED
@@ -276903,9 +276903,18 @@ var ERROR_CODES = {
276903
276903
  // ── quota / rate limit (429) ──────────────────────────────────────
276904
276904
  quota_exceeded: wire("rate_limit", 429, true, "The organization exceeded its configured usage quota."),
276905
276905
  connection_limit_exceeded: wire("rate_limit", 429, true, "Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain."),
276906
+ // Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
276907
+ // the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
276908
+ // so one noisy key backs off without affecting the rest of the org. The
276909
+ // `Retry-After` header carries the bucket-refill delay.
276910
+ rate_limit_exceeded: wire("rate_limit", 429, true, "This API key is sending requests too quickly; slow down and retry after the indicated delay."),
276906
276911
  // ── server (5xx) ───────────────────────────────────────────────────
276907
276912
  internal_error: wire("server", 500, true, "An unexpected server error occurred."),
276908
276913
  quota_lookup_failed: wire("server", 503, true, "The quota decision could not be loaded."),
276914
+ // The per-key rate-limiter backend (Redis) was unreachable and the API is
276915
+ // configured to FAIL CLOSED on that path, so the request was rejected rather
276916
+ // than admitted unchecked. Retryable: the next attempt re-probes the backend.
276917
+ rate_limiter_unavailable: wire("server", 503, true, "The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly."),
276909
276918
  turn_open_failed: wire("server", 500, true, "The agent turn failed to open."),
276910
276919
  turn_close_failed: wire("server", 500, true, "The agent turn failed to close cleanly."),
276911
276920
  // ── client-only invariants (never serialized) ──────────────────────
@@ -277058,12 +277067,65 @@ var targetRefSchema = import_zod3.z.object({
277058
277067
  field: import_zod3.z.string().optional(),
277059
277068
  meta: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional()
277060
277069
  });
277061
- var onStaleModeSchema = import_zod3.z.enum(["reject", "force", "flag", "merge"]);
277070
+ var onStaleModeSchema = import_zod3.z.enum(["reject", "overwrite", "notify"]);
277062
277071
  var writeGuardSchema = import_zod3.z.object({
277063
277072
  readAt: import_zod3.z.number().nullish(),
277064
277073
  onStale: onStaleModeSchema.nullish(),
277065
277074
  bypass: import_zod3.z.boolean().optional()
277066
277075
  });
277076
+ var staleNotificationSchema = import_zod3.z.object({
277077
+ /** Stripe-style object tag — every returned object names its type. */
277078
+ object: import_zod3.z.literal("stale_notification").optional(),
277079
+ /** Model name of the conflicting row. */
277080
+ model: import_zod3.z.string(),
277081
+ /** Row id. */
277082
+ id: import_zod3.z.string(),
277083
+ /** The watermark the committer reasoned against (its `readAt`). */
277084
+ readAt: import_zod3.z.number(),
277085
+ /**
277086
+ * Newest delta id on the row — the committer's new watermark. Re-capture
277087
+ * context at/after this id to reconcile.
277088
+ */
277089
+ observedSyncId: import_zod3.z.number(),
277090
+ /**
277091
+ * Fields whose concurrent change collided with this write (intersection of
277092
+ * the committer's written columns and a newer delta's `changed_fields`).
277093
+ * Empty ⇒ a whole-entity change (CREATE/DELETE/legacy delta).
277094
+ */
277095
+ conflictingFields: import_zod3.z.array(import_zod3.z.string()),
277096
+ /**
277097
+ * Post-conflict live values of `conflictingFields` — the part a plain stale
277098
+ * error never carried. Lets the LLM self-heal without a round-trip read.
277099
+ */
277100
+ currentValues: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()),
277101
+ /** Who wrote the conflicting delta. */
277102
+ writtenBy: import_zod3.z.object({
277103
+ kind: participantKindSchema,
277104
+ id: import_zod3.z.string()
277105
+ }),
277106
+ /**
277107
+ * Set when this notification is for a GROUP read-dependency (e.g. `deck:abc`,
277108
+ * `slide:s1`) rather than a single row — "something in the group you read
277109
+ * changed." For a group notification `conflictingFields`/`currentValues` are
277110
+ * empty (the change could span many rows); re-read the group at
277111
+ * `observedSyncId` to reconcile. Absent ⇒ a row-scoped notification.
277112
+ */
277113
+ group: import_zod3.z.string().optional()
277114
+ });
277115
+ var readDependencySchema = import_zod3.z.union([
277116
+ import_zod3.z.object({
277117
+ model: import_zod3.z.string(),
277118
+ id: import_zod3.z.string(),
277119
+ readAt: import_zod3.z.number(),
277120
+ fields: import_zod3.z.array(import_zod3.z.string()).optional(),
277121
+ onStale: onStaleModeSchema.optional()
277122
+ }),
277123
+ import_zod3.z.object({
277124
+ group: import_zod3.z.string(),
277125
+ readAt: import_zod3.z.number(),
277126
+ onStale: onStaleModeSchema.optional()
277127
+ })
277128
+ ]);
277067
277129
  var claimStatusSchema = import_zod3.z.enum([
277068
277130
  "active",
277069
277131
  "committed",
@@ -18,6 +18,7 @@
18
18
  * });
19
19
  * await sync.reports.delete({ id: reportId });
20
20
  */
21
+ import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
21
22
  import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
22
23
  import type { SyncEngineConfig, SyncLogger, MutationExecutor, MutationDispatcher, SyncObservabilityProvider, SyncAnalytics, SessionErrorDetector, OnlineStatusProvider } from '../interfaces/index.js';
23
24
  import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
@@ -28,7 +29,7 @@ import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
28
29
  import type { SyncGroupInput } from '../schema/roles.js';
29
30
  import { type SyncStatus } from '../BaseSyncedStore.js';
30
31
  import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
31
- import type { ClaimHandle, Duration, Claim } from '../types/streams.js';
32
+ import type { ClaimHandle, Duration } from '../types/streams.js';
32
33
  import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
33
34
  import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
34
35
  /**
@@ -371,7 +372,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
371
372
  * `claim({ id })` — durable claim handle for coordinated writes
372
373
  */
373
374
  export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
374
- import type { ModelOperations, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ServerReadOptions } from './createModelProxy.js';
375
+ import type { ModelOperations, ClaimOptions, ClaimParams, ClaimReadApi, AwaitedClaimMethod, ServerReadOptions } from './createModelProxy.js';
375
376
  export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
376
377
  export type CommitWait = 'queued' | 'confirmed';
377
378
  export interface ModelRead<T = Record<string, unknown>> {
@@ -424,7 +425,7 @@ export interface CommitOperationInput {
424
425
  readonly data?: Record<string, unknown> | null;
425
426
  readonly transactionId?: string | null;
426
427
  readonly readAt?: number | null;
427
- readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
428
+ readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
428
429
  }
429
430
  export interface CommitCreateOptions {
430
431
  readonly claimRef?: string | {
@@ -432,7 +433,7 @@ export interface CommitCreateOptions {
432
433
  } | null;
433
434
  readonly idempotencyKey?: string | null;
434
435
  readonly readAt?: number | null;
435
- readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
436
+ readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
436
437
  /**
437
438
  * A claim handle from `ablo.<model>.claim({ id })` (or the HTTP claim
438
439
  * surface). Same vocabulary as the per-model writes: the handle's
@@ -445,11 +446,29 @@ export interface CommitCreateOptions {
445
446
  readonly operation?: CommitOperationInput;
446
447
  readonly operations?: readonly CommitOperationInput[];
447
448
  readonly wait?: CommitWait;
449
+ /**
450
+ * Batch-level read dependencies (the STORM "did anything I looked at change?"
451
+ * layer). Declare the rows (`{model,id,readAt,fields?}`) or sync groups
452
+ * (`{group,readAt}`, e.g. `deck:abc`) this batch was premised on; the server
453
+ * validates none moved since `readAt` and fires the entry's `onStale` over the
454
+ * batch. Distinct from the write-target `readAt` — this guards what you READ,
455
+ * not what you write.
456
+ */
457
+ readonly reads?: readonly ReadDependency[] | null;
448
458
  }
449
459
  export interface CommitReceipt {
450
460
  readonly id: string;
451
461
  readonly status: CommitWait;
452
462
  readonly lastSyncId?: number;
463
+ /**
464
+ * Stale-context notifications (notify-instead-of-abort, non-coercion). Present
465
+ * only when this commit guarded a write with `onStale: 'notify' and
466
+ * the premise moved concurrently — the conflicting field's current value,
467
+ * handed back as data instead of a forced `AbloStaleContextError`. The engine
468
+ * surfaces state; the intelligent actor (agent or human) decides how to
469
+ * resolve. Also fires on `conflict:notified`.
470
+ */
471
+ readonly notifications?: readonly StaleNotification[];
453
472
  }
454
473
  export interface CommitResource {
455
474
  create(options: CommitCreateOptions): Promise<CommitReceipt>;
@@ -465,7 +484,7 @@ export interface ModelMutationOptions extends ClaimedOptions {
465
484
  } | null;
466
485
  readonly idempotencyKey?: string | null;
467
486
  readonly readAt?: number | null;
468
- readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
487
+ readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
469
488
  readonly wait?: CommitWait;
470
489
  readonly claim?: ClaimHandle | ClaimOptions | null;
471
490
  }
@@ -473,30 +492,21 @@ export interface ModelMutationOptions extends ClaimedOptions {
473
492
  * The HTTP/stateless claim surface. Normal tools usually put `claim` directly
474
493
  * on the write (`update({ id, data, claim })`) and let the SDK release it. Use
475
494
  * this namespace for multi-step handles and coordination screens.
495
+ *
496
+ * Same surface as the reactive {@link ClaimApi}, but every read is a server
497
+ * round-trip, so `state`/`queue`/`reorder` are **awaited** here (the WebSocket
498
+ * client resolves them synchronously from its local pool — which is what lets
499
+ * `useAblo((ablo) => ablo.x.claim.state({ id }))` work inside a React render; a
500
+ * stateless client has no pool to read, so the `Promise` is unavoidable).
501
+ *
502
+ * Mechanically DERIVED from `ClaimReadApi` via {@link AwaitedClaimMethod} so the
503
+ * two transports can never drift: the ONLY difference is the uniform `Promise`
504
+ * wrapper that statelessness forces. `claim({ id })` is identical (already async
505
+ * on both); `state`/`queue`/`reorder`/`release` are the awaited form.
476
506
  */
477
- export interface HttpClaimApi<T> {
478
- /** Take a manual claim handle for multi-step work. Release it when done. */
479
- (params: ClaimParams<T>): Promise<ClaimHandle<T>>;
480
- /** Release a manual claim you hold. */
481
- release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
482
- /**
483
- * Current holder of the lease on a row, or `null` when free. For UI badges,
484
- * preflight checks, and operators.
485
- */
486
- state(params: ClaimLookupParams<T>): Promise<Claim | null>;
487
- /**
488
- * FIFO wait line behind the holder. Advanced: useful for operator UIs and
489
- * schedulers.
490
- */
491
- queue(params: ClaimLookupParams<T>): Promise<{
492
- readonly object: 'list';
493
- readonly data: readonly Claim[];
494
- }>;
495
- /**
496
- * Re-rank the wait line. Advanced and permission-gated.
497
- */
498
- reorder(params: ClaimReorderParams<T>): Promise<void>;
499
- }
507
+ export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>) => Promise<ClaimHandle<T>>) & {
508
+ [K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
509
+ };
500
510
  export interface ModelClient<T = Record<string, unknown>> {
501
511
  /**
502
512
  * Single-row read over HTTP. **Returns an envelope, not the bare row** — the
@@ -552,6 +562,11 @@ export interface CreateUserSessionParams {
552
562
  user: {
553
563
  id: string;
554
564
  };
565
+ /** Mint the session into THIS organization instead of the key's own org — the
566
+ * Stripe Connect `Stripe-Account` pattern, for a platform serving many tenants
567
+ * from one backend. Requires the `sk_` to carry the `ephemeral:mint-any-org`
568
+ * scope; omit for the normal single-tenant case. */
569
+ organizationId?: string;
555
570
  /** Sync groups this session may subscribe to — typed (`'default'` or
556
571
  * `<namespace>:<id>`; build with `syncGroup(kind, id)` from
557
572
  * `@abloatai/ablo/schema`). Omit for the server default:
@@ -606,7 +606,7 @@ function createDefaultMutationExecutor(getWs) {
606
606
  : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
607
607
  try {
608
608
  return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
609
- options?.causedByTaskId);
609
+ options?.causedByTaskId, options?.reads);
610
610
  }
611
611
  catch (err) {
612
612
  // Wrap transport-level failures as connection errors so the
@@ -1325,7 +1325,7 @@ export function Ablo(options) {
1325
1325
  }),
1326
1326
  queue: (target) => publicClaims.queueFor({ type: target.model, id: target.id }),
1327
1327
  reorder: (target, order) => publicClaims.reorder({ type: target.model, id: target.id }, order),
1328
- observe: (target) => {
1328
+ state: (target) => {
1329
1329
  // The live claim stream only tracks *open* (active) claims;
1330
1330
  // terminal states (committed / expired / canceled) drop out of
1331
1331
  // the list entirely — exactly the ephemeral coordination model.
@@ -1412,12 +1412,19 @@ export function Ablo(options) {
1412
1412
  // SyncClient we already hold from createInternalComponents —
1413
1413
  // no need to leak an accessor through BaseSyncedStore.
1414
1414
  const queue = syncClient.getTransactionQueue();
1415
- queue.enqueueCommit(clientTxId, operations);
1415
+ queue.enqueueCommit(clientTxId, operations, {
1416
+ ...(commitOptions.reads ? { reads: [...commitOptions.reads] } : {}),
1417
+ });
1416
1418
  if (wait === 'queued') {
1417
1419
  return { id: clientTxId, status: 'queued' };
1418
1420
  }
1419
- const { lastSyncId } = await queue.waitForCommitReceipt(clientTxId);
1420
- return { id: clientTxId, status: 'confirmed', lastSyncId };
1421
+ const { lastSyncId, notifications } = await queue.waitForCommitReceipt(clientTxId);
1422
+ return {
1423
+ id: clientTxId,
1424
+ status: 'confirmed',
1425
+ lastSyncId,
1426
+ ...(notifications && notifications.length > 0 ? { notifications } : {}),
1427
+ };
1421
1428
  },
1422
1429
  };
1423
1430
  async function retrieveModel(modelName, id, options) {