@abloatai/ablo 0.3.1 → 0.5.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 (92) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/NOTICE +2 -2
  3. package/README.md +99 -78
  4. package/dist/BaseSyncedStore.d.ts +3 -2
  5. package/dist/agent/Agent.d.ts +1 -1
  6. package/dist/agent/Agent.js +1 -1
  7. package/dist/agent/index.d.ts +4 -4
  8. package/dist/agent/index.js +6 -6
  9. package/dist/agent/types.d.ts +1 -1
  10. package/dist/ai-sdk/index.d.ts +3 -3
  11. package/dist/ai-sdk/index.js +3 -3
  12. package/dist/ai-sdk/intent-broadcast.d.ts +1 -1
  13. package/dist/ai-sdk/intent-broadcast.js +1 -1
  14. package/dist/auth/index.d.ts +1 -1
  15. package/dist/client/Ablo.d.ts +53 -27
  16. package/dist/client/Ablo.js +32 -1
  17. package/dist/client/auth.d.ts +3 -3
  18. package/dist/client/auth.js +5 -5
  19. package/dist/client/createModelProxy.d.ts +118 -32
  20. package/dist/client/createModelProxy.js +87 -44
  21. package/dist/client/index.d.ts +3 -3
  22. package/dist/client/index.js +3 -3
  23. package/dist/config/index.d.ts +1 -1
  24. package/dist/config/index.js +1 -1
  25. package/dist/core/index.d.ts +1 -1
  26. package/dist/core/index.js +2 -2
  27. package/dist/errors.d.ts +9 -7
  28. package/dist/errors.js +9 -7
  29. package/dist/index.d.ts +20 -6
  30. package/dist/index.js +41 -22
  31. package/dist/interfaces/headless.d.ts +1 -1
  32. package/dist/interfaces/headless.js +2 -2
  33. package/dist/policy/index.d.ts +2 -2
  34. package/dist/policy/index.js +2 -2
  35. package/dist/policy/types.d.ts +10 -0
  36. package/dist/principal.d.ts +3 -3
  37. package/dist/principal.js +3 -3
  38. package/dist/query/client.d.ts +7 -6
  39. package/dist/react/AbloProvider.d.ts +44 -1
  40. package/dist/react/AbloProvider.js +3 -1
  41. package/dist/react/ClientSideSuspense.d.ts +1 -1
  42. package/dist/react/SyncGroupProvider.js +1 -1
  43. package/dist/react/context.d.ts +1 -1
  44. package/dist/react/context.js +1 -1
  45. package/dist/react/index.d.ts +1 -1
  46. package/dist/react/index.js +1 -1
  47. package/dist/react/useCurrentUserId.js +1 -1
  48. package/dist/react/useErrorListener.js +1 -1
  49. package/dist/react/useMutate.d.ts +1 -1
  50. package/dist/react/useMutationFailureListener.js +1 -1
  51. package/dist/react/useReader.d.ts +1 -1
  52. package/dist/schema/field.d.ts +1 -1
  53. package/dist/schema/field.js +1 -1
  54. package/dist/schema/index.d.ts +2 -2
  55. package/dist/schema/index.js +2 -2
  56. package/dist/schema/model.d.ts +2 -2
  57. package/dist/schema/model.js +2 -2
  58. package/dist/schema/queries.d.ts +1 -1
  59. package/dist/schema/queries.js +1 -1
  60. package/dist/schema/relation.d.ts +1 -1
  61. package/dist/schema/relation.js +1 -1
  62. package/dist/schema/schema.d.ts +1 -1
  63. package/dist/schema/schema.js +1 -1
  64. package/dist/source/index.d.ts +22 -28
  65. package/dist/source/index.js +23 -20
  66. package/dist/source/pushQueue.d.ts +1 -1
  67. package/dist/source/pushQueue.js +2 -2
  68. package/dist/sync/SyncWebSocket.d.ts +20 -5
  69. package/dist/sync/createIntentStream.js +7 -0
  70. package/dist/testing/fixtures/models.d.ts +1 -1
  71. package/dist/testing/fixtures/models.js +1 -1
  72. package/dist/testing/helpers/react-wrapper.d.ts +2 -2
  73. package/dist/testing/helpers/react-wrapper.js +2 -2
  74. package/dist/testing/index.d.ts +1 -1
  75. package/dist/testing/index.js +1 -1
  76. package/dist/types/streams.d.ts +41 -1
  77. package/docs/api.md +78 -20
  78. package/docs/data-sources.md +50 -16
  79. package/docs/examples/ai-sdk-tool.md +14 -31
  80. package/docs/examples/existing-python-backend.md +6 -6
  81. package/docs/integration-guide.md +8 -7
  82. package/docs/interaction-model.md +16 -4
  83. package/docs/mcp.md +1 -1
  84. package/docs/quickstart.md +20 -18
  85. package/examples/data-source/README.md +1 -1
  86. package/examples/data-source/ablo-driver.ts +5 -5
  87. package/examples/data-source/customer-server.ts +10 -10
  88. package/examples/data-source/run.ts +9 -11
  89. package/examples/data-source/schema.ts +1 -1
  90. package/examples/quickstart.ts +2 -2
  91. package/llms.txt +1 -1
  92. package/package.json +1 -1
@@ -5,7 +5,7 @@
5
5
  * bootstrap, offline queue, DI adapters) behind a single function call.
6
6
  *
7
7
  * Usage:
8
- * import { Ablo } from '@ablo/sync-engine/client';
8
+ * import { Ablo } from '@abloatai/ablo/client';
9
9
  * import { schema } from './schema';
10
10
  *
11
11
  * const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
@@ -21,7 +21,7 @@ import { ObjectPool } from '../ObjectPool.js';
21
21
  import type { SyncStoreContract } from '../react/context.js';
22
22
  import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
23
23
  import { type SyncStatus } from '../BaseSyncedStore.js';
24
- import type { IntentStream, PresenceStream, Snapshot } from '../types/streams.js';
24
+ import type { IntentStream, IntentWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
25
25
  import type { ParticipantManager } from '../sync/participants.js';
26
26
  import type { ActiveIntent, Duration, TargetRange } from '../types/streams.js';
27
27
  import { type AbloApi, type AbloApiClientOptions, type AbloApiIntents } from './ApiClient.js';
@@ -48,34 +48,75 @@ export interface Turn {
48
48
  * `ApiKeySetter` exactly so any rotation pattern that works with
49
49
  * `@anthropic-ai/sdk` works here.
50
50
  *
51
- * Re-exported from `./auth` so existing import paths (`@ablo/sync-engine`)
51
+ * Re-exported from `./auth` so existing import paths (`@abloatai/ablo`)
52
52
  * keep resolving; the canonical definition lives there alongside the
53
53
  * resolvers that consume it.
54
54
  */
55
55
  export type { ApiKeySetter } from './auth.js';
56
56
  import type { ApiKeySetter } from './auth.js';
57
57
  import { type AbloPersistence } from './persistence.js';
58
+ /**
59
+ * Options for `Ablo({...})`.
60
+ *
61
+ * The only required field is `schema`. The default path is one line:
62
+ *
63
+ * ```ts
64
+ * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
65
+ * ```
66
+ *
67
+ * `apiKey` itself defaults to `process.env.ABLO_API_KEY`, so in most
68
+ * server setups `Ablo({ schema })` is enough. Every other field is
69
+ * optional tuning (timeouts, retries, custom fetch, persistence) —
70
+ * if you're not sure whether you need one, you don't. Reach for them
71
+ * the way you'd reach for the equivalent option on the Stripe / OpenAI
72
+ * / Anthropic clients: rarely, and deliberately.
73
+ *
74
+ * @see https://docs.ablo.finance — full option reference
75
+ */
58
76
  export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
77
+ /**
78
+ * TypeScript schema defined with `defineSchema()`. Required — it's what
79
+ * makes `ablo.tasks.update(...)` typed. This is the one field you must
80
+ * pass; start here.
81
+ */
82
+ schema: Schema<S>;
59
83
  /**
60
84
  * API key used for authentication.
61
85
  *
62
86
  * Accepts a static string (`sk_live_...`) or an async function that
63
- * resolves to one. Defaults to `process.env['ABLO_API_KEY']`.
87
+ * resolves to one. Defaults to `process.env['ABLO_API_KEY']`, so you
88
+ * usually don't pass this explicitly server-side.
64
89
  */
65
90
  apiKey?: string | ApiKeySetter | null | undefined;
91
+ /**
92
+ * Local persistence mode. Pass `indexeddb` only when you want offline
93
+ * queueing and a reload-surviving browser cache.
94
+ *
95
+ * @default 'volatile'
96
+ */
97
+ persistence?: AbloPersistence;
66
98
  /**
67
99
  * Bearer auth token. Hosted-cloud consumers pass `apiKey`; self-hosted
68
100
  * deployments may pass a bearer token minted by their own auth layer.
69
101
  */
70
102
  authToken?: string | null | undefined;
71
103
  /**
72
- * Override the Ablo API base URL. Defaults to hosted production and reads
73
- * `process.env['ABLO_BASE_URL']` if unset.
104
+ * Override the Ablo API base URL. Defaults to hosted production.
74
105
  */
75
106
  baseURL?: string | null | undefined;
76
- /** Per-request timeout in milliseconds. */
107
+ /**
108
+ * Maximum time (ms) to wait for a single request before timing out.
109
+ * Timed-out requests are retried, so worst-case wait can exceed this.
110
+ *
111
+ * @default 600_000
112
+ */
77
113
  timeout?: number | undefined;
78
- /** Number of retries for transient failures. */
114
+ /**
115
+ * Maximum retries on transient failure (network error / 5xx / 429).
116
+ * Honors `Retry-After`.
117
+ *
118
+ * @default 2
119
+ */
79
120
  maxRetries?: number | undefined;
80
121
  /** Custom fetch implementation for tests, proxies, or non-standard runtimes. */
81
122
  fetch?: typeof fetch | undefined;
@@ -89,16 +130,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
89
130
  * key or a controlled server proxy.
90
131
  */
91
132
  dangerouslyAllowBrowser?: boolean | undefined;
92
- /**
93
- * TypeScript schema defined with `defineSchema()`. This enables typed
94
- * resources such as `ablo.tasks.update(...)`.
95
- */
96
- schema: Schema<S>;
97
- /**
98
- * Local persistence mode. Defaults to `volatile`. Pass `indexeddb` only
99
- * when you want offline queueing and a reload-surviving browser cache.
100
- */
101
- persistence?: AbloPersistence;
102
133
  }
103
134
  export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
104
135
  /**
@@ -118,7 +149,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
118
149
  apiKey?: string | ApiKeySetter | null | undefined;
119
150
  /**
120
151
  * Bearer auth token. Sent as `Authorization: Bearer <token>` on
121
- * every request. Defaults to `process.env['ABLO_AUTH_TOKEN']`.
152
+ * every request.
122
153
  *
123
154
  * Use this for self-hosted deployments where your auth layer mints
124
155
  * cap tokens directly. Hosted-cloud consumers pass `apiKey` instead;
@@ -129,7 +160,6 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
129
160
  * Override the default base URL. Defaults to
130
161
  * `wss://mesh.ablo.finance` for hosted production; pass an explicit
131
162
  * URL for self-hosted or staging (e.g. `wss://mesh-staging.ablo.finance`).
132
- * Reads `process.env['ABLO_BASE_URL']` if unset.
133
163
  */
134
164
  baseURL?: string | null | undefined;
135
165
  /**
@@ -327,7 +357,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
327
357
  * deprecated aliases for one release cycle so consumers can migrate
328
358
  * without a flag day.
329
359
  */
330
- export type { ModelCountOptions, ModelListOptions, ModelListScope, ModelLoadOptions, ModelEditHandle, ModelEditOptions, ModelOperations, } from './createModelProxy.js';
360
+ export type { ModelCountOptions, ModelListOptions, ModelListScope, ModelLoadOptions, ModelIntentAcquireOptions, ModelIntentHandle, ModelOperations, } from './createModelProxy.js';
331
361
  import type { ModelOperations } from './createModelProxy.js';
332
362
  export type ResourceOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
333
363
  export type CommitWait = 'queued' | 'confirmed';
@@ -366,11 +396,7 @@ export interface BusyOptions {
366
396
  /** HTTP API polling interval while waiting. WebSocket clients ignore it. */
367
397
  readonly busyPollInterval?: number;
368
398
  }
369
- export interface IntentWaitOptions {
370
- readonly timeout?: number;
371
- readonly pollInterval?: number;
372
- readonly signal?: AbortSignal;
373
- }
399
+ export type { IntentWaitOptions } from '../types/streams.js';
374
400
  export interface ResourceReadOptions extends BusyOptions {
375
401
  }
376
402
  export interface IntentCreateOptions {
@@ -816,7 +842,7 @@ export declare namespace Ablo {
816
842
  type Event = import('../source/index.js').SourceEvent;
817
843
  type EventsResult = import('../source/index.js').SourceEventsResult;
818
844
  type Scope = import('../source/index.js').SourceScope;
819
- type Secret = import('../source/index.js').SourceSecret;
845
+ type ApiKey = import('../source/index.js').SourceApiKey;
820
846
  type Options<S extends _SchemaTypes.SchemaRecord = _SchemaTypes.SchemaRecord, TAuth = unknown> = import('../source/index.js').AbloSourceOptions<S, TAuth>;
821
847
  type ModelHandlers<Row, CreateInput, TAuth = unknown> = import('../source/index.js').SourceModelHandlers<Row, CreateInput, TAuth>;
822
848
  type SignatureVerificationResult = import('../source/index.js').SourceSignatureVerificationResult;
@@ -5,7 +5,7 @@
5
5
  * bootstrap, offline queue, DI adapters) behind a single function call.
6
6
  *
7
7
  * Usage:
8
- * import { Ablo } from '@ablo/sync-engine/client';
8
+ * import { Ablo } from '@abloatai/ablo/client';
9
9
  * import { schema } from './schema';
10
10
  *
11
11
  * const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
@@ -1147,6 +1147,37 @@ export function Ablo(options) {
1147
1147
  getLastSyncId: () => store.getSyncWebSocket()?.getLastSyncId() ?? store.lastSyncId ?? 0,
1148
1148
  entities: { [modelKey]: id },
1149
1149
  }),
1150
+ observe: (target) => {
1151
+ // The live intent stream only tracks *open* (active) claims;
1152
+ // terminal states (committed / expired / canceled) drop out of
1153
+ // the list entirely — exactly the ephemeral coordination model.
1154
+ // So a present entry is, by definition, `status: 'active'`.
1155
+ const held = publicIntents.list({
1156
+ resource: target.resource,
1157
+ id: target.id,
1158
+ })[0];
1159
+ if (!held)
1160
+ return null;
1161
+ return {
1162
+ object: 'intent',
1163
+ id: held.id,
1164
+ status: 'active',
1165
+ target: {
1166
+ type: held.target.resource,
1167
+ id: held.target.id,
1168
+ ...(held.target.path ? { path: held.target.path } : {}),
1169
+ ...(held.target.range ? { range: held.target.range } : {}),
1170
+ ...(held.target.field ? { field: held.target.field } : {}),
1171
+ ...(held.target.meta ? { meta: held.target.meta } : {}),
1172
+ },
1173
+ action: held.action,
1174
+ heldBy: held.actor,
1175
+ participantKind: held.participantKind,
1176
+ expiresAt: held.expiresAt,
1177
+ };
1178
+ },
1179
+ waitFor: (target, waitOptions) => publicIntents.waitFor({ resource: target.resource, id: target.id }, waitOptions),
1180
+ selfParticipantId: participantId,
1150
1181
  });
1151
1182
  }
1152
1183
  const commits = {
@@ -7,9 +7,9 @@
7
7
  * with an actionable message — so the constructor reads as a
8
8
  * sequence of named decisions rather than a stream of `??`-chains.
9
9
  *
10
- * Precedence for every resolver: explicit option → environment
11
- * variable built-in default. The same shape Anthropic, OpenAI,
12
- * and Stripe SDKs use.
10
+ * Customer-facing env surface is intentionally small: `ABLO_API_KEY`
11
+ * is the only environment fallback. Other routing/auth overrides are
12
+ * explicit options so generated apps do not accrete hidden env knobs.
13
13
  */
14
14
  /**
15
15
  * Async callable that resolves to a fresh API key. Mirrors the shape
@@ -7,9 +7,9 @@
7
7
  * with an actionable message — so the constructor reads as a
8
8
  * sequence of named decisions rather than a stream of `??`-chains.
9
9
  *
10
- * Precedence for every resolver: explicit option → environment
11
- * variable built-in default. The same shape Anthropic, OpenAI,
12
- * and Stripe SDKs use.
10
+ * Customer-facing env surface is intentionally small: `ABLO_API_KEY`
11
+ * is the only environment fallback. Other routing/auth overrides are
12
+ * explicit options so generated apps do not accrete hidden env knobs.
13
13
  */
14
14
  import { AbloAuthenticationError } from '../errors.js';
15
15
  /**
@@ -25,11 +25,11 @@ export function resolveApiKey(input) {
25
25
  return input.options.apiKey ?? input.env.ABLO_API_KEY ?? null;
26
26
  }
27
27
  export function resolveAuthToken(input) {
28
- return input.options.authToken ?? input.env.ABLO_AUTH_TOKEN ?? null;
28
+ return input.options.authToken ?? null;
29
29
  }
30
30
  export const ABLO_DEFAULT_BASE_URL = 'wss://mesh.ablo.finance';
31
31
  export function resolveBaseURL(input) {
32
- return (input.options.baseURL ?? input.env.ABLO_BASE_URL ?? ABLO_DEFAULT_BASE_URL);
32
+ return input.options.baseURL ?? ABLO_DEFAULT_BASE_URL;
33
33
  }
34
34
  /**
35
35
  * Browser guard — apiKey is server-side-only by default. Same check
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * Each schema model gets one `ModelOperations<T, CreateInput>` —
10
10
  * exposes `retrieve`, `list`, `count`, `create`, `update`, `delete`,
11
- * `edit`,
12
- * `subscribe`, and `load`. The factory returns a plain object; the
13
- * client assembles `ablo.<model>` lookup table from these.
11
+ * `intent` (the coordination handle), `subscribe`, and `load`. The
12
+ * factory returns a plain object; the client assembles the
13
+ * `ablo.<model>` lookup table from these.
14
14
  */
15
15
  import type { MutationOptions } from '../interfaces/index.js';
16
16
  import type { ModelRegistry } from '../ModelRegistry.js';
@@ -19,7 +19,7 @@ import type { SyncClient } from '../SyncClient.js';
19
19
  import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
20
20
  import type { LoadWhere } from '../query/types.js';
21
21
  import { ModelScope } from '../types/index.js';
22
- import type { Duration, Snapshot } from '../types/streams.js';
22
+ import type { Duration, Intent, IntentStatus, IntentWaitOptions, Snapshot } from '../types/streams.js';
23
23
  export interface ModelResourceMeta {
24
24
  readonly key: string;
25
25
  readonly typename: string;
@@ -66,30 +66,7 @@ export interface ModelLoadOptions<T> {
66
66
  */
67
67
  expand?: readonly string[];
68
68
  }
69
- export interface ModelEditOptions<T = Record<string, unknown>> {
70
- /**
71
- * Human-readable activity shown to other participants while this handle
72
- * is open. Examples: `editing`, `summarizing`, `rewriting`, `reviewing`.
73
- */
74
- activity?: string;
75
- /** Optional field-level target for UI affordances such as busy badges. */
76
- field?: keyof T & string;
77
- /** Lease duration for the visible activity. Runtime death is cleaned up by TTL. */
78
- ttl?: Duration;
79
- /** Default wait mode for `handle.update(...)`. Defaults to `confirmed`. */
80
- wait?: MutationOptions['wait'];
81
- }
82
- export interface ModelEditHandle<T> extends AsyncDisposable {
83
- readonly id: string;
84
- readonly intentId: string;
85
- readonly activity: string;
86
- readonly current: T;
87
- readonly signal: AbortSignal;
88
- update(data: Partial<T>, options?: MutationOptions): Promise<T>;
89
- release(): Promise<void>;
90
- revoke(): void;
91
- }
92
- export interface ModelIntentHandle {
69
+ export interface IntentLeaseHandle {
93
70
  readonly id: string;
94
71
  release(): Promise<void>;
95
72
  revoke(): void;
@@ -103,8 +80,107 @@ export interface ModelCollaboration<T> {
103
80
  };
104
81
  action: string;
105
82
  ttl?: Duration;
106
- }): Promise<ModelIntentHandle>;
83
+ }): Promise<IntentLeaseHandle>;
107
84
  createSnapshot(modelKey: string, id: string): Snapshot;
85
+ /**
86
+ * Current coordination state on a target — who (if anyone) holds it.
87
+ * Synchronous reactive snapshot read off the presence/intent stream;
88
+ * `null` when the target is free. The wiring site computes it because
89
+ * only it knows the local participant id (needed to distinguish "I
90
+ * hold it" from "someone else holds it").
91
+ */
92
+ observe(target: {
93
+ resource: string;
94
+ id: string;
95
+ }): Intent | null;
96
+ /**
97
+ * Resolve once no participant holds an active intent on the target.
98
+ * The contender's "wait until it's free" — delegates to the intent
99
+ * stream's `waitFor`.
100
+ */
101
+ waitFor(target: {
102
+ resource: string;
103
+ id: string;
104
+ }, options?: IntentWaitOptions): Promise<void>;
105
+ /**
106
+ * The local participant's id. Used to distinguish "I already hold this"
107
+ * from "someone else holds it" in `claimOrWait`.
108
+ */
109
+ readonly selfParticipantId: string;
110
+ }
111
+ /** Options for acquiring a per-model coordination intent. */
112
+ export interface ModelIntentAcquireOptions {
113
+ /** Phase shown to others while held. Defaults to `'editing'`. */
114
+ action?: string;
115
+ /** Field-level target for busy badges. */
116
+ field?: string;
117
+ /** Lease duration; runtime death is cleaned up by TTL. */
118
+ ttl?: Duration;
119
+ /** Default wait mode for `handle.update(...)`. Defaults to `confirmed`. */
120
+ wait?: MutationOptions['wait'];
121
+ }
122
+ /**
123
+ * Per-entity coordination handle, returned synchronously by
124
+ * `ablo.<model>.intent(id)`. It lets humans and agents claim a row before
125
+ * they work on it, so two of them don't edit the same thing at once.
126
+ *
127
+ * The lifecycle reads like a sentence:
128
+ *
129
+ * ```ts
130
+ * const report = ablo.weatherReports.intent('weather_stockholm');
131
+ *
132
+ * if (report.current) await report.whenFree(); // someone's on it — wait
133
+ * await report.claim({ action: 'checking_weather' }); // it's mine now
134
+ * await report.update({ status: 'ready' }); // write, then auto-finish
135
+ * ```
136
+ *
137
+ * `current` is the live `Intent` (or `null` if free). `claim()` announces
138
+ * you're working so others yield. `whenFree()` waits for whoever holds it.
139
+ * `claimOrWait()` does both — claim, or wait your turn then claim — which
140
+ * is what you bind to an agent's write tool so it never reasons about
141
+ * coordination itself. `finish()`/`cancel()` give the claim back.
142
+ */
143
+ export interface ModelIntentHandle<T> extends AsyncDisposable {
144
+ /** The target entity id this handle coordinates. */
145
+ readonly id: string;
146
+ /**
147
+ * Live coordination state on this target — `null` when free, otherwise
148
+ * the holder's `Intent` (who, what phase, until when). Reactive
149
+ * snapshot; pair with the model's `subscribe` for change notifications.
150
+ */
151
+ readonly current: Intent | null;
152
+ /** Convenience: `current?.status ?? 'idle'`. */
153
+ readonly status: IntentStatus | 'idle';
154
+ /**
155
+ * Claim this row so other participants yield while you work. Resolves
156
+ * once the claim is announced. Throws if someone else already holds it
157
+ * — call `whenFree()` first, or use `claimOrWait()` to do both.
158
+ */
159
+ claim(options?: ModelIntentAcquireOptions): Promise<void>;
160
+ /**
161
+ * Claim the row, or — if someone else holds it — wait for them to
162
+ * finish, re-read the (now-changed) row, then claim. The caller never
163
+ * branches on who holds it; it just gets the row safely. A claim you
164
+ * already hold is treated as yours and taken without waiting. Bind this
165
+ * to an agent's write-tool boundary.
166
+ */
167
+ claimOrWait(options?: ModelIntentAcquireOptions): Promise<void>;
168
+ /**
169
+ * Optimistic update guarded by the claim this handle holds. Rejects
170
+ * with `AbloStaleContextError` if the row changed under you, then
171
+ * auto-finishes. Call `claim()` first.
172
+ */
173
+ update(data: Partial<T>, options?: MutationOptions): Promise<T>;
174
+ /** Finish: give back a claim you hold once the work is committed. */
175
+ finish(): Promise<void>;
176
+ /**
177
+ * Wait until the row is free, then resolve. On resolution your cached
178
+ * copy may be stale — re-read before writing (the stale-context guard
179
+ * enforces this if you go through `claim()` + `update()`).
180
+ */
181
+ whenFree(options?: IntentWaitOptions): Promise<void>;
182
+ /** Cancel: drop a claim you hold without committing any work. */
183
+ cancel(): void;
108
184
  }
109
185
  export interface ModelOperations<T, CreateInput> {
110
186
  /**
@@ -135,10 +211,20 @@ export interface ModelOperations<T, CreateInput> {
135
211
  /** Delete an entity by id — optimistic, offline-first (see `create`). */
136
212
  delete(id: string, options?: MutationOptions): Promise<void>;
137
213
  /**
138
- * Start a model-scoped activity lease for long-running AI or background work.
139
- * Other participants can see the activity until `update`, `release`, or TTL.
214
+ * Coordination accessor for one entity the same `ablo.<model>(id)`
215
+ * shape as `create`/`update`/`retrieve`, but on the coordination plane
216
+ * (ephemeral, TTL'd, never persisted). Returns a handle synchronously:
217
+ * read `.current` to see who's editing, `claim()` to take it, `update()`
218
+ * to write under the claim, `whenFree()` to wait for a holder to finish.
219
+ *
220
+ * ```ts
221
+ * const lock = ablo.slide.intent(slideId);
222
+ * if (lock.current) await lock.whenFree(); // someone's editing — wait
223
+ * await lock.claim({ action: 'editing' });
224
+ * await lock.update({ title: 'New' }); // auto-finishes
225
+ * ```
140
226
  */
141
- edit(id: string, options?: ModelEditOptions<T>): Promise<ModelEditHandle<T>>;
227
+ intent(id: string): ModelIntentHandle<T>;
142
228
  /** Subscribe to changes (callback called on every change). */
143
229
  subscribe(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
144
230
  /**
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * Each schema model gets one `ModelOperations<T, CreateInput>` —
10
10
  * exposes `retrieve`, `list`, `count`, `create`, `update`, `delete`,
11
- * `edit`,
12
- * `subscribe`, and `load`. The factory returns a plain object; the
13
- * client assembles `ablo.<model>` lookup table from these.
11
+ * `intent` (the coordination handle), `subscribe`, and `load`. The
12
+ * factory returns a plain object; the client assembles the
13
+ * `ablo.<model>` lookup table from these.
14
14
  */
15
15
  import { autorun } from 'mobx';
16
16
  import { AbloStaleContextError, AbloValidationError } from '../errors.js';
@@ -111,54 +111,96 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
111
111
  syncClient.delete(model, options);
112
112
  await waitForMutation(model, options);
113
113
  },
114
- async edit(id, options) {
115
- if (!collaboration) {
116
- throw new AbloValidationError(`Model "${schemaKey}" cannot start edit activity without collaboration wiring.`, { code: 'model_edit_not_configured' });
117
- }
118
- let model = objectPool.get(id);
119
- if (!model) {
120
- await load({ where: { id } });
121
- model = objectPool.get(id);
122
- }
123
- if (!model) {
124
- throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
125
- }
126
- const activity = options?.activity ?? 'editing';
127
- const snapshot = collaboration.createSnapshot(schemaKey, id);
128
- const intent = await collaboration.createIntent({
129
- target: {
130
- resource: schemaKey,
131
- id,
132
- ...(options?.field ? { field: options.field } : {}),
133
- },
134
- action: activity,
135
- ttl: options?.ttl,
136
- });
114
+ intent(id) {
115
+ const target = { resource: schemaKey, id };
116
+ let acquired = null;
117
+ let snapshot = null;
137
118
  let released = false;
138
- const revoke = () => {
119
+ let acquireWait;
120
+ // Public `cancel()` — drop the claim without committing. Calls the
121
+ // lower-level lease handle's `revoke()` (a different API; leave it).
122
+ const cancel = () => {
139
123
  if (released)
140
124
  return;
141
125
  released = true;
142
- snapshot.signal.removeEventListener('abort', revoke);
143
- intent.revoke();
126
+ if (snapshot)
127
+ snapshot.signal.removeEventListener('abort', cancel);
128
+ acquired?.revoke();
144
129
  };
145
- const release = async () => {
130
+ // Public `finish()` give the claim back after committing. Calls the
131
+ // lower-level lease handle's `release()` (a different API; leave it).
132
+ const finish = async () => {
146
133
  if (released)
147
134
  return;
148
135
  released = true;
149
- snapshot.signal.removeEventListener('abort', revoke);
150
- await intent.release();
136
+ if (snapshot)
137
+ snapshot.signal.removeEventListener('abort', cancel);
138
+ await acquired?.release();
139
+ };
140
+ const whenFree = async (options) => {
141
+ if (!collaboration)
142
+ return;
143
+ await collaboration.waitFor(target, options);
144
+ };
145
+ const claim = async (options) => {
146
+ if (!collaboration) {
147
+ throw new AbloValidationError(`Model "${schemaKey}" cannot claim an intent without collaboration wiring.`, { code: 'model_intent_not_configured' });
148
+ }
149
+ if (acquired)
150
+ return;
151
+ acquireWait = options?.wait;
152
+ // Load the row so update() has a snapshot to guard against.
153
+ let model = objectPool.get(id);
154
+ if (!model) {
155
+ await load({ where: { id } });
156
+ model = objectPool.get(id);
157
+ }
158
+ if (!model) {
159
+ throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
160
+ }
161
+ const snap = collaboration.createSnapshot(schemaKey, id);
162
+ snap.signal.addEventListener('abort', cancel, { once: true });
163
+ snapshot = snap;
164
+ released = false;
165
+ acquired = await collaboration.createIntent({
166
+ target: {
167
+ resource: schemaKey,
168
+ id,
169
+ ...(options?.field ? { field: options.field } : {}),
170
+ },
171
+ action: options?.action ?? 'editing',
172
+ ttl: options?.ttl,
173
+ });
174
+ };
175
+ const claimOrWait = async (options) => {
176
+ if (!collaboration) {
177
+ throw new AbloValidationError(`Model "${schemaKey}" cannot claim an intent without collaboration wiring.`, { code: 'model_intent_not_configured' });
178
+ }
179
+ const held = collaboration.observe(target);
180
+ // A foreign holder: wait for them to finish, then re-read before
181
+ // claiming. Our own claim (or a free target) goes straight to claim.
182
+ if (held && held.heldBy !== collaboration.selfParticipantId) {
183
+ await whenFree();
184
+ await load({ where: { id } });
185
+ }
186
+ await claim(options);
151
187
  };
152
- snapshot.signal.addEventListener('abort', revoke, { once: true });
153
188
  const handle = {
154
189
  id,
155
- intentId: intent.id,
156
- activity,
157
- current: modelAsRow(model),
158
- signal: snapshot.signal,
190
+ get current() {
191
+ return collaboration?.observe(target) ?? null;
192
+ },
193
+ get status() {
194
+ return collaboration?.observe(target)?.status ?? 'idle';
195
+ },
196
+ claim,
197
+ claimOrWait,
159
198
  async update(data, updateOptions) {
199
+ if (!acquired || !snapshot) {
200
+ throw new AbloValidationError(`Call claim() before update() on ablo.${schemaKey}.intent(${id}).`, { code: 'intent_not_acquired' });
201
+ }
160
202
  if (snapshot.signal.aborted) {
161
- throw new AbloStaleContextError(`Edit context is stale for ${schemaKey}/${id}. Re-read the row and retry.`, {
203
+ throw new AbloStaleContextError(`Intent context is stale for ${schemaKey}/${id}. Re-read the row and retry.`, {
162
204
  code: 'edit_context_stale',
163
205
  readAt: snapshot.stamp,
164
206
  cause: snapshot.signal.reason,
@@ -166,20 +208,21 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
166
208
  }
167
209
  try {
168
210
  return await operations.update(id, data, {
169
- wait: options?.wait ?? 'confirmed',
211
+ wait: acquireWait ?? 'confirmed',
170
212
  readAt: snapshot.stamp,
171
213
  onStale: 'reject',
172
214
  ...updateOptions,
173
- intent,
215
+ intent: acquired,
174
216
  });
175
217
  }
176
218
  finally {
177
- await release();
219
+ await finish();
178
220
  }
179
221
  },
180
- release,
181
- revoke,
182
- [Symbol.asyncDispose]: release,
222
+ finish,
223
+ whenFree,
224
+ cancel,
225
+ [Symbol.asyncDispose]: finish,
183
226
  };
184
227
  return handle;
185
228
  },
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @ablo/sync-engine/client — Consumer API
2
+ * @abloatai/ablo/client — Consumer API
3
3
  *
4
4
  * The one-liner entry point for external consumers.
5
5
  *
@@ -7,7 +7,7 @@
7
7
  * when you want the realtime sync engine with typed model proxies.
8
8
  *
9
9
  * ```ts
10
- * import { Ablo } from '@ablo/sync-engine/client';
10
+ * import { Ablo } from '@abloatai/ablo/client';
11
11
  * import { schema } from './schema';
12
12
  *
13
13
  * const ablo = Ablo({
@@ -20,7 +20,7 @@
20
20
  * ```
21
21
  *
22
22
  * For headless agents (workers, bots), pass `kind: 'agent'` plus a
23
- * Biscuit `capabilityToken`:
23
+ * restricted (`rk_`) API key as `capabilityToken`:
24
24
  *
25
25
  * ```ts
26
26
  * const bot = Ablo({
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @ablo/sync-engine/client — Consumer API
2
+ * @abloatai/ablo/client — Consumer API
3
3
  *
4
4
  * The one-liner entry point for external consumers.
5
5
  *
@@ -7,7 +7,7 @@
7
7
  * when you want the realtime sync engine with typed model proxies.
8
8
  *
9
9
  * ```ts
10
- * import { Ablo } from '@ablo/sync-engine/client';
10
+ * import { Ablo } from '@abloatai/ablo/client';
11
11
  * import { schema } from './schema';
12
12
  *
13
13
  * const ablo = Ablo({
@@ -20,7 +20,7 @@
20
20
  * ```
21
21
  *
22
22
  * For headless agents (workers, bots), pass `kind: 'agent'` plus a
23
- * Biscuit `capabilityToken`:
23
+ * restricted (`rk_`) API key as `capabilityToken`:
24
24
  *
25
25
  * ```ts
26
26
  * const bot = Ablo({
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @ablo/sync-engine/config — App initialization
2
+ * @abloatai/ablo/config — App initialization
3
3
  *
4
4
  * One-time setup at app boot. Provides DI interface types
5
5
  * and the initSyncEngine() function to wire real implementations.