@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
@@ -171,32 +171,36 @@ export function createProtocolClient(options) {
171
171
  function createAgentModelClient(agentClient, name) {
172
172
  const base = agentClient.model(name);
173
173
  return {
174
- retrieve(id, options) {
174
+ retrieve(params) {
175
175
  // Reads are never blocked by a claim (coordination.md): a claim
176
176
  // serializes WRITERS, not readers. So — unlike the create/update/
177
177
  // delete paths below — retrieve does NOT apply the agent claimed
178
178
  // default; options pass through and the read path's `'return'`
179
179
  // default keeps a claimed row readable. A caller can still opt into
180
180
  // gating with an explicit `ifClaimed` (developer's choice).
181
- return base.retrieve(id, options);
181
+ return base.retrieve(params);
182
182
  },
183
- create(data, mutationOptions) {
184
- const id = mutationOptions?.id ?? createModelId();
185
- return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.create(data, {
186
- ...stripAgentRuntimeOptions(mutationOptions),
183
+ create(params) {
184
+ const id = params.id ?? createModelId();
185
+ return withAgentIntent(agentClient, name, id, params, (commitIntent) => base.create({
186
+ ...stripAgentRuntimeOptions(params),
187
187
  id,
188
+ data: params.data,
188
189
  intent: commitIntent,
189
190
  }));
190
191
  },
191
- update(id, data, mutationOptions) {
192
- return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.update(id, data, {
193
- ...stripAgentRuntimeOptions(mutationOptions),
192
+ update(params) {
193
+ return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.update({
194
+ ...stripAgentRuntimeOptions(params),
195
+ id: params.id,
196
+ data: params.data,
194
197
  intent: commitIntent,
195
198
  }));
196
199
  },
197
- delete(id, mutationOptions) {
198
- return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.delete(id, {
199
- ...stripAgentRuntimeOptions(mutationOptions),
200
+ delete(params) {
201
+ return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.delete({
202
+ ...stripAgentRuntimeOptions(params),
203
+ id: params.id,
200
204
  intent: commitIntent,
201
205
  }));
202
206
  },
@@ -439,6 +443,35 @@ export function createProtocolClient(options) {
439
443
  activeSessionsClosed: body.activeSessionsClosed,
440
444
  };
441
445
  },
446
+ async rotate(id, rotateOptions = {}) {
447
+ const graceSeconds = rotateOptions.graceSeconds ??
448
+ (rotateOptions.grace !== undefined ? toSeconds(rotateOptions.grace) : undefined);
449
+ const leaseSeconds = rotateOptions.leaseSeconds ??
450
+ (rotateOptions.lease !== undefined ? toSeconds(rotateOptions.lease) : undefined);
451
+ const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}/rotate`, {
452
+ method: 'POST',
453
+ body: JSON.stringify({
454
+ ...(graceSeconds !== undefined ? { graceSeconds } : {}),
455
+ ...(leaseSeconds !== undefined ? { ttlSeconds: leaseSeconds } : {}),
456
+ }),
457
+ });
458
+ const newId = body.capabilityId ?? body.id;
459
+ if (!newId) {
460
+ throw new AbloValidationError('Capability rotate response did not include an id.', { code: 'capability_id_missing' });
461
+ }
462
+ return {
463
+ id: newId,
464
+ token: body.token,
465
+ expiresAt: body.expiresAt,
466
+ organizationId: body.organizationId,
467
+ scope: body.scope,
468
+ rotatedFrom: {
469
+ id: body.rotatedFrom.capabilityId ?? body.rotatedFrom.id ?? id,
470
+ expiresAt: body.rotatedFrom.expiresAt,
471
+ },
472
+ client: () => childClient(body.token),
473
+ };
474
+ },
442
475
  mint(options) {
443
476
  return capabilities.create(options);
444
477
  },
@@ -533,14 +566,39 @@ export function createProtocolClient(options) {
533
566
  return waitForNoIntents(target, options);
534
567
  },
535
568
  };
536
- async function retrieveModel(modelName, id, options) {
537
- await applyClaimedPolicy({ model: modelName, id }, options);
538
- const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(id)}`, {
569
+ async function listModel(modelName, options) {
570
+ const params = new URLSearchParams();
571
+ if (options?.limit !== undefined)
572
+ params.set('limit', String(options.limit));
573
+ if (options?.orderBy) {
574
+ const [col, dir] = Object.entries(options.orderBy)[0] ?? [];
575
+ if (col) {
576
+ params.set('order_by', col);
577
+ if (dir === 'desc')
578
+ params.set('order', 'desc');
579
+ }
580
+ }
581
+ // The collection route turns any non-reserved query param into an equality
582
+ // filter (`?status=todo`). The wire is AND-only equality — matches what a
583
+ // stateless reactor needs; richer predicates stay on the stateful path.
584
+ if (options?.where && typeof options.where === 'object') {
585
+ for (const [k, v] of Object.entries(options.where)) {
586
+ if (v !== undefined && v !== null && typeof v !== 'object')
587
+ params.set(k, String(v));
588
+ }
589
+ }
590
+ const qs = params.toString();
591
+ const res = await requestJson(`/v1/models/${encodeURIComponent(modelName)}${qs ? `?${qs}` : ''}`, { method: 'GET' });
592
+ return res.data ?? [];
593
+ }
594
+ async function retrieveModel(modelName, params) {
595
+ await applyClaimedPolicy({ model: modelName, id: params.id }, params);
596
+ const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(params.id)}`, {
539
597
  method: 'GET',
540
598
  });
541
599
  const data = query.data;
542
600
  if (!data) {
543
- throw new AbloValidationError(`Model row not found: ${modelName}/${id}`, { code: 'model_not_found' });
601
+ throw new AbloValidationError(`Model row not found: ${modelName}/${params.id}`, { code: 'model_not_found' });
544
602
  }
545
603
  return {
546
604
  data,
@@ -548,63 +606,170 @@ export function createProtocolClient(options) {
548
606
  claims: query.claims ?? [],
549
607
  };
550
608
  }
609
+ /**
610
+ * Single-op mutation over the model-scoped routes — the canonical surface
611
+ * that mirrors `ablo.<model>.create/update/delete`:
612
+ *
613
+ * POST /v1/models/:model create
614
+ * PATCH /v1/models/:model/:id update
615
+ * DELETE /v1/models/:model/:id delete
616
+ *
617
+ * This replaces the previous indirection through `POST /v1/commits`. The raw
618
+ * `commits.create(...)` resource is still the path for ATOMIC MULTI-OP
619
+ * envelopes — this helper is the one-op, one-record path only.
620
+ */
621
+ async function mutateModel(action, modelName, id, data, options) {
622
+ const clientTxId = createClientTxId(options?.idempotencyKey);
623
+ const encModel = encodeURIComponent(modelName);
624
+ const path = action === 'create'
625
+ ? `/v1/models/${encModel}`
626
+ : `/v1/models/${encModel}/${encodeURIComponent(id)}`;
627
+ const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
628
+ const requestBody = {
629
+ idempotencyKey: clientTxId,
630
+ intent: normalizeIntentId(options?.intent),
631
+ onStale: options?.onStale,
632
+ readAt: options?.readAt,
633
+ };
634
+ if (action === 'create')
635
+ requestBody.id = id;
636
+ if (data !== undefined)
637
+ requestBody.data = data;
638
+ const body = await requestJson(path, {
639
+ method,
640
+ idempotencyKey: clientTxId,
641
+ body: JSON.stringify(requestBody),
642
+ });
643
+ // `requestJson` throws via `translateHttpError` on any non-2xx, so reaching
644
+ // here implies success. Narrow `status` to the `CommitWait`-compatible
645
+ // subset; `'rejected'` only appears on a thrown rejection body.
646
+ const status = body.status === 'queued' ? 'queued' : 'confirmed';
647
+ return {
648
+ id: body.serverTxId ?? body.id ?? body.clientTxId ?? clientTxId,
649
+ status,
650
+ lastSyncId: body.lastSyncId,
651
+ };
652
+ }
551
653
  function model(name) {
654
+ // Durable lease + FIFO wait-line over HTTP (the existing claim routes). A
655
+ // claim is server state, not a subscription — acquire/hold/release are plain
656
+ // request/response, so a stateless agent participates in coordination too.
657
+ const claimPath = (id) => `/v1/models/${encodeURIComponent(name)}/${encodeURIComponent(id)}/claim`;
658
+ const isClaimHandle = (value) => typeof value === 'object' &&
659
+ value !== null &&
660
+ value.object === 'claim' &&
661
+ typeof value.claimId === 'string' &&
662
+ typeof value.release === 'function';
663
+ const claimMeta = (options) => {
664
+ if (!options?.description)
665
+ return options?.meta;
666
+ return { ...(options.meta ?? {}), description: options.description };
667
+ };
668
+ const acquireClaim = async (params) => {
669
+ const body = await requestJson(claimPath(params.id), {
670
+ method: 'POST',
671
+ body: JSON.stringify({
672
+ action: params.action ?? 'editing',
673
+ ...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
674
+ ...(params.description !== undefined ? { description: params.description } : {}),
675
+ ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
676
+ // `wait` (default true) → queue behind the holder; false → fail-fast
677
+ // with AbloClaimedError (work-distribution dedup).
678
+ queue: params.wait ?? true,
679
+ }),
680
+ });
681
+ if (body.status === 'queued') {
682
+ throw new AbloClaimedError(`Target ${name}/${params.id} is held; queued at position ${body.position ?? 0}. ` +
683
+ `The HTTP client cannot await the grant without a WebSocket.`, { code: 'intent_queued' });
684
+ }
685
+ return body.intent?.id ?? body.id ?? body.intentId ?? createIntentId();
686
+ };
687
+ const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
688
+ async function claimImpl(params) {
689
+ const claimId = await acquireClaim(params);
690
+ const { data } = await retrieveModel(name, { id: params.id });
691
+ const release = () => releaseClaim(params);
692
+ return {
693
+ object: 'claim',
694
+ claimId,
695
+ target: {
696
+ model: name,
697
+ id: params.id,
698
+ ...(params.field ? { field: params.field } : {}),
699
+ ...(params.path ? { path: params.path } : {}),
700
+ ...(params.range ? { range: params.range } : {}),
701
+ ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
702
+ },
703
+ action: params.action ?? 'editing',
704
+ ...(params.description ? { description: params.description } : {}),
705
+ data,
706
+ release,
707
+ revoke: () => {
708
+ void release().catch(() => { });
709
+ },
710
+ [Symbol.asyncDispose]: release,
711
+ };
712
+ }
713
+ const intentsForEntity = async (params) => requestJson(`/v1/intents?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${params.field ? `&field=${encodeURIComponent(params.field)}` : ''}`, { method: 'GET' });
714
+ const claim = Object.assign(claimImpl, {
715
+ release: releaseClaim,
716
+ state: async (params) => {
717
+ const res = await intentsForEntity(params);
718
+ return res.intents?.[0] ?? null;
719
+ },
720
+ queue: async (params) => {
721
+ const res = await intentsForEntity(params);
722
+ return { object: 'list', data: res.queue ?? [] };
723
+ },
724
+ reorder: async (params) => {
725
+ await requestJson(`${claimPath(params.id)}/reorder`, {
726
+ method: 'POST',
727
+ // The reorder route's payload is `{ heldBy, intentId }[]` — Intent's id
728
+ // IS the intentId.
729
+ body: JSON.stringify({ order: params.order.map((i) => ({ heldBy: i.heldBy, intentId: i.id })) }),
730
+ });
731
+ },
732
+ });
733
+ const withMutationClaim = async (id, input, run) => {
734
+ const claimInput = input?.claim;
735
+ if (!claimInput)
736
+ return run(input);
737
+ if (isClaimHandle(claimInput)) {
738
+ return run({ ...input, intent: { id: claimInput.claimId }, claim: undefined });
739
+ }
740
+ const claimId = await acquireClaim({ id, ...claimInput });
741
+ try {
742
+ return await run({ ...input, intent: { id: claimId }, claim: undefined });
743
+ }
744
+ finally {
745
+ await releaseClaim({ id }).catch(() => { });
746
+ }
747
+ };
552
748
  return {
553
- retrieve(id, options) {
554
- return retrieveModel(name, id, options);
749
+ claim,
750
+ retrieve(params) {
751
+ return retrieveModel(name, params);
752
+ },
753
+ list(options) {
754
+ return listModel(name, options);
555
755
  },
556
- async create(data, mutationOptions) {
557
- const id = mutationOptions?.id ?? createModelId();
558
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
559
- return commits.create({
560
- intent: mutationOptions?.intent,
561
- idempotencyKey: mutationOptions?.idempotencyKey,
562
- readAt: mutationOptions?.readAt,
563
- onStale: mutationOptions?.onStale,
564
- wait: mutationOptions?.wait,
565
- operations: [
566
- {
567
- action: 'create',
568
- model: name,
569
- id,
570
- data,
571
- },
572
- ],
756
+ async create(params) {
757
+ const id = params.id ?? createModelId();
758
+ return withMutationClaim(id, params, async (options) => {
759
+ await applyClaimedPolicy({ model: name, id }, options);
760
+ return mutateModel('create', name, id, params.data, options);
573
761
  });
574
762
  },
575
- async update(id, data, mutationOptions) {
576
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
577
- return commits.create({
578
- intent: mutationOptions?.intent,
579
- idempotencyKey: mutationOptions?.idempotencyKey,
580
- readAt: mutationOptions?.readAt,
581
- onStale: mutationOptions?.onStale,
582
- wait: mutationOptions?.wait,
583
- operations: [
584
- {
585
- action: 'update',
586
- model: name,
587
- id,
588
- data,
589
- },
590
- ],
763
+ async update(params) {
764
+ return withMutationClaim(params.id, params, async (options) => {
765
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
766
+ return mutateModel('update', name, params.id, params.data, options);
591
767
  });
592
768
  },
593
- async delete(id, mutationOptions) {
594
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
595
- return commits.create({
596
- intent: mutationOptions?.intent,
597
- idempotencyKey: mutationOptions?.idempotencyKey,
598
- readAt: mutationOptions?.readAt,
599
- onStale: mutationOptions?.onStale,
600
- wait: mutationOptions?.wait,
601
- operations: [
602
- {
603
- action: 'delete',
604
- model: name,
605
- id,
606
- },
607
- ],
769
+ async delete(params) {
770
+ return withMutationClaim(params.id, params, async (options) => {
771
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
772
+ return mutateModel('delete', name, params.id, undefined, options);
608
773
  });
609
774
  },
610
775
  };
@@ -620,6 +785,11 @@ export function createProtocolClient(options) {
620
785
  commits,
621
786
  model,
622
787
  agent: createAgent,
788
+ async getAuthToken() {
789
+ // Mirror `authHeaders()`: a configured API key wins, else the
790
+ // construction-time auth token. Resolve the (possibly async) key setter.
791
+ return (await resolveApiKeyValue(configuredApiKey)) ?? configuredAuthToken ?? null;
792
+ },
623
793
  async beginTurn(turnOptions) {
624
794
  const task = await tasks.create(turnOptions);
625
795
  let closed = false;
@@ -29,6 +29,7 @@ export interface AuthResolveInput {
29
29
  readonly apiKey?: string | ApiKeySetter | null;
30
30
  readonly authToken?: string | null;
31
31
  readonly baseURL?: string | null;
32
+ readonly databaseUrl?: string | null;
32
33
  readonly dangerouslyAllowBrowser?: boolean;
33
34
  };
34
35
  readonly env: Record<string, string | undefined>;
@@ -41,16 +42,34 @@ export interface AuthResolveInput {
41
42
  export declare function readProcessEnv(): Record<string, string | undefined>;
42
43
  export declare function resolveApiKey(input: AuthResolveInput): string | ApiKeySetter | null;
43
44
  export declare function resolveAuthToken(input: AuthResolveInput): string | null;
44
- export declare const ABLO_DEFAULT_BASE_URL = "wss://mesh.ablo.finance";
45
+ /**
46
+ * Resolve the direct-URL connector's Postgres connection string.
47
+ *
48
+ * The default Data Source path should not call this: the customer keeps
49
+ * `DATABASE_URL` in their app and exposes `dataSource(...)`. This helper exists
50
+ * only for the opt-in direct connector where Ablo registers a dedicated tenant
51
+ * database. Returns null for Ablo-managed storage.
52
+ */
53
+ export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
54
+ export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
55
+ export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
56
+ export declare const ABLO_DEFAULT_BASE_URL = "wss://api.abloatai.com";
57
+ /**
58
+ * Normalize old hosted aliases to the public API domain. Self-hosted/custom
59
+ * URLs pass through unchanged; only first-party legacy hosts are rewritten.
60
+ */
61
+ export declare function normalizeAbloHostedBaseUrl(rawUrl: string): string;
45
62
  export declare function resolveBaseURL(input: AuthResolveInput): string;
46
63
  /**
47
64
  * Browser guard — apiKey is server-side-only by default. Same check
48
65
  * Anthropic, OpenAI, and Stripe ship: shipping `sk_live_...` to a
49
66
  * browser exposes it in every visitor's network tab. Consumers opt
50
- * in explicitly when they have a publishable key or a server proxy.
67
+ * in explicitly when the browser holds a minted session token
68
+ * (`ek_`/`rk_`) or routes through a server proxy.
51
69
  */
52
70
  export declare function assertBrowserSafety(input: {
53
71
  apiKey: string | ApiKeySetter | null;
72
+ databaseUrl?: string | null;
54
73
  dangerouslyAllowBrowser: boolean | undefined;
55
74
  }): void;
56
75
  /**
@@ -27,19 +27,71 @@ export function resolveApiKey(input) {
27
27
  export function resolveAuthToken(input) {
28
28
  return input.options.authToken ?? null;
29
29
  }
30
- export const ABLO_DEFAULT_BASE_URL = 'wss://mesh.ablo.finance';
30
+ /**
31
+ * Resolve the direct-URL connector's Postgres connection string.
32
+ *
33
+ * The default Data Source path should not call this: the customer keeps
34
+ * `DATABASE_URL` in their app and exposes `dataSource(...)`. This helper exists
35
+ * only for the opt-in direct connector where Ablo registers a dedicated tenant
36
+ * database. Returns null for Ablo-managed storage.
37
+ */
38
+ export function resolveDatabaseUrl(input) {
39
+ return input.options.databaseUrl ?? input.env.DATABASE_URL ?? null;
40
+ }
41
+ export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
42
+ export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
43
+ export const ABLO_DEFAULT_BASE_URL = `wss://${ABLO_HOSTED_API_DOMAIN}`;
44
+ const LEGACY_HOSTED_API_HOSTS = new Set([
45
+ 'mesh.ablo.finance',
46
+ 'mesh-staging.ablo.finance',
47
+ 'api.ablo.finance',
48
+ 'sync-staging.ablo.finance',
49
+ ]);
50
+ /**
51
+ * Normalize old hosted aliases to the public API domain. Self-hosted/custom
52
+ * URLs pass through unchanged; only first-party legacy hosts are rewritten.
53
+ */
54
+ export function normalizeAbloHostedBaseUrl(rawUrl) {
55
+ const trimmed = rawUrl.trim();
56
+ if (!trimmed)
57
+ return trimmed;
58
+ // A scheme-less value (e.g. `api-staging.abloatai.com`) is a RELATIVE URL:
59
+ // `new URL()` throws on it, and downstream `fetch` then resolves it against
60
+ // the current page — producing `https://<app-host>/<route>/api-staging…/api/
61
+ // auth/identity`, a 404 from the app's own origin. Prepend a scheme so the
62
+ // base is absolute. `https` mirrors `ABLO_HOSTED_HTTP_BASE_URL`; the socket
63
+ // layer derives `wss` from it. An existing scheme (ws/wss/http/https) is
64
+ // preserved untouched.
65
+ const schemed = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
66
+ try {
67
+ const url = new URL(schemed);
68
+ if (!LEGACY_HOSTED_API_HOSTS.has(url.hostname))
69
+ return schemed.replace(/\/+$/, '');
70
+ url.hostname = ABLO_HOSTED_API_DOMAIN;
71
+ if (url.protocol === 'http:')
72
+ url.protocol = 'https:';
73
+ if (url.protocol === 'ws:')
74
+ url.protocol = 'wss:';
75
+ return url.toString().replace(/\/+$/, '');
76
+ }
77
+ catch {
78
+ return schemed;
79
+ }
80
+ }
31
81
  export function resolveBaseURL(input) {
32
- return input.options.baseURL ?? ABLO_DEFAULT_BASE_URL;
82
+ return normalizeAbloHostedBaseUrl(input.options.baseURL ?? ABLO_DEFAULT_BASE_URL);
33
83
  }
34
84
  /**
35
85
  * Browser guard — apiKey is server-side-only by default. Same check
36
86
  * Anthropic, OpenAI, and Stripe ship: shipping `sk_live_...` to a
37
87
  * browser exposes it in every visitor's network tab. Consumers opt
38
- * in explicitly when they have a publishable key or a server proxy.
88
+ * in explicitly when the browser holds a minted session token
89
+ * (`ek_`/`rk_`) or routes through a server proxy.
39
90
  */
40
91
  export function assertBrowserSafety(input) {
92
+ const inBrowser = typeof window !== 'undefined';
41
93
  if (!input.dangerouslyAllowBrowser &&
42
- typeof window !== 'undefined' &&
94
+ inBrowser &&
43
95
  typeof input.apiKey === 'string' &&
44
96
  input.apiKey.startsWith('sk_')) {
45
97
  throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
@@ -49,6 +101,14 @@ export function assertBrowserSafety(input) {
49
101
  '`dangerouslyAllowBrowser` option to `true`, e.g.,\n\n' +
50
102
  ' Ablo({ schema, apiKey, dangerouslyAllowBrowser: true });\n', { code: 'browser_apikey_blocked' });
51
103
  }
104
+ // `databaseUrl` carries DB credentials and is NEVER browser-safe, so
105
+ // `dangerouslyAllowBrowser` does not override it. Register your database from
106
+ // a server-side runtime.
107
+ if (inBrowser && typeof input.databaseUrl === 'string' && input.databaseUrl.length > 0) {
108
+ throw new AbloAuthenticationError('Ablo `databaseUrl` cannot be used in a browser-like environment — it ' +
109
+ 'carries your database credentials. Initialize the client with ' +
110
+ '`databaseUrl` from a server-side runtime only.', { code: 'browser_database_url_blocked' });
111
+ }
52
112
  }
53
113
  /**
54
114
  * Resolve an `ApiKeySetter` callable to its current string value.
@@ -77,5 +137,17 @@ export async function resolveApiKeyValue(apiKey) {
77
137
  * preserves the protocol family (ws → http, wss → https).
78
138
  */
79
139
  export function resolveBootstrapBaseUrl(input) {
80
- return input.bootstrapBaseUrl ?? `${input.url.replace(/^ws/, 'http')}/api`;
140
+ if (input.bootstrapBaseUrl) {
141
+ // Coerce ws/wss → http/https on the override path too. This base URL is
142
+ // used for HTTP fetches (identity resolve, apiKey exchange, bootstrap) and
143
+ // the browser `fetch` rejects ws/wss schemes outright ("URL scheme \"wss\"
144
+ // is not supported"). apps/web derives this override as `${baseUrl}/api`
145
+ // where `baseUrl` may carry a WebSocket scheme, so the override can
146
+ // legitimately arrive as `wss://…` — normalize it here rather than
147
+ // faceplanting at fetch time. The derive branch below already does this;
148
+ // the override branch silently skipped it.
149
+ return normalizeAbloHostedBaseUrl(input.bootstrapBaseUrl).replace(/^ws/, 'http');
150
+ }
151
+ const url = normalizeAbloHostedBaseUrl(input.url);
152
+ return `${url.replace(/^ws/, 'http')}/api`;
81
153
  }
@@ -16,6 +16,7 @@ import { ObjectPool } from '../ObjectPool.js';
16
16
  import { SyncClient } from '../SyncClient.js';
17
17
  import { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
18
18
  import { BootstrapHelper } from '../sync/BootstrapHelper.js';
19
+ import type { AuthCredentialSource } from '../auth/credentialSource.js';
19
20
  import type { Schema, SchemaRecord } from '../schema/schema.js';
20
21
  import { type AbloPersistence } from './persistence.js';
21
22
  export interface InternalComponentsInput<S extends SchemaRecord> {
@@ -31,6 +32,7 @@ export interface InternalComponentsInput<S extends SchemaRecord> {
31
32
  readonly offline?: boolean;
32
33
  readonly inMemory?: boolean;
33
34
  };
35
+ readonly auth?: AuthCredentialSource;
34
36
  }
35
37
  export interface InternalComponents {
36
38
  readonly modelRegistry: ModelRegistry;
@@ -19,7 +19,7 @@ import { BootstrapHelper } from '../sync/BootstrapHelper.js';
19
19
  import { resolveBootstrapBaseUrl } from './auth.js';
20
20
  import { shouldUseInMemoryPersistence } from './persistence.js';
21
21
  export function createInternalComponents(input) {
22
- const { schema, url, options } = input;
22
+ const { schema, url, options, auth } = input;
23
23
  // The registry is created here but model registration happens in
24
24
  // the caller (Ablo.ts owns `registerModelsFromSchema` since the
25
25
  // schema-to-class translation depends on private helpers there).
@@ -37,6 +37,7 @@ export function createInternalComponents(input) {
37
37
  baseUrl: bootstrapBaseUrl,
38
38
  syncGroups: options.syncGroups,
39
39
  instantModels: deriveInstantModels(schema),
40
+ getAuthToken: auth?.getAuthToken,
40
41
  });
41
42
  const database = new Database(modelRegistry, bootstrapHelper, {
42
43
  // Point-solution default: no browser-local durable store unless the
@@ -55,7 +56,13 @@ export function createInternalComponents(input) {
55
56
  registry: modelRegistry,
56
57
  schema,
57
58
  baseUrl: bootstrapBaseUrl,
59
+ getAuthToken: auth?.getAuthToken,
58
60
  });
61
+ // Drop the lazy-lane hydration ledger on reconnect. While connected, the
62
+ // WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
63
+ // pure-local with no network; after a drop, deltas may have been missed, so
64
+ // the next read of each query must re-confirm with the server once.
65
+ syncClient.on('sync:reconnecting', () => hydration.invalidate());
59
66
  return {
60
67
  modelRegistry,
61
68
  objectPool,