@abloatai/transaction 0.59.1 → 0.59.2

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 (166) hide show
  1. package/dist/auth/capability.d.ts +15 -15
  2. package/dist/auth/capabilityLifecycle.d.ts +2 -2
  3. package/dist/auth/credentialEndpoint.js.map +1 -1
  4. package/dist/auth/credentialSource.js.map +1 -1
  5. package/dist/auth/identity.js.map +1 -1
  6. package/dist/auth/schemas.d.ts +5 -5
  7. package/dist/auth/sessionIssuanceProtocol.d.ts +8 -8
  8. package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -1
  9. package/dist/branches.d.ts +38 -38
  10. package/dist/claims/contract.d.ts +27 -27
  11. package/dist/claims/eventContract.d.ts +18 -18
  12. package/dist/claims/routes.d.ts +9 -9
  13. package/dist/claims/routes.d.ts.map +1 -1
  14. package/dist/claims/trace.d.ts.map +1 -1
  15. package/dist/claims/trace.js.map +1 -1
  16. package/dist/client/ablo.d.ts +6 -5
  17. package/dist/client/ablo.d.ts.map +1 -1
  18. package/dist/client/ablo.js +0 -8
  19. package/dist/client/ablo.js.map +1 -1
  20. package/dist/client/resources/httpResources.js.map +1 -1
  21. package/dist/client/resources/modelOperations.d.ts +6 -6
  22. package/dist/client/resources/modelOperations.d.ts.map +1 -1
  23. package/dist/client/resources/where.d.ts +12 -12
  24. package/dist/client/surface.d.ts +2 -2
  25. package/dist/client/surface.d.ts.map +1 -1
  26. package/dist/client/surface.js +7 -0
  27. package/dist/client/surface.js.map +1 -1
  28. package/dist/commit/confirmation/commitEnvelope.d.ts +7 -7
  29. package/dist/commit/confirmation/httpCommitEnvelope.d.ts +1 -1
  30. package/dist/commit/confirmation/pendingWrite.d.ts +5 -5
  31. package/dist/commit/contract.d.ts +249 -249
  32. package/dist/commit/contract.d.ts.map +1 -1
  33. package/dist/commit/recordRuntime.d.ts +3 -2
  34. package/dist/commit/recordRuntime.d.ts.map +1 -1
  35. package/dist/commit/recordRuntime.js +1 -0
  36. package/dist/commit/recordRuntime.js.map +1 -1
  37. package/dist/coordination/schema.d.ts +54 -54
  38. package/dist/coordination/schema.js.map +1 -1
  39. package/dist/environment.d.ts +3 -3
  40. package/dist/environment.d.ts.map +1 -1
  41. package/dist/environment.js.map +1 -1
  42. package/dist/errorCodes.d.ts +6 -5
  43. package/dist/errorCodes.d.ts.map +1 -1
  44. package/dist/errorCodes.js +2 -1
  45. package/dist/errorCodes.js.map +1 -1
  46. package/dist/errorObservation.d.ts +14 -14
  47. package/dist/errors.d.ts +11 -11
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js +16 -1
  50. package/dist/errors.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/keys/index.d.ts +1 -1
  55. package/dist/keys/index.d.ts.map +1 -1
  56. package/dist/keys/index.js.map +1 -1
  57. package/dist/observation/contract.d.ts +34 -34
  58. package/dist/observation/feedContract.d.ts +86 -86
  59. package/dist/observation/persistence/syncDeltaRow.d.ts +23 -23
  60. package/dist/observation/persistence/syncDeltaRow.d.ts.map +1 -1
  61. package/dist/pricing.d.ts +12 -12
  62. package/dist/schema/audit.js.map +1 -1
  63. package/dist/schema/ddl.js.map +1 -1
  64. package/dist/schema/ddlLock.js.map +1 -1
  65. package/dist/schema/deployment/contracts.d.ts +64 -64
  66. package/dist/schema/deployment/index.js.map +1 -1
  67. package/dist/schema/deployment/reconcile.js.map +1 -1
  68. package/dist/schema/diff.js.map +1 -1
  69. package/dist/schema/field.d.ts +1 -1
  70. package/dist/schema/field.d.ts.map +1 -1
  71. package/dist/schema/loadStrategy.d.ts +2 -2
  72. package/dist/schema/loadStrategy.d.ts.map +1 -1
  73. package/dist/schema/openapi.js.map +1 -1
  74. package/dist/schema/relation.d.ts +3 -3
  75. package/dist/schema/relation.d.ts.map +1 -1
  76. package/dist/schema/residency.d.ts +1 -1
  77. package/dist/schema/roles.d.ts +1 -1
  78. package/dist/schema/roles.d.ts.map +1 -1
  79. package/dist/schema/roles.js.map +1 -1
  80. package/dist/schema/schema.d.ts +1 -1
  81. package/dist/schema/schema.d.ts.map +1 -1
  82. package/dist/schema/serialize.js.map +1 -1
  83. package/dist/schema/sugar.d.ts.map +1 -1
  84. package/dist/schema/tenancy.d.ts +2 -2
  85. package/dist/server/storageMode.d.ts +1 -1
  86. package/dist/source/adapters/adapterProfile.d.ts +6 -6
  87. package/dist/source/adapters/conformance.js.map +1 -1
  88. package/dist/source/adapters/contract.d.ts +14 -14
  89. package/dist/source/adapters/kysely.js.map +1 -1
  90. package/dist/source/adapters/memory.js.map +1 -1
  91. package/dist/source/adapters/prisma.js.map +1 -1
  92. package/dist/source/connector/client.d.ts.map +1 -1
  93. package/dist/source/connector/client.js.map +1 -1
  94. package/dist/source/connector/protocol.d.ts.map +1 -1
  95. package/dist/source/delivery/pushQueue.d.ts.map +1 -1
  96. package/dist/source/delivery/pushQueue.js.map +1 -1
  97. package/dist/source/endpoint/signing.d.ts +4 -4
  98. package/dist/source/endpoint/signing.d.ts.map +1 -1
  99. package/dist/source/outbox/contract.d.ts +4 -4
  100. package/dist/testing/fixtures/httpResponses.d.ts +2 -2
  101. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  102. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  103. package/dist/transport/connection/credentialLifecycle.d.ts.map +1 -1
  104. package/dist/transport/connection/credentialLifecycle.js.map +1 -1
  105. package/dist/transport/connection/manager.d.ts.map +1 -1
  106. package/dist/transport/connection/manager.js.map +1 -1
  107. package/dist/transport/connection/networkProbe.d.ts +18 -5
  108. package/dist/transport/connection/networkProbe.d.ts.map +1 -1
  109. package/dist/transport/connection/networkProbe.js.map +1 -1
  110. package/dist/transport/http/client.d.ts +37 -1
  111. package/dist/transport/http/client.d.ts.map +1 -1
  112. package/dist/transport/http/client.js +117 -2
  113. package/dist/transport/http/client.js.map +1 -1
  114. package/dist/transport/http/transport.d.ts +21 -0
  115. package/dist/transport/http/transport.d.ts.map +1 -1
  116. package/dist/transport/http/transport.js +93 -5
  117. package/dist/transport/http/transport.js.map +1 -1
  118. package/dist/transport/websocket/commitFrames.d.ts +4 -3
  119. package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
  120. package/dist/transport/websocket/commitFrames.js +1 -0
  121. package/dist/transport/websocket/commitFrames.js.map +1 -1
  122. package/dist/transport/websocket/contextSubscription.d.ts +6 -0
  123. package/dist/transport/websocket/contextSubscription.d.ts.map +1 -0
  124. package/dist/transport/websocket/contextSubscription.js +45 -0
  125. package/dist/transport/websocket/contextSubscription.js.map +1 -0
  126. package/dist/transport/websocket/heartbeat.d.ts.map +1 -1
  127. package/dist/transport/websocket/heartbeat.js.map +1 -1
  128. package/dist/transport/websocket/session.d.ts +4 -0
  129. package/dist/transport/websocket/session.d.ts.map +1 -0
  130. package/dist/transport/websocket/session.js +385 -0
  131. package/dist/transport/websocket/session.js.map +1 -0
  132. package/dist/transport/websocket/sessionContract.d.ts +89 -0
  133. package/dist/transport/websocket/sessionContract.d.ts.map +1 -0
  134. package/dist/transport/websocket/sessionContract.js +2 -0
  135. package/dist/transport/websocket/sessionContract.js.map +1 -0
  136. package/dist/transport/websocket/transport.d.ts +22 -0
  137. package/dist/transport/websocket/transport.d.ts.map +1 -1
  138. package/dist/transport/websocket/transport.js +63 -1
  139. package/dist/transport/websocket/transport.js.map +1 -1
  140. package/dist/wire/accountResponses.d.ts +31 -31
  141. package/dist/wire/auth.d.ts +3 -3
  142. package/dist/wire/bootstrapReason.d.ts +2 -2
  143. package/dist/wire/dataSourceResponses.d.ts +2 -2
  144. package/dist/wire/dataSourceResponses.d.ts.map +1 -1
  145. package/dist/wire/frames.d.ts +8 -8
  146. package/dist/wire/inboundFrames.d.ts +56 -56
  147. package/dist/wire/inboundFrames.d.ts.map +1 -1
  148. package/dist/wire/listEnvelope.js.map +1 -1
  149. package/dist/wire/modelResponses.d.ts +2 -2
  150. package/dist/wire/modelShape.d.ts +4 -4
  151. package/dist/wire/pricing.d.ts +3 -3
  152. package/dist/wire/protocolVersion.d.ts.map +1 -1
  153. package/package.json +2 -1
  154. package/src/client/ablo.ts +10 -5
  155. package/src/client/surface.ts +8 -1
  156. package/src/commit/recordRuntime.ts +4 -2
  157. package/src/errorCodes.ts +5 -1
  158. package/src/errors.ts +18 -1
  159. package/src/index.ts +1 -0
  160. package/src/transport/http/client.ts +171 -4
  161. package/src/transport/http/transport.ts +126 -4
  162. package/src/transport/websocket/commitFrames.ts +5 -3
  163. package/src/transport/websocket/contextSubscription.ts +55 -0
  164. package/src/transport/websocket/session.ts +467 -0
  165. package/src/transport/websocket/sessionContract.ts +115 -0
  166. package/src/transport/websocket/transport.ts +72 -0
@@ -56,6 +56,7 @@ import {
56
56
  type FunctionalUpdateOptions,
57
57
  } from '../../client/resources/functionalUpdate.js';
58
58
  import type { HeldClaim, HeldLease } from '../../types/streams.js';
59
+ import type { JsonValue } from '../../types/streams.js';
59
60
  import { AbloConnectionError, AbloValidationError } from '../../errors.js';
60
61
  import type { ReadDependency } from '../../coordination/schema.js';
61
62
  import {
@@ -65,8 +66,22 @@ import {
65
66
  prepareReadSet,
66
67
  type ReadSetContext,
67
68
  } from '../../commit/readSetContext.js';
68
- import { recordHttpCommitReceipt } from '../../commit/recordRuntime.js';
69
+ import {
70
+ recordHttpCommitReceipt,
71
+ recordWebSocketCommitReceipt,
72
+ } from '../../commit/recordRuntime.js';
69
73
  import type { EffectiveAuthority } from '../../auth/capability.js';
74
+ import {
75
+ createWebSocketSession,
76
+ } from '../websocket/session.js';
77
+ import type {
78
+ AbloWebSocketSession,
79
+ WebSocketObservedDelta,
80
+ } from '../websocket/sessionContract.js';
81
+ import { subscribeWebSocketReadChanges } from '../websocket/contextSubscription.js';
82
+ import type {
83
+ CoreSyncEventMap,
84
+ } from '../websocket/transport.js';
70
85
 
71
86
  export interface AbloHttpClientOptions<S extends SchemaRecord>
72
87
  extends HttpClientConfig<S> {
@@ -76,6 +91,16 @@ export interface AbloHttpClientOptions<S extends SchemaRecord>
76
91
  * @default 30_000
77
92
  */
78
93
  readonly timeoutMs?: number;
94
+ /** Initial area of interest for `transport: 'websocket'`. */
95
+ readonly syncGroups?: readonly string[];
96
+ /** Application frame names accepted by `subscribe` on the WebSocket transport. */
97
+ readonly collaborationEvents?: readonly string[];
98
+ /** Durable resume position for WebSocket observation. */
99
+ readonly cursorStore?: import('../../client/contract.js').ObserveCursorStore;
100
+ readonly cursorKey?: string;
101
+ readonly reconnectDelay?: number;
102
+ readonly maxReconnectDelay?: number;
103
+ readonly connectTimeoutMs?: number;
79
104
  }
80
105
 
81
106
  declare const capturedRowBrand: unique symbol;
@@ -219,6 +244,30 @@ export type AbloHttpClient<S extends SchemaRecord> = {
219
244
  readonly sessions: SessionResource<S>;
220
245
  };
221
246
 
247
+ /** The same typed resource client, with server-pushed capabilities carried by WebSocket. */
248
+ export type AbloWebSocketClient<S extends SchemaRecord> = AbloHttpClient<S> & {
249
+ observe(options?: { signal?: AbortSignal }): AsyncIterable<WebSocketObservedDelta>;
250
+ subscribe<K extends keyof CoreSyncEventMap>(
251
+ event: K,
252
+ listener: (...args: CoreSyncEventMap[K]) => void,
253
+ ): () => void;
254
+ updateSubscription(
255
+ syncGroups: readonly string[],
256
+ options?: { timeoutMs?: number },
257
+ ): Promise<{ syncGroups: string[] }>;
258
+ readonly presence: {
259
+ update(input?: {
260
+ readonly status?: 'online' | 'away' | 'offline';
261
+ readonly customStatus?: string;
262
+ readonly timezone?: string;
263
+ readonly activity?: Readonly<Record<string, JsonValue>>;
264
+ }): Promise<void>;
265
+ };
266
+ readonly collaboration: {
267
+ send(event: string, payload: Readonly<Record<string, JsonValue>>): Promise<void>;
268
+ };
269
+ };
270
+
222
271
  /**
223
272
  * Private transport members that pass straight through the facade. Everything
224
273
  * else must be a declared schema model; there is no dynamic fallback namespace.
@@ -497,8 +546,9 @@ function createHttpModelClient<T, C = T>(
497
546
  /** @internal Constructed only through the public `Ablo({ transport: 'http' })` factory. */
498
547
  export function createAbloHttpClient<S extends SchemaRecord>(
499
548
  options: AbloHttpClientOptions<S>,
500
- ): AbloHttpClient<S> {
549
+ ): AbloHttpClient<S> | AbloWebSocketClient<S> {
501
550
  const { schema, onCommitReceipt, ...rest } = options;
551
+ const usesWebSocket = options.transport === 'websocket';
502
552
  const readSetContext = createReadSetContext();
503
553
  const transport: HttpTransport = createHttpTransport({
504
554
  ...rest,
@@ -509,6 +559,21 @@ export function createAbloHttpClient<S extends SchemaRecord>(
509
559
  recordHttpCommitReceipt(readSetContext, observation);
510
560
  onCommitReceipt?.(observation);
511
561
  },
562
+ ...(usesWebSocket ? {
563
+ dispatchCommit: async (input) => {
564
+ const receipt = await (await webSocketSession()).commit(input);
565
+ recordWebSocketCommitReceipt(readSetContext, {
566
+ receipt,
567
+ operations: input.operations,
568
+ reads: input.reads,
569
+ });
570
+ return receipt;
571
+ },
572
+ dispatchClaim: async (input) => (await webSocketSession()).claim(input),
573
+ releaseDispatchedClaim: (input) => {
574
+ void webSocketSession().then((session) => session.release(input));
575
+ },
576
+ } : {}),
512
577
  });
513
578
  const schemaModels = new Set(Object.keys(schema.models));
514
579
  const clientIdentity = {};
@@ -517,6 +582,56 @@ export function createAbloHttpClient<S extends SchemaRecord>(
517
582
  string,
518
583
  HttpModelClient<Record<string, unknown>, Record<string, unknown>>
519
584
  >();
585
+ let webSocket: AbloWebSocketSession | null = null;
586
+ let webSocketPromise: Promise<AbloWebSocketSession> | null = null;
587
+ const webSocketOpen = new AbortController();
588
+ let disposed = false;
589
+
590
+ const webSocketSession = (): Promise<AbloWebSocketSession> => {
591
+ if (disposed) {
592
+ return Promise.reject(new AbloConnectionError('This Ablo client is disposed.'));
593
+ }
594
+ if (webSocket) return Promise.resolve(webSocket);
595
+ if (webSocketPromise) return webSocketPromise;
596
+ webSocketPromise = (async () => {
597
+ await transport.ready();
598
+ const session = await createWebSocketSession({
599
+ baseUrl: rest.baseURL ?? undefined,
600
+ getAuthToken: async () => (await transport.getAuthToken()) ?? undefined,
601
+ syncGroups: options.syncGroups,
602
+ collaborationEvents: options.collaborationEvents,
603
+ cursorStore: options.cursorStore,
604
+ cursorKey: options.cursorKey,
605
+ reconnectDelay: options.reconnectDelay,
606
+ maxReconnectDelay: options.maxReconnectDelay,
607
+ connectTimeoutMs: options.connectTimeoutMs,
608
+ }, webSocketOpen.signal);
609
+ if (disposed) {
610
+ await session.close();
611
+ throw new AbloConnectionError('This Ablo client is disposed.');
612
+ }
613
+ webSocket = session;
614
+ return session;
615
+ })().finally(() => {
616
+ webSocketPromise = null;
617
+ });
618
+ return webSocketPromise;
619
+ };
620
+
621
+ const ready = async (): Promise<void> => {
622
+ await transport.ready();
623
+ if (usesWebSocket) await webSocketSession();
624
+ };
625
+
626
+ const dispose = async (): Promise<void> => {
627
+ if (disposed) return;
628
+ disposed = true;
629
+ webSocketOpen.abort();
630
+ const opening = webSocketPromise;
631
+ if (webSocket) await webSocket.close();
632
+ else await opening?.then((session) => session.close()).catch(() => undefined);
633
+ await transport.dispose();
634
+ };
520
635
 
521
636
  const model = (
522
637
  name: string,
@@ -563,11 +678,50 @@ export function createAbloHttpClient<S extends SchemaRecord>(
563
678
  return {
564
679
  context: readSetContext,
565
680
  client: clientIdentity,
566
- onChange: transport.onChange,
681
+ onChange: (reads: readonly ReadDependency[], listener: Parameters<typeof transport.onChange>[1]) =>
682
+ usesWebSocket
683
+ ? subscribeWhenConnected(webSocketSession, reads, listener)
684
+ : transport.onChange(reads, listener),
567
685
  };
568
686
  }
569
687
  if (typeof prop !== 'string') return undefined;
570
688
  if (prop === 'commits') return commits;
689
+ if (prop === 'ready') return ready;
690
+ if (prop === 'dispose') return dispose;
691
+ if (usesWebSocket && prop === 'observe') {
692
+ return async function* (observeOptions?: { signal?: AbortSignal }) {
693
+ yield* (await webSocketSession()).observe(observeOptions);
694
+ };
695
+ }
696
+ if (usesWebSocket && prop === 'subscribe') {
697
+ return <K extends keyof CoreSyncEventMap>(
698
+ event: K,
699
+ listener: (...args: CoreSyncEventMap[K]) => void,
700
+ ) => {
701
+ let stop: (() => void) | undefined;
702
+ let cancelled = false;
703
+ void webSocketSession().then((session) => {
704
+ if (!cancelled) stop = session.subscribe(event, listener);
705
+ });
706
+ return () => { cancelled = true; stop?.(); };
707
+ };
708
+ }
709
+ if (usesWebSocket && prop === 'updateSubscription') {
710
+ return async (groups: readonly string[], subscriptionOptions?: { timeoutMs?: number }) =>
711
+ (await webSocketSession()).updateSubscription(groups, subscriptionOptions);
712
+ }
713
+ if (usesWebSocket && prop === 'presence') {
714
+ return {
715
+ update: async (input?: Parameters<AbloWebSocketSession['presence']['update']>[0]) =>
716
+ (await webSocketSession()).presence.update(input),
717
+ };
718
+ }
719
+ if (usesWebSocket && prop === 'collaboration') {
720
+ return {
721
+ send: async (event: string, payload: Readonly<Record<string, JsonValue>>) =>
722
+ (await webSocketSession()).collaboration.send(event, payload),
723
+ };
724
+ }
571
725
  // Real protocol members pass through unchanged.
572
726
  if (isProtocolMember(prop)) {
573
727
  return transport[prop];
@@ -586,5 +740,18 @@ export function createAbloHttpClient<S extends SchemaRecord>(
586
740
  // A single boundary cast. `AbloHttpClient<S>` declares only what the model
587
741
  // accessor and the passed-through protocol members actually implement, so no
588
742
  // method on this type is missing at runtime.
589
- return facade as AbloHttpClient<S>;
743
+ return facade as AbloHttpClient<S> | AbloWebSocketClient<S>;
744
+ }
745
+
746
+ function subscribeWhenConnected(
747
+ session: () => Promise<AbloWebSocketSession>,
748
+ reads: readonly ReadDependency[],
749
+ listener: Parameters<typeof subscribeWebSocketReadChanges>[2],
750
+ ): () => void {
751
+ let stop: (() => void) | undefined;
752
+ let cancelled = false;
753
+ void session().then((connected) => {
754
+ if (!cancelled) stop = subscribeWebSocketReadChanges(connected, reads, listener);
755
+ });
756
+ return () => { cancelled = true; stop?.(); };
590
757
  }
@@ -124,6 +124,12 @@ import {
124
124
  } from '../../client/resources/modelOperations.js';
125
125
  import type { Duration } from '../../utils/duration.js';
126
126
  import { claimDescription, partName } from '../../coordination/schema.js';
127
+ import type {
128
+ ClaimAcquired,
129
+ ClaimBeginPayload,
130
+ ClaimGranted,
131
+ ClaimQueued,
132
+ } from '../../coordination/schema.js';
127
133
  import type { BatchFence } from '../../claims/locator.js';
128
134
  import {
129
135
  subTarget,
@@ -169,6 +175,7 @@ import {
169
175
  } from '../../commit/httpRequest.js';
170
176
  import { resolveDurableWrites } from '../../commit/durableWrites.js';
171
177
  import { createHttpReadOnChange, type HttpReadOnChange } from './subscription.js';
178
+ import type { CommitFrameOperation } from '../websocket/commitFrames.js';
172
179
 
173
180
  /** @internal Private options for the schema-agnostic HTTP protocol transport. */
174
181
  export type HttpTransportOptions = Omit<HttpClientConfig, 'schema'> & {
@@ -191,6 +198,24 @@ export type HttpTransportOptions = Omit<HttpClientConfig, 'schema'> & {
191
198
  * @default 30_000
192
199
  */
193
200
  readonly timeoutMs?: number;
201
+ /** @internal Routes writes through the selected duplex transport. */
202
+ readonly dispatchCommit?: ((input: {
203
+ readonly clientTxId: string;
204
+ readonly operations: readonly CommitFrameOperation[];
205
+ readonly reads?: readonly import('../../coordination/schema.js').ReadDependency[] | null;
206
+ }) => Promise<CommitReceiptWire>) | undefined;
207
+ /** @internal Routes held-claim acquisition through the shared socket. */
208
+ readonly dispatchClaim?: ((input: ClaimBeginPayload & {
209
+ readonly timeoutMs?: number;
210
+ readonly signal?: AbortSignal;
211
+ readonly onQueued?: (event: ClaimQueued) => Error | undefined;
212
+ }) => Promise<ClaimAcquired | ClaimGranted>) | undefined;
213
+ /** @internal Releases a socket-acquired claim on that same session. */
214
+ readonly releaseDispatchedClaim?: ((input: {
215
+ readonly claimId: string;
216
+ readonly entityType: string;
217
+ readonly entityId: string;
218
+ }) => void) | undefined;
194
219
  };
195
220
 
196
221
  /** @internal Default per-request deadline for the private HTTP transport. */
@@ -981,11 +1006,22 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
981
1006
  path: string;
982
1007
  body: unknown;
983
1008
  wait: CommitWait;
1009
+ operations: readonly CommitFrameOperation[];
1010
+ reads?: readonly import('../../coordination/schema.js').ReadDependency[] | null;
984
1011
  },
985
1012
  beforeConfirmation?: (response: CommitResponse) => Promise<void>
986
1013
  ): Promise<CommitResponse> {
987
1014
  return runInHttpCommitLane(async () => {
988
1015
  await prepare();
1016
+ if (options.dispatchCommit) {
1017
+ const response = await options.dispatchCommit({
1018
+ clientTxId: input.idempotencyKey,
1019
+ operations: input.operations,
1020
+ ...(input.reads !== undefined ? { reads: input.reads } : {}),
1021
+ });
1022
+ if (response.status === 'confirmed') await beforeConfirmation?.(response);
1023
+ return response;
1024
+ }
989
1025
  // Startup preparation covers replay. Re-draining here makes every later write wait
990
1026
  // behind an ambiguous predecessor from this same process.
991
1027
  const replayed = await replayHttpCommitOutbox();
@@ -1296,6 +1332,17 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1296
1332
  reads: commitOptions.reads,
1297
1333
  };
1298
1334
  const wait = commitOptions.wait ?? 'confirmed';
1335
+ const frameOperations: CommitFrameOperation[] = operations.map((operation) => ({
1336
+ type: operation.action.toUpperCase(),
1337
+ model: operation.model,
1338
+ id: operation.id ?? null,
1339
+ input: operation.data ?? null,
1340
+ where: operation.where ?? null,
1341
+ transactionId: operation.transactionId ?? null,
1342
+ claimId: operation.claimId ?? null,
1343
+ readAt: operation.readAt ?? null,
1344
+ fenceToken: operation.fenceToken ?? null,
1345
+ }));
1299
1346
  let body: CommitResponse;
1300
1347
  try {
1301
1348
  body = await dispatchHttpCommit({
@@ -1304,6 +1351,8 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1304
1351
  idempotencyKey: clientTxId,
1305
1352
  body: requestBody,
1306
1353
  wait,
1354
+ operations: frameOperations,
1355
+ reads: commitOptions.reads,
1307
1356
  });
1308
1357
  } catch (error) {
1309
1358
  // Coordination collision over HTTP — surface it to observability on the
@@ -1529,6 +1578,17 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1529
1578
  if (action === 'create') requestBody.id = id;
1530
1579
  if (data !== undefined) requestBody.data = data;
1531
1580
 
1581
+ const frameOperation: CommitFrameOperation = {
1582
+ type: action.toUpperCase(),
1583
+ model: modelName,
1584
+ id: action === 'create' ? (id || null) : id,
1585
+ input: data ?? null,
1586
+ transactionId: null,
1587
+ claimId: normalizeClaimId(options?.claimRef) ?? claimHandle?.id ?? null,
1588
+ readAt: readAt ?? null,
1589
+ fenceToken: options?.fenceToken ?? claimHandle?.fenceToken ?? null,
1590
+ };
1591
+
1532
1592
  let body: CommitResponse;
1533
1593
  try {
1534
1594
  body = await dispatchHttpCommit(
@@ -1541,6 +1601,8 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1541
1601
  // confirmation. Early queued receipts remain available through the
1542
1602
  // lower-level `commits.create` lifecycle surface.
1543
1603
  wait: 'confirmed',
1604
+ operations: [frameOperation],
1605
+ reads: options?.reads,
1544
1606
  },
1545
1607
  beforeConfirmation
1546
1608
  );
@@ -1579,6 +1641,59 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1579
1641
  params: ClaimParams<Fields>
1580
1642
  ): Promise<{ id: string; fenceToken?: number }> => {
1581
1643
  const contention = resolveClaimContentionOptions(params);
1644
+ if (options.dispatchClaim) {
1645
+ const claimId = createClientTxId();
1646
+ let waited = false;
1647
+ try {
1648
+ const grant = await options.dispatchClaim({
1649
+ claimId,
1650
+ entityType: name,
1651
+ entityId: params.id,
1652
+ ...subTarget(params, name),
1653
+ description: claimDescription(params),
1654
+ ...(params.ttl !== undefined ? { estimatedMs: toMs(params.ttl) } : {}),
1655
+ queue: contention.wait,
1656
+ ...(contention.timeoutMs !== undefined ? { timeoutMs: contention.timeoutMs } : {}),
1657
+ ...(contention.signal !== undefined ? { signal: contention.signal } : {}),
1658
+ onQueued: (event) => {
1659
+ waited = true;
1660
+ emitClaimStatus(contention.onStatus, {
1661
+ type: 'queued',
1662
+ claimId: event.claimId,
1663
+ position: event.position,
1664
+ ahead: event.position + 1,
1665
+ });
1666
+ if (
1667
+ contention.maxDepth !== undefined
1668
+ && event.position >= contention.maxDepth
1669
+ ) {
1670
+ return new AbloClaimedError(
1671
+ `Claim queue for ${name}/${params.id} is ${event.position} deep (max ${contention.maxDepth}).`,
1672
+ { code: 'queue_too_deep' },
1673
+ );
1674
+ }
1675
+ return undefined;
1676
+ },
1677
+ });
1678
+ emitClaimStatus(contention.onStatus, {
1679
+ type: 'granted',
1680
+ claimId: grant.claimId,
1681
+ waited,
1682
+ });
1683
+ return grant.fenceToken !== undefined
1684
+ ? { id: grant.claimId, fenceToken: grant.fenceToken }
1685
+ : { id: grant.claimId };
1686
+ } catch (error) {
1687
+ const normalized = error instanceof AbloError
1688
+ ? error
1689
+ : new AbloConnectionError(String(error));
1690
+ emitClaimStatus(
1691
+ contention.onStatus,
1692
+ claimAttemptFailure(contention.wait, normalized),
1693
+ );
1694
+ throw error;
1695
+ }
1696
+ }
1582
1697
  // The row is named by the URL, so `target` carries only the narrowing a
1583
1698
  // claim adds below it. Sending it is what makes a field-scoped claim
1584
1699
  // actually field-scoped: the server's conflict rule reads `path`,
@@ -1656,10 +1771,17 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1656
1771
  };
1657
1772
  const releaseClaim = (
1658
1773
  params: ClaimLookupParams<T> | ClaimParams<Fields> | Claim<T>,
1659
- ): Promise<void> =>
1660
- requestRaw(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
1774
+ claimId?: string,
1775
+ ): Promise<void> => {
1776
+ const entityId = isClaimHandle(params) ? params.target.id : params.id;
1777
+ if (claimId && options.releaseDispatchedClaim) {
1778
+ options.releaseDispatchedClaim({ claimId, entityType: name, entityId });
1779
+ return Promise.resolve();
1780
+ }
1781
+ return requestRaw(claimPath(entityId), {
1661
1782
  method: 'DELETE',
1662
1783
  }).then(() => undefined);
1784
+ };
1663
1785
 
1664
1786
  // One beat on the held lease. A lapsed lease answers `claim_lost`
1665
1787
  // (409), which the wire error mapping surfaces as AbloClaimedError —
@@ -1763,7 +1885,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1763
1885
 
1764
1886
  const release = () => {
1765
1887
  stopHeartbeatLoop?.();
1766
- return releaseClaim(params);
1888
+ return releaseClaim(params, claimId);
1767
1889
  };
1768
1890
  // The handle handed back is a public claim, so its `meta` is the declared
1769
1891
  // shape — the same crossing the two decodes above make, spelled the same
@@ -1904,7 +2026,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1904
2026
  claim: undefined,
1905
2027
  });
1906
2028
  } finally {
1907
- await releaseClaim({ id }).catch(() => {});
2029
+ await releaseClaim({ id }, claimId).catch(() => {});
1908
2030
  }
1909
2031
  };
1910
2032
 
@@ -37,9 +37,10 @@ export type CommitAck = CanonicalCommitAck;
37
37
  export interface CommitFrameOperation {
38
38
  readonly type: string;
39
39
  readonly model: string;
40
- readonly id: string;
41
- readonly input?: Record<string, unknown>;
42
- readonly transactionId?: string;
40
+ readonly id: string | null;
41
+ readonly input?: Record<string, unknown> | null;
42
+ readonly where?: Record<string, unknown> | null;
43
+ readonly transactionId?: string | null;
43
44
  readonly claimId?: string | null;
44
45
  readonly readAt?: number | null;
45
46
  readonly fenceToken?: number | null;
@@ -65,6 +66,7 @@ export function buildCommitFrame(
65
66
  model: op.model,
66
67
  id: op.id,
67
68
  input: op.input,
69
+ where: op.where,
68
70
  transactionId: op.transactionId,
69
71
  claimId: op.claimId,
70
72
  readAt: op.readAt,
@@ -0,0 +1,55 @@
1
+ import type { ReadDependency } from '../../coordination/schema.js';
2
+ import { AbloStaleContextError } from '../../errors.js';
3
+ import type { AbloWebSocketSession } from './sessionContract.js';
4
+
5
+ /** Watch captured reads over the client's already-open WebSocket. */
6
+ export function subscribeWebSocketReadChanges(
7
+ session: AbloWebSocketSession,
8
+ reads: readonly ReadDependency[],
9
+ listener: (error: AbloStaleContextError) => void,
10
+ ): () => void {
11
+ if (reads.length === 0) return () => undefined;
12
+ const watchedRows = new Map(
13
+ reads
14
+ .filter((read): read is Extract<ReadDependency, { model: string }> => 'model' in read)
15
+ .map((read) => [`${read.model.toLowerCase()}:${read.id}`, read.readAt]),
16
+ );
17
+ const watchedGroups = new Map<string, number>(
18
+ reads
19
+ .filter((read): read is Extract<ReadDependency, { group: string }> => 'group' in read)
20
+ .map((read) => [read.group, read.readAt]),
21
+ );
22
+ let stopped = false;
23
+ let unsubscribe = (): void => undefined;
24
+ unsubscribe = session.subscribe('delta', (delta) => {
25
+ if (stopped) return;
26
+ const readAt = watchedRows.get(`${delta.modelName.toLowerCase()}:${delta.modelId}`)
27
+ ?? delta.syncGroups.reduce<number | undefined>((latest, group) => {
28
+ const groupReadAt = watchedGroups.get(group);
29
+ return groupReadAt === undefined
30
+ ? latest
31
+ : Math.max(latest ?? groupReadAt, groupReadAt);
32
+ }, undefined);
33
+ if (readAt === undefined || delta.id <= readAt) return;
34
+ stopped = true;
35
+ unsubscribe();
36
+ listener(new AbloStaleContextError(
37
+ `Captured context changed: ${delta.modelName}/${delta.modelId}.`,
38
+ {
39
+ code: 'stale_context',
40
+ details: {
41
+ conflicts: [{
42
+ model: delta.modelName,
43
+ id: delta.modelId,
44
+ readAt,
45
+ current: delta.id,
46
+ }],
47
+ },
48
+ },
49
+ ));
50
+ });
51
+ return () => {
52
+ stopped = true;
53
+ unsubscribe();
54
+ };
55
+ }