@abloatai/transaction 0.59.1 → 0.60.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 (166) hide show
  1. package/dist/auth/apiKey.d.ts +10 -11
  2. package/dist/auth/apiKey.d.ts.map +1 -1
  3. package/dist/auth/apiKey.js +15 -29
  4. package/dist/auth/apiKey.js.map +1 -1
  5. package/dist/auth/browserCredentialSafety.js +1 -1
  6. package/dist/auth/browserCredentialSafety.js.map +1 -1
  7. package/dist/auth/capabilityLifecycle.d.ts +1 -1
  8. package/dist/auth/capabilityLifecycle.d.ts.map +1 -1
  9. package/dist/auth/capabilityLifecycle.js +2 -1
  10. package/dist/auth/capabilityLifecycle.js.map +1 -1
  11. package/dist/auth/credentialEndpoint.js +1 -1
  12. package/dist/auth/credentialEndpoint.js.map +1 -1
  13. package/dist/auth/credentialResult.d.ts +2 -1
  14. package/dist/auth/credentialResult.d.ts.map +1 -1
  15. package/dist/auth/credentialResult.js.map +1 -1
  16. package/dist/claims/events.d.ts +1 -1
  17. package/dist/claims/events.d.ts.map +1 -1
  18. package/dist/client/ablo.d.ts +15 -11
  19. package/dist/client/ablo.d.ts.map +1 -1
  20. package/dist/client/ablo.js +5 -14
  21. package/dist/client/ablo.js.map +1 -1
  22. package/dist/client/resources/httpResources.d.ts +1 -169
  23. package/dist/client/resources/httpResources.d.ts.map +1 -1
  24. package/dist/client/resources/httpResources.js +1 -1
  25. package/dist/client/resources/httpResources.js.map +1 -1
  26. package/dist/client/resources/modelOperations.d.ts +0 -9
  27. package/dist/client/resources/modelOperations.d.ts.map +1 -1
  28. package/dist/client/surface.d.ts +2 -2
  29. package/dist/client/surface.d.ts.map +1 -1
  30. package/dist/client/surface.js +8 -3
  31. package/dist/client/surface.js.map +1 -1
  32. package/dist/commit/contract.d.ts +10 -10
  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/index.d.ts +2 -2
  38. package/dist/coordination/index.d.ts.map +1 -1
  39. package/dist/coordination/index.js +3 -4
  40. package/dist/coordination/index.js.map +1 -1
  41. package/dist/coordination/schema.d.ts +3 -47
  42. package/dist/coordination/schema.d.ts.map +1 -1
  43. package/dist/coordination/schema.js +7 -69
  44. package/dist/coordination/schema.js.map +1 -1
  45. package/dist/errorCodes.d.ts +2 -2
  46. package/dist/errorCodes.d.ts.map +1 -1
  47. package/dist/errorCodes.js +5 -9
  48. package/dist/errorCodes.js.map +1 -1
  49. package/dist/errors.d.ts.map +1 -1
  50. package/dist/errors.js +16 -1
  51. package/dist/errors.js.map +1 -1
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/sessions/client.d.ts +29 -0
  56. package/dist/sessions/client.d.ts.map +1 -0
  57. package/dist/sessions/client.js +69 -0
  58. package/dist/sessions/client.js.map +1 -0
  59. package/dist/sessions/contract.d.ts +97 -0
  60. package/dist/sessions/contract.d.ts.map +1 -0
  61. package/dist/sessions/contract.js +5 -0
  62. package/dist/sessions/contract.js.map +1 -0
  63. package/dist/{auth/sessionMint.d.ts → sessions/create.d.ts} +4 -4
  64. package/dist/sessions/create.d.ts.map +1 -0
  65. package/dist/{auth/sessionMint.js → sessions/create.js} +11 -10
  66. package/dist/sessions/create.js.map +1 -0
  67. package/dist/sessions/handler.d.ts +23 -0
  68. package/dist/sessions/handler.d.ts.map +1 -0
  69. package/dist/sessions/handler.js +55 -0
  70. package/dist/sessions/handler.js.map +1 -0
  71. package/dist/sessions/index.d.ts +14 -0
  72. package/dist/sessions/index.d.ts.map +1 -0
  73. package/dist/sessions/index.js +14 -0
  74. package/dist/sessions/index.js.map +1 -0
  75. package/dist/{transport/connection/credentialLifecycle.d.ts → sessions/lifecycle.d.ts} +6 -6
  76. package/dist/sessions/lifecycle.d.ts.map +1 -0
  77. package/dist/{transport/connection/credentialLifecycle.js → sessions/lifecycle.js} +5 -5
  78. package/dist/sessions/lifecycle.js.map +1 -0
  79. package/dist/sessions/source.d.ts +24 -0
  80. package/dist/sessions/source.d.ts.map +1 -0
  81. package/dist/sessions/source.js +56 -0
  82. package/dist/sessions/source.js.map +1 -0
  83. package/dist/transport/connection/index.d.ts +0 -1
  84. package/dist/transport/connection/index.d.ts.map +1 -1
  85. package/dist/transport/connection/index.js +0 -1
  86. package/dist/transport/connection/index.js.map +1 -1
  87. package/dist/transport/connection/manager.d.ts +1 -1
  88. package/dist/transport/connection/manager.d.ts.map +1 -1
  89. package/dist/transport/http/client.d.ts +44 -16
  90. package/dist/transport/http/client.d.ts.map +1 -1
  91. package/dist/transport/http/client.js +125 -12
  92. package/dist/transport/http/client.js.map +1 -1
  93. package/dist/transport/http/options.d.ts +4 -1
  94. package/dist/transport/http/options.d.ts.map +1 -1
  95. package/dist/transport/http/transport.d.ts +25 -10
  96. package/dist/transport/http/transport.d.ts.map +1 -1
  97. package/dist/transport/http/transport.js +102 -56
  98. package/dist/transport/http/transport.js.map +1 -1
  99. package/dist/transport/websocket/commitFrames.d.ts +5 -5
  100. package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
  101. package/dist/transport/websocket/commitFrames.js +1 -0
  102. package/dist/transport/websocket/commitFrames.js.map +1 -1
  103. package/dist/transport/websocket/contextSubscription.d.ts +6 -0
  104. package/dist/transport/websocket/contextSubscription.d.ts.map +1 -0
  105. package/dist/transport/websocket/contextSubscription.js +45 -0
  106. package/dist/transport/websocket/contextSubscription.js.map +1 -0
  107. package/dist/transport/websocket/frameHandlers.d.ts +0 -14
  108. package/dist/transport/websocket/frameHandlers.d.ts.map +1 -1
  109. package/dist/transport/websocket/frameHandlers.js +1 -71
  110. package/dist/transport/websocket/frameHandlers.js.map +1 -1
  111. package/dist/transport/websocket/session.d.ts +4 -0
  112. package/dist/transport/websocket/session.d.ts.map +1 -0
  113. package/dist/transport/websocket/session.js +425 -0
  114. package/dist/transport/websocket/session.js.map +1 -0
  115. package/dist/transport/websocket/sessionContract.d.ts +78 -0
  116. package/dist/transport/websocket/sessionContract.d.ts.map +1 -0
  117. package/dist/transport/websocket/sessionContract.js +2 -0
  118. package/dist/transport/websocket/sessionContract.js.map +1 -0
  119. package/dist/transport/websocket/transport.d.ts +27 -42
  120. package/dist/transport/websocket/transport.d.ts.map +1 -1
  121. package/dist/transport/websocket/transport.js +77 -102
  122. package/dist/transport/websocket/transport.js.map +1 -1
  123. package/dist/wire/inboundFrames.d.ts +0 -10
  124. package/dist/wire/inboundFrames.d.ts.map +1 -1
  125. package/dist/wire/inboundFrames.js +1 -6
  126. package/dist/wire/inboundFrames.js.map +1 -1
  127. package/package.json +7 -1
  128. package/src/auth/apiKey.ts +25 -44
  129. package/src/auth/browserCredentialSafety.ts +1 -1
  130. package/src/auth/capabilityLifecycle.ts +3 -2
  131. package/src/auth/credentialEndpoint.ts +1 -1
  132. package/src/auth/credentialResult.ts +2 -0
  133. package/src/claims/events.ts +1 -2
  134. package/src/client/ablo.ts +23 -13
  135. package/src/client/resources/httpResources.ts +2 -174
  136. package/src/client/resources/modelOperations.ts +0 -10
  137. package/src/client/surface.ts +9 -4
  138. package/src/commit/recordRuntime.ts +4 -2
  139. package/src/coordination/index.ts +2 -9
  140. package/src/coordination/schema.ts +7 -81
  141. package/src/errorCodes.ts +8 -13
  142. package/src/errors.ts +18 -1
  143. package/src/index.ts +1 -0
  144. package/src/sessions/client.ts +121 -0
  145. package/src/sessions/contract.ts +116 -0
  146. package/src/{auth/sessionMint.ts → sessions/create.ts} +13 -12
  147. package/src/sessions/handler.ts +96 -0
  148. package/src/sessions/index.ts +13 -0
  149. package/src/{transport/connection/credentialLifecycle.ts → sessions/lifecycle.ts} +5 -5
  150. package/src/sessions/source.ts +94 -0
  151. package/src/transport/connection/index.ts +0 -1
  152. package/src/transport/connection/manager.ts +1 -1
  153. package/src/transport/http/client.ts +183 -26
  154. package/src/transport/http/options.ts +4 -1
  155. package/src/transport/http/transport.ts +140 -78
  156. package/src/transport/websocket/commitFrames.ts +5 -5
  157. package/src/transport/websocket/contextSubscription.ts +55 -0
  158. package/src/transport/websocket/frameHandlers.ts +0 -102
  159. package/src/transport/websocket/session.ts +522 -0
  160. package/src/transport/websocket/sessionContract.ts +106 -0
  161. package/src/transport/websocket/transport.ts +85 -137
  162. package/src/wire/inboundFrames.ts +0 -6
  163. package/dist/auth/sessionMint.d.ts.map +0 -1
  164. package/dist/auth/sessionMint.js.map +0 -1
  165. package/dist/transport/connection/credentialLifecycle.d.ts.map +0 -1
  166. package/dist/transport/connection/credentialLifecycle.js.map +0 -1
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * Keeps the short-lived access credential fresh. It owns the re-mint hook, a
3
3
  * single-flight guard that stops concurrent triggers from minting more than
4
- * once, and a browser-only proactive refresh — a timer plus an OS-wake listener
5
- * — that renews the credential ahead of expiry. It reaches the rest of the
4
+ * once, and proactive refresh — an expiry-aware timer plus a browser OS-wake
5
+ * listener — that renews the credential ahead of expiry. It reaches the rest of the
6
6
  * client only through the small {@link CredentialLifecycleContext} interface,
7
7
  * so the two can reference each other without an import cycle.
8
8
  */
9
9
 
10
- import type { RecoveryClass } from '../../errorCodes.js';
11
- import { noopLogger, type Logger } from '../../logger.js';
10
+ import type { RecoveryClass } from '../errorCodes.js';
11
+ import { noopLogger, type Logger } from '../logger.js';
12
12
  import {
13
13
  credentialExpiry,
14
14
  credentialToken,
15
15
  type CredentialProvider,
16
16
  type CredentialProviderResult,
17
- } from '../../auth/credentialResult.js';
17
+ } from '../auth/credentialResult.js';
18
18
 
19
19
  /**
20
20
  * Tri-state outcome of a credential re-mint, mirroring the `getToken`
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Runtime lifecycle for a renewable session supplied to `Ablo({ session })`.
3
+ *
4
+ * A session provider mints a credential, while this boundary decides when to
5
+ * reuse it. Keeping that policy here prevents HTTP requests and the WebSocket
6
+ * bootstrap from independently minting duplicate sessions for the same actor.
7
+ */
8
+
9
+ import type {
10
+ CredentialProvider,
11
+ CredentialProviderResult,
12
+ } from '../auth/credentialResult.js';
13
+ import type {
14
+ SessionProvider,
15
+ SessionProviderResult,
16
+ SessionSource,
17
+ } from './contract.js';
18
+ import { AbloValidationError } from '../errors.js';
19
+ import {
20
+ createEndpointCredentialResolver,
21
+ isCredentialEndpoint,
22
+ } from '../auth/credentialEndpoint.js';
23
+ import { protectBrowserCredentialProvider } from '../auth/browserCredentialSafety.js';
24
+
25
+ const SESSION_REFRESH_SKEW_MS = 30_000;
26
+
27
+ function remainsUsable(session: Exclude<SessionProviderResult, null>, now: number): boolean {
28
+ const expiresAt = Date.parse(session.expiresAt);
29
+ return Number.isFinite(expiresAt) && expiresAt - now > SESSION_REFRESH_SKEW_MS;
30
+ }
31
+
32
+ /** Cache one minted session until it approaches expiry; concurrent refreshes share one mint. */
33
+ export function cachedSessionProvider(provider: SessionProvider): CredentialProvider {
34
+ let current: Exclude<SessionProviderResult, null> | null = null;
35
+ let refreshing: Promise<SessionProviderResult> | null = null;
36
+
37
+ return async () => {
38
+ if (current && remainsUsable(current, Date.now())) return current;
39
+ refreshing ??= provider().then((next) => {
40
+ current = next;
41
+ return next;
42
+ }).finally(() => {
43
+ refreshing = null;
44
+ });
45
+ return refreshing;
46
+ };
47
+ }
48
+
49
+ /** The one credential input a live transport consumes. */
50
+ export interface SessionAccess {
51
+ readonly renewable: boolean;
52
+ credential(): Promise<CredentialProviderResult>;
53
+ }
54
+
55
+ /** Normalize a static or renewable session before it reaches a transport. */
56
+ export function createSessionAccess(
57
+ source: SessionSource | null | undefined,
58
+ credential: () => Promise<CredentialProviderResult>,
59
+ ): SessionAccess {
60
+ return {
61
+ renewable:
62
+ typeof source === 'function'
63
+ || (typeof source === 'object' && source !== null && 'endpoint' in source),
64
+ credential,
65
+ };
66
+ }
67
+
68
+ /** Resolve the public session forms into the credential shape auth consumes. */
69
+ export function resolveSessionCredential(
70
+ source: SessionSource,
71
+ options: {
72
+ readonly fetch?: typeof globalThis.fetch;
73
+ readonly dangerouslyAllowBrowser?: boolean;
74
+ },
75
+ ): string | CredentialProvider {
76
+ if (typeof source === 'function') {
77
+ return protectBrowserCredentialProvider(
78
+ cachedSessionProvider(source),
79
+ options.dangerouslyAllowBrowser,
80
+ );
81
+ }
82
+ if (!('endpoint' in source)) return source.token;
83
+ if (!isCredentialEndpoint(source.endpoint)) {
84
+ throw new AbloValidationError(
85
+ '`session.endpoint` expects a URL or path such as \'/api/ablo-session\'.',
86
+ { code: 'invalid_options', param: 'session.endpoint' },
87
+ );
88
+ }
89
+ return protectBrowserCredentialProvider(createEndpointCredentialResolver(source.endpoint, {
90
+ fetch: options.fetch,
91
+ timeoutMs: source.timeoutMs,
92
+ allowCrossOrigin: source.allowCrossOrigin,
93
+ }), options.dangerouslyAllowBrowser);
94
+ }
@@ -1,4 +1,3 @@
1
1
  /** Shared connection lifecycle boundary. */
2
2
  export * from './manager.js';
3
- export * from './credentialLifecycle.js';
4
3
  export * from './networkProbe.js';
@@ -42,7 +42,7 @@
42
42
 
43
43
  import { probeNetwork, type ProbeResult } from './networkProbe.js';
44
44
  import type { AuthTokenGetter } from '../../auth/credentialSource.js';
45
- import type { CredentialRefreshOutcome } from './credentialLifecycle.js';
45
+ import type { CredentialRefreshOutcome } from '../../sessions/lifecycle.js';
46
46
  import { noopLogger, type Logger } from '../../logger.js';
47
47
  import {
48
48
  noopSocketObservability,
@@ -21,7 +21,6 @@ import {
21
21
  createHttpTransport,
22
22
  type HttpTransport,
23
23
  } from './transport.js';
24
- import { modelWireNames } from '../../auth/capability.js';
25
24
  import type { HttpClientConfig } from './options.js';
26
25
  import type {
27
26
  CommitResource,
@@ -33,9 +32,6 @@ import type {
33
32
  ModelReadOptions,
34
33
  ModelMutationOptions,
35
34
  ModelList,
36
- CreateSessionParams,
37
- AbloSession,
38
- SessionResource,
39
35
  } from '../../client/resources/httpResources.js';
40
36
  import { collectModelList, modelList } from '../../client/resources/httpResources.js';
41
37
  import { resolveCreateId } from '../../client/resources/modelCreate.js';
@@ -56,6 +52,7 @@ import {
56
52
  type FunctionalUpdateOptions,
57
53
  } from '../../client/resources/functionalUpdate.js';
58
54
  import type { HeldClaim, HeldLease } from '../../types/streams.js';
55
+ import type { JsonValue } from '../../types/streams.js';
59
56
  import { AbloConnectionError, AbloValidationError } from '../../errors.js';
60
57
  import type { ReadDependency } from '../../coordination/schema.js';
61
58
  import {
@@ -65,8 +62,22 @@ import {
65
62
  prepareReadSet,
66
63
  type ReadSetContext,
67
64
  } from '../../commit/readSetContext.js';
68
- import { recordHttpCommitReceipt } from '../../commit/recordRuntime.js';
65
+ import {
66
+ recordHttpCommitReceipt,
67
+ recordWebSocketCommitReceipt,
68
+ } from '../../commit/recordRuntime.js';
69
69
  import type { EffectiveAuthority } from '../../auth/capability.js';
70
+ import {
71
+ createWebSocketSession,
72
+ } from '../websocket/session.js';
73
+ import type {
74
+ AbloWebSocketSession,
75
+ WebSocketObservedDelta,
76
+ } from '../websocket/sessionContract.js';
77
+ import { subscribeWebSocketReadChanges } from '../websocket/contextSubscription.js';
78
+ import type {
79
+ CoreSyncEventMap,
80
+ } from '../websocket/transport.js';
70
81
 
71
82
  export interface AbloHttpClientOptions<S extends SchemaRecord>
72
83
  extends HttpClientConfig<S> {
@@ -76,6 +87,16 @@ export interface AbloHttpClientOptions<S extends SchemaRecord>
76
87
  * @default 30_000
77
88
  */
78
89
  readonly timeoutMs?: number;
90
+ /** Initial groups observed by a session-backed WebSocket client. */
91
+ readonly groups?: readonly string[];
92
+ /** Application frame names accepted by `subscribe` on the WebSocket transport. */
93
+ readonly collaborationEvents?: readonly string[];
94
+ /** Durable resume position for WebSocket observation. */
95
+ readonly cursorStore?: import('../../client/contract.js').ObserveCursorStore;
96
+ readonly cursorKey?: string;
97
+ readonly reconnectDelay?: number;
98
+ readonly maxReconnectDelay?: number;
99
+ readonly connectTimeoutMs?: number;
79
100
  }
80
101
 
81
102
  declare const capturedRowBrand: unique symbol;
@@ -98,7 +119,7 @@ export type HttpModelMutationParams<P> = Omit<P, 'reads'> & {
98
119
  * coordinated writes.
99
120
  * It deliberately omits the stateful client's local-only
100
121
  * reads (the `local` namespace) and live subscriptions (`onChange`), which need
101
- * a resident graph and a persistent socket; those are absent from the type, so
122
+ * a local graph and a persistent socket; those are absent from the type, so
102
123
  * reaching for one is a compile error rather than a runtime gap.
103
124
  *
104
125
  * This is also the base the reactive per-model surface is composed from, rather
@@ -174,7 +195,7 @@ export interface HttpModelClient<T, C = T> {
174
195
 
175
196
  /**
176
197
  * The type of the stateless HTTP client: a typed {@link HttpModelClient} per
177
- * schema model, plus `commits`, `dispose`, and the session-mint surface. It
198
+ * schema model, plus `commits` and lifecycle operations. It
178
199
  * exposes only what request/response transport can do, so reaching for a
179
200
  * stateful-only capability — the `local` reads, `onChange`, or the synchronous
180
201
  * `claim.state`/`queue`/`reorder` reads — is a compile error rather than a value
@@ -210,13 +231,30 @@ export type AbloHttpClient<S extends SchemaRecord> = {
210
231
  dispose(): Promise<void>;
211
232
  /** Resolves the bearer credential this client authenticates with, or `null` if none is set. */
212
233
  getAuthToken(): Promise<string | null>;
213
- /**
214
- * Mints a short-lived, scoped session token. Minting is itself a stateless
215
- * request, so it is available here even though the local-cache reads are not.
216
- * Pass `{ user }` to mint an end-user key (`ek_`) or `{ agent, can }` to mint a
217
- * scoped agent key (`rk_`). See {@link CreateSessionParams}.
218
- */
219
- readonly sessions: SessionResource<S>;
234
+ };
235
+
236
+ /** The same typed resource client, with server-pushed capabilities carried by WebSocket. */
237
+ export type AbloWebSocketClient<S extends SchemaRecord> = AbloHttpClient<S> & {
238
+ observe(options?: { signal?: AbortSignal }): AsyncIterable<WebSocketObservedDelta>;
239
+ subscribe<K extends keyof CoreSyncEventMap>(
240
+ event: K,
241
+ listener: (...args: CoreSyncEventMap[K]) => void,
242
+ ): () => void;
243
+ updateSubscription(
244
+ groups: readonly string[],
245
+ options?: { timeoutMs?: number },
246
+ ): Promise<{ groups: string[] }>;
247
+ readonly presence: {
248
+ update(input?: {
249
+ readonly status?: 'online' | 'away' | 'offline';
250
+ readonly customStatus?: string;
251
+ readonly timezone?: string;
252
+ readonly activity?: Readonly<Record<string, JsonValue>>;
253
+ }): Promise<void>;
254
+ };
255
+ readonly collaboration: {
256
+ send(event: string, payload: Readonly<Record<string, JsonValue>>): Promise<void>;
257
+ };
220
258
  };
221
259
 
222
260
  /**
@@ -232,7 +270,6 @@ const PROTOCOL_MEMBERS = new Set<string>([
232
270
  'logs',
233
271
  'identity',
234
272
  'getAuthToken',
235
- 'sessions',
236
273
  ]);
237
274
 
238
275
  /** Narrows a bare property name to a transport key so the facade can index it typed. */
@@ -489,26 +526,39 @@ function createHttpModelClient<T, C = T>(
489
526
  }
490
527
 
491
528
  /**
492
- * Builds the stateless, typed HTTP client. Each `client.<model>` resolves to the
493
- * protocol client's model accessor, while `commits`, `dispose`, and the other
494
- * protocol members pass through unchanged. No socket is ever opened; the bearer
495
- * credential is the identity.
529
+ * Builds the typed headless client. An API-key identity uses HTTP. A session
530
+ * identity uses one lazily opened WebSocket by default for commits and live
531
+ * coordination until `dispose()`; point reads and administration remain HTTP.
496
532
  */
497
- /** @internal Constructed only through the public `Ablo({ transport: 'http' })` factory. */
533
+ /** @internal Constructed only through the public `Ablo()` factory. */
498
534
  export function createAbloHttpClient<S extends SchemaRecord>(
499
535
  options: AbloHttpClientOptions<S>,
500
- ): AbloHttpClient<S> {
536
+ ): AbloHttpClient<S> | AbloWebSocketClient<S> {
501
537
  const { schema, onCommitReceipt, ...rest } = options;
538
+ const usesWebSocket = options.transport === 'websocket'
539
+ || (options.transport === undefined && options.session != null);
502
540
  const readSetContext = createReadSetContext();
503
541
  const transport: HttpTransport = createHttpTransport({
504
542
  ...rest,
505
- // Derived from the schema this client is bound to, never assembled by hand
506
- // — see `auth/capability.ts`.
507
- modelTypenames: modelWireNames(schema.models),
508
543
  onCommitReceipt: (observation) => {
509
544
  recordHttpCommitReceipt(readSetContext, observation);
510
545
  onCommitReceipt?.(observation);
511
546
  },
547
+ ...(usesWebSocket ? {
548
+ dispatchCommit: async (input) => {
549
+ const receipt = await (await webSocketSession()).commit(input);
550
+ recordWebSocketCommitReceipt(readSetContext, {
551
+ receipt,
552
+ operations: input.operations,
553
+ reads: input.reads,
554
+ });
555
+ return receipt;
556
+ },
557
+ dispatchClaim: async (input) => (await webSocketSession()).claim(input),
558
+ releaseDispatchedClaim: (input) => {
559
+ void webSocketSession().then((session) => session.release(input));
560
+ },
561
+ } : {}),
512
562
  });
513
563
  const schemaModels = new Set(Object.keys(schema.models));
514
564
  const clientIdentity = {};
@@ -517,6 +567,56 @@ export function createAbloHttpClient<S extends SchemaRecord>(
517
567
  string,
518
568
  HttpModelClient<Record<string, unknown>, Record<string, unknown>>
519
569
  >();
570
+ let webSocket: AbloWebSocketSession | null = null;
571
+ let webSocketPromise: Promise<AbloWebSocketSession> | null = null;
572
+ const webSocketOpen = new AbortController();
573
+ let disposed = false;
574
+
575
+ const webSocketSession = (): Promise<AbloWebSocketSession> => {
576
+ if (disposed) {
577
+ return Promise.reject(new AbloConnectionError('This Ablo client is disposed.'));
578
+ }
579
+ if (webSocket) return Promise.resolve(webSocket);
580
+ if (webSocketPromise) return webSocketPromise;
581
+ webSocketPromise = (async () => {
582
+ await transport.ready();
583
+ const session = await createWebSocketSession({
584
+ baseUrl: rest.baseURL ?? undefined,
585
+ access: transport.access,
586
+ syncGroups: options.groups,
587
+ collaborationEvents: options.collaborationEvents,
588
+ cursorStore: options.cursorStore,
589
+ cursorKey: options.cursorKey,
590
+ reconnectDelay: options.reconnectDelay,
591
+ maxReconnectDelay: options.maxReconnectDelay,
592
+ connectTimeoutMs: options.connectTimeoutMs,
593
+ }, webSocketOpen.signal);
594
+ if (disposed) {
595
+ await session.close();
596
+ throw new AbloConnectionError('This Ablo client is disposed.');
597
+ }
598
+ webSocket = session;
599
+ return session;
600
+ })().finally(() => {
601
+ webSocketPromise = null;
602
+ });
603
+ return webSocketPromise;
604
+ };
605
+
606
+ const ready = async (): Promise<void> => {
607
+ await transport.ready();
608
+ if (usesWebSocket) await webSocketSession();
609
+ };
610
+
611
+ const dispose = async (): Promise<void> => {
612
+ if (disposed) return;
613
+ disposed = true;
614
+ webSocketOpen.abort();
615
+ const opening = webSocketPromise;
616
+ if (webSocket) await webSocket.close();
617
+ else await opening?.then((session) => session.close()).catch(() => undefined);
618
+ await transport.dispose();
619
+ };
520
620
 
521
621
  const model = (
522
622
  name: string,
@@ -563,11 +663,55 @@ export function createAbloHttpClient<S extends SchemaRecord>(
563
663
  return {
564
664
  context: readSetContext,
565
665
  client: clientIdentity,
566
- onChange: transport.onChange,
666
+ onChange: (reads: readonly ReadDependency[], listener: Parameters<typeof transport.onChange>[1]) =>
667
+ usesWebSocket
668
+ ? subscribeWhenConnected(webSocketSession, reads, listener)
669
+ : transport.onChange(reads, listener),
567
670
  };
568
671
  }
569
672
  if (typeof prop !== 'string') return undefined;
570
673
  if (prop === 'commits') return commits;
674
+ if (prop === 'ready') return ready;
675
+ if (prop === 'dispose') return dispose;
676
+ if (usesWebSocket && prop === 'observe') {
677
+ return async function* (observeOptions?: { signal?: AbortSignal }) {
678
+ yield* (await webSocketSession()).observe(observeOptions);
679
+ };
680
+ }
681
+ if (usesWebSocket && prop === 'subscribe') {
682
+ return <K extends keyof CoreSyncEventMap>(
683
+ event: K,
684
+ listener: (...args: CoreSyncEventMap[K]) => void,
685
+ ) => {
686
+ let stop: (() => void) | undefined;
687
+ let cancelled = false;
688
+ void webSocketSession().then((session) => {
689
+ if (!cancelled) stop = session.subscribe(event, listener);
690
+ });
691
+ return () => { cancelled = true; stop?.(); };
692
+ };
693
+ }
694
+ if (usesWebSocket && prop === 'updateSubscription') {
695
+ return async (groups: readonly string[], subscriptionOptions?: { timeoutMs?: number }) => {
696
+ const result = await (await webSocketSession()).updateSubscription(
697
+ groups,
698
+ subscriptionOptions,
699
+ );
700
+ return { groups: result.syncGroups };
701
+ };
702
+ }
703
+ if (usesWebSocket && prop === 'presence') {
704
+ return {
705
+ update: async (input?: Parameters<AbloWebSocketSession['presence']['update']>[0]) =>
706
+ (await webSocketSession()).presence.update(input),
707
+ };
708
+ }
709
+ if (usesWebSocket && prop === 'collaboration') {
710
+ return {
711
+ send: async (event: string, payload: Readonly<Record<string, JsonValue>>) =>
712
+ (await webSocketSession()).collaboration.send(event, payload),
713
+ };
714
+ }
571
715
  // Real protocol members pass through unchanged.
572
716
  if (isProtocolMember(prop)) {
573
717
  return transport[prop];
@@ -586,5 +730,18 @@ export function createAbloHttpClient<S extends SchemaRecord>(
586
730
  // A single boundary cast. `AbloHttpClient<S>` declares only what the model
587
731
  // accessor and the passed-through protocol members actually implement, so no
588
732
  // method on this type is missing at runtime.
589
- return facade as AbloHttpClient<S>;
733
+ return facade as AbloHttpClient<S> | AbloWebSocketClient<S>;
734
+ }
735
+
736
+ function subscribeWhenConnected(
737
+ session: () => Promise<AbloWebSocketSession>,
738
+ reads: readonly ReadDependency[],
739
+ listener: Parameters<typeof subscribeWebSocketReadChanges>[2],
740
+ ): () => void {
741
+ let stop: (() => void) | undefined;
742
+ let cancelled = false;
743
+ void session().then((connected) => {
744
+ if (!cancelled) stop = subscribeWebSocketReadChanges(connected, reads, listener);
745
+ });
746
+ return () => { cancelled = true; stop?.(); };
590
747
  }
@@ -37,7 +37,10 @@ export interface HttpClientConfig<S extends SchemaRecord = SchemaRecord>
37
37
  commitOutbox?: DurableWriteStore;
38
38
  /** Compatibility input and internal child-client identity seam. */
39
39
  commitOutboxScope?: CommitOutboxScope;
40
- /** Which transport carries this client's traffic. */
40
+ /**
41
+ * Which transport carries this client's traffic. Defaults to WebSocket for
42
+ * a scoped `session` and HTTP for an API key or direct auth token.
43
+ */
41
44
  transport?: 'websocket' | 'http' | undefined;
42
45
  /** @internal Exact receipt phases observed while dispatching one HTTP commit. */
43
46
  onCommitReceipt?: (observation: {