@abloatai/ablo 0.8.0 → 0.9.1

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 (165) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +33 -28
  3. package/dist/BaseSyncedStore.d.ts +83 -0
  4. package/dist/BaseSyncedStore.js +194 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +158 -50
  52. package/dist/mutators/UndoManager.js +345 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/context.d.ts +31 -0
  63. package/dist/react/useAblo.d.ts +2 -2
  64. package/dist/react/useCurrentUserId.d.ts +1 -1
  65. package/dist/react/useCurrentUserId.js +1 -1
  66. package/dist/react/useMutators.js +19 -12
  67. package/dist/schema/ddl.d.ts +34 -3
  68. package/dist/schema/ddl.js +162 -4
  69. package/dist/schema/index.d.ts +5 -1
  70. package/dist/schema/index.js +13 -1
  71. package/dist/schema/model.d.ts +11 -0
  72. package/dist/schema/model.js +2 -0
  73. package/dist/schema/openapi.d.ts +28 -0
  74. package/dist/schema/openapi.js +118 -0
  75. package/dist/schema/plane.d.ts +23 -0
  76. package/dist/schema/plane.js +19 -0
  77. package/dist/schema/relation.d.ts +20 -0
  78. package/dist/schema/serialize.d.ts +4 -0
  79. package/dist/schema/serialize.js +4 -0
  80. package/dist/schema/sync-delta-row.d.ts +157 -0
  81. package/dist/schema/sync-delta-row.js +102 -0
  82. package/dist/schema/sync-delta-wire.d.ts +180 -0
  83. package/dist/schema/sync-delta-wire.js +102 -0
  84. package/dist/server/adapter.d.ts +156 -0
  85. package/dist/server/adapter.js +19 -0
  86. package/dist/server/commit.d.ts +82 -0
  87. package/dist/server/commit.js +1 -0
  88. package/dist/server/index.d.ts +14 -0
  89. package/dist/server/index.js +1 -0
  90. package/dist/server/next.d.ts +51 -0
  91. package/dist/server/next.js +47 -0
  92. package/dist/server/read-config.d.ts +60 -0
  93. package/dist/server/read-config.js +8 -0
  94. package/dist/server/storage-mode.d.ts +17 -0
  95. package/dist/server/storage-mode.js +12 -0
  96. package/dist/source/adapter.d.ts +65 -0
  97. package/dist/source/adapter.js +20 -0
  98. package/dist/source/adapters/drizzle.d.ts +43 -0
  99. package/dist/source/adapters/drizzle.js +185 -0
  100. package/dist/source/adapters/memory.d.ts +12 -0
  101. package/dist/source/adapters/memory.js +114 -0
  102. package/dist/source/adapters/prisma.d.ts +57 -0
  103. package/dist/source/adapters/prisma.js +176 -0
  104. package/dist/source/conformance.d.ts +32 -0
  105. package/dist/source/conformance.js +134 -0
  106. package/dist/source/contract.d.ts +144 -0
  107. package/dist/source/contract.js +99 -0
  108. package/dist/source/index.d.ts +62 -10
  109. package/dist/source/index.js +99 -0
  110. package/dist/source/migrations.d.ts +14 -0
  111. package/dist/source/migrations.js +39 -0
  112. package/dist/source/next.d.ts +33 -0
  113. package/dist/source/next.js +26 -0
  114. package/dist/sync/BootstrapHelper.d.ts +10 -0
  115. package/dist/sync/BootstrapHelper.js +10 -15
  116. package/dist/sync/ConnectionManager.d.ts +55 -1
  117. package/dist/sync/ConnectionManager.js +155 -16
  118. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  119. package/dist/sync/HydrationCoordinator.js +238 -39
  120. package/dist/sync/NetworkProbe.d.ts +58 -24
  121. package/dist/sync/NetworkProbe.js +118 -42
  122. package/dist/sync/SyncWebSocket.d.ts +45 -70
  123. package/dist/sync/SyncWebSocket.js +70 -36
  124. package/dist/sync/createIntentStream.js +10 -1
  125. package/dist/types/streams.d.ts +9 -0
  126. package/dist/utils/mobx-setup.js +1 -0
  127. package/dist/webhooks/events.d.ts +38 -0
  128. package/dist/webhooks/events.js +40 -0
  129. package/dist/webhooks/index.d.ts +10 -0
  130. package/dist/webhooks/index.js +10 -0
  131. package/dist/wire/errorEnvelope.d.ts +34 -0
  132. package/dist/wire/errorEnvelope.js +86 -0
  133. package/dist/wire/frames.d.ts +119 -0
  134. package/dist/wire/frames.js +1 -0
  135. package/dist/wire/index.d.ts +24 -0
  136. package/dist/wire/index.js +21 -0
  137. package/dist/wire/listEnvelope.d.ts +45 -0
  138. package/dist/wire/listEnvelope.js +17 -0
  139. package/docs/api.md +47 -44
  140. package/docs/cli.md +44 -44
  141. package/docs/client-behavior.md +30 -30
  142. package/docs/coordination.md +33 -36
  143. package/docs/data-sources.md +35 -15
  144. package/docs/examples/agent-human.md +45 -43
  145. package/docs/examples/ai-sdk-tool.md +20 -16
  146. package/docs/examples/existing-python-backend.md +16 -12
  147. package/docs/examples/nextjs.md +14 -12
  148. package/docs/examples/scoped-agent.md +1 -1
  149. package/docs/examples/server-agent.md +24 -21
  150. package/docs/guarantees.md +15 -13
  151. package/docs/index.md +2 -2
  152. package/docs/integration-guide.md +30 -30
  153. package/docs/interaction-model.md +19 -23
  154. package/docs/mcp/claude-code.md +3 -3
  155. package/docs/mcp/cursor.md +1 -1
  156. package/docs/mcp/windsurf.md +2 -2
  157. package/docs/mcp.md +6 -6
  158. package/docs/quickstart.md +41 -31
  159. package/docs/react.md +13 -9
  160. package/docs/schema-contract.md +12 -10
  161. package/docs/the-loop.md +21 -0
  162. package/examples/data-source/README.md +4 -5
  163. package/examples/data-source/customer-server.ts +27 -25
  164. package/llms.txt +28 -5
  165. package/package.json +43 -3
@@ -10,7 +10,7 @@
10
10
  * exposes the async server reads `retrieve` / `list`, the synchronous
11
11
  * local-graph snapshots `get` / `getAll` / `getCount`, the writes
12
12
  * `create` / `update` / `delete`, the coordination namespace `claim`
13
- * (`claim(id, work)` plus `claim.state` / `claim.queue` / `claim.release` /
13
+ * (`claim({ id })` plus `claim.state` / `claim.queue` / `claim.release` /
14
14
  * `claim.reorder`), and `onChange`. The factory returns a plain object; the
15
15
  * client assembles the `ablo.<model>` lookup table from these.
16
16
  */
@@ -61,9 +61,23 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
61
61
  await syncClient.waitForConfirmation(model.getModelName(), model.id);
62
62
  };
63
63
  // Claims this proxy currently holds, keyed by entity id. Lets the flat
64
- // `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 })`
65
65
  // took — no per-call handle. Released on dispose, explicit release, or TTL.
66
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
+ };
67
81
  const releaseClaim = async (id) => {
68
82
  const held = activeClaims.get(id);
69
83
  if (!held)
@@ -71,10 +85,11 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
71
85
  activeClaims.delete(id);
72
86
  await held.lease.release();
73
87
  };
74
- const takeClaim = async (id, options) => {
88
+ const takeClaim = async (params) => {
75
89
  if (!collaboration) {
76
90
  throw new AbloValidationError(`Model "${schemaKey}" cannot claim a row without collaboration wiring.`, { code: 'model_claim_not_configured' });
77
91
  }
92
+ const { id, ...options } = params;
78
93
  // Is someone ELSE already on this target? Read the local coordination
79
94
  // snapshot up front — it decides whether we'll need to re-read after the
80
95
  // claim (a free / already-mine target can't have changed under us).
@@ -109,6 +124,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
109
124
  model: schemaKey,
110
125
  id,
111
126
  ...(options?.field ? { field: options.field } : {}),
127
+ ...(options?.path ? { path: options.path } : {}),
128
+ ...(options?.range ? { range: options.range } : {}),
129
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
112
130
  },
113
131
  action: options?.action ?? 'editing',
114
132
  ttl: options?.ttl,
@@ -124,53 +142,54 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
124
142
  }
125
143
  const snapshot = collaboration.createSnapshot(schemaKey, id);
126
144
  activeClaims.set(id, { lease, snapshot });
127
- const row = modelAsRow(model);
128
- // `await using` calls this on scope exit; releases the claim.
129
- Object.defineProperty(row, Symbol.asyncDispose, {
130
- value: () => releaseClaim(id),
131
- enumerable: false,
132
- configurable: true,
133
- });
134
- 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
+ };
135
167
  };
136
- function claim(id, a, b) {
137
- if (typeof a === 'function') {
138
- return (async () => {
139
- const row = await takeClaim(id, b);
140
- try {
141
- return await a(row);
142
- }
143
- finally {
144
- await releaseClaim(id);
145
- }
146
- })();
147
- }
148
- return takeClaim(id, a);
149
- }
168
+ const claim = (params) => takeClaim(params);
150
169
  // `claim` is a callable namespace: invoke it to take a claim, reach its
151
170
  // members to read/steer the coordination plane. Attach the readers to the
152
171
  // guarded callable so `ablo.<model>.claim(...)` and `ablo.<model>.claim.state(...)`
153
172
  // are the same object.
154
173
  const claimApi = Object.assign(guard(claim), {
155
- state(id) {
156
- return collaboration?.observe({ model: schemaKey, id }) ?? null;
174
+ state(params) {
175
+ return collaboration?.observe({ model: schemaKey, id: params.id }) ?? null;
157
176
  },
158
- queue(id) {
177
+ queue(params) {
159
178
  return {
160
179
  object: 'list',
161
- data: collaboration?.queue({ model: schemaKey, id }) ?? [],
180
+ data: collaboration?.queue({ model: schemaKey, id: params.id }) ?? [],
162
181
  };
163
182
  },
164
- reorder(id, order) {
165
- collaboration?.reorder({ model: schemaKey, id }, order);
183
+ reorder(params) {
184
+ collaboration?.reorder({ model: schemaKey, id: params.id }, params.order);
166
185
  },
167
- release: guard((id) => releaseClaim(id)),
186
+ release: guard((params) => releaseClaim(isClaimHandle(params) ? params.target.id : params.id)),
168
187
  });
169
188
  const operations = {
170
- retrieve: guard(async (id, options) => {
189
+ retrieve: guard(async (params) => {
171
190
  const rows = await load({
172
- ...options,
173
- where: { id },
191
+ ...params,
192
+ where: { id: params.id },
174
193
  limit: 1,
175
194
  });
176
195
  return rows[0];
@@ -215,46 +234,130 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
215
234
  getCount(options) {
216
235
  return this.getAll(options).length;
217
236
  },
218
- create: guard(async (data, options) => {
219
- // TODO(options-persistence): stash `options` alongside the
237
+ create: guard(async (params) => {
238
+ // TODO(options-persistence): stash `params` alongside the
220
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
+ }
221
263
  const model = new ModelClass({
222
- id: Model.generateId(),
223
- ...data,
264
+ id,
265
+ ...params.data,
224
266
  createdAt: new Date(),
225
267
  updatedAt: new Date(),
226
268
  });
227
- syncClient.add(model, options);
228
- await waitForMutation(model, options);
229
- return modelAsRow(model);
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
+ }
230
282
  }),
231
- update: guard(async (id, data, options) => {
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;
232
295
  const model = objectPool.get(id);
233
296
  if (!model)
234
297
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
235
298
  // If we hold a claim on this row, guard the write with its snapshot
236
299
  // watermark + lease so it's stale-rejected and attributed to the claim.
237
300
  const claimed = activeClaims.get(id);
301
+ const opts = mutationOptions(params);
302
+ const handleIntent = isClaimHandle(params.claim)
303
+ ? { id: params.claim.claimId }
304
+ : undefined;
238
305
  const effective = claimed
239
306
  ? {
240
307
  wait: 'confirmed',
241
308
  readAt: claimed.snapshot.stamp,
242
309
  onStale: 'reject',
243
310
  intent: claimed.lease,
244
- ...options,
311
+ ...opts,
245
312
  }
246
- : options;
247
- 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);
248
322
  syncClient.update(model, effective);
249
323
  await waitForMutation(model, effective);
250
324
  return modelAsRow(model);
251
325
  }),
252
- delete: guard(async (id, options) => {
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;
253
339
  const model = objectPool.get(id);
254
340
  if (!model)
255
341
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
256
- syncClient.delete(model, options);
257
- await waitForMutation(model, options);
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);
258
361
  }),
259
362
  // `claim` is a callable namespace (take a claim) carrying the coordination
260
363
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
@@ -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,8 +69,8 @@ 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)
@@ -58,7 +90,12 @@ export async function resolveParticipantIdentity(input) {
58
90
  'missing or expired. Ensure `getToken()` returns a valid token, or ' +
59
91
  'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
60
92
  }
61
- const baseUrl = options.bootstrapBaseUrl ?? `${url.replace(/^ws/, 'http')}/api`;
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
+ });
62
99
  const identity = await resolveIdentity({
63
100
  baseUrl,
64
101
  authToken: initialCapToken,
@@ -80,7 +117,7 @@ export async function resolveParticipantIdentity(input) {
80
117
  : identity.syncGroups;
81
118
  bootstrapHelper.setCacheScope(identity.accountScope);
82
119
  bootstrapHelper.setSyncGroups(mergedSyncGroups);
83
- bootstrapHelper.setAuthToken(initialCapToken);
120
+ auth.setAuthToken(initialCapToken);
84
121
  return {
85
122
  userId: identity.participantId,
86
123
  accountScope: identity.accountScope,
@@ -97,7 +134,7 @@ export async function resolveParticipantIdentity(input) {
97
134
  const accountScope = internalOptions.organizationId;
98
135
  bootstrapHelper.setCacheScope(accountScope);
99
136
  bootstrapHelper.setSyncGroups(options.syncGroups);
100
- bootstrapHelper.setAuthToken(initialCapToken);
137
+ auth.setAuthToken(initialCapToken);
101
138
  return {
102
139
  userId,
103
140
  accountScope,
@@ -111,8 +148,10 @@ export async function resolveParticipantIdentity(input) {
111
148
  async function resolveHosted(input) {
112
149
  // Pure managed-cloud shape: `Ablo({schema, apiKey})`. Server returns
113
150
  // scope + userMeta; SDK populates internals.
114
- const baseUrl = input.options.bootstrapBaseUrl ??
115
- `${input.url.replace(/^ws/, 'http')}/api`;
151
+ const baseUrl = resolveBootstrapBaseUrl({
152
+ url: input.url,
153
+ bootstrapBaseUrl: input.options.bootstrapBaseUrl,
154
+ });
116
155
  const exchangeArgs = {
117
156
  baseUrl,
118
157
  participantKind: (input.kind === 'agent' ? 'agent' : 'system'),
@@ -126,7 +165,7 @@ async function resolveHosted(input) {
126
165
  });
127
166
  input.bootstrapHelper.setCacheScope(exchange.scope.organizationId);
128
167
  input.bootstrapHelper.setSyncGroups(exchange.scope.syncGroups);
129
- input.bootstrapHelper.setAuthToken(exchange.token);
168
+ input.auth.setAuthToken(exchange.token);
130
169
  // Cap tokens have a server-set TTL (3600s by default). Without
131
170
  // proactive refresh the WS would either get force-closed at expiry
132
171
  // or fail its next reconnect with 401. The scheduler re-mints
@@ -146,8 +185,7 @@ async function resolveHosted(input) {
146
185
  ...exchangeArgs,
147
186
  apiKey: freshApiKey,
148
187
  });
149
- input.bootstrapHelper.setAuthToken(next.token);
150
- input.applyRotatedToken(next.token);
188
+ input.auth.setAuthToken(next.token);
151
189
  return { expiresAtMs: Date.parse(next.expiresAt) };
152
190
  },
153
191
  onError: (err) => {
@@ -30,6 +30,7 @@
30
30
  * ```
31
31
  */
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
+ export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './auth.js';
33
34
  export type { AbloPersistence } from './persistence.js';
34
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
36
  export type { EngineParticipant, JoinedParticipant, ParticipantJoinOptions, ParticipantManager, ParticipantScope, ParticipantStatus, ScopedIntents, ScopedPresence, } from '../sync/participants.js';
@@ -30,3 +30,4 @@
30
30
  * ```
31
31
  */
32
32
  export { Ablo, computeFKDepthPriority, } from './Ablo.js';
33
+ export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './auth.js';
@@ -3,7 +3,7 @@ export interface RegisterDataSourceInput {
3
3
  readonly baseUrl: string;
4
4
  /** Secret key (`sk_…`) used to authenticate + derive the org. */
5
5
  readonly apiKey: string | null;
6
- /** The customer's own Postgres connection string. */
6
+ /** Postgres connection string for the direct connector. */
7
7
  readonly databaseUrl: string;
8
8
  /** Optional Postgres schema (defaults server-side to `public`). */
9
9
  readonly schema?: string;
@@ -11,8 +11,8 @@ export interface RegisterDataSourceInput {
11
11
  readonly fetchImpl?: typeof fetch;
12
12
  }
13
13
  /**
14
- * POST the connection string to the self-serve datasource route. Resolves on
15
- * success (the org is now a dedicated tenant pointed at this DB); throws an
14
+ * POST the connection string to the self-serve direct connector route. Resolves
15
+ * on success (the org is now a dedicated tenant pointed at this DB); throws an
16
16
  * `AbloError` with `datasource_registration_failed` otherwise so `ready()`
17
17
  * surfaces it instead of silently bootstrapping against the wrong store.
18
18
  */
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Self-serve data-source registration.
2
+ * Self-serve direct-Postgres connector registration.
3
+ *
4
+ * Historical note: this module name says "DataSource", but this path registers
5
+ * a direct database URL. It is not the signed `dataSource(...)` endpoint path.
3
6
  *
4
7
  * When a client is constructed with `databaseUrl`, the SDK registers that
5
- * connection string as the project's own-Postgres data store BEFORE bootstrap,
6
- * so the server resolves the org's data plane to the customer's DB and writes
7
- * synced rows back into it (dedicated/BYO tenant).
8
+ * connection string BEFORE bootstrap so the server resolves the org's data plane
9
+ * to that direct connector.
8
10
  *
9
11
  * The org is derived server-side from the API key — the caller never sends an
10
12
  * organization id. The connection string is sent once over TLS and is never
@@ -13,14 +15,14 @@
13
15
  */
14
16
  import { AbloError } from '../errors.js';
15
17
  /**
16
- * POST the connection string to the self-serve datasource route. Resolves on
17
- * success (the org is now a dedicated tenant pointed at this DB); throws an
18
+ * POST the connection string to the self-serve direct connector route. Resolves
19
+ * on success (the org is now a dedicated tenant pointed at this DB); throws an
18
20
  * `AbloError` with `datasource_registration_failed` otherwise so `ready()`
19
21
  * surfaces it instead of silently bootstrapping against the wrong store.
20
22
  */
21
23
  export async function registerDataSource(input) {
22
24
  if (!input.apiKey) {
23
- throw new AbloError('databaseUrl requires an apiKey to register the data source (the org is derived from the key).', { code: 'datasource_registration_failed' });
25
+ throw new AbloError('databaseUrl requires an apiKey to register the direct Postgres connector (the org is derived from the key).', { code: 'datasource_registration_failed' });
24
26
  }
25
27
  const doFetch = input.fetchImpl ?? fetch;
26
28
  const endpoint = `${input.baseUrl.replace(/\/+$/, '')}/v1/datasource`;
@@ -39,7 +41,7 @@ export async function registerDataSource(input) {
39
41
  });
40
42
  }
41
43
  catch (cause) {
42
- throw new AbloError('Could not reach the Ablo API to register databaseUrl.', {
44
+ throw new AbloError('Could not reach the Ablo API to register the direct Postgres connector.', {
43
45
  code: 'datasource_registration_failed',
44
46
  cause,
45
47
  });
@@ -52,6 +54,6 @@ export async function registerDataSource(input) {
52
54
  catch {
53
55
  // ignore body read failures — the status alone is enough to fail loud
54
56
  }
55
- throw new AbloError(`databaseUrl registration failed (HTTP ${response.status}). ${detail}`, { code: 'datasource_registration_failed', httpStatus: response.status });
57
+ throw new AbloError(`Direct Postgres connector registration failed (HTTP ${response.status}). ${detail}`, { code: 'datasource_registration_failed', httpStatus: response.status });
56
58
  }
57
59
  }