@abloatai/ablo 0.29.2 → 0.30.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 (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +561 -90
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
@@ -6,17 +6,15 @@
6
6
  * transport envelopes, watermarks, replay, and route details.
7
7
  */
8
8
  import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
9
- import { z } from 'zod';
10
9
  import { v5 as uuidv5 } from 'uuid';
11
10
  import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
12
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
13
- import { registerDataSource } from './registerDataSource.js';
11
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, rejectRemovedDatabaseUrlOption, warnIfCliKeyMismatch, } from './auth.js';
14
12
  import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
13
+ import { commitReceiptSchema } from '../wire/commit.js';
15
14
  import { toMs } from '../utils/duration.js';
16
15
  import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from './claimHeartbeatLoop.js';
17
16
  import { mintSession } from './sessionMint.js';
18
17
  import { parseIdentityResolveResponse } from '../auth/schemas.js';
19
- import { staleNotificationSchema } from '../coordination/schema.js';
20
18
  /**
21
19
  * Interpret a heartbeat reply for a lease this handle HOLDS: anything other
22
20
  * than `held` means the lease is no longer ours (a holder cannot be `queued`;
@@ -38,23 +36,9 @@ import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCo
38
36
  import { resolveDurableWrites } from './durableWrites.js';
39
37
  /** @internal Default per-request deadline for the private HTTP transport. */
40
38
  export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
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();
39
+ const HTTP_CONFIRMATION_POLL_INTERVAL_MS = 250;
56
40
  function parseSuccessfulCommitResponse(value, idempotencyKey) {
57
- const parsed = successfulCommitResponseSchema.safeParse(value);
41
+ const parsed = commitReceiptSchema.safeParse(value);
58
42
  if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
59
43
  throw new AbloConnectionError('The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.', {
60
44
  code: 'commit_no_result',
@@ -71,8 +55,8 @@ function claimFromModelClaim(claim) {
71
55
  object: 'claim',
72
56
  id: claim.id,
73
57
  ...(claim.status ? { status: claim.status } : {}),
74
- reason: claim.reason,
75
- ...(claim.description ? { description: claim.description } : {}),
58
+ // The server always stamps a description; default only for total safety.
59
+ description: claim.description ?? 'editing',
76
60
  heldBy: claim.actor,
77
61
  participantKind: claim.participantKind,
78
62
  expiresAt: claim.expiresAt,
@@ -93,15 +77,10 @@ export function createHttpTransport(options) {
93
77
  const authInput = { options, env };
94
78
  const configuredApiKey = resolveApiKey(authInput);
95
79
  const configuredAuthToken = resolveAuthToken(authInput);
96
- const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
97
- // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
98
- // passed — no logger on this path, so the helper falls back to console.warn.
99
- warnIfDatabaseUrlEnvIgnored(authInput);
100
- warnIfDatabaseUrlDeprecated(authInput);
101
80
  void warnIfCliKeyMismatch(authInput);
81
+ rejectRemovedDatabaseUrlOption(options);
102
82
  assertBrowserSafety({
103
83
  apiKey: configuredApiKey,
104
- databaseUrl: configuredDatabaseUrl,
105
84
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
106
85
  });
107
86
  // Observability hook for the stateless HTTP transport. The WebSocket transport
@@ -154,7 +133,8 @@ export function createHttpTransport(options) {
154
133
  // constructor exposes the behavior as `durableWrites`.
155
134
  const commitOutbox = durableWrites.store;
156
135
  const durableWriteNamespace = durableWrites.namespace ?? 'http';
157
- const legacyCommitOutboxScope = options.commitOutboxScope;
136
+ const legacyCommitOutboxScope = options
137
+ .commitOutboxScope;
158
138
  const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
159
139
  apiBaseUrl,
160
140
  defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
@@ -197,14 +177,6 @@ export function createHttpTransport(options) {
197
177
  if (readyPromise)
198
178
  return readyPromise;
199
179
  readyPromise = (async () => {
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
180
  await resolveHttpOutboxScope();
209
181
  await replayHttpCommitOutbox();
210
182
  })();
@@ -270,7 +242,9 @@ export function createHttpTransport(options) {
270
242
  // same pattern already guards `query/client.ts` and `BootstrapFetcher`.
271
243
  const callerSignal = requestInit.signal ?? undefined;
272
244
  const controller = new AbortController();
273
- const onCallerAbort = () => { controller.abort(callerSignal?.reason); };
245
+ const onCallerAbort = () => {
246
+ controller.abort(callerSignal?.reason);
247
+ };
274
248
  if (callerSignal) {
275
249
  if (callerSignal.aborted)
276
250
  onCallerAbort();
@@ -349,6 +323,110 @@ export function createHttpTransport(options) {
349
323
  throw new AbloConnectionError('The server settled the commit, but its local outbox record could not be cleared.', { code: 'db_not_opened', cause });
350
324
  }
351
325
  }
326
+ /**
327
+ * Persist the first queued source receipt before exposing acceptance to the
328
+ * caller. This is a monotonic upgrade of the same sealed request: connected
329
+ * source keys are permanent, so the envelope may safely remain replayable
330
+ * after the hosted 24-hour idempotency window while it awaits its WAL echo.
331
+ */
332
+ async function persistHttpAcceptance(envelope, response) {
333
+ if (!commitOutbox || response.status !== 'queued')
334
+ return envelope;
335
+ const correlationId = response.correlationId;
336
+ if (!correlationId) {
337
+ throw new AbloConnectionError('The source accepted the commit without durable correlation evidence.', { code: 'commit_no_result' });
338
+ }
339
+ if (envelope.correlationId !== undefined && envelope.correlationId !== correlationId) {
340
+ throw new AbloIdempotencyError('The same HTTP commit replay returned a different source correlation.', { code: 'idempotency_conflict' });
341
+ }
342
+ if (envelope.acceptedAt !== undefined)
343
+ return envelope;
344
+ const accepted = durableHttpCommitEnvelopeSchema.parse({
345
+ ...envelope,
346
+ acceptedAt: Date.now(),
347
+ correlationId,
348
+ });
349
+ try {
350
+ await commitOutbox.seal(accepted, []);
351
+ }
352
+ catch (cause) {
353
+ throw new AbloConnectionError('The source accepted the commit, but that acceptance could not be persisted locally.', { code: 'db_not_opened', cause });
354
+ }
355
+ return accepted;
356
+ }
357
+ function replicationLagTimeout(request, response) {
358
+ return new AbloConnectionError(`The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`, {
359
+ code: 'replication_lag_timeout',
360
+ httpStatus: 504,
361
+ details: {
362
+ clientTxId: request.idempotencyKey,
363
+ ...(response.correlationId ? { correlationId: response.correlationId } : {}),
364
+ timeoutMs: requestTimeoutMs,
365
+ accepted: true,
366
+ },
367
+ });
368
+ }
369
+ /**
370
+ * Replays one byte-identical, idempotent HTTP commit until mutation-log
371
+ * replay reports the source echo as confirmed. `queued` is acceptance only:
372
+ * this loop never clears the durable envelope and never converts it into a
373
+ * successful `wait: 'confirmed'` result.
374
+ */
375
+ async function pollHttpCommitConfirmation(request, initial) {
376
+ let current = initial;
377
+ const correlationId = initial.correlationId;
378
+ const deadlineAt = requestTimeoutMs > 0 ? Date.now() + requestTimeoutMs : null;
379
+ while (current.status === 'queued') {
380
+ const remaining = deadlineAt === null ? null : deadlineAt - Date.now();
381
+ if (remaining !== null && remaining <= 0) {
382
+ throw replicationLagTimeout(request, current);
383
+ }
384
+ const confirmationController = new AbortController();
385
+ const confirmationDeadline = remaining !== null
386
+ ? setTimeout(() => {
387
+ confirmationController.abort();
388
+ }, remaining)
389
+ : null;
390
+ try {
391
+ const raw = await requestJson(request.path, {
392
+ method: request.method,
393
+ idempotencyKey: request.idempotencyKey,
394
+ ...(request.sealedProtocolVersion !== undefined
395
+ ? { sealedProtocolVersion: request.sealedProtocolVersion }
396
+ : {}),
397
+ body: request.body,
398
+ signal: confirmationController.signal,
399
+ }, true);
400
+ const next = parseSuccessfulCommitResponse(raw, request.idempotencyKey);
401
+ if (next.correlationId !== correlationId) {
402
+ throw new AbloIdempotencyError('The same HTTP commit replay returned different source correlation evidence.', { code: 'idempotency_conflict' });
403
+ }
404
+ current = next;
405
+ }
406
+ catch (error) {
407
+ if (confirmationController.signal.aborted ||
408
+ (deadlineAt !== null && Date.now() >= deadlineAt)) {
409
+ throw replicationLagTimeout(request, current);
410
+ }
411
+ throw error;
412
+ }
413
+ finally {
414
+ if (confirmationDeadline)
415
+ clearTimeout(confirmationDeadline);
416
+ }
417
+ if (current.status === 'confirmed')
418
+ return current;
419
+ const delayMs = deadlineAt === null
420
+ ? HTTP_CONFIRMATION_POLL_INTERVAL_MS
421
+ : Math.min(HTTP_CONFIRMATION_POLL_INTERVAL_MS, Math.max(0, deadlineAt - Date.now()));
422
+ if (delayMs > 0) {
423
+ await new Promise((resolve) => {
424
+ setTimeout(resolve, delayMs);
425
+ });
426
+ }
427
+ }
428
+ return current;
429
+ }
352
430
  async function replayHttpCommitOutbox() {
353
431
  const replayed = new Map();
354
432
  if (!commitOutbox)
@@ -374,8 +452,7 @@ export function createHttpTransport(options) {
374
452
  }
375
453
  envelopes.push(parsed.data);
376
454
  }
377
- envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
378
- (b.sequence ?? b.sealedAt * 1_000) ||
455
+ envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) - (b.sequence ?? b.sealedAt * 1_000) ||
379
456
  a.id.localeCompare(b.id));
380
457
  for (const envelope of envelopes) {
381
458
  try {
@@ -386,8 +463,21 @@ export function createHttpTransport(options) {
386
463
  body: envelope.request.body,
387
464
  }, true);
388
465
  const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
389
- await settleHttpEnvelope(envelope.id);
390
- replayed.set(envelope.idempotencyKey, { envelope, response });
466
+ if (envelope.correlationId !== undefined &&
467
+ response.correlationId !== envelope.correlationId) {
468
+ throw new AbloIdempotencyError('The saved HTTP commit replay returned different source correlation evidence.', { code: 'idempotency_conflict' });
469
+ }
470
+ const replayEnvelope = await persistHttpAcceptance(envelope, response);
471
+ // A queued source receipt is only acceptance. Keep the exact request
472
+ // durable so startup/retry can ask mutation-log replay whether its WAL
473
+ // echo has materialized; only confirmed is a definitive success.
474
+ if (response.status === 'confirmed') {
475
+ await settleHttpEnvelope(envelope.id);
476
+ }
477
+ replayed.set(envelope.idempotencyKey, {
478
+ envelope: replayEnvelope,
479
+ response,
480
+ });
391
481
  }
392
482
  catch (error) {
393
483
  if (isDefinitiveHttpRejection(error)) {
@@ -398,6 +488,37 @@ export function createHttpTransport(options) {
398
488
  }
399
489
  return replayed;
400
490
  }
491
+ /**
492
+ * A flush is stronger than startup replay: it promises that every retained
493
+ * envelope has reached a definitive outcome, not merely that the server
494
+ * accepted it for forwarding. Poll queued receipts through mutation-log
495
+ * replay and leave their envelopes intact if the confirmation deadline
496
+ * expires.
497
+ */
498
+ async function confirmReplayedHttpCommits(replayed) {
499
+ for (const { envelope, response } of replayed.values()) {
500
+ if (response.status !== 'queued')
501
+ continue;
502
+ try {
503
+ const confirmed = await pollHttpCommitConfirmation({
504
+ idempotencyKey: envelope.idempotencyKey,
505
+ method: envelope.request.method,
506
+ path: envelope.request.path,
507
+ body: envelope.request.body,
508
+ sealedProtocolVersion: envelope.protocolVersion,
509
+ }, response);
510
+ if (confirmed.status === 'confirmed') {
511
+ await settleHttpEnvelope(envelope.id);
512
+ }
513
+ }
514
+ catch (error) {
515
+ if (isDefinitiveHttpRejection(error)) {
516
+ await settleHttpEnvelope(envelope.id);
517
+ }
518
+ throw error;
519
+ }
520
+ }
521
+ }
401
522
  let lastHttpCommitSequence = 0;
402
523
  function nextHttpCommitSequence() {
403
524
  const wallSequence = Date.now() * 1_000;
@@ -457,22 +578,56 @@ export function createHttpTransport(options) {
457
578
  prior.envelope.request.body !== serializedBody) {
458
579
  throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
459
580
  }
460
- await beforeSettlement?.(prior.response);
461
- return prior.response;
581
+ let priorResponse = prior.response;
582
+ if (priorResponse.status === 'queued' && input.wait === 'confirmed') {
583
+ try {
584
+ priorResponse = await pollHttpCommitConfirmation({
585
+ idempotencyKey: prior.envelope.idempotencyKey,
586
+ method: prior.envelope.request.method,
587
+ path: prior.envelope.request.path,
588
+ body: prior.envelope.request.body,
589
+ sealedProtocolVersion: prior.envelope.protocolVersion,
590
+ }, priorResponse);
591
+ }
592
+ catch (error) {
593
+ if (isDefinitiveHttpRejection(error)) {
594
+ await settleHttpEnvelope(prior.envelope.id);
595
+ }
596
+ throw error;
597
+ }
598
+ }
599
+ if (priorResponse.status === 'confirmed') {
600
+ await beforeSettlement?.(priorResponse);
601
+ await settleHttpEnvelope(prior.envelope.id);
602
+ }
603
+ return priorResponse;
462
604
  }
463
605
  const durableEnvelope = await sealHttpCommit(input);
464
606
  const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
607
+ const exactRequest = {
608
+ idempotencyKey: input.idempotencyKey,
609
+ method: input.method,
610
+ path: input.path,
611
+ body: requestBody,
612
+ ...(durableEnvelope ? { sealedProtocolVersion: durableEnvelope.protocolVersion } : {}),
613
+ };
465
614
  let response;
466
615
  try {
467
- const raw = await requestJson(input.path, {
468
- method: input.method,
469
- idempotencyKey: input.idempotencyKey,
470
- ...(durableEnvelope
471
- ? { sealedProtocolVersion: durableEnvelope.protocolVersion }
616
+ const raw = await requestJson(exactRequest.path, {
617
+ method: exactRequest.method,
618
+ idempotencyKey: exactRequest.idempotencyKey,
619
+ ...(exactRequest.sealedProtocolVersion !== undefined
620
+ ? { sealedProtocolVersion: exactRequest.sealedProtocolVersion }
472
621
  : {}),
473
- body: requestBody,
622
+ body: exactRequest.body,
474
623
  }, true);
475
624
  response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
625
+ if (durableEnvelope && response.status === 'queued') {
626
+ await persistHttpAcceptance(durableEnvelope, response);
627
+ }
628
+ if (response.status === 'queued' && input.wait === 'confirmed') {
629
+ response = await pollHttpCommitConfirmation(exactRequest, response);
630
+ }
476
631
  }
477
632
  catch (error) {
478
633
  if (durableEnvelope && isDefinitiveHttpRejection(error)) {
@@ -482,9 +637,13 @@ export function createHttpTransport(options) {
482
637
  }
483
638
  // A model-create readback can participate in settlement: if it fails,
484
639
  // 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);
640
+ // A queued source receipt cannot be read back from the log yet and stays
641
+ // durable until a later confirmed replay.
642
+ if (response.status === 'confirmed') {
643
+ await beforeSettlement?.(response);
644
+ if (durableEnvelope)
645
+ await settleHttpEnvelope(durableEnvelope.id);
646
+ }
488
647
  return response;
489
648
  });
490
649
  }
@@ -508,7 +667,7 @@ export function createHttpTransport(options) {
508
667
  ? crypto.randomUUID()
509
668
  : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
510
669
  }
511
- function normalizeCommitOperation(op, defaults) {
670
+ function normalizeCommitOperation(op, defaults, fenceToken) {
512
671
  return {
513
672
  action: op.action,
514
673
  model: op.model,
@@ -517,13 +676,18 @@ export function createHttpTransport(options) {
517
676
  transactionId: op.transactionId ?? null,
518
677
  readAt: op.readAt ?? defaults.readAt ?? null,
519
678
  onStale: op.onStale ?? defaults.onStale ?? null,
679
+ // The batch's claim (if any) supplies one token for every op, mirroring
680
+ // how it supplies the batch `readAt`.
681
+ fenceToken: op.fenceToken ?? fenceToken ?? null,
520
682
  };
521
683
  }
522
- function normalizeCommitOperations(commitOptions) {
684
+ function normalizeCommitOperations(commitOptions, fenceToken) {
523
685
  if (commitOptions.operations.length === 0) {
524
- throw new AbloValidationError('Commit requires a non-empty `operations` array.', { code: 'commit_operation_required' });
686
+ throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
687
+ code: 'commit_operation_required',
688
+ });
525
689
  }
526
- return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions));
690
+ return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions, fenceToken));
527
691
  }
528
692
  async function listClaimState(target) {
529
693
  const params = new URLSearchParams();
@@ -534,7 +698,9 @@ export function createHttpTransport(options) {
534
698
  if (target?.field)
535
699
  params.set('field', target.field);
536
700
  const suffix = params.toString();
537
- const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
701
+ const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, {
702
+ method: 'GET',
703
+ });
538
704
  return {
539
705
  active: body.claims ?? [],
540
706
  queue: body.queue ?? [],
@@ -568,11 +734,12 @@ export function createHttpTransport(options) {
568
734
  ...commitOptions,
569
735
  readAt: commitOptions.readAt ?? claim?.readAt ?? null,
570
736
  onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
571
- });
737
+ }, claim?.fenceToken ?? null);
572
738
  const requestBody = {
573
739
  operations,
574
740
  reads: commitOptions.reads,
575
741
  };
742
+ const wait = commitOptions.wait ?? 'confirmed';
576
743
  let body;
577
744
  try {
578
745
  body = await dispatchHttpCommit({
@@ -580,6 +747,7 @@ export function createHttpTransport(options) {
580
747
  method: 'POST',
581
748
  idempotencyKey: clientTxId,
582
749
  body: requestBody,
750
+ wait,
583
751
  });
584
752
  }
585
753
  catch (error) {
@@ -604,9 +772,7 @@ export function createHttpTransport(options) {
604
772
  ...(body.notifications && body.notifications.length > 0
605
773
  ? { notifications: body.notifications }
606
774
  : {}),
607
- ...(body.missingIds && body.missingIds.length > 0
608
- ? { missingIds: body.missingIds }
609
- : {}),
775
+ ...(body.missingIds && body.missingIds.length > 0 ? { missingIds: body.missingIds } : {}),
610
776
  };
611
777
  },
612
778
  };
@@ -693,6 +859,9 @@ export function createHttpTransport(options) {
693
859
  claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
694
860
  onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
695
861
  readAt,
862
+ // The claim's fencing token (Option B), so the per-model HTTP write door
863
+ // fences the same as the WS proxy and `commits.create`.
864
+ fenceToken: options?.fenceToken ?? claimHandle?.fenceToken,
696
865
  };
697
866
  if (action === 'create')
698
867
  requestBody.id = id;
@@ -705,6 +874,7 @@ export function createHttpTransport(options) {
705
874
  method,
706
875
  idempotencyKey: clientTxId,
707
876
  body: requestBody,
877
+ wait: options?.wait ?? 'confirmed',
708
878
  }, beforeSettlement);
709
879
  }
710
880
  catch (error) {
@@ -733,18 +903,13 @@ export function createHttpTransport(options) {
733
903
  value.object === 'claim' &&
734
904
  typeof value.id === 'string' &&
735
905
  typeof value.release === 'function';
736
- const claimMeta = (options) => {
737
- if (!options?.description)
738
- return options?.meta;
739
- return { ...(options.meta ?? {}), description: options.description };
740
- };
906
+ const claimMeta = (options) => options?.meta;
741
907
  const acquireClaim = async (params) => {
742
908
  const body = await requestJson(claimPath(params.id), {
743
909
  method: 'POST',
744
910
  body: JSON.stringify({
745
- reason: params.reason ?? 'editing',
911
+ description: params.description ?? 'editing',
746
912
  ...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
747
- ...(params.description !== undefined ? { description: params.description } : {}),
748
913
  ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
749
914
  // `queue` (default true) → queue behind the holder; false → fail-fast
750
915
  // with AbloClaimedError (work-distribution dedup).
@@ -757,9 +922,13 @@ export function createHttpTransport(options) {
757
922
  }
758
923
  // `claimId` is the field name the queued response uses; check it alongside
759
924
  // the other id shapes the response may carry.
760
- return body.claim?.id ?? body.id ?? body.claimId ?? createClaimId();
925
+ const id = body.claim?.id ?? body.id ?? body.claimId ?? createClaimId();
926
+ const fenceToken = body.claim?.fenceToken;
927
+ return fenceToken !== undefined ? { id, fenceToken } : { id };
761
928
  };
762
- const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
929
+ const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
930
+ method: 'DELETE',
931
+ }).then(() => undefined);
763
932
  // One beat on the held lease. A lapsed lease answers `claim_lost`
764
933
  // (409), which the wire error mapping surfaces as AbloClaimedError —
765
934
  // the definitive signal that stops the auto-heartbeat loop.
@@ -775,14 +944,14 @@ export function createHttpTransport(options) {
775
944
  return heldHeartbeatReply(reply, `claim ${claimId} on ${name}/${id}`);
776
945
  };
777
946
  async function claimImpl(params) {
778
- const claimId = await acquireClaim(params);
947
+ const { id: claimId, fenceToken } = await acquireClaim(params);
779
948
  observability?.captureClaim({
780
949
  phase: 'acquired',
781
950
  claimId,
782
951
  model: name,
783
952
  id: params.id,
784
953
  ...(params.field ? { field: params.field } : {}),
785
- reason: params.reason ?? 'editing',
954
+ reason: params.description ?? 'editing',
786
955
  });
787
956
  const { data, stamp } = await retrieveModel(name, { id: params.id });
788
957
  // A held claim hands back a snapshot; the typed `HeldClaim.data` is `T`.
@@ -795,9 +964,7 @@ export function createHttpTransport(options) {
795
964
  const resolved = resolveHeartbeatOptions(beatOptions);
796
965
  const beat = await heartbeatClaim(params.id, claimId, {
797
966
  ttl: resolved.ttl ?? params.ttl,
798
- ...(resolved.details !== undefined
799
- ? { details: resolved.details }
800
- : {}),
967
+ ...(resolved.details !== undefined ? { details: resolved.details } : {}),
801
968
  });
802
969
  params.onHeartbeat?.(beat);
803
970
  return beat;
@@ -809,9 +976,7 @@ export function createHttpTransport(options) {
809
976
  ? startClaimHeartbeatLoop({
810
977
  beat: () => heartbeat(),
811
978
  intervalMs: heartbeatCadenceMs(params.ttl !== undefined ? toMs(params.ttl) : 60_000, params.heartbeat),
812
- ...(params.onHeartbeatLost
813
- ? { onLost: params.onHeartbeatLost }
814
- : {}),
979
+ ...(params.onHeartbeatLost ? { onLost: params.onHeartbeatLost } : {}),
815
980
  })
816
981
  : undefined;
817
982
  const release = () => {
@@ -822,6 +987,7 @@ export function createHttpTransport(options) {
822
987
  object: 'claim',
823
988
  id: claimId,
824
989
  readAt: stamp,
990
+ ...(fenceToken !== undefined ? { fenceToken } : {}),
825
991
  target: {
826
992
  type: name,
827
993
  id: params.id,
@@ -830,8 +996,7 @@ export function createHttpTransport(options) {
830
996
  ...(params.range ? { range: params.range } : {}),
831
997
  ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
832
998
  },
833
- reason: params.reason ?? 'editing',
834
- ...(params.description ? { description: params.description } : {}),
999
+ description: params.description ?? 'editing',
835
1000
  data,
836
1001
  release,
837
1002
  revoke: () => {
@@ -861,7 +1026,9 @@ export function createHttpTransport(options) {
861
1026
  method: 'POST',
862
1027
  // The reorder route's payload is `{ heldBy, claimId }[]` — a Claim's id
863
1028
  // is the claimId.
864
- body: JSON.stringify({ order: params.order.map((i) => ({ heldBy: i.heldBy, claimId: i.id })) }),
1029
+ body: JSON.stringify({
1030
+ order: params.order.map((i) => ({ heldBy: i.heldBy, claimId: i.id })),
1031
+ }),
865
1032
  });
866
1033
  },
867
1034
  });
@@ -875,12 +1042,17 @@ export function createHttpTransport(options) {
875
1042
  // `isClaimHandle` ruled out the handle form above; the generic mismatch
876
1043
  // (the union carries `Claim`, the guard narrows `Claim<T>`) keeps the
877
1044
  // compiler from subtracting it, so narrow to the inline-options form.
878
- const claimId = await acquireClaim({
1045
+ const { id: claimId, fenceToken } = await acquireClaim({
879
1046
  id,
880
1047
  ...claimInput,
881
1048
  });
882
1049
  try {
883
- return await run({ ...input, claimRef: { id: claimId }, claim: undefined });
1050
+ return await run({
1051
+ ...input,
1052
+ claimRef: { id: claimId },
1053
+ ...(fenceToken !== undefined ? { fenceToken } : {}),
1054
+ claim: undefined,
1055
+ });
884
1056
  }
885
1057
  finally {
886
1058
  await releaseClaim({ id }).catch(() => { });
@@ -937,7 +1109,10 @@ export function createHttpTransport(options) {
937
1109
  let created;
938
1110
  await mutateModel('create', name, id, params.data, {
939
1111
  ...options,
940
- wait: options?.wait ?? 'confirmed',
1112
+ // This method returns the authoritative row, not a receipt. A
1113
+ // queued source acceptance cannot satisfy that return contract,
1114
+ // even when the caller supplied `wait: 'queued'`.
1115
+ wait: 'confirmed',
941
1116
  }, async () => {
942
1117
  const read = await retrieveModel(name, { id });
943
1118
  if (read.data === undefined) {
@@ -966,7 +1141,8 @@ export function createHttpTransport(options) {
966
1141
  ready,
967
1142
  waitForFlush: () => runInHttpCommitLane(async () => {
968
1143
  await ready();
969
- await replayHttpCommitOutbox();
1144
+ const replayed = await replayHttpCommitOutbox();
1145
+ await confirmReplayedHttpCommits(replayed);
970
1146
  }),
971
1147
  async dispose() { },
972
1148
  async purge() { },
@@ -985,9 +1161,7 @@ export function createHttpTransport(options) {
985
1161
  apiKey,
986
1162
  baseUrl: apiBaseUrl,
987
1163
  ...(options.fetch ? { fetch: options.fetch } : {}),
988
- ...(options.modelTypenames
989
- ? { modelTypenames: options.modelTypenames }
990
- : {}),
1164
+ ...(options.modelTypenames ? { modelTypenames: options.modelTypenames } : {}),
991
1165
  });
992
1166
  },
993
1167
  },
@@ -89,21 +89,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
89
89
  * route, and passing both is a validation error.
90
90
  */
91
91
  authEndpoint?: string | ApiKeySetter | null | undefined;
92
- /**
93
- * @deprecated The direct connector lets Ablo dial into your Postgres and write to
94
- * it directly. Prefer the signed data-source endpoint: keep your `DATABASE_URL`
95
- * in your own app, expose `dataSource(...)`, and let your server own the write
96
- * while Ablo coordinates the sync stream. Ablo hosts only the ordered
97
- * `sync_deltas` log and coordination, never your rows. To keep the log in your own
98
- * infrastructure as well, self-host the engine.
99
- *
100
- * Still honored at runtime for backward compatibility. It is server-only: because
101
- * it carries credentials it is never sent from the browser, and constructing a
102
- * client with both `databaseUrl` and `dangerouslyAllowBrowser` throws. If you do
103
- * use it, supply a role that is neither a superuser nor `BYPASSRLS`; the connector
104
- * rejects privileged roles that cannot enforce row-level security.
105
- */
106
- databaseUrl?: string | null | undefined;
107
92
  /**
108
93
  * Local persistence mode. Pass `indexeddb` only when you want offline
109
94
  * queueing and a reload-surviving browser cache.