@abloatai/ablo 0.27.0 → 0.29.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 (126) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +1 -1
  3. package/dist/BaseSyncedStore.js +8 -9
  4. package/dist/Database.d.ts +14 -1
  5. package/dist/Database.js +228 -28
  6. package/dist/Model.d.ts +17 -0
  7. package/dist/Model.js +32 -1
  8. package/dist/SyncClient.d.ts +10 -6
  9. package/dist/SyncClient.js +390 -75
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +29 -13
  12. package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
  13. package/dist/auth/schemas.d.ts +6 -0
  14. package/dist/auth/schemas.js +7 -0
  15. package/dist/cli.cjs +821 -402
  16. package/dist/client/Ablo.d.ts +53 -24
  17. package/dist/client/Ablo.js +12 -199
  18. package/dist/client/claimHeartbeatLoop.d.ts +1 -1
  19. package/dist/client/claimHeartbeatLoop.js +1 -1
  20. package/dist/client/createInternalComponents.d.ts +4 -0
  21. package/dist/client/createInternalComponents.js +3 -1
  22. package/dist/client/durableWrites.d.ts +21 -0
  23. package/dist/client/durableWrites.js +46 -0
  24. package/dist/client/httpClient.d.ts +21 -45
  25. package/dist/client/httpClient.js +104 -26
  26. package/dist/client/httpTransport.d.ts +8 -0
  27. package/dist/client/{ApiClient.js → httpTransport.js} +361 -308
  28. package/dist/client/modelRegistration.js +11 -0
  29. package/dist/client/options.d.ts +54 -7
  30. package/dist/client/options.js +3 -2
  31. package/dist/client/resourceTypes.d.ts +9 -85
  32. package/dist/client/resourceTypes.js +1 -1
  33. package/dist/client/sessionMint.d.ts +5 -6
  34. package/dist/client/sessionMint.js +4 -5
  35. package/dist/client/validateAbloOptions.js +3 -3
  36. package/dist/client/wsMutationExecutor.d.ts +3 -2
  37. package/dist/client/wsMutationExecutor.js +3 -2
  38. package/dist/coordination/schema.d.ts +30 -0
  39. package/dist/coordination/schema.js +14 -0
  40. package/dist/core/StoreManager.d.ts +2 -0
  41. package/dist/core/StoreManager.js +12 -0
  42. package/dist/core/index.d.ts +1 -1
  43. package/dist/errorCodes.js +6 -2
  44. package/dist/errors.d.ts +4 -40
  45. package/dist/errors.js +5 -5
  46. package/dist/index.d.ts +20 -8
  47. package/dist/index.js +9 -5
  48. package/dist/interfaces/index.d.ts +5 -2
  49. package/dist/mutators/UndoManager.d.ts +2 -0
  50. package/dist/mutators/UndoManager.js +32 -0
  51. package/dist/mutators/defineMutators.d.ts +18 -0
  52. package/dist/mutators/defineMutators.js +4 -8
  53. package/dist/react/index.d.ts +1 -1
  54. package/dist/react/useAblo.d.ts +6 -4
  55. package/dist/react/useAblo.js +25 -3
  56. package/dist/schema/index.d.ts +2 -2
  57. package/dist/schema/index.js +1 -1
  58. package/dist/schema/schema.d.ts +31 -1
  59. package/dist/schema/select.d.ts +15 -0
  60. package/dist/schema/select.js +27 -3
  61. package/dist/source/connector.d.ts +3 -3
  62. package/dist/source/factory.d.ts +4 -6
  63. package/dist/source/factory.js +4 -7
  64. package/dist/source/index.d.ts +4 -4
  65. package/dist/source/index.js +2 -2
  66. package/dist/source/signing.d.ts +0 -3
  67. package/dist/source/types.d.ts +0 -26
  68. package/dist/stores/ObjectStore.d.ts +14 -1
  69. package/dist/stores/ObjectStore.js +33 -10
  70. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  71. package/dist/surface.d.ts +1 -1
  72. package/dist/surface.js +3 -0
  73. package/dist/sync/BootstrapFetcher.d.ts +10 -0
  74. package/dist/sync/BootstrapFetcher.js +27 -4
  75. package/dist/sync/SyncWebSocket.d.ts +2 -1
  76. package/dist/sync/createClaimStream.d.ts +11 -2
  77. package/dist/sync/createClaimStream.js +4 -3
  78. package/dist/sync/persistedPrefix.d.ts +12 -0
  79. package/dist/sync/persistedPrefix.js +22 -0
  80. package/dist/testing/index.d.ts +2 -0
  81. package/dist/testing/index.js +1 -0
  82. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  83. package/dist/testing/mocks/FakeDatabase.js +10 -0
  84. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  85. package/dist/transactions/TransactionQueue.d.ts +66 -8
  86. package/dist/transactions/TransactionQueue.js +607 -89
  87. package/dist/transactions/commitEnvelope.d.ts +132 -0
  88. package/dist/transactions/commitEnvelope.js +139 -0
  89. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  90. package/dist/transactions/commitOutboxStore.js +26 -0
  91. package/dist/transactions/commitPayload.d.ts +15 -0
  92. package/dist/transactions/commitPayload.js +6 -0
  93. package/dist/transactions/durableWriteStore.d.ts +123 -0
  94. package/dist/transactions/durableWriteStore.js +30 -0
  95. package/dist/transactions/httpCommitEnvelope.d.ts +44 -0
  96. package/dist/transactions/httpCommitEnvelope.js +181 -0
  97. package/dist/transactions/idempotencyKey.d.ts +10 -0
  98. package/dist/transactions/idempotencyKey.js +9 -0
  99. package/dist/transactions/replayValidation.d.ts +83 -0
  100. package/dist/transactions/replayValidation.js +46 -1
  101. package/dist/types/global.d.ts +10 -29
  102. package/dist/types/global.js +4 -7
  103. package/dist/types/streams.d.ts +2 -28
  104. package/dist/wire/bootstrapReason.d.ts +9 -0
  105. package/dist/wire/bootstrapReason.js +8 -0
  106. package/dist/wire/frames.d.ts +6 -21
  107. package/dist/wire/frames.js +4 -4
  108. package/dist/wire/index.d.ts +6 -3
  109. package/dist/wire/index.js +3 -2
  110. package/dist/wire/protocolVersion.d.ts +30 -17
  111. package/dist/wire/protocolVersion.js +34 -18
  112. package/docs/agents.md +8 -3
  113. package/docs/api.md +16 -14
  114. package/docs/client-behavior.md +6 -3
  115. package/docs/coordination.md +4 -5
  116. package/docs/data-sources.md +5 -8
  117. package/docs/guarantees.md +21 -0
  118. package/docs/integration-guide.md +1 -0
  119. package/docs/mcp.md +1 -1
  120. package/docs/migration.md +21 -1
  121. package/docs/quickstart.md +11 -0
  122. package/docs/react.md +0 -46
  123. package/examples/README.md +6 -5
  124. package/llms.txt +15 -15
  125. package/package.json +3 -3
  126. package/dist/client/ApiClient.d.ts +0 -177
@@ -1,17 +1,22 @@
1
1
  /**
2
- * The stateless API client behind `Ablo({ apiKey })`. It carries no schema,
3
- * object pool, local database, or WebSocket, and maps the public Model, Claim,
4
- * and Commit nouns directly to HTTP routes on the server. This is the transport
5
- * used for server-side agents, workers, and serverless code.
2
+ * Private HTTP protocol client behind `Ablo({ schema, transport: 'http' })`.
3
+ * It carries no object pool, local database, or WebSocket and maps Model,
4
+ * Claim, and Commit protocol shapes directly to server routes. The typed
5
+ * facade in `httpClient.ts` is the application boundary; this module owns
6
+ * transport envelopes, watermarks, replay, and route details.
6
7
  */
7
- import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
8
+ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
9
+ import { z } from 'zod';
10
+ import { v5 as uuidv5 } from 'uuid';
8
11
  import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
9
12
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
10
13
  import { registerDataSource } from './registerDataSource.js';
11
14
  import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
12
- import { toMs, toSeconds } from '../utils/duration.js';
15
+ import { toMs } from '../utils/duration.js';
13
16
  import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from './claimHeartbeatLoop.js';
14
17
  import { mintSession } from './sessionMint.js';
18
+ import { parseIdentityResolveResponse } from '../auth/schemas.js';
19
+ import { staleNotificationSchema } from '../coordination/schema.js';
15
20
  /**
16
21
  * Interpret a heartbeat reply for a lease this handle HOLDS: anything other
17
22
  * than `held` means the lease is no longer ours (a holder cannot be `queued`;
@@ -29,10 +34,61 @@ function heldHeartbeatReply(reply, label) {
29
34
  throw new AbloClaimedError(`The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`, { code: 'claim_lost' });
30
35
  }
31
36
  import { assertWriteOptions } from './writeOptionsSchema.js';
32
- /** Default per-request deadline for the stateless HTTP transport. */
37
+ import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCommitEnvelopeSchema, httpCommitEnvelopeRecordId, isHttpCommitReplayExpired, } from '../transactions/httpCommitEnvelope.js';
38
+ import { resolveDurableWrites } from './durableWrites.js';
39
+ /** @internal Default per-request deadline for the private HTTP transport. */
33
40
  export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
34
- const DEFAULT_AGENT_LEASE = '10m';
35
- export function createProtocolClient(options) {
41
+ const successfulCommitResponseSchema = z
42
+ .object({
43
+ object: z.literal('commit_receipt'),
44
+ id: z.string().min(1).optional(),
45
+ clientTxId: z.string().min(1),
46
+ serverTxId: z.string().min(1),
47
+ status: z.enum(['queued', 'confirmed']),
48
+ success: z.literal(true),
49
+ lastSyncId: z.number().int().nonnegative().optional(),
50
+ ops: z.number().int().positive(),
51
+ notifications: z.array(staleNotificationSchema).optional(),
52
+ /** Ids of UPDATE/DELETE targets that matched zero rows (loud 0-row writes). */
53
+ missingIds: z.array(z.string().min(1)).optional(),
54
+ })
55
+ .loose();
56
+ function parseSuccessfulCommitResponse(value, idempotencyKey) {
57
+ const parsed = successfulCommitResponseSchema.safeParse(value);
58
+ if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
59
+ throw new AbloConnectionError('The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.', {
60
+ code: 'commit_no_result',
61
+ cause: parsed.success
62
+ ? new Error('Commit receipt clientTxId did not match its idempotency key')
63
+ : parsed.error,
64
+ });
65
+ }
66
+ return parsed.data;
67
+ }
68
+ /** Decode the HTTP claim DTO into the one public Claim shape. */
69
+ function claimFromModelClaim(claim) {
70
+ return {
71
+ object: 'claim',
72
+ id: claim.id,
73
+ ...(claim.status ? { status: claim.status } : {}),
74
+ reason: claim.reason,
75
+ ...(claim.description ? { description: claim.description } : {}),
76
+ heldBy: claim.actor,
77
+ participantKind: claim.participantKind,
78
+ expiresAt: claim.expiresAt,
79
+ ...(claim.position !== undefined ? { position: claim.position } : {}),
80
+ target: {
81
+ type: claim.target.model,
82
+ id: claim.target.id,
83
+ ...(claim.target.path ? { path: claim.target.path } : {}),
84
+ ...(claim.target.range ? { range: claim.target.range } : {}),
85
+ ...(claim.target.field ? { field: claim.target.field } : {}),
86
+ ...(claim.target.meta ? { meta: claim.target.meta } : {}),
87
+ },
88
+ };
89
+ }
90
+ /** @internal Constructed only by the typed HTTP facade. */
91
+ export function createHttpTransport(options) {
36
92
  const env = readProcessEnv();
37
93
  const authInput = { options, env };
38
94
  const configuredApiKey = resolveApiKey(authInput);
@@ -64,14 +120,17 @@ export function createProtocolClient(options) {
64
120
  const recordCoordinationConflict = (error, clientTxId, fallbackRows) => {
65
121
  if (!observability)
66
122
  return;
67
- const code = error?.code;
123
+ const errorRecord = typeof error === 'object' && error !== null
124
+ ? error
125
+ : undefined;
126
+ const code = errorRecord?.code;
68
127
  const isConflict = code === 'stale_context' ||
69
128
  code === 'claim_conflict' ||
70
129
  code === 'entity_claimed' ||
71
130
  (typeof code === 'string' && code.startsWith('policy:'));
72
131
  if (!isConflict)
73
132
  return;
74
- const rawConflicts = error?.conflicts;
133
+ const rawConflicts = errorRecord?.conflicts;
75
134
  const rows = Array.isArray(rawConflicts) && rawConflicts.length > 0
76
135
  ? rawConflicts.map((r) => ({
77
136
  model: typeof r.model === 'string' ? r.model : 'unknown',
@@ -90,19 +149,64 @@ export function createProtocolClient(options) {
90
149
  url,
91
150
  bootstrapBaseUrl: options.bootstrapBaseUrl,
92
151
  }).replace(/\/+$/, '');
152
+ const durableWrites = resolveDurableWrites(options);
153
+ // Internal replay code retains transactional-outbox terminology. The public
154
+ // constructor exposes the behavior as `durableWrites`.
155
+ const commitOutbox = durableWrites.store;
156
+ const durableWriteNamespace = durableWrites.namespace ?? 'http';
157
+ const legacyCommitOutboxScope = options.commitOutboxScope;
158
+ const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
159
+ apiBaseUrl,
160
+ defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
161
+ });
162
+ let httpOutboxScopeNamespace = null;
93
163
  let readyPromise = null;
164
+ let httpCommitLane = Promise.resolve();
165
+ function runInHttpCommitLane(work) {
166
+ const result = httpCommitLane.then(work);
167
+ httpCommitLane = result.then(() => undefined, () => undefined);
168
+ return result;
169
+ }
170
+ async function resolveHttpOutboxScope() {
171
+ if (!commitOutbox)
172
+ return null;
173
+ if (httpOutboxScopeNamespace)
174
+ return httpOutboxScopeNamespace;
175
+ let scope = legacyCommitOutboxScope
176
+ ? {
177
+ ...legacyCommitOutboxScope,
178
+ namespace: durableWriteNamespace,
179
+ }
180
+ : undefined;
181
+ if (!scope) {
182
+ const rawIdentity = await requestJson('/auth/identity', { method: 'GET' }, true);
183
+ const identity = parseIdentityResolveResponse(rawIdentity);
184
+ scope = {
185
+ organizationId: identity.accountScope,
186
+ participantId: identity.participantId,
187
+ namespace: durableWriteNamespace,
188
+ };
189
+ }
190
+ httpOutboxScopeNamespace = canonicalHttpCommitBody({
191
+ ...scope,
192
+ plane: httpOutboxPlaneNamespace,
193
+ });
194
+ return httpOutboxScopeNamespace;
195
+ }
94
196
  async function ready() {
95
197
  if (readyPromise)
96
198
  return readyPromise;
97
199
  readyPromise = (async () => {
98
- if (!configuredDatabaseUrl)
99
- return;
100
- await registerDataSource({
101
- baseUrl: apiBaseUrl,
102
- apiKey: await resolveApiKeyValue(configuredApiKey),
103
- databaseUrl: configuredDatabaseUrl,
104
- ...(options.fetch ? { fetchImpl: options.fetch } : {}),
105
- });
200
+ if (configuredDatabaseUrl) {
201
+ await registerDataSource({
202
+ baseUrl: apiBaseUrl,
203
+ apiKey: await resolveApiKeyValue(configuredApiKey),
204
+ databaseUrl: configuredDatabaseUrl,
205
+ ...(options.fetch ? { fetchImpl: options.fetch } : {}),
206
+ });
207
+ }
208
+ await resolveHttpOutboxScope();
209
+ await replayHttpCommitOutbox();
106
210
  })();
107
211
  try {
108
212
  await readyPromise;
@@ -112,11 +216,11 @@ export function createProtocolClient(options) {
112
216
  throw error;
113
217
  }
114
218
  }
115
- async function authHeaders() {
219
+ async function authHeaders(sealedProtocolVersion) {
116
220
  const apiKey = await resolveApiKeyValue(configuredApiKey);
117
221
  const token = apiKey ?? configuredAuthToken;
118
222
  if (!token) {
119
- throw new AbloAuthenticationError('Ablo({ apiKey }) requires an API key. Pass `apiKey` or set ABLO_API_KEY.', { code: 'api_key_required' });
223
+ throw new AbloAuthenticationError('The HTTP client requires an API key. Pass `apiKey` or set ABLO_API_KEY.', { code: 'api_key_required' });
120
224
  }
121
225
  const headers = {
122
226
  'Content-Type': 'application/json',
@@ -133,6 +237,12 @@ export function createProtocolClient(options) {
133
237
  headers[key] = value;
134
238
  }
135
239
  }
240
+ // A durable write owns its wire version. Force the sealed value after
241
+ // caller defaults so a restarted (or rolled-back) SDK cannot rewrite the
242
+ // protocol identity of a request that may already have reached the server.
243
+ if (sealedProtocolVersion !== undefined) {
244
+ headers[PROTOCOL_VERSION_HEADER] = String(sealedProtocolVersion);
245
+ }
136
246
  return headers;
137
247
  }
138
248
  function endpoint(path) {
@@ -144,10 +254,11 @@ export function createProtocolClient(options) {
144
254
  return target.toString();
145
255
  }
146
256
  const requestTimeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
147
- async function requestJson(path, init) {
148
- await ready();
149
- const { idempotencyKey, ...requestInit } = init;
150
- const headers = await authHeaders();
257
+ async function requestJson(path, init, skipReady = false) {
258
+ if (!skipReady)
259
+ await ready();
260
+ const { idempotencyKey, sealedProtocolVersion, ...requestInit } = init;
261
+ const headers = await authHeaders(sealedProtocolVersion);
151
262
  if (idempotencyKey)
152
263
  headers['Idempotency-Key'] = idempotencyKey;
153
264
  // Deadline: abort the request after `timeoutMs` so a black-holed server
@@ -189,6 +300,7 @@ export function createProtocolClient(options) {
189
300
  bodyText = await res.text();
190
301
  }
191
302
  catch (error) {
303
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- assigned asynchronously by the deadline callback
192
304
  if (timedOut) {
193
305
  // Retryable by contract: `wait_for_timeout` is a registered transient
194
306
  // transport code, so `isRetryableCode` steers callers to retry.
@@ -209,6 +321,173 @@ export function createProtocolClient(options) {
209
321
  }
210
322
  return body;
211
323
  }
324
+ function isDefinitiveHttpRejection(error) {
325
+ if (typeof error !== 'object' || error === null)
326
+ return false;
327
+ const candidate = error;
328
+ const status = typeof candidate.httpStatus === 'number'
329
+ ? candidate.httpStatus
330
+ : typeof candidate.status === 'number'
331
+ ? candidate.status
332
+ : undefined;
333
+ return (status !== undefined &&
334
+ status >= 400 &&
335
+ status < 500 &&
336
+ status !== 408 &&
337
+ status !== 425 &&
338
+ status !== 429);
339
+ }
340
+ async function settleHttpEnvelope(recordId) {
341
+ if (!commitOutbox)
342
+ return;
343
+ try {
344
+ await commitOutbox.remove(recordId);
345
+ }
346
+ catch (cause) {
347
+ // Do not report the remote outcome until local settlement is durable.
348
+ // The retained record can still be replayed inside the safe window.
349
+ throw new AbloConnectionError('The server settled the commit, but its local outbox record could not be cleared.', { code: 'db_not_opened', cause });
350
+ }
351
+ }
352
+ async function replayHttpCommitOutbox() {
353
+ const replayed = new Map();
354
+ if (!commitOutbox)
355
+ return replayed;
356
+ const scopeNamespace = await resolveHttpOutboxScope();
357
+ if (!scopeNamespace)
358
+ return replayed;
359
+ const rows = await commitOutbox.list();
360
+ const envelopes = [];
361
+ for (const row of rows) {
362
+ if (typeof row !== 'object' ||
363
+ row === null ||
364
+ row.type !== 'http_commit_envelope')
365
+ continue;
366
+ const parsed = durableHttpCommitEnvelopeSchema.safeParse(row);
367
+ if (!parsed.success) {
368
+ throw new AbloValidationError('A saved HTTP write is unreadable; replay stopped before any newer write was sent.', { code: 'write_options_invalid', cause: parsed.error });
369
+ }
370
+ if (parsed.data.scopeNamespace !== scopeNamespace)
371
+ continue;
372
+ if (isHttpCommitReplayExpired(parsed.data)) {
373
+ throw new AbloIdempotencyError('A saved HTTP write is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
374
+ }
375
+ envelopes.push(parsed.data);
376
+ }
377
+ envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
378
+ (b.sequence ?? b.sealedAt * 1_000) ||
379
+ a.id.localeCompare(b.id));
380
+ for (const envelope of envelopes) {
381
+ try {
382
+ const raw = await requestJson(envelope.request.path, {
383
+ method: envelope.request.method,
384
+ idempotencyKey: envelope.idempotencyKey,
385
+ sealedProtocolVersion: envelope.protocolVersion,
386
+ body: envelope.request.body,
387
+ }, true);
388
+ const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
389
+ await settleHttpEnvelope(envelope.id);
390
+ replayed.set(envelope.idempotencyKey, { envelope, response });
391
+ }
392
+ catch (error) {
393
+ if (isDefinitiveHttpRejection(error)) {
394
+ await settleHttpEnvelope(envelope.id);
395
+ }
396
+ throw error;
397
+ }
398
+ }
399
+ return replayed;
400
+ }
401
+ let lastHttpCommitSequence = 0;
402
+ function nextHttpCommitSequence() {
403
+ const wallSequence = Date.now() * 1_000;
404
+ lastHttpCommitSequence = Math.max(wallSequence, lastHttpCommitSequence + 1);
405
+ return lastHttpCommitSequence;
406
+ }
407
+ async function sealHttpCommit(input) {
408
+ if (!commitOutbox)
409
+ return null;
410
+ const scopeNamespace = await resolveHttpOutboxScope();
411
+ if (!scopeNamespace) {
412
+ throw new AbloValidationError('HTTP durable-write scope was not resolved', {
413
+ code: 'write_options_invalid',
414
+ });
415
+ }
416
+ const recordId = httpCommitEnvelopeRecordId(input.idempotencyKey, scopeNamespace);
417
+ const legacyRecordId = httpCommitEnvelopeRecordId(input.idempotencyKey);
418
+ const existingRows = await commitOutbox.list();
419
+ const existingRaw = existingRows.find((row) => typeof row === 'object' &&
420
+ row !== null &&
421
+ (row.id === recordId ||
422
+ row.id === legacyRecordId));
423
+ const serializedBody = canonicalHttpCommitBody(input.body);
424
+ if (existingRaw !== undefined) {
425
+ const existing = durableHttpCommitEnvelopeSchema.parse(existingRaw);
426
+ if (isHttpCommitReplayExpired(existing)) {
427
+ throw new AbloIdempotencyError('This saved HTTP write is older than the server idempotency window and cannot be retried safely.', { code: 'idempotency_conflict' });
428
+ }
429
+ if (existing.scopeNamespace !== scopeNamespace ||
430
+ existing.request.method !== input.method ||
431
+ existing.request.path !== input.path ||
432
+ existing.request.body !== serializedBody) {
433
+ throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
434
+ }
435
+ return existing;
436
+ }
437
+ const envelope = createDurableHttpCommitEnvelope({
438
+ idempotencyKey: input.idempotencyKey,
439
+ request: { method: input.method, path: input.path, body: input.body },
440
+ scopeNamespace,
441
+ sequence: nextHttpCommitSequence(),
442
+ });
443
+ await commitOutbox.seal(envelope, []);
444
+ return envelope;
445
+ }
446
+ async function dispatchHttpCommit(input, beforeSettlement) {
447
+ return runInHttpCommitLane(async () => {
448
+ await ready();
449
+ // `ready()` covers startup. Re-draining here makes every later write wait
450
+ // behind an ambiguous predecessor from this same process.
451
+ const replayed = await replayHttpCommitOutbox();
452
+ const prior = replayed.get(input.idempotencyKey);
453
+ if (prior) {
454
+ const serializedBody = canonicalHttpCommitBody(input.body);
455
+ if (prior.envelope.request.method !== input.method ||
456
+ prior.envelope.request.path !== input.path ||
457
+ prior.envelope.request.body !== serializedBody) {
458
+ throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
459
+ }
460
+ await beforeSettlement?.(prior.response);
461
+ return prior.response;
462
+ }
463
+ const durableEnvelope = await sealHttpCommit(input);
464
+ const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
465
+ let response;
466
+ try {
467
+ const raw = await requestJson(input.path, {
468
+ method: input.method,
469
+ idempotencyKey: input.idempotencyKey,
470
+ ...(durableEnvelope
471
+ ? { sealedProtocolVersion: durableEnvelope.protocolVersion }
472
+ : {}),
473
+ body: requestBody,
474
+ }, true);
475
+ response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
476
+ }
477
+ catch (error) {
478
+ if (durableEnvelope && isDefinitiveHttpRejection(error)) {
479
+ await settleHttpEnvelope(durableEnvelope.id);
480
+ }
481
+ throw error;
482
+ }
483
+ // A model-create readback can participate in settlement: if it fails,
484
+ // retain the exact write so a same-key retry recovers the generated id.
485
+ await beforeSettlement?.(response);
486
+ if (durableEnvelope)
487
+ await settleHttpEnvelope(durableEnvelope.id);
488
+ return response;
489
+ });
490
+ }
212
491
  function createClientTxId(idempotencyKey) {
213
492
  if (idempotencyKey && idempotencyKey.length > 0)
214
493
  return idempotencyKey;
@@ -221,29 +500,19 @@ export function createProtocolClient(options) {
221
500
  ? `int_${crypto.randomUUID()}`
222
501
  : `int_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
223
502
  }
224
- function createModelId() {
503
+ function createModelId(modelName, idempotencyKey) {
504
+ if (idempotencyKey) {
505
+ return uuidv5(`${modelName}:${idempotencyKey}`, 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548');
506
+ }
225
507
  return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
226
508
  ? crypto.randomUUID()
227
509
  : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
228
510
  }
229
- function childClient(authToken) {
230
- return createProtocolClient({
231
- ...options,
232
- apiKey: null,
233
- authToken,
234
- schema: null,
235
- });
236
- }
237
511
  function normalizeCommitOperation(op, defaults) {
238
- const model = op.model ?? op.target?.model;
239
- if (!model) {
240
- throw new AbloValidationError('Commit operation requires `model` or `target.model`.', { code: 'commit_operation_model_required' });
241
- }
242
- const id = op.id ?? op.target?.id ?? null;
243
512
  return {
244
513
  action: op.action,
245
- model,
246
- id,
514
+ model: op.model,
515
+ id: op.id ?? null,
247
516
  data: op.data ?? null,
248
517
  transactionId: op.transactionId ?? null,
249
518
  readAt: op.readAt ?? defaults.readAt ?? null,
@@ -251,20 +520,10 @@ export function createProtocolClient(options) {
251
520
  };
252
521
  }
253
522
  function normalizeCommitOperations(commitOptions) {
254
- if (commitOptions.operation && commitOptions.operations) {
255
- throw new AbloValidationError('Pass either `operation` or `operations`, not both.', { code: 'commit_operations_ambiguous' });
523
+ if (commitOptions.operations.length === 0) {
524
+ throw new AbloValidationError('Commit requires a non-empty `operations` array.', { code: 'commit_operation_required' });
256
525
  }
257
- const inputOperations = commitOptions.operation
258
- ? [commitOptions.operation]
259
- : commitOptions.operations ?? [];
260
- if (inputOperations.length === 0) {
261
- throw new AbloValidationError('Commit requires at least one operation.', { code: 'commit_operation_required' });
262
- }
263
- return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
264
- }
265
- async function listClaims(target) {
266
- const state = await listClaimState(target);
267
- return state.active;
526
+ return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions));
268
527
  }
269
528
  async function listClaimState(target) {
270
529
  const params = new URLSearchParams();
@@ -281,54 +540,6 @@ export function createProtocolClient(options) {
281
540
  queue: body.queue ?? [],
282
541
  };
283
542
  }
284
- function delay(ms, signal) {
285
- if (signal?.aborted) {
286
- return Promise.reject(new AbloConnectionError('Claim wait aborted.', {
287
- code: 'claim_wait_aborted',
288
- cause: signal.reason,
289
- }));
290
- }
291
- return new Promise((resolve, reject) => {
292
- const timeout = setTimeout(done, ms);
293
- function cleanup() {
294
- clearTimeout(timeout);
295
- signal?.removeEventListener('abort', onAbort);
296
- }
297
- function done() {
298
- cleanup();
299
- resolve();
300
- }
301
- function onAbort() {
302
- cleanup();
303
- reject(new AbloConnectionError('Claim wait aborted.', {
304
- code: 'claim_wait_aborted',
305
- cause: signal?.reason,
306
- }));
307
- }
308
- signal?.addEventListener('abort', onAbort, { once: true });
309
- });
310
- }
311
- async function waitForNoClaims(target, options) {
312
- const startedAt = Date.now();
313
- const pollInterval = options?.pollInterval;
314
- for (;;) {
315
- const claims = await listClaims(target);
316
- if (claims.length === 0)
317
- return;
318
- if (pollInterval == null) {
319
- throw new AbloValidationError('Cannot wait for claims over the HTTP client without `pollInterval`. ' +
320
- 'Use the schema client for event-driven claim waits, pass `ifClaimed: "return"`, ' +
321
- 'or provide an explicit poll interval for this runtime.', { code: 'claim_wait_poll_interval_required' });
322
- }
323
- if (options?.timeout != null && Date.now() - startedAt >= options.timeout) {
324
- throw claimedError(target, claims, 'model_claimed_timeout');
325
- }
326
- const remaining = options?.timeout == null
327
- ? pollInterval
328
- : Math.max(0, Math.min(pollInterval, options.timeout - (Date.now() - startedAt)));
329
- await delay(remaining, options?.signal);
330
- }
331
- }
332
543
  async function applyClaimedPolicy(target, options, defaultPolicy = 'return') {
333
544
  const policy = options?.ifClaimed ?? defaultPolicy;
334
545
  if (policy === 'return')
@@ -358,17 +569,17 @@ export function createProtocolClient(options) {
358
569
  readAt: commitOptions.readAt ?? claim?.readAt ?? null,
359
570
  onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
360
571
  });
572
+ const requestBody = {
573
+ operations,
574
+ reads: commitOptions.reads,
575
+ };
361
576
  let body;
362
577
  try {
363
- body = await requestJson('/v1/commits', {
578
+ body = await dispatchHttpCommit({
579
+ path: '/v1/commits',
364
580
  method: 'POST',
365
581
  idempotencyKey: clientTxId,
366
- body: JSON.stringify({
367
- clientTxId,
368
- idempotencyKey: clientTxId,
369
- claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
370
- operations,
371
- }),
582
+ body: requestBody,
372
583
  });
373
584
  }
374
585
  catch (error) {
@@ -387,192 +598,18 @@ export function createProtocolClient(options) {
387
598
  // the rejection body (already thrown).
388
599
  const status = body.status === 'queued' ? 'queued' : 'confirmed';
389
600
  return {
390
- id: body.id ?? body.clientTxId ?? clientTxId,
601
+ id: body.id ?? body.clientTxId,
391
602
  status,
392
603
  lastSyncId: body.lastSyncId,
604
+ ...(body.notifications && body.notifications.length > 0
605
+ ? { notifications: body.notifications }
606
+ : {}),
393
607
  ...(body.missingIds && body.missingIds.length > 0
394
608
  ? { missingIds: body.missingIds }
395
609
  : {}),
396
610
  };
397
611
  },
398
612
  };
399
- const capabilities = {
400
- async create(capabilityOptions) {
401
- const ttlSeconds = capabilityOptions.ttlSeconds ??
402
- capabilityOptions.leaseSeconds ??
403
- toSeconds(capabilityOptions.ttl ?? capabilityOptions.lease ?? DEFAULT_AGENT_LEASE);
404
- const body = await requestJson('/v1/capabilities', {
405
- method: 'POST',
406
- body: JSON.stringify({
407
- participantKind: capabilityOptions.participantKind ?? 'agent',
408
- participantId: capabilityOptions.participantId,
409
- syncGroups: capabilityOptions.syncGroups,
410
- operations: capabilityOptions.operations,
411
- ttlSeconds,
412
- label: capabilityOptions.label,
413
- wideScope: capabilityOptions.wideScope,
414
- userMeta: capabilityOptions.userMeta,
415
- }),
416
- });
417
- const id = body.capabilityId ?? body.id;
418
- if (!id) {
419
- throw new AbloValidationError('Capability create response did not include an id.', { code: 'capability_id_missing' });
420
- }
421
- return {
422
- id,
423
- token: body.token,
424
- expiresAt: body.expiresAt,
425
- organizationId: body.organizationId,
426
- scope: body.scope,
427
- userMeta: body.userMeta,
428
- client: () => childClient(body.token),
429
- };
430
- },
431
- async retrieve(id) {
432
- const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}`, { method: 'GET' });
433
- return {
434
- id: body.capabilityId ?? body.id ?? id,
435
- organizationId: body.organizationId,
436
- participantKind: body.participantKind,
437
- participantId: body.participantId,
438
- label: body.label,
439
- status: body.status,
440
- issuedAt: body.issuedAt,
441
- expiresAt: body.expiresAt,
442
- revokedAt: body.revokedAt,
443
- lastUsedAt: body.lastUsedAt,
444
- operations: body.operations ?? [],
445
- syncGroups: body.syncGroups ?? [],
446
- };
447
- },
448
- async revoke(id) {
449
- const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}`, { method: 'DELETE' });
450
- return {
451
- id: body.capabilityId ?? body.id ?? id,
452
- deleted: body.deleted ?? true,
453
- activeSessionsClosed: body.activeSessionsClosed,
454
- };
455
- },
456
- async rotate(id, rotateOptions = {}) {
457
- const graceSeconds = rotateOptions.graceSeconds ??
458
- (rotateOptions.grace !== undefined ? toSeconds(rotateOptions.grace) : undefined);
459
- const leaseSeconds = rotateOptions.leaseSeconds ??
460
- (rotateOptions.lease !== undefined ? toSeconds(rotateOptions.lease) : undefined);
461
- const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}/rotate`, {
462
- method: 'POST',
463
- body: JSON.stringify({
464
- ...(graceSeconds !== undefined ? { graceSeconds } : {}),
465
- ...(leaseSeconds !== undefined ? { ttlSeconds: leaseSeconds } : {}),
466
- }),
467
- });
468
- const newId = body.capabilityId ?? body.id;
469
- if (!newId) {
470
- throw new AbloValidationError('Capability rotate response did not include an id.', { code: 'capability_id_missing' });
471
- }
472
- return {
473
- id: newId,
474
- token: body.token,
475
- expiresAt: body.expiresAt,
476
- organizationId: body.organizationId,
477
- scope: body.scope,
478
- rotatedFrom: {
479
- id: body.rotatedFrom.capabilityId ?? body.rotatedFrom.id ?? id,
480
- expiresAt: body.rotatedFrom.expiresAt,
481
- },
482
- client: () => childClient(body.token),
483
- };
484
- },
485
- mint(options) {
486
- return capabilities.create(options);
487
- },
488
- };
489
- const claims = {
490
- async create(claimOptions) {
491
- const claimId = createClaimId();
492
- const body = await requestJson('/v1/claims', {
493
- method: 'POST',
494
- body: JSON.stringify({
495
- claimId,
496
- target: claimOptions.target,
497
- reason: claimOptions.reason,
498
- ttl: claimOptions.ttl,
499
- queue: claimOptions.queue,
500
- }),
501
- });
502
- // The fair-queue grant is pushed over a WebSocket (`claim_granted`), which
503
- // this stateless HTTP client doesn't hold. Returning a handle here would be
504
- // a phantom holder — a lease we can't confirm is ours. So a queued response
505
- // is surfaced as a typed claimed signal; callers that need to wait in line
506
- // use the realtime (WebSocket-backed) `ablo.<model>.claim`.
507
- if (body.status === 'queued') {
508
- throw new AbloClaimedError(`Target ${claimOptions.target.model}/${claimOptions.target.id} is held; ` +
509
- `queued at position ${body.position ?? 0}. The HTTP client can't await ` +
510
- `the grant (no socket) — use the realtime client to wait in line.`, { code: 'claim_queued' });
511
- }
512
- const id = body.claim?.id ?? claimId;
513
- let released = false;
514
- const release = async () => {
515
- if (released)
516
- return;
517
- released = true;
518
- await requestJson(`/v1/claims/${encodeURIComponent(id)}`, { method: 'DELETE' });
519
- };
520
- // The by-id twin of the model-scoped heartbeat — same reply contract.
521
- const heartbeat = async (beatOptions) => {
522
- const resolved = resolveHeartbeatOptions(beatOptions);
523
- const reply = await requestJson(`/v1/claims/${encodeURIComponent(id)}/heartbeat`, {
524
- method: 'POST',
525
- body: JSON.stringify({
526
- ...(resolved.ttl !== undefined ? { ttl: resolved.ttl } : {}),
527
- ...(resolved.details !== undefined
528
- ? { details: resolved.details }
529
- : {}),
530
- }),
531
- });
532
- return heldHeartbeatReply(reply, `claim ${id}`);
533
- };
534
- return {
535
- object: 'claim',
536
- id,
537
- heartbeat,
538
- reason: claimOptions.reason,
539
- target: {
540
- type: claimOptions.target.model,
541
- id: claimOptions.target.id,
542
- ...(claimOptions.target.field ? { field: claimOptions.target.field } : {}),
543
- ...(claimOptions.target.path ? { path: claimOptions.target.path } : {}),
544
- ...(claimOptions.target.range ? { range: claimOptions.target.range } : {}),
545
- ...(claimOptions.target.meta ? { meta: claimOptions.target.meta } : {}),
546
- },
547
- release,
548
- revoke: () => {
549
- void release().catch(() => { });
550
- },
551
- [Symbol.asyncDispose]: release,
552
- };
553
- },
554
- list: listClaims,
555
- waitFor(target, options) {
556
- return waitForNoClaims(target, options);
557
- },
558
- async heartbeatAll(options) {
559
- const reply = await requestJson('/v1/claims/heartbeat', {
560
- method: 'POST',
561
- body: JSON.stringify(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
562
- });
563
- return (reply.results ?? []).flatMap((entry) => typeof entry.claimId === 'string' && typeof entry.expiresAt === 'number'
564
- ? [
565
- {
566
- claimId: entry.claimId,
567
- expiresAt: entry.expiresAt,
568
- ...(entry.queueDepth !== undefined
569
- ? { queueDepth: entry.queueDepth }
570
- : {}),
571
- },
572
- ]
573
- : []);
574
- },
575
- };
576
613
  async function listModel(modelName, options) {
577
614
  const params = new URLSearchParams();
578
615
  if (options?.limit !== undefined)
@@ -628,7 +665,7 @@ export function createProtocolClient(options) {
628
665
  * multi-operation envelopes; this helper handles the one-operation,
629
666
  * one-record case.
630
667
  */
631
- async function mutateModel(action, modelName, id, data, options) {
668
+ async function mutateModel(action, modelName, id, data, options, beforeSettlement) {
632
669
  assertWriteOptions(options && {
633
670
  idempotencyKey: options.idempotencyKey,
634
671
  readAt: options.readAt,
@@ -644,15 +681,15 @@ export function createProtocolClient(options) {
644
681
  const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
645
682
  // A carried claim handle supplies the stale-guard defaults — one claim
646
683
  // vocabulary across the WS proxy, `commits.create`, and these routes.
647
- const claimHandle = typeof options?.claim === 'object' &&
648
- options?.claim !== null &&
649
- options.claim.object === 'claim' &&
650
- typeof options.claim.id === 'string'
651
- ? options.claim
684
+ const rawClaim = options?.claim;
685
+ const claimHandle = typeof rawClaim === 'object' &&
686
+ rawClaim !== null &&
687
+ rawClaim.object === 'claim' &&
688
+ typeof rawClaim.id === 'string'
689
+ ? rawClaim
652
690
  : undefined;
653
691
  const readAt = options?.readAt ?? claimHandle?.readAt;
654
692
  const requestBody = {
655
- idempotencyKey: clientTxId,
656
693
  claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
657
694
  onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
658
695
  readAt,
@@ -663,11 +700,12 @@ export function createProtocolClient(options) {
663
700
  requestBody.data = data;
664
701
  let body;
665
702
  try {
666
- body = await requestJson(path, {
703
+ body = await dispatchHttpCommit({
704
+ path,
667
705
  method,
668
706
  idempotencyKey: clientTxId,
669
- body: JSON.stringify(requestBody),
670
- });
707
+ body: requestBody,
708
+ }, beforeSettlement);
671
709
  }
672
710
  catch (error) {
673
711
  // The per-model write door (`ablo.<model>.update/create/delete`). Capture
@@ -680,7 +718,7 @@ export function createProtocolClient(options) {
680
718
  // subset; `'rejected'` only appears on a thrown rejection body.
681
719
  const status = body.status === 'queued' ? 'queued' : 'confirmed';
682
720
  return {
683
- id: body.serverTxId ?? body.id ?? body.clientTxId ?? clientTxId,
721
+ id: body.serverTxId,
684
722
  status,
685
723
  lastSyncId: body.lastSyncId,
686
724
  };
@@ -809,11 +847,14 @@ export function createProtocolClient(options) {
809
847
  state: async (params) => {
810
848
  const res = await claimsForEntity(params);
811
849
  const first = res.claims?.[0];
812
- return first ?? null;
850
+ return first ? claimFromModelClaim(first) : null;
813
851
  },
814
852
  queue: async (params) => {
815
853
  const res = await claimsForEntity(params);
816
- return { object: 'list', data: res.queue ?? [] };
854
+ return {
855
+ object: 'list',
856
+ data: (res.queue ?? []).map(claimFromModelClaim),
857
+ };
817
858
  },
818
859
  reorder: async (params) => {
819
860
  await requestJson(`${claimPath(params.id)}/reorder`, {
@@ -885,7 +926,7 @@ export function createProtocolClient(options) {
885
926
  return listModel(name, options);
886
927
  },
887
928
  async create(params) {
888
- const id = params.id ?? createModelId();
929
+ const id = params.id ?? createModelId(name, params.idempotencyKey);
889
930
  return withMutationClaim(id, params, async (options) => {
890
931
  await applyClaimedPolicy({ model: name, id }, options);
891
932
  // Confirm the write, then return the row — the obvious expectation of
@@ -893,15 +934,23 @@ export function createProtocolClient(options) {
893
934
  // back is the authoritative server row, so it carries the framework
894
935
  // defaults (createdAt, createdBy, …) and, for an idempotent re-create of
895
936
  // an existing id, the existing row rather than the caller's input.
937
+ let created;
896
938
  await mutateModel('create', name, id, params.data, {
897
939
  ...options,
898
940
  wait: options?.wait ?? 'confirmed',
941
+ }, async () => {
942
+ const read = await retrieveModel(name, { id });
943
+ if (read.data === undefined) {
944
+ throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
945
+ }
946
+ created = read.data;
899
947
  });
900
- const { data } = await retrieveModel(name, { id });
901
- if (data === undefined) {
902
- throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
948
+ if (created === undefined) {
949
+ throw new AbloConnectionError('Create settlement did not return its row.', {
950
+ code: 'commit_no_result',
951
+ });
903
952
  }
904
- return data;
953
+ return created;
905
954
  });
906
955
  },
907
956
  update: updateModel,
@@ -915,11 +964,12 @@ export function createProtocolClient(options) {
915
964
  }
916
965
  return {
917
966
  ready,
918
- async waitForFlush() { },
967
+ waitForFlush: () => runInHttpCommitLane(async () => {
968
+ await ready();
969
+ await replayHttpCommitOutbox();
970
+ }),
919
971
  async dispose() { },
920
972
  async purge() { },
921
- capabilities,
922
- claims,
923
973
  commits,
924
974
  model,
925
975
  sessions: {
@@ -935,6 +985,9 @@ export function createProtocolClient(options) {
935
985
  apiKey,
936
986
  baseUrl: apiBaseUrl,
937
987
  ...(options.fetch ? { fetch: options.fetch } : {}),
988
+ ...(options.modelTypenames
989
+ ? { modelTypenames: options.modelTypenames }
990
+ : {}),
938
991
  });
939
992
  },
940
993
  },