@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.
- package/CHANGELOG.md +49 -0
- package/dist/BaseSyncedStore.js +39 -32
- package/dist/Database.d.ts +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +1 -0
- package/dist/batching/index.d.ts +57 -0
- package/dist/batching/index.js +150 -0
- package/dist/cli.cjs +63 -1
- package/dist/client/Ablo.d.ts +43 -28
- package/dist/client/Ablo.js +12 -5
- package/dist/client/auth.js +11 -0
- package/dist/client/createModelProxy.d.ts +33 -8
- package/dist/client/createModelProxy.js +4 -4
- package/dist/client/sessionMint.js +1 -0
- package/dist/client/writeOptionsSchema.d.ts +4 -6
- package/dist/client/writeOptionsSchema.js +1 -1
- package/dist/coordination/schema.d.ts +90 -12
- package/dist/coordination/schema.js +99 -4
- package/dist/errorCodes.d.ts +3 -1
- package/dist/errorCodes.js +10 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/interfaces/index.d.ts +18 -2
- package/dist/policy/types.d.ts +35 -3
- package/dist/policy/types.js +20 -7
- package/dist/server/commit.d.ts +17 -0
- package/dist/source/connector-protocol.d.ts +159 -0
- package/dist/source/connector-protocol.js +161 -0
- package/dist/source/connector.d.ts +96 -0
- package/dist/source/connector.js +264 -0
- package/dist/source/contract.d.ts +4 -6
- package/dist/source/contract.js +1 -1
- package/dist/source/index.d.ts +3 -1
- package/dist/source/index.js +6 -0
- package/dist/sync/SyncWebSocket.d.ts +32 -5
- package/dist/sync/SyncWebSocket.js +40 -6
- package/dist/transactions/TransactionQueue.d.ts +7 -1
- package/dist/transactions/TransactionQueue.js +43 -2
- package/dist/wire/frames.d.ts +21 -4
- package/docs/api.md +6 -5
- package/docs/concurrency-convention.md +222 -0
- package/docs/coordination.md +16 -11
- package/docs/data-sources.md +41 -0
- package/docs/react.md +69 -0
- 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
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -781,47 +781,54 @@ export class BaseSyncedStore {
|
|
|
781
781
|
startCredentialLifecycle(getToken) {
|
|
782
782
|
this.stopCredentialLifecycle();
|
|
783
783
|
this.setCredentialRefresher(getToken);
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
800
|
-
//
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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)
|
package/dist/Database.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ interface PersistedMutation {
|
|
|
17
17
|
timestamp: string;
|
|
18
18
|
writeOptions?: {
|
|
19
19
|
readAt?: number | null;
|
|
20
|
-
onStale?: 'reject' | '
|
|
20
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
/** Persisted transaction for offline/retry support.
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/auth/index.js
CHANGED
|
@@ -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", "
|
|
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",
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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' | '
|
|
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' | '
|
|
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' | '
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
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:
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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) {
|