@abloatai/ablo 0.7.0 → 0.9.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 (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
@@ -7,13 +7,15 @@
7
7
  * testable in isolation and the constructor doesn't carry it.
8
8
  *
9
9
  * Each schema model gets one `ModelOperations<T, CreateInput>` —
10
- * exposes `retrieve`, `list`, `count`, `create`, `update`, `delete`,
11
- * `claim`, `claimState`, `queue`, `release`, `subscribe`, and `load`.
12
- * The factory returns a plain object; the client assembles the
13
- * `ablo.<model>` lookup table from these.
10
+ * exposes the async server reads `retrieve` / `list`, the synchronous
11
+ * local-graph snapshots `get` / `getAll` / `getCount`, the writes
12
+ * `create` / `update` / `delete`, the coordination namespace `claim`
13
+ * (`claim({ id })` plus `claim.state` / `claim.queue` / `claim.release` /
14
+ * `claim.reorder`), and `onChange`. The factory returns a plain object; the
15
+ * client assembles the `ablo.<model>` lookup table from these.
14
16
  */
15
17
  import { autorun } from 'mobx';
16
- import { AbloClaimedError, AbloValidationError } from '../errors.js';
18
+ import { AbloClaimedError, AbloValidationError, toAbloError, } from '../errors.js';
17
19
  import { Model, modelAsRow } from '../Model.js';
18
20
  import { ModelScope } from '../types/index.js';
19
21
  const modelClientMeta = new WeakMap();
@@ -28,6 +30,22 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
28
30
  throw new AbloValidationError(`Ablo: schema model "${schemaKey}" resolved to "${registeredModelName}", ` +
29
31
  'but no matching constructor was registered.', { code: 'model_not_registered' });
30
32
  }
33
+ // Last-line guarantee for the public surface: any rejection from a lower
34
+ // layer (transport timeout, IndexedDB failure, a third-party throw) is
35
+ // coerced to an AbloError before it reaches the consumer. The SDK's
36
+ // contract is that callers only ever catch tagged errors — `instanceof
37
+ // AbloError` / `e.type` always hold. Internal helpers stay unwrapped; only
38
+ // the methods exposed on `operations` are guarded.
39
+ const guard = (fn) => {
40
+ return async (...args) => {
41
+ try {
42
+ return await fn(...args);
43
+ }
44
+ catch (err) {
45
+ throw toAbloError(err);
46
+ }
47
+ };
48
+ };
31
49
  const load = async (options) => {
32
50
  const rows = await hydration.fetch(schemaKey, options);
33
51
  // The coordinator returns Model instances. ModelOperations is
@@ -43,9 +61,23 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
43
61
  await syncClient.waitForConfirmation(model.getModelName(), model.id);
44
62
  };
45
63
  // Claims this proxy currently holds, keyed by entity id. Lets the flat
46
- // `release(id)` and `update(id)` find the lease + snapshot a `claim(id)`
64
+ // `release({ id })` and `update({ id, data })` find the lease + snapshot a `claim({ id })`
47
65
  // took — no per-call handle. Released on dispose, explicit release, or TTL.
48
66
  const activeClaims = new Map();
67
+ const isClaimHandle = (value) => typeof value === 'object' &&
68
+ value !== null &&
69
+ value.object === 'claim' &&
70
+ typeof value.claimId === 'string' &&
71
+ typeof value.release === 'function';
72
+ const claimMeta = (options) => {
73
+ if (!options?.description)
74
+ return options?.meta;
75
+ return { ...(options.meta ?? {}), description: options.description };
76
+ };
77
+ const mutationOptions = (params) => {
78
+ const { id: _id, data: _data, claim: _claim, ...rest } = params;
79
+ return rest;
80
+ };
49
81
  const releaseClaim = async (id) => {
50
82
  const held = activeClaims.get(id);
51
83
  if (!held)
@@ -53,10 +85,11 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
53
85
  activeClaims.delete(id);
54
86
  await held.lease.release();
55
87
  };
56
- const takeClaim = async (id, options) => {
88
+ const takeClaim = async (params) => {
57
89
  if (!collaboration) {
58
90
  throw new AbloValidationError(`Model "${schemaKey}" cannot claim a row without collaboration wiring.`, { code: 'model_claim_not_configured' });
59
91
  }
92
+ const { id, ...options } = params;
60
93
  // Is someone ELSE already on this target? Read the local coordination
61
94
  // snapshot up front — it decides whether we'll need to re-read after the
62
95
  // claim (a free / already-mine target can't have changed under us).
@@ -91,6 +124,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
91
124
  model: schemaKey,
92
125
  id,
93
126
  ...(options?.field ? { field: options.field } : {}),
127
+ ...(options?.path ? { path: options.path } : {}),
128
+ ...(options?.range ? { range: options.range } : {}),
129
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
94
130
  },
95
131
  action: options?.action ?? 'editing',
96
132
  ttl: options?.ttl,
@@ -106,34 +142,63 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
106
142
  }
107
143
  const snapshot = collaboration.createSnapshot(schemaKey, id);
108
144
  activeClaims.set(id, { lease, snapshot });
109
- const row = modelAsRow(model);
110
- // `await using` calls this on scope exit; releases the claim.
111
- Object.defineProperty(row, Symbol.asyncDispose, {
112
- value: () => releaseClaim(id),
113
- enumerable: false,
114
- configurable: true,
115
- });
116
- return row;
145
+ const target = {
146
+ model: schemaKey,
147
+ id,
148
+ ...(options?.field ? { field: options.field } : {}),
149
+ ...(options?.path ? { path: options.path } : {}),
150
+ ...(options?.range ? { range: options.range } : {}),
151
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
152
+ };
153
+ const release = () => releaseClaim(id);
154
+ return {
155
+ object: 'claim',
156
+ claimId: lease.id,
157
+ target,
158
+ action: options?.action ?? 'editing',
159
+ ...(options?.description ? { description: options.description } : {}),
160
+ data: modelAsRow(model),
161
+ release,
162
+ revoke: () => {
163
+ void release();
164
+ },
165
+ [Symbol.asyncDispose]: release,
166
+ };
117
167
  };
118
- function claim(id, a, b) {
119
- if (typeof a === 'function') {
120
- return (async () => {
121
- const row = await takeClaim(id, b);
122
- try {
123
- return await a(row);
124
- }
125
- finally {
126
- await releaseClaim(id);
127
- }
128
- })();
129
- }
130
- return takeClaim(id, a);
131
- }
168
+ const claim = (params) => takeClaim(params);
169
+ // `claim` is a callable namespace: invoke it to take a claim, reach its
170
+ // members to read/steer the coordination plane. Attach the readers to the
171
+ // guarded callable so `ablo.<model>.claim(...)` and `ablo.<model>.claim.state(...)`
172
+ // are the same object.
173
+ const claimApi = Object.assign(guard(claim), {
174
+ state(params) {
175
+ return collaboration?.observe({ model: schemaKey, id: params.id }) ?? null;
176
+ },
177
+ queue(params) {
178
+ return {
179
+ object: 'list',
180
+ data: collaboration?.queue({ model: schemaKey, id: params.id }) ?? [],
181
+ };
182
+ },
183
+ reorder(params) {
184
+ collaboration?.reorder({ model: schemaKey, id: params.id }, params.order);
185
+ },
186
+ release: guard((params) => releaseClaim(isClaimHandle(params) ? params.target.id : params.id)),
187
+ });
132
188
  const operations = {
133
- retrieve(id) {
189
+ retrieve: guard(async (params) => {
190
+ const rows = await load({
191
+ ...params,
192
+ where: { id: params.id },
193
+ limit: 1,
194
+ });
195
+ return rows[0];
196
+ }),
197
+ list: guard(load),
198
+ get(id) {
134
199
  return objectPool.get(id);
135
200
  },
136
- list(options) {
201
+ getAll(options) {
137
202
  const all = objectPool.getByType(ModelClass, (options?.state ?? ModelScope.live));
138
203
  let result = all;
139
204
  if (options?.where) {
@@ -166,73 +231,143 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
166
231
  result = result.slice(0, options.limit);
167
232
  return result;
168
233
  },
169
- count(options) {
170
- return this.list(options).length;
234
+ getCount(options) {
235
+ return this.getAll(options).length;
171
236
  },
172
- async create(data, options) {
173
- // TODO(options-persistence): stash `options` alongside the
237
+ create: guard(async (params) => {
238
+ // TODO(options-persistence): stash `params` alongside the
174
239
  // queued transaction so idempotencyKey survives offline flush.
240
+ const id = params.id ?? Model.generateId();
241
+ const opts = mutationOptions(params);
242
+ const claim = params.claim;
243
+ let autoLease;
244
+ if (claim && !isClaimHandle(claim)) {
245
+ if (!collaboration) {
246
+ throw new AbloValidationError(`Model "${schemaKey}" cannot claim a row without collaboration wiring.`, { code: 'model_claim_not_configured' });
247
+ }
248
+ autoLease = await collaboration.createIntent({
249
+ target: {
250
+ model: schemaKey,
251
+ id,
252
+ ...(claim.field ? { field: claim.field } : {}),
253
+ ...(claim.path ? { path: claim.path } : {}),
254
+ ...(claim.range ? { range: claim.range } : {}),
255
+ ...(claimMeta(claim) ? { meta: claimMeta(claim) } : {}),
256
+ },
257
+ action: claim.action ?? 'creating',
258
+ ttl: claim.ttl,
259
+ queue: claim.wait !== false,
260
+ maxQueueDepth: claim.maxQueueDepth,
261
+ });
262
+ }
175
263
  const model = new ModelClass({
176
- id: Model.generateId(),
177
- ...data,
264
+ id,
265
+ ...params.data,
178
266
  createdAt: new Date(),
179
267
  updatedAt: new Date(),
180
268
  });
181
- syncClient.add(model, options);
182
- await waitForMutation(model, options);
183
- return modelAsRow(model);
184
- },
185
- async update(id, data, options) {
269
+ const effective = {
270
+ ...opts,
271
+ ...(autoLease ? { intent: autoLease } : {}),
272
+ ...(isClaimHandle(claim) ? { intent: { id: claim.claimId } } : {}),
273
+ };
274
+ try {
275
+ syncClient.add(model, effective);
276
+ await waitForMutation(model, effective);
277
+ return modelAsRow(model);
278
+ }
279
+ finally {
280
+ await autoLease?.release().catch(() => { });
281
+ }
282
+ }),
283
+ update: guard(async (params) => {
284
+ const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
285
+ if (autoClaim) {
286
+ const handle = await takeClaim({ ...autoClaim, id: params.id });
287
+ try {
288
+ return await operations.update({ ...params, claim: handle });
289
+ }
290
+ finally {
291
+ await handle.release();
292
+ }
293
+ }
294
+ const { id } = params;
186
295
  const model = objectPool.get(id);
187
296
  if (!model)
188
297
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
189
298
  // If we hold a claim on this row, guard the write with its snapshot
190
299
  // watermark + lease so it's stale-rejected and attributed to the claim.
191
300
  const claimed = activeClaims.get(id);
301
+ const opts = mutationOptions(params);
302
+ const handleIntent = isClaimHandle(params.claim)
303
+ ? { id: params.claim.claimId }
304
+ : undefined;
192
305
  const effective = claimed
193
306
  ? {
194
307
  wait: 'confirmed',
195
308
  readAt: claimed.snapshot.stamp,
196
309
  onStale: 'reject',
197
310
  intent: claimed.lease,
198
- ...options,
311
+ ...opts,
199
312
  }
200
- : options;
201
- model.updateFromData(data);
313
+ : {
314
+ ...opts,
315
+ ...(handleIntent ? { intent: handleIntent } : {}),
316
+ };
317
+ // Local user update: `applyChanges` keeps change tracking ON so
318
+ // the edited fields land in `modifiedProperties` and actually get
319
+ // sent to the server. (`updateFromData` is the hydration path and
320
+ // would discard the tracking → empty `input: {}` no-op mutation.)
321
+ model.applyChanges(params.data);
202
322
  syncClient.update(model, effective);
203
323
  await waitForMutation(model, effective);
204
324
  return modelAsRow(model);
205
- },
206
- async delete(id, options) {
325
+ }),
326
+ delete: guard(async (params) => {
327
+ const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
328
+ if (autoClaim) {
329
+ const handle = await takeClaim({ ...autoClaim, id: params.id });
330
+ try {
331
+ await operations.delete({ ...params, claim: handle });
332
+ }
333
+ finally {
334
+ await handle.release();
335
+ }
336
+ return;
337
+ }
338
+ const { id } = params;
207
339
  const model = objectPool.get(id);
208
340
  if (!model)
209
341
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
210
- syncClient.delete(model, options);
211
- await waitForMutation(model, options);
212
- },
213
- claim,
214
- claimState(id) {
215
- return collaboration?.observe({ model: schemaKey, id }) ?? null;
216
- },
217
- queue(id) {
218
- return {
219
- object: 'list',
220
- data: collaboration?.queue({ model: schemaKey, id }) ?? [],
221
- };
222
- },
223
- reorder(id, order) {
224
- collaboration?.reorder({ model: schemaKey, id }, order);
225
- },
226
- release(id) {
227
- return releaseClaim(id);
228
- },
342
+ const claimed = activeClaims.get(id);
343
+ const opts = mutationOptions(params);
344
+ const handleIntent = isClaimHandle(params.claim)
345
+ ? { id: params.claim.claimId }
346
+ : undefined;
347
+ const effective = claimed
348
+ ? {
349
+ wait: 'confirmed',
350
+ readAt: claimed.snapshot.stamp,
351
+ onStale: 'reject',
352
+ intent: claimed.lease,
353
+ ...opts,
354
+ }
355
+ : {
356
+ ...opts,
357
+ ...(handleIntent ? { intent: handleIntent } : {}),
358
+ };
359
+ syncClient.delete(model, effective);
360
+ await waitForMutation(model, effective);
361
+ }),
362
+ // `claim` is a callable namespace (take a claim) carrying the coordination
363
+ // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
364
+ claim: claimApi,
229
365
  onChange(callback, options) {
230
366
  return autorun(() => {
231
- const entities = this.list(options);
367
+ const entities = this.getAll(options);
232
368
  callback(entities);
233
369
  });
234
370
  },
235
- load,
236
371
  };
237
372
  modelClientMeta.set(operations, {
238
373
  key: schemaKey,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `createAbloHttpClient` — a STATELESS, typed HTTP client for server-side actors
3
+ * (agents, workers, serverless), modelled on `@liveblocks/node` / the Stripe
4
+ * server SDK / Netflix Conductor workers: it talks to Ablo over plain HTTP with
5
+ * the credential as identity, and holds **no WebSocket and no connection state**.
6
+ *
7
+ * Why this exists (docs/plans/agent-transport-event-driven.md): the stateful
8
+ * `Ablo({ schema })` client is for INTERACTIVE participants — it opens a
9
+ * WebSocket and seeds its identity (userId/orgId) during the connect/bootstrap
10
+ * step, then routes writes through a `TransactionQueue` that drops mutations
11
+ * until that identity exists. A reactive agent has no socket, so that identity is
12
+ * never seeded and writes drop. The proven fix (unanimous across Liveblocks,
13
+ * Stripe, PlanetScale, Conductor, Better Auth) is NOT to de-socket the stateful
14
+ * client — it's a separate stateless client where the credential carries identity
15
+ * and the SERVER resolves it per request.
16
+ *
17
+ * Ablo already has that stateless surface: `Ablo({ schema: null })` returns the
18
+ * protocol client (`createProtocolClient` → `AbloApi`), which commits via
19
+ * `POST /v1/commits` and reads via the HTTP `ApiClient`, authenticating with the
20
+ * Bearer token on every request. Its only ergonomic gap is that model access is
21
+ * string-keyed (`api.model('slides')`) rather than typed (`api.slides`). This
22
+ * wraps it in a typed proxy facade so server code gets the SAME `client.<model>`
23
+ * surface as the browser client — typed proxies, stateless transport.
24
+ */
25
+ import { type AbloApiClientOptions, type TaskCreateOptions } from './ApiClient.js';
26
+ import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions, Turn } from './Ablo.js';
27
+ import type { ModelCreateParams, ModelDeleteParams, ModelLoadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
28
+ import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
29
+ export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<AbloApiClientOptions, 'schema'> {
30
+ /** The schema — used for TYPING only (typed model proxies); never sent or used at runtime. */
31
+ readonly schema: Schema<S>;
32
+ }
33
+ /**
34
+ * The per-model HTTP surface — exactly what a stateless client can do over
35
+ * request/response: reads (`retrieve`/`list`), writes (`create`/`update`/`delete`),
36
+ * and the durable-lease claim plane (`claim` — acquire/hold/release). It does NOT
37
+ * include `get`/`getAll`/`getCount` (local synced-pool reads) or `onChange` (live
38
+ * subscription); those need the stateful plane and are absent BY TYPE here.
39
+ */
40
+ export interface HttpModelClient<T, C = T> {
41
+ retrieve(params: ModelRetrieveParams & ModelReadOptions): Promise<ModelRead<T>>;
42
+ list(options?: ModelLoadOptions<T>): Promise<T[]>;
43
+ create(params: ModelCreateParams<T, C>): Promise<CommitReceipt>;
44
+ update(params: ModelUpdateParams<C>): Promise<CommitReceipt>;
45
+ delete(params: ModelDeleteParams<T>): Promise<CommitReceipt>;
46
+ claim: HttpClaimApi<T>;
47
+ }
48
+ /**
49
+ * The honest type of the stateless HTTP client: typed model proxies (the
50
+ * request/response subset) + `commits` + `beginTurn` + `dispose`. Reaching for a
51
+ * stateful-only capability (`get`/`getAll`/`getCount`, `onChange`,
52
+ * `claim.state`/`queue`/`reorder`) is a COMPILE error here, not a latent runtime
53
+ * `undefined` — the type matches what the transport can actually do.
54
+ */
55
+ export type AbloHttpClient<S extends SchemaRecord> = {
56
+ readonly [K in keyof S & string]: HttpModelClient<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
57
+ } & {
58
+ readonly commits: CommitResource;
59
+ beginTurn(options: TaskCreateOptions): Promise<Turn>;
60
+ dispose(): Promise<void>;
61
+ /** Resolve the bearer credential this client authenticates with (see `AbloApi.getAuthToken`). */
62
+ getAuthToken(): Promise<string | null>;
63
+ /** String-keyed model accessor (for dynamic model names). */
64
+ model<T = Record<string, unknown>>(name: string): HttpModelClient<T>;
65
+ };
66
+ /**
67
+ * 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.
70
+ */
71
+ export declare function createAbloHttpClient<S extends SchemaRecord>(options: AbloHttpClientOptions<S>): AbloHttpClient<S>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `createAbloHttpClient` — a STATELESS, typed HTTP client for server-side actors
3
+ * (agents, workers, serverless), modelled on `@liveblocks/node` / the Stripe
4
+ * server SDK / Netflix Conductor workers: it talks to Ablo over plain HTTP with
5
+ * the credential as identity, and holds **no WebSocket and no connection state**.
6
+ *
7
+ * Why this exists (docs/plans/agent-transport-event-driven.md): the stateful
8
+ * `Ablo({ schema })` client is for INTERACTIVE participants — it opens a
9
+ * WebSocket and seeds its identity (userId/orgId) during the connect/bootstrap
10
+ * step, then routes writes through a `TransactionQueue` that drops mutations
11
+ * until that identity exists. A reactive agent has no socket, so that identity is
12
+ * never seeded and writes drop. The proven fix (unanimous across Liveblocks,
13
+ * Stripe, PlanetScale, Conductor, Better Auth) is NOT to de-socket the stateful
14
+ * client — it's a separate stateless client where the credential carries identity
15
+ * and the SERVER resolves it per request.
16
+ *
17
+ * Ablo already has that stateless surface: `Ablo({ schema: null })` returns the
18
+ * protocol client (`createProtocolClient` → `AbloApi`), which commits via
19
+ * `POST /v1/commits` and reads via the HTTP `ApiClient`, authenticating with the
20
+ * Bearer token on every request. Its only ergonomic gap is that model access is
21
+ * string-keyed (`api.model('slides')`) rather than typed (`api.slides`). This
22
+ * wraps it in a typed proxy facade so server code gets the SAME `client.<model>`
23
+ * surface as the browser client — typed proxies, stateless transport.
24
+ */
25
+ import { createProtocolClient, } from './ApiClient.js';
26
+ /**
27
+ * Members of the underlying `AbloApi` that pass straight through the facade.
28
+ * Deliberately EXCLUDES the resource names that collide with common schema model
29
+ * names — `tasks`, `intents`, `capabilities`, `agent` — so `client.tasks` resolves
30
+ * to the schema model `tasks`, not the protocol `TaskResource`. Only lifecycle +
31
+ * the genuinely-protocol methods an agent uses pass through.
32
+ */
33
+ const PROTOCOL_MEMBERS = new Set([
34
+ 'ready',
35
+ 'waitForFlush',
36
+ 'dispose',
37
+ 'purge',
38
+ 'commits',
39
+ 'beginTurn',
40
+ 'model',
41
+ 'getAuthToken',
42
+ ]);
43
+ /**
44
+ * 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.
47
+ */
48
+ export function createAbloHttpClient(options) {
49
+ // The schema is type-level only; the protocol client is schema-agnostic.
50
+ const { schema: _schema, ...rest } = options;
51
+ const api = createProtocolClient({ ...rest, schema: null });
52
+ const facade = new Proxy(api, {
53
+ get(target, prop) {
54
+ if (typeof prop !== 'string')
55
+ return Reflect.get(target, prop);
56
+ // Real protocol members pass through unchanged.
57
+ if (PROTOCOL_MEMBERS.has(prop) && prop in target)
58
+ return Reflect.get(target, prop);
59
+ // Anything else is a typed model accessor → the string-keyed protocol model
60
+ // (which implements retrieve/list/create/update/delete/claim — every method
61
+ // `HttpModelClient` declares).
62
+ return api.model(prop);
63
+ },
64
+ });
65
+ // One boundary cast — and now an HONEST one: `AbloHttpClient<S>` declares only
66
+ // what `api.model()` + the passed-through protocol members actually implement,
67
+ // so there is no method on this type that fails at runtime.
68
+ return facade;
69
+ }
@@ -22,6 +22,7 @@
22
22
  import { type RefreshScheduler } from '../auth/index.js';
23
23
  import type { BootstrapHelper } from '../sync/BootstrapHelper.js';
24
24
  import type { SyncLogger } from '../interfaces/index.js';
25
+ import type { AuthCredentialSource } from '../auth/credentialSource.js';
25
26
  import type { ApiKeySetter } from './auth.js';
26
27
  export interface IdentityResolveInput {
27
28
  readonly options: {
@@ -42,13 +43,8 @@ export interface IdentityResolveInput {
42
43
  readonly configuredApiKey: string | ApiKeySetter | null;
43
44
  readonly configuredAuthToken: string | null;
44
45
  readonly bootstrapHelper: BootstrapHelper;
46
+ readonly auth: AuthCredentialSource;
45
47
  readonly logger: SyncLogger;
46
- /**
47
- * Called when the hosted-cloud refresh scheduler rotates the
48
- * capability token — caller forwards the new token to the live
49
- * WebSocket via `ws.setCapabilityToken(...)`.
50
- */
51
- readonly applyRotatedToken: (token: string) => void;
52
48
  }
53
49
  export interface ResolvedIdentity {
54
50
  readonly userId: string;
@@ -23,11 +23,43 @@ import { AbloAuthenticationError } from '../errors.js';
23
23
  import { exchangeApiKey } from '../auth/index.js';
24
24
  import { resolveIdentity } from '../auth/index.js';
25
25
  import { createRefreshScheduler, } from '../auth/index.js';
26
- import { resolveApiKeyValue } from './auth.js';
26
+ import { resolveApiKeyValue, resolveBootstrapBaseUrl } from './auth.js';
27
27
  export async function resolveParticipantIdentity(input) {
28
- const { options, internalOptions, url, kind, configuredApiKey, configuredAuthToken, bootstrapHelper, logger, applyRotatedToken, } = input;
28
+ const { options, internalOptions, url, kind, configuredApiKey, configuredAuthToken, bootstrapHelper, auth, logger, } = input;
29
29
  const apiKeyValue = await resolveApiKeyValue(configuredApiKey);
30
30
  const initialCapToken = options.capabilityToken ?? configuredAuthToken ?? undefined;
31
+ // Branch 0: publishable key (`pk_`) — a long-lived, browser-safe, READ-ONLY
32
+ // project key. Unlike a secret `sk_` (Branch 1), it is used DIRECTLY as the
33
+ // bearer and is NEVER exchanged for a short-lived capability — so it never
34
+ // expires and there is nothing to refresh (no `credential_stale`, no
35
+ // wake-from-sleep re-mint). The sync-server's `apiKeyProvider` resolves the
36
+ // org + read-only scope from the key itself; we still call `/auth/identity`
37
+ // (authenticated by the `pk_` bearer) to learn the account scope + syncGroups
38
+ // for the bootstrap cache. Plain `startsWith` check because the `keys` module
39
+ // is node-only (`node:crypto`) and must not enter the browser bundle.
40
+ if (apiKeyValue && apiKeyValue.startsWith('pk_') && !options.capabilityToken) {
41
+ const baseUrl = resolveBootstrapBaseUrl({
42
+ url,
43
+ bootstrapBaseUrl: options.bootstrapBaseUrl,
44
+ });
45
+ const identity = await resolveIdentity({ baseUrl, authToken: apiKeyValue });
46
+ const callerGroups = options.syncGroups ?? [];
47
+ const mergedSyncGroups = callerGroups.length > 0
48
+ ? [...new Set([...callerGroups, ...identity.syncGroups])]
49
+ : identity.syncGroups;
50
+ bootstrapHelper.setCacheScope(identity.accountScope);
51
+ bootstrapHelper.setSyncGroups(mergedSyncGroups);
52
+ auth.setAuthToken(apiKeyValue);
53
+ return {
54
+ userId: identity.participantId,
55
+ accountScope: identity.accountScope,
56
+ teamIds: undefined,
57
+ capabilityToken: apiKeyValue,
58
+ syncGroups: mergedSyncGroups,
59
+ participantKind: identity.participantKind,
60
+ refreshScheduler: null,
61
+ };
62
+ }
31
63
  // Branch 1: hosted-cloud (apiKey only, no caller-supplied capability token)
32
64
  if (apiKeyValue && !options.capabilityToken) {
33
65
  return resolveHosted({
@@ -37,14 +69,33 @@ export async function resolveParticipantIdentity(input) {
37
69
  kind,
38
70
  options,
39
71
  bootstrapHelper,
72
+ auth,
40
73
  logger,
41
- applyRotatedToken,
42
74
  });
43
75
  }
44
76
  // Branch 2: self-derived (capability token present, identity unknown)
45
77
  if (!internalOptions.organizationId ||
46
78
  (kind === 'agent' ? !options.agentId : !options.user?.id)) {
47
- const baseUrl = options.bootstrapBaseUrl ?? `${url.replace(/^ws/, 'http')}/api`;
79
+ // Fail fast on the missing-credential case. We're here because there's no
80
+ // apiKey (Branch 1) and the identity isn't caller-supplied (Branch 3), so
81
+ // `initialCapToken` is the only thing that can authenticate the
82
+ // `/auth/identity` call. When it's absent — the common cause being
83
+ // `getToken()` resolving to `null` (no/expired session, see
84
+ // `getSyncCapabilityToken`) — the request can only come back as the server's
85
+ // opaque `identity_resolve_failed: no_matching_provider`. Surface the real
86
+ // condition locally instead: `session_expired` is the registered,
87
+ // re-authenticate-able code, and we never make a doomed round-trip.
88
+ if (!initialCapToken) {
89
+ throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
90
+ 'missing or expired. Ensure `getToken()` returns a valid token, or ' +
91
+ 'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
92
+ }
93
+ // Single source of truth for the http(s) base — coerces ws/wss → http/https
94
+ // even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
95
+ const baseUrl = resolveBootstrapBaseUrl({
96
+ url,
97
+ bootstrapBaseUrl: options.bootstrapBaseUrl,
98
+ });
48
99
  const identity = await resolveIdentity({
49
100
  baseUrl,
50
101
  authToken: initialCapToken,
@@ -66,7 +117,7 @@ export async function resolveParticipantIdentity(input) {
66
117
  : identity.syncGroups;
67
118
  bootstrapHelper.setCacheScope(identity.accountScope);
68
119
  bootstrapHelper.setSyncGroups(mergedSyncGroups);
69
- bootstrapHelper.setAuthToken(initialCapToken);
120
+ auth.setAuthToken(initialCapToken);
70
121
  return {
71
122
  userId: identity.participantId,
72
123
  accountScope: identity.accountScope,
@@ -83,7 +134,7 @@ export async function resolveParticipantIdentity(input) {
83
134
  const accountScope = internalOptions.organizationId;
84
135
  bootstrapHelper.setCacheScope(accountScope);
85
136
  bootstrapHelper.setSyncGroups(options.syncGroups);
86
- bootstrapHelper.setAuthToken(initialCapToken);
137
+ auth.setAuthToken(initialCapToken);
87
138
  return {
88
139
  userId,
89
140
  accountScope,
@@ -97,8 +148,10 @@ export async function resolveParticipantIdentity(input) {
97
148
  async function resolveHosted(input) {
98
149
  // Pure managed-cloud shape: `Ablo({schema, apiKey})`. Server returns
99
150
  // scope + userMeta; SDK populates internals.
100
- const baseUrl = input.options.bootstrapBaseUrl ??
101
- `${input.url.replace(/^ws/, 'http')}/api`;
151
+ const baseUrl = resolveBootstrapBaseUrl({
152
+ url: input.url,
153
+ bootstrapBaseUrl: input.options.bootstrapBaseUrl,
154
+ });
102
155
  const exchangeArgs = {
103
156
  baseUrl,
104
157
  participantKind: (input.kind === 'agent' ? 'agent' : 'system'),
@@ -112,7 +165,7 @@ async function resolveHosted(input) {
112
165
  });
113
166
  input.bootstrapHelper.setCacheScope(exchange.scope.organizationId);
114
167
  input.bootstrapHelper.setSyncGroups(exchange.scope.syncGroups);
115
- input.bootstrapHelper.setAuthToken(exchange.token);
168
+ input.auth.setAuthToken(exchange.token);
116
169
  // Cap tokens have a server-set TTL (3600s by default). Without
117
170
  // proactive refresh the WS would either get force-closed at expiry
118
171
  // or fail its next reconnect with 401. The scheduler re-mints
@@ -132,8 +185,7 @@ async function resolveHosted(input) {
132
185
  ...exchangeArgs,
133
186
  apiKey: freshApiKey,
134
187
  });
135
- input.bootstrapHelper.setAuthToken(next.token);
136
- input.applyRotatedToken(next.token);
188
+ input.auth.setAuthToken(next.token);
137
189
  return { expiresAtMs: Date.parse(next.expiresAt) };
138
190
  },
139
191
  onError: (err) => {