@abloatai/ablo 0.12.0 → 0.14.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 (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +3 -3
  4. package/dist/BaseSyncedStore.js +39 -32
  5. package/dist/batching/index.d.ts +57 -0
  6. package/dist/batching/index.js +150 -0
  7. package/dist/cli.cjs +158 -40
  8. package/dist/client/Ablo.d.ts +16 -25
  9. package/dist/client/Ablo.js +1 -1
  10. package/dist/client/auth.js +11 -0
  11. package/dist/client/createModelProxy.d.ts +33 -8
  12. package/dist/client/createModelProxy.js +4 -4
  13. package/dist/errorCodes.d.ts +3 -1
  14. package/dist/errorCodes.js +10 -1
  15. package/dist/schema/index.d.ts +2 -2
  16. package/dist/schema/index.js +2 -2
  17. package/dist/schema/model.d.ts +38 -84
  18. package/dist/schema/model.js +12 -12
  19. package/dist/schema/roles.d.ts +49 -0
  20. package/dist/schema/roles.js +21 -0
  21. package/dist/schema/schema.d.ts +1 -1
  22. package/dist/schema/schema.js +1 -1
  23. package/dist/schema/serialize.d.ts +4 -2
  24. package/dist/schema/serialize.js +4 -2
  25. package/dist/schema/sugar.d.ts +7 -28
  26. package/dist/schema/sugar.js +2 -7
  27. package/dist/schema/sync-delta-row.d.ts +2 -0
  28. package/dist/schema/sync-delta-row.js +2 -1
  29. package/dist/schema/tenancy.d.ts +67 -28
  30. package/dist/schema/tenancy.js +93 -23
  31. package/dist/server/commit.d.ts +8 -3
  32. package/docs/api.md +7 -6
  33. package/docs/cli.md +43 -4
  34. package/docs/client-behavior.md +2 -2
  35. package/docs/coordination.md +12 -12
  36. package/docs/examples/agent-human.md +6 -6
  37. package/docs/examples/ai-sdk-tool.md +1 -1
  38. package/docs/examples/existing-python-backend.md +0 -2
  39. package/docs/examples/nextjs.md +2 -2
  40. package/docs/examples/scoped-agent.md +3 -3
  41. package/docs/examples/server-agent.md +4 -4
  42. package/docs/identity.md +27 -20
  43. package/docs/index.md +0 -1
  44. package/docs/integration-guide.md +12 -9
  45. package/docs/interaction-model.md +1 -1
  46. package/docs/mcp.md +17 -5
  47. package/docs/quickstart.md +3 -3
  48. package/docs/react.md +69 -0
  49. package/llms.txt +2 -3
  50. package/package.json +8 -2
  51. package/docs/mcp/claude-code.md +0 -35
  52. package/docs/mcp/cursor.md +0 -35
  53. package/docs/mcp/windsurf.md +0 -33
  54. package/docs/roadmap.md +0 -55
  55. package/docs/the-loop.md +0 -21
  56. package/llms-full.txt +0 -396
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ Every model verb takes ONE options object. The common loop:
31
31
 
32
32
  1. **Read** the row — `await ablo.<model>.retrieve({ id })` (async; from the server) or `await ablo.<model>.list({ where })` for many. In React render, read synchronously with `useAblo((a) => a.<model>.get(id))`.
33
33
  2. **See who's active** (optional) — `ablo.<model>.claim.state({ id })` (synchronous; never blocks).
34
- 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, action?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
34
+ 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, reason?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
35
35
  4. **Write** — `await ablo.<model>.update({ id: claim.data.id, data })`. Because you hold the claim, the write is rejected if the row changed underneath you.
36
36
 
37
37
  Keep coding assistants on this schema-backed path.
@@ -59,7 +59,7 @@ if (!report) throw new Error('Report not found');
59
59
  // row before resolving. Auto-released at the end of this scope (`await using`).
60
60
  await using claim = await ablo.weatherReports.claim({
61
61
  id: 'report_stockholm',
62
- action: 'forecasting',
62
+ reason: 'forecasting',
63
63
  ttl: '2m',
64
64
  });
65
65
  const claimed = claim.data;
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Claim API consistency + coordination docs
8
+ - **React:** document `useWatch` (scoped presence + read-interest, with `claim`/`hydrate`/`paused` options) and `usePeers` (read-only presence) — previously exported but undocumented.
9
+ - **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`.
10
+ - **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 })`).
11
+ - **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`.
12
+
13
+ ## 0.13.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Schema authoring: split model routing into two orthogonal axes — `policy` (row access) and `groups` (sync-group routing).
18
+
19
+ **Breaking (schema authoring).** The flat, collision-prone model options are replaced by two namespaced ones:
20
+ - **`policy`** — row-access / tenant isolation (named after Postgres/Supabase RLS policies: the rule that scopes which rows a tenant may read). A discriminated union on `by` replaces the old `orgScoped` / `scopedVia` / `orgColumn` trio:
21
+ - `{ by: 'column' }` — row-local tenancy column (the default when omitted; column name still overridable).
22
+ - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when the table has no tenancy column of its own (e.g. `slide_layers` → `slides`).
23
+ - Type `TenancyInput` is renamed `PolicyInput`; `policyInputSchema` / `resolvePolicy` are now exported.
24
+ - **`groups: { root, grants, roles }`** — which delta channels a row fans into (orthogonal to `policy`, which governs read access). One namespaced object replaces the old flat `scope` / `grants` / `entityRoles`:
25
+ - `root` (was `scope`) — mark a model a scope root; its records form the group `<kind>:<id>`. Renamed so it no longer collides with the old `scopedVia` tenancy sugar or the inner `grants.scope` relation name.
26
+ - `grants` — a membership edge granting an identity access to a scope root.
27
+ - `roles` (was `entityRoles`) — explicit non-relational record→group roles; accepts one role or an array.
28
+ - `groupsInputSchema` / `GroupsInput` are now exported.
29
+
30
+ **CLI.** `config.json` now stores per-project profile key pairs (`profiles: Record<string, ProfileKeys>`) instead of a single top-level pair; older flat layouts are folded into the active profile automatically on read, so existing logins keep working. `login` / `projects` updated to the profile model.
31
+
3
32
  ## 0.12.0
4
33
 
5
34
  ### Minor Changes
package/README.md CHANGED
@@ -274,7 +274,7 @@ ablo.weatherReports.claim.state({ id: 'report_stockholm' });
274
274
  ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
275
275
 
276
276
  {
277
- await using claim = await ablo.weatherReports.claim({ id, wait: false });
277
+ await using claim = await ablo.weatherReports.claim({ id, queue: false });
278
278
  /* do the held work */
279
279
  }
280
280
 
@@ -285,11 +285,11 @@ ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
285
285
  ```
286
286
 
287
287
  `claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
288
- behind it. `wait: false` skips rather than waiting when the row is held;
288
+ behind it. `queue: false` skips rather than waiting when the row is held;
289
289
  `maxQueueDepth: 2` bails when two or more are already ahead.
290
290
 
291
291
  Default reads keep working while a row is claimed. Server reads that need claimed
292
- semantics can opt in with `ifClaimed: 'return' | 'wait' | 'fail'`.
292
+ semantics can opt in with `ifClaimed: 'return' | 'fail'`.
293
293
 
294
294
  Even an unclaimed write can't land on stale reasoning — the commit is guarded:
295
295
 
@@ -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)
@@ -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
+ }