@abloatai/ablo 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,6 @@ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloVal
9
9
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, } from './auth.js';
10
10
  import { toSeconds } from '../utils/duration.js';
11
11
  const DEFAULT_AGENT_LEASE = '10m';
12
- const DEFAULT_INTENT_LEASE = '2m';
13
12
  export function createProtocolClient(options) {
14
13
  const env = readProcessEnv();
15
14
  const authInput = { options, env };
@@ -100,144 +99,6 @@ export function createProtocolClient(options) {
100
99
  schema: null,
101
100
  });
102
101
  }
103
- function createAgent(id, agentOptions) {
104
- return {
105
- id,
106
- async run(runOptions, handler) {
107
- if (runOptions.signal?.aborted) {
108
- return { status: 'cancelled' };
109
- }
110
- let capability = null;
111
- let task = null;
112
- try {
113
- const leaseOptions = agentOptions.leaseSeconds !== undefined
114
- ? { leaseSeconds: agentOptions.leaseSeconds }
115
- : { lease: agentOptions.lease ?? DEFAULT_AGENT_LEASE };
116
- capability = await capabilities.create({
117
- participantKind: 'agent',
118
- participantId: id,
119
- syncGroups: agentOptions.syncGroups ?? ['default'],
120
- operations: agentOptions.can,
121
- label: agentOptions.label ?? id,
122
- userMeta: agentOptions.userMeta,
123
- ...leaseOptions,
124
- });
125
- const agentClient = capability.client();
126
- task = await agentClient.tasks.create({
127
- prompt: runOptions.prompt,
128
- parentTaskId: runOptions.parentTaskId,
129
- surface: runOptions.surface ?? 'agent',
130
- metadata: runOptions.metadata,
131
- });
132
- const context = createAgentRunContext(agentClient, task);
133
- const value = await handler(context);
134
- await task.close({
135
- costInputTokens: runOptions.costInputTokens,
136
- costOutputTokens: runOptions.costOutputTokens,
137
- costComputeMs: runOptions.costComputeMs,
138
- });
139
- return { status: 'done', task, value };
140
- }
141
- catch (error) {
142
- if (task) {
143
- await task.close({
144
- costInputTokens: runOptions.costInputTokens,
145
- costOutputTokens: runOptions.costOutputTokens,
146
- costComputeMs: runOptions.costComputeMs,
147
- }).catch(() => { });
148
- }
149
- if (isAbortError(error) || runOptions.signal?.aborted) {
150
- return { status: 'cancelled', task: task ?? undefined, error };
151
- }
152
- return { status: 'failed', task: task ?? undefined, error };
153
- }
154
- finally {
155
- if (capability) {
156
- await capabilities.revoke(capability.id).catch(() => { });
157
- }
158
- }
159
- },
160
- };
161
- }
162
- function createAgentRunContext(agentClient, task) {
163
- return {
164
- task,
165
- ablo: agentClient,
166
- model(name) {
167
- return createAgentModelClient(agentClient, name);
168
- },
169
- };
170
- }
171
- function createAgentModelClient(agentClient, name) {
172
- const base = agentClient.model(name);
173
- return {
174
- retrieve(params) {
175
- // Reads are never blocked by a claim (coordination.md): a claim
176
- // serializes WRITERS, not readers. So — unlike the create/update/
177
- // delete paths below — retrieve does NOT apply the agent claimed
178
- // default; options pass through and the read path's `'return'`
179
- // default keeps a claimed row readable. A caller can still opt into
180
- // gating with an explicit `ifClaimed` (developer's choice).
181
- return base.retrieve(params);
182
- },
183
- create(params) {
184
- const id = params.id ?? createModelId();
185
- return withAgentIntent(agentClient, name, id, params, (commitIntent) => base.create({
186
- ...stripAgentRuntimeOptions(params),
187
- id,
188
- data: params.data,
189
- intent: commitIntent,
190
- }));
191
- },
192
- update(params) {
193
- return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.update({
194
- ...stripAgentRuntimeOptions(params),
195
- id: params.id,
196
- data: params.data,
197
- intent: commitIntent,
198
- }));
199
- },
200
- delete(params) {
201
- return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.delete({
202
- ...stripAgentRuntimeOptions(params),
203
- id: params.id,
204
- intent: commitIntent,
205
- }));
206
- },
207
- };
208
- }
209
- async function withAgentIntent(agentClient, modelName, id, mutationOptions, commit) {
210
- const intentInput = mutationOptions?.intent;
211
- const targetOverride = intentInput != null && typeof intentInput === 'object' && !isIntentHandleRef(intentInput)
212
- ? intentInput.target ?? {}
213
- : {};
214
- const target = {
215
- ...targetOverride,
216
- model: targetOverride.model ?? modelName,
217
- id: targetOverride.id ?? id,
218
- ...(intentInput != null && typeof intentInput === 'object' && !isIntentHandleRef(intentInput) && intentInput.field
219
- ? { field: intentInput.field }
220
- : {}),
221
- };
222
- await applyClaimedPolicy(target, withAgentClaimedDefault(mutationOptions), 'wait');
223
- if (intentInput == null || isIntentHandleRef(intentInput)) {
224
- return commit(intentInput);
225
- }
226
- const action = typeof intentInput === 'string' ? intentInput : intentInput.action;
227
- const intent = await agentClient.intents.create({
228
- target,
229
- action,
230
- ttl: typeof intentInput === 'object'
231
- ? intentInput.ttl ?? DEFAULT_INTENT_LEASE
232
- : DEFAULT_INTENT_LEASE,
233
- });
234
- try {
235
- return await commit(intent);
236
- }
237
- finally {
238
- await intent.release().catch(() => { });
239
- }
240
- }
241
102
  function normalizeCommitOperation(op, defaults) {
242
103
  const model = op.model ?? op.target?.model;
243
104
  if (!model) {
@@ -476,51 +337,6 @@ export function createProtocolClient(options) {
476
337
  return capabilities.create(options);
477
338
  },
478
339
  };
479
- const tasks = {
480
- async create(taskOptions) {
481
- const body = await requestJson('/v1/tasks', {
482
- method: 'POST',
483
- body: JSON.stringify({
484
- prompt: taskOptions.prompt,
485
- parentTaskId: taskOptions.parentTaskId,
486
- surface: taskOptions.surface,
487
- metadata: taskOptions.metadata,
488
- }),
489
- });
490
- const id = body.id ?? body.taskId ?? body.turnId;
491
- if (!id) {
492
- throw new AbloValidationError('Task create response did not include an id.', { code: 'task_id_missing' });
493
- }
494
- return {
495
- id,
496
- turnId: id,
497
- promptHash: body.promptHash,
498
- openedAt: body.openedAt,
499
- close: (stats) => tasks.close(id, stats),
500
- };
501
- },
502
- async close(id, stats) {
503
- const body = await requestJson(`/v1/tasks/${encodeURIComponent(id)}/close`, {
504
- method: 'POST',
505
- body: JSON.stringify({
506
- costInputTokens: stats?.costInputTokens ?? 0,
507
- costOutputTokens: stats?.costOutputTokens ?? 0,
508
- costComputeMs: stats?.costComputeMs ?? 0,
509
- }),
510
- });
511
- const closedId = body.id ?? body.taskId ?? body.turnId ?? id;
512
- return {
513
- id: closedId,
514
- turnId: closedId,
515
- closed: body.closed ?? body.alreadyClosed ?? true,
516
- alreadyClosed: body.alreadyClosed,
517
- endedAt: body.endedAt,
518
- };
519
- },
520
- open(options) {
521
- return tasks.create(options);
522
- },
523
- };
524
340
  const intents = {
525
341
  async create(intentOptions) {
526
342
  const intentId = createIntentId();
@@ -780,35 +596,14 @@ export function createProtocolClient(options) {
780
596
  async dispose() { },
781
597
  async purge() { },
782
598
  capabilities,
783
- tasks,
784
599
  intents,
785
600
  commits,
786
601
  model,
787
- agent: createAgent,
788
602
  async getAuthToken() {
789
603
  // Mirror `authHeaders()`: a configured API key wins, else the
790
604
  // construction-time auth token. Resolve the (possibly async) key setter.
791
605
  return (await resolveApiKeyValue(configuredApiKey)) ?? configuredAuthToken ?? null;
792
606
  },
793
- async beginTurn(turnOptions) {
794
- const task = await tasks.create(turnOptions);
795
- let closed = false;
796
- const close = async (stats) => {
797
- if (closed)
798
- return;
799
- closed = true;
800
- await task.close(stats);
801
- };
802
- const dispose = () => {
803
- closed = true;
804
- };
805
- return {
806
- turnId: task.id,
807
- close,
808
- dispose,
809
- [Symbol.asyncDispose]: close,
810
- };
811
- },
812
607
  };
813
608
  }
814
609
  function normalizeIntentId(intent) {
@@ -816,33 +611,6 @@ function normalizeIntentId(intent) {
816
611
  return intent;
817
612
  return intent?.id;
818
613
  }
819
- function withAgentClaimedDefault(options) {
820
- return {
821
- ifClaimed: 'fail',
822
- ...(options ?? {}),
823
- };
824
- }
825
- function stripAgentRuntimeOptions(options) {
826
- if (!options)
827
- return undefined;
828
- const { intent: _intent, ifClaimed: _ifClaimed, claimedTimeout: _claimedTimeout, claimedPollInterval: _claimedPollInterval, maxQueueDepth: _maxQueueDepth, ...rest } = options;
829
- return rest;
830
- }
831
- function isIntentHandleRef(input) {
832
- return (typeof input === 'object' &&
833
- input !== null &&
834
- 'id' in input &&
835
- typeof input.id === 'string' &&
836
- !('action' in input));
837
- }
838
- function isAbortError(error) {
839
- return (typeof DOMException !== 'undefined' &&
840
- error instanceof DOMException &&
841
- error.name === 'AbortError') || (typeof error === 'object' &&
842
- error !== null &&
843
- 'name' in error &&
844
- error.name === 'AbortError');
845
- }
846
614
  function parseBody(bodyText) {
847
615
  if (bodyText.length === 0)
848
616
  return null;
@@ -146,8 +146,38 @@ export function resolveBootstrapBaseUrl(input) {
146
146
  // legitimately arrive as `wss://…` — normalize it here rather than
147
147
  // faceplanting at fetch time. The derive branch below already does this;
148
148
  // the override branch silently skipped it.
149
- return normalizeAbloHostedBaseUrl(input.bootstrapBaseUrl).replace(/^ws/, 'http');
149
+ return ensureApiSuffix(normalizeAbloHostedBaseUrl(input.bootstrapBaseUrl).replace(/^ws/, 'http'));
150
150
  }
151
151
  const url = normalizeAbloHostedBaseUrl(input.url);
152
- return `${url.replace(/^ws/, 'http')}/api`;
152
+ return ensureApiSuffix(url.replace(/^ws/, 'http'));
153
+ }
154
+ /**
155
+ * Guarantee the HTTP base ends in the `/api` route segment the sync-server
156
+ * mounts every endpoint under (`apps/sync-server/src/index.ts` — `app.route('/api', …)`).
157
+ *
158
+ * The derive branch always appended `/api`; the override branch did NOT,
159
+ * trusting the caller (apps/web passes `${baseUrl}/api`). But a hosted
160
+ * customer setting a custom `baseURL`/`bootstrapBaseUrl` (their own subdomain,
161
+ * staging, etc.) without the suffix sent every credential exchange to
162
+ * `…/auth/capability` instead of `…/api/auth/capability` → a 404 surfaced as
163
+ * `exchange_failed`. Since the SDK hardcodes routes relative to this base and
164
+ * there is no valid Ablo deployment that serves them off the root, normalizing
165
+ * to a single trailing `/api` here is always correct — and idempotent for
166
+ * callers who already include it.
167
+ */
168
+ function ensureApiSuffix(httpBase) {
169
+ const trimmed = httpBase.replace(/\/+$/, '');
170
+ try {
171
+ const u = new URL(trimmed);
172
+ const segments = u.pathname.split('/').filter(Boolean);
173
+ if (segments[segments.length - 1] === 'api')
174
+ return trimmed;
175
+ u.pathname = `${u.pathname.replace(/\/+$/, '')}/api`;
176
+ return u.toString().replace(/\/+$/, '');
177
+ }
178
+ catch {
179
+ // Should be unreachable post-`normalizeAbloHostedBaseUrl` (which yields an
180
+ // absolute URL), but fall back to a string check rather than throwing.
181
+ return /\/api$/.test(trimmed) ? trimmed : `${trimmed}/api`;
182
+ }
153
183
  }
@@ -22,8 +22,8 @@
22
22
  * wraps it in a typed proxy facade so server code gets the SAME `client.<model>`
23
23
  * surface as the browser client — typed proxies, stateless transport.
24
24
  */
25
- import { type AbloApiClientOptions, type TaskCreateOptions } from './ApiClient.js';
26
- import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions, Turn } from './Ablo.js';
25
+ import { type AbloApiClientOptions } from './ApiClient.js';
26
+ import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions } from './Ablo.js';
27
27
  import type { ModelCreateParams, ModelDeleteParams, ModelLoadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
28
28
  import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
29
29
  export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<AbloApiClientOptions, 'schema'> {
@@ -47,7 +47,7 @@ export interface HttpModelClient<T, C = T> {
47
47
  }
48
48
  /**
49
49
  * The honest type of the stateless HTTP client: typed model proxies (the
50
- * request/response subset) + `commits` + `beginTurn` + `dispose`. Reaching for a
50
+ * request/response subset) + `commits` + `dispose`. Reaching for a
51
51
  * stateful-only capability (`get`/`getAll`/`getCount`, `onChange`,
52
52
  * `claim.state`/`queue`/`reorder`) is a COMPILE error here, not a latent runtime
53
53
  * `undefined` — the type matches what the transport can actually do.
@@ -56,7 +56,6 @@ export type AbloHttpClient<S extends SchemaRecord> = {
56
56
  readonly [K in keyof S & string]: HttpModelClient<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
57
57
  } & {
58
58
  readonly commits: CommitResource;
59
- beginTurn(options: TaskCreateOptions): Promise<Turn>;
60
59
  dispose(): Promise<void>;
61
60
  /** Resolve the bearer credential this client authenticates with (see `AbloApi.getAuthToken`). */
62
61
  getAuthToken(): Promise<string | null>;
@@ -65,7 +64,7 @@ export type AbloHttpClient<S extends SchemaRecord> = {
65
64
  };
66
65
  /**
67
66
  * Stateless, typed HTTP client. Each `client.<model>` resolves to the protocol
68
- * client's `model(name)`; `commits`, `beginTurn`, `tasks`, `dispose`, etc. pass
69
- * through. No socket is ever opened; identity is the Bearer credential.
67
+ * client's `model(name)`; `commits`, `dispose`, etc. pass through. No socket is
68
+ * ever opened; identity is the Bearer credential.
70
69
  */
71
70
  export declare function createAbloHttpClient<S extends SchemaRecord>(options: AbloHttpClientOptions<S>): AbloHttpClient<S>;
@@ -36,14 +36,13 @@ const PROTOCOL_MEMBERS = new Set([
36
36
  'dispose',
37
37
  'purge',
38
38
  'commits',
39
- 'beginTurn',
40
39
  'model',
41
40
  'getAuthToken',
42
41
  ]);
43
42
  /**
44
43
  * Stateless, typed HTTP client. Each `client.<model>` resolves to the protocol
45
- * client's `model(name)`; `commits`, `beginTurn`, `tasks`, `dispose`, etc. pass
46
- * through. No socket is ever opened; identity is the Bearer credential.
44
+ * client's `model(name)`; `commits`, `dispose`, etc. pass through. No socket is
45
+ * ever opened; identity is the Bearer credential.
47
46
  */
48
47
  export function createAbloHttpClient(options) {
49
48
  // The schema is type-level only; the protocol client is schema-agnostic.
@@ -32,5 +32,5 @@
32
32
  export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type IntentWaitOptions, type ModelCountOptions, type ModelListOptions, type ModelListScope, type ModelLoadOptions, type ModelOperations, type ModelReadOptions, } from './Ablo.js';
33
33
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './auth.js';
34
34
  export type { AbloPersistence } from './persistence.js';
35
- export type { AbloApi, AbloApiClientOptions, AbloApiIntents, Agent, AgentIntentInput, AgentIntentOptions, AgentOptions, AgentModelClient, AgentModelReadOptions, AgentModelMutationOptions, AgentRunContext, AgentRunDone, AgentRunFailed, AgentRunCancelled, AgentRunOptions, AgentRunResult, AgentRunStatus, Capability, CapabilityCreateOptions, CapabilityParticipantKind, CapabilityRecord, CapabilityResource, CapabilityRevocation, CapabilityScope, Task, TaskCloseOptions, TaskCloseResult, TaskCreateOptions, TaskResource, } from './ApiClient.js';
35
+ export type { AbloApi, AbloApiClientOptions, AbloApiIntents, Capability, CapabilityCreateOptions, CapabilityParticipantKind, CapabilityRecord, CapabilityResource, CapabilityRevocation, CapabilityScope, } from './ApiClient.js';
36
36
  export type { EngineParticipant, JoinedParticipant, ParticipantJoinOptions, ParticipantManager, ParticipantScope, ParticipantStatus, ScopedIntents, ScopedPresence, } from '../sync/participants.js';
@@ -176,8 +176,8 @@ export const ERROR_CODES = {
176
176
  check_violation: wire('validation', 400, false, 'A value violates a database check constraint.'),
177
177
  constraint_violation: wire('validation', 400, false, 'A database integrity constraint was violated.'),
178
178
  // ── tenant / unknown model (400) ───────────────────────────────────
179
- server_execute_unknown_model: wire('tenant', 400, false, 'The server-execute request named a model not in the tenant schema.'),
180
- mutate_create_unknown_model: wire('tenant', 400, false, 'A create targeted a model not in the tenant schema.'),
179
+ server_execute_unknown_model: wire('tenant', 400, false, 'Wrote to a model the server does not know. The server keeps its own copy of the schema — run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` before writing to new or changed models.'),
180
+ mutate_create_unknown_model: wire('tenant', 400, false, 'Created a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` first — the server keeps its own copy of the schema.'),
181
181
  tenant_model_columns_unknown: wire('tenant', 400, false, "The tenant model's columns could not be resolved."),
182
182
  tenant_model_missing_organization_id: wire('tenant', 400, false, 'The tenant model is missing the organization_id column required for isolation.'),
183
183
  // ── schema migration / declaration (validation) ────────────────────
@@ -286,7 +286,7 @@ export const ERROR_CODES = {
286
286
  provisioner_unavailable: wire('server', 503, false, 'No database provisioner is configured.'),
287
287
  invalid_model: wire('validation', 400, false, 'The request named an invalid model.'),
288
288
  invalid_id: wire('validation', 400, false, 'The request carried an invalid id.'),
289
- unknown_model: wire('tenant', 400, false, 'The request named a model not in the tenant schema.'),
289
+ unknown_model: wire('tenant', 400, false, 'Named a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` — the server keeps its own copy of the schema.'),
290
290
  model_not_tenant_scoped: wire('tenant', 400, false, 'The model is not tenant-scoped and cannot be queried this way.'),
291
291
  schema_table_invalid: wire('schema', 500, false, "The model's table identifier is invalid."),
292
292
  schema_scope_invalid: wire('schema', 500, false, "The model's scope predicate could not be built."),
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_UR
61
61
  // Participant types live under `Ablo.Participant.*` —
62
62
  // `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
63
63
  // `Ablo.Participant.JoinOptions`, etc. Same dot-access shape as
64
- // `Ablo.Peer`, `Ablo.Claim`, `Ablo.Turn`. No flat re-exports.
64
+ // `Ablo.Peer`, `Ablo.Claim`. No flat re-exports.
65
65
  // Advanced — most apps never import this. Principal constructors for
66
66
  // delegated agent paths (`Ablo({ kind: 'agent', as: session({...}) })`).
67
67
  // The default `Ablo({ schema, apiKey })` resolves identity from the key;
@@ -164,10 +164,10 @@ export interface MutationOptions {
164
164
  readonly id: string;
165
165
  } | null;
166
166
  /**
167
- * Active agent turn id to stamp on every delta row produced by this
168
- * commit. Forwarded as the wire-level `causedByTaskId` field on the
169
- * `{ type: 'commit' }` envelope. Set automatically by the SDK while
170
- * `beginTurn(...)` is open.
167
+ * Dormant agent-task lineage field, forwarded as the wire-level
168
+ * `causedByTaskId`. Turns/tasks were removed from the SDK; nothing
169
+ * populates this anymore (write attribution rides on the claim/intent
170
+ * id). Kept optional for wire-compat; always `null` from the client.
171
171
  */
172
172
  causedByTaskId?: string | null;
173
173
  }
@@ -75,6 +75,15 @@ export declare class UndoScope<S extends Schema> {
75
75
  * observer can never wedge the editor's recording path.
76
76
  */
77
77
  private readonly recordListeners;
78
+ /**
79
+ * Observers notified after ANY stack change — record, undo, redo, or clear.
80
+ * Distinct from {@link recordListeners} (forward actions only): this fires on
81
+ * reversals too, so React consumers can keep `canUndo`/`canRedo` live. The
82
+ * stream-recording path pushes entries WITHOUT a React render, so without this
83
+ * a freshly-recorded entry leaves `canUndo` stale (snapshot from last render)
84
+ * and a Cmd+Z handler gated on `canUndo !== false` silently no-ops.
85
+ */
86
+ private readonly changeListeners;
78
87
  /**
79
88
  * Serialization tail. Recording, undo, and redo all chain off this single
80
89
  * promise so they run strictly in the order they were *invoked* — never
@@ -175,6 +184,14 @@ export declare class UndoScope<S extends Schema> {
175
184
  */
176
185
  onRecord(listener: (entry: UndoEntry) => void): () => void;
177
186
  private emitRecord;
187
+ /**
188
+ * Subscribe to ANY stack change (record/undo/redo/clear). Used by
189
+ * `useUndoScope` to re-render so `canUndo`/`canRedo` stay live across every
190
+ * consumer — not just the component that invoked undo/redo. Returns an
191
+ * unsubscribe function.
192
+ */
193
+ onChange(listener: () => void): () => void;
194
+ private emitChange;
178
195
  canUndo(): boolean;
179
196
  canRedo(): boolean;
180
197
  /**
@@ -46,6 +46,15 @@ export class UndoScope {
46
46
  * observer can never wedge the editor's recording path.
47
47
  */
48
48
  recordListeners = new Set();
49
+ /**
50
+ * Observers notified after ANY stack change — record, undo, redo, or clear.
51
+ * Distinct from {@link recordListeners} (forward actions only): this fires on
52
+ * reversals too, so React consumers can keep `canUndo`/`canRedo` live. The
53
+ * stream-recording path pushes entries WITHOUT a React render, so without this
54
+ * a freshly-recorded entry leaves `canUndo` stale (snapshot from last render)
55
+ * and a Cmd+Z handler gated on `canUndo !== false` silently no-ops.
56
+ */
57
+ changeListeners = new Set();
49
58
  /**
50
59
  * Serialization tail. Recording, undo, and redo all chain off this single
51
60
  * promise so they run strictly in the order they were *invoked* — never
@@ -246,6 +255,7 @@ export class UndoScope {
246
255
  this.undoStack.shift();
247
256
  this.redoStack = [];
248
257
  this.emitRecord(entry);
258
+ this.emitChange();
249
259
  }
250
260
  /**
251
261
  * Subscribe to every recorded mutation. Fires synchronously at the tail of
@@ -275,6 +285,30 @@ export class UndoScope {
275
285
  }
276
286
  }
277
287
  }
288
+ /**
289
+ * Subscribe to ANY stack change (record/undo/redo/clear). Used by
290
+ * `useUndoScope` to re-render so `canUndo`/`canRedo` stay live across every
291
+ * consumer — not just the component that invoked undo/redo. Returns an
292
+ * unsubscribe function.
293
+ */
294
+ onChange(listener) {
295
+ this.changeListeners.add(listener);
296
+ return () => {
297
+ this.changeListeners.delete(listener);
298
+ };
299
+ }
300
+ emitChange() {
301
+ for (const listener of this.changeListeners) {
302
+ try {
303
+ listener();
304
+ }
305
+ catch (err) {
306
+ if (typeof console !== 'undefined') {
307
+ console.error('[UndoScope] onChange listener threw', err);
308
+ }
309
+ }
310
+ }
311
+ }
278
312
  canUndo() {
279
313
  return this.undoStack.length > 0;
280
314
  }
@@ -302,12 +336,21 @@ export class UndoScope {
302
336
  try {
303
337
  await applyOps(tx, ops);
304
338
  }
339
+ catch (err) {
340
+ // The replay was rejected (e.g. a server 409): the world didn't change,
341
+ // so restore the entry to the undo stack rather than silently dropping
342
+ // it (which would also strand it off the redo stack — invisible undo).
343
+ this.undoStack.push(entry);
344
+ this.emitChange();
345
+ throw err;
346
+ }
305
347
  finally {
306
348
  this.replaying = false;
307
349
  }
308
350
  this.redoStack.push(entry);
309
351
  if (this.redoStack.length > this.maxHistory)
310
352
  this.redoStack.shift();
353
+ this.emitChange();
311
354
  });
312
355
  }
313
356
  /**
@@ -327,12 +370,20 @@ export class UndoScope {
327
370
  try {
328
371
  await applyOps(tx, ops);
329
372
  }
373
+ catch (err) {
374
+ // Symmetric to undo: a rejected re-apply leaves state unchanged, so put
375
+ // the entry back on the redo stack instead of losing it.
376
+ this.redoStack.push(entry);
377
+ this.emitChange();
378
+ throw err;
379
+ }
330
380
  finally {
331
381
  this.replaying = false;
332
382
  }
333
383
  this.undoStack.push(entry);
334
384
  if (this.undoStack.length > this.maxHistory)
335
385
  this.undoStack.shift();
386
+ this.emitChange();
336
387
  });
337
388
  }
338
389
  /** Drop all history. Use after bootstrap / sync group change / sync error. */
@@ -340,6 +391,7 @@ export class UndoScope {
340
391
  this.undoStack = [];
341
392
  this.redoStack = [];
342
393
  this.batch = [];
394
+ this.emitChange();
343
395
  }
344
396
  /** Introspection — for debug panels / e2e tests. */
345
397
  size() {
@@ -353,6 +405,7 @@ export class UndoScope {
353
405
  dispose() {
354
406
  this.unsubscribe();
355
407
  this.recordListeners.clear();
408
+ this.changeListeners.clear();
356
409
  this.batch = [];
357
410
  }
358
411
  }
@@ -28,20 +28,30 @@ import { type SyncStoreContract } from './context.js';
28
28
  /**
29
29
  * Props for `<AbloProvider>`.
30
30
  *
31
- * The default path is one prop:
31
+ * The one required prop is a prebuilt {@link Ablo} client — the client
32
+ * owns auth and the credential lifecycle; this provider is the reactive
33
+ * binding over it (Stripe's `<Elements stripe={...}>` model):
32
34
  *
33
35
  * ```tsx
34
- * <AbloProvider schema={schema}>
36
+ * // Build once at module scope — a new instance per render tears down the socket.
37
+ * const ablo = Ablo({
38
+ * schema,
39
+ * getToken: () =>
40
+ * fetch('/api/ablo-session', { method: 'POST' })
41
+ * .then((r) => r.json())
42
+ * .then((d) => d.token),
43
+ * });
44
+ *
45
+ * <AbloProvider client={ablo}>
35
46
  * <App />
36
47
  * </AbloProvider>
37
48
  * ```
38
49
  *
39
- * That's it for most apps the provider resolves identity, account
40
- * scope, and realtime permissions from auth. `userId`/`apiKey`/`url`
41
- * are situational; the `bootstrapMode`, `persistence`, and `fallback`
42
- * props are opt-in tuning; and the block tagged "Optional DI (advanced)"
43
- * below is escape-hatch wiring for tests and platform builders — if you
44
- * don't recognize a prop there, you don't need it.
50
+ * That's it for most apps. `userId` is informational; the `fallback`,
51
+ * `preventUnsavedChanges`, and `on*` props are opt-in app glue; and the
52
+ * block tagged "Optional DI (advanced)" below is escape-hatch wiring for
53
+ * tests and platform builders if you don't recognize a prop there, you
54
+ * don't need it.
45
55
  */
46
56
  export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
47
57
  /**
@@ -27,7 +27,7 @@
27
27
  * useCurrentUserId() — the provider's userId prop
28
28
  *
29
29
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
- * useAblo((ablo) => ablo.intents.list(...)) — reactive coordination reads
30
+ * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
33
  * useIntent(name) — typed intent dispatcher
@@ -27,7 +27,7 @@
27
27
  * useCurrentUserId() — the provider's userId prop
28
28
  *
29
29
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
- * useAblo((ablo) => ablo.intents.list(...)) — reactive coordination reads
30
+ * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
33
  * useIntent(name) — typed intent dispatcher
@@ -52,6 +52,13 @@ export function useUndoScope(schemaOrName, nameOrOptions, maybeOptions) {
52
52
  useEffect(() => {
53
53
  setTick(0);
54
54
  }, [scope]);
55
+ // Re-render on ANY stack change — including entries recorded from the local-
56
+ // mutation stream, which don't otherwise trigger a React update. Without this
57
+ // `canUndo`/`canRedo` go stale in every consumer that didn't itself call
58
+ // undo/redo (e.g. a keyboard handler whose Cmd+Z gate then never fires).
59
+ useEffect(() => {
60
+ return scope.onChange(() => setTick((t) => t + 1));
61
+ }, [scope]);
55
62
  const size = scope.size();
56
63
  return {
57
64
  scope,