spacetimedb 2.1.0 → 2.2.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 (110) hide show
  1. package/LICENSE.txt +2 -2
  2. package/dist/angular/index.cjs +7 -2
  3. package/dist/angular/index.cjs.map +1 -1
  4. package/dist/angular/index.mjs +7 -2
  5. package/dist/angular/index.mjs.map +1 -1
  6. package/dist/browser/angular/index.mjs +7 -2
  7. package/dist/browser/angular/index.mjs.map +1 -1
  8. package/dist/browser/react/index.mjs +57 -6
  9. package/dist/browser/react/index.mjs.map +1 -1
  10. package/dist/browser/svelte/index.mjs +7 -2
  11. package/dist/browser/svelte/index.mjs.map +1 -1
  12. package/dist/browser/vue/index.mjs +7 -2
  13. package/dist/browser/vue/index.mjs.map +1 -1
  14. package/dist/index.browser.mjs +459 -138
  15. package/dist/index.browser.mjs.map +1 -1
  16. package/dist/index.cjs +459 -138
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +459 -138
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/lib/binary_reader.d.ts +1 -1
  21. package/dist/lib/binary_reader.d.ts.map +1 -1
  22. package/dist/lib/binary_writer.d.ts +2 -1
  23. package/dist/lib/binary_writer.d.ts.map +1 -1
  24. package/dist/lib/filter.d.ts +2 -1
  25. package/dist/lib/filter.d.ts.map +1 -1
  26. package/dist/lib/table.d.ts +6 -0
  27. package/dist/lib/table.d.ts.map +1 -1
  28. package/dist/min/index.browser.mjs +1 -1
  29. package/dist/min/index.browser.mjs.map +1 -1
  30. package/dist/min/react/index.mjs +1 -1
  31. package/dist/min/react/index.mjs.map +1 -1
  32. package/dist/min/sdk/index.browser.mjs +1 -1
  33. package/dist/min/sdk/index.browser.mjs.map +1 -1
  34. package/dist/react/index.cjs +57 -5
  35. package/dist/react/index.cjs.map +1 -1
  36. package/dist/react/index.d.ts +1 -0
  37. package/dist/react/index.d.ts.map +1 -1
  38. package/dist/react/index.mjs +57 -6
  39. package/dist/react/index.mjs.map +1 -1
  40. package/dist/react/useProcedure.d.ts +4 -0
  41. package/dist/react/useProcedure.d.ts.map +1 -0
  42. package/dist/react/useTable.d.ts +2 -0
  43. package/dist/react/useTable.d.ts.map +1 -1
  44. package/dist/sdk/db_connection_builder.d.ts +3 -3
  45. package/dist/sdk/db_connection_builder.d.ts.map +1 -1
  46. package/dist/sdk/db_connection_impl.d.ts +3 -3
  47. package/dist/sdk/db_connection_impl.d.ts.map +1 -1
  48. package/dist/sdk/decompress.d.ts +1 -1
  49. package/dist/sdk/decompress.d.ts.map +1 -1
  50. package/dist/sdk/index.browser.mjs +459 -138
  51. package/dist/sdk/index.browser.mjs.map +1 -1
  52. package/dist/sdk/index.cjs +459 -138
  53. package/dist/sdk/index.cjs.map +1 -1
  54. package/dist/sdk/index.mjs +459 -138
  55. package/dist/sdk/index.mjs.map +1 -1
  56. package/dist/sdk/table_cache.d.ts +1 -0
  57. package/dist/sdk/table_cache.d.ts.map +1 -1
  58. package/dist/sdk/type_utils.d.ts +4 -1
  59. package/dist/sdk/type_utils.d.ts.map +1 -1
  60. package/dist/sdk/websocket_decompress_adapter.d.ts +5 -21
  61. package/dist/sdk/websocket_decompress_adapter.d.ts.map +1 -1
  62. package/dist/sdk/websocket_protocols.d.ts +6 -0
  63. package/dist/sdk/websocket_protocols.d.ts.map +1 -0
  64. package/dist/sdk/websocket_test_adapter.d.ts +14 -18
  65. package/dist/sdk/websocket_test_adapter.d.ts.map +1 -1
  66. package/dist/sdk/websocket_v3_frames.d.ts +9 -0
  67. package/dist/sdk/websocket_v3_frames.d.ts.map +1 -0
  68. package/dist/sdk/ws.d.ts +26 -1
  69. package/dist/sdk/ws.d.ts.map +1 -1
  70. package/dist/server/http_internal.d.ts.map +1 -1
  71. package/dist/server/index.d.ts +1 -1
  72. package/dist/server/index.d.ts.map +1 -1
  73. package/dist/server/index.mjs +53 -6
  74. package/dist/server/index.mjs.map +1 -1
  75. package/dist/server/runtime.d.ts +29 -2
  76. package/dist/server/runtime.d.ts.map +1 -1
  77. package/dist/svelte/index.cjs +7 -2
  78. package/dist/svelte/index.cjs.map +1 -1
  79. package/dist/svelte/index.mjs +7 -2
  80. package/dist/svelte/index.mjs.map +1 -1
  81. package/dist/tanstack/index.cjs +7 -2
  82. package/dist/tanstack/index.cjs.map +1 -1
  83. package/dist/tanstack/index.mjs +7 -2
  84. package/dist/tanstack/index.mjs.map +1 -1
  85. package/dist/vue/index.cjs +7 -2
  86. package/dist/vue/index.cjs.map +1 -1
  87. package/dist/vue/index.mjs +7 -2
  88. package/dist/vue/index.mjs.map +1 -1
  89. package/package.json +2 -2
  90. package/src/lib/binary_reader.ts +5 -2
  91. package/src/lib/binary_writer.ts +7 -1
  92. package/src/lib/filter.ts +12 -1
  93. package/src/lib/table.ts +9 -1
  94. package/src/react/index.ts +1 -0
  95. package/src/react/useProcedure.ts +60 -0
  96. package/src/react/useTable.ts +17 -2
  97. package/src/sdk/db_connection_builder.ts +16 -7
  98. package/src/sdk/db_connection_impl.ts +404 -89
  99. package/src/sdk/decompress.ts +7 -23
  100. package/src/sdk/table_cache.ts +5 -5
  101. package/src/sdk/type_utils.ts +10 -1
  102. package/src/sdk/websocket_decompress_adapter.ts +15 -77
  103. package/src/sdk/websocket_protocols.ts +25 -0
  104. package/src/sdk/websocket_test_adapter.ts +65 -29
  105. package/src/sdk/websocket_v3_frames.ts +126 -0
  106. package/src/sdk/ws.ts +81 -3
  107. package/src/server/http_internal.ts +10 -1
  108. package/src/server/index.ts +1 -1
  109. package/src/server/runtime.ts +39 -1
  110. package/src/server/sys.d.ts +4 -0
@@ -1,7 +1,7 @@
1
1
  import { ConnectionId, ProductBuilder, ProductType } from '../';
2
2
  import { AlgebraicType, type ComparablePrimitive } from '../';
3
- import { BinaryReader } from '../';
4
- import { BinaryWriter } from '../';
3
+ import BinaryReader from '../lib/binary_reader.ts';
4
+ import BinaryWriter from '../lib/binary_writer.ts';
5
5
  import {
6
6
  BsatnRowList,
7
7
  ClientMessage,
@@ -37,10 +37,6 @@ import {
37
37
  type PendingCallback,
38
38
  type TableUpdate as CacheTableUpdate,
39
39
  } from './table_cache.ts';
40
- import {
41
- WebsocketDecompressAdapter,
42
- type WebsocketAdapter,
43
- } from './websocket_decompress_adapter.ts';
44
40
  import {
45
41
  SubscriptionBuilderImpl,
46
42
  SubscriptionHandleImpl,
@@ -60,6 +56,19 @@ import type { ProceduresView } from './procedures.ts';
60
56
  import type { Values } from '../lib/type_util.ts';
61
57
  import type { TransactionUpdate } from './client_api/types.ts';
62
58
  import { InternalError, SenderError } from '../lib/errors.ts';
59
+ import type { WebSocketAdapter, WebSocketFactory } from './ws.ts';
60
+ import {
61
+ normalizeWsProtocol,
62
+ PREFERRED_WS_PROTOCOLS,
63
+ V2_WS_PROTOCOL,
64
+ V3_WS_PROTOCOL,
65
+ type NegotiatedWsProtocol,
66
+ } from './websocket_protocols';
67
+ import {
68
+ countClientMessagesForV3Frame,
69
+ encodeClientMessagesV3,
70
+ forEachServerMessageV3,
71
+ } from './websocket_v3_frames.ts';
63
72
 
64
73
  export {
65
74
  DbConnectionBuilder,
@@ -89,8 +98,8 @@ export type DbConnectionConfig<RemoteModule extends UntypedRemoteModule> = {
89
98
  identity?: Identity;
90
99
  token?: string;
91
100
  emitter: EventEmitter<ConnectionEvent>;
92
- createWSFn: typeof WebsocketDecompressAdapter.createWebSocketFn;
93
- compression: 'gzip' | 'none';
101
+ createWSFn: WebSocketFactory;
102
+ compression: 'gzip' | 'brotli' | 'none';
94
103
  lightMode: boolean;
95
104
  confirmedReads?: boolean;
96
105
  remoteModule: RemoteModule;
@@ -98,6 +107,29 @@ export type DbConnectionConfig<RemoteModule extends UntypedRemoteModule> = {
98
107
 
99
108
  type ProcedureCallback = (result: ProcedureResultMessage['result']) => void;
100
109
 
110
+ const TEXT_ENCODER = new TextEncoder();
111
+
112
+ function getClientMessageVariantTag(name: string): number {
113
+ if (ClientMessage.algebraicType.tag !== 'Sum') {
114
+ throw new TypeError('ClientMessage must be a sum type');
115
+ }
116
+ const tag = ClientMessage.algebraicType.value.variants.findIndex(
117
+ variant => variant.name === name
118
+ );
119
+ if (tag === -1) {
120
+ throw new RangeError(`Unknown ClientMessage variant: ${name}`);
121
+ }
122
+ return tag;
123
+ }
124
+
125
+ const CLIENT_MESSAGE_CALL_REDUCER_TAG =
126
+ getClientMessageVariantTag('CallReducer');
127
+ const CLIENT_MESSAGE_CALL_PROCEDURE_TAG =
128
+ getClientMessageVariantTag('CallProcedure');
129
+ // Keep individual v3 frames bounded so one burst does not monopolize the send
130
+ // path or create very large websocket writes.
131
+ const MAX_V3_OUTBOUND_FRAME_BYTES = 256 * 1024;
132
+
101
133
  export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
102
134
  implements DbContext<RemoteModule>
103
135
  {
@@ -141,14 +173,19 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
141
173
  * The `ConnectionId` of the connection to to the database.
142
174
  */
143
175
  connectionId: ConnectionId = ConnectionId.random();
176
+ #connectionIdHex = this.connectionId.toHexString();
144
177
 
145
178
  // These fields are meant to be strictly private.
146
179
  #queryId = 0;
147
180
  #requestId = 0;
148
181
  #eventId = 0;
149
182
  #emitter: EventEmitter<ConnectionEvent>;
150
- #messageQueue = Promise.resolve();
151
- #outboundQueue: Uint8Array[] = [];
183
+ #inboundQueue: Uint8Array[] = [];
184
+ #inboundQueueOffset = 0;
185
+ #isDrainingInboundQueue = false;
186
+ #outboundQueue: Uint8Array<ArrayBuffer>[] = [];
187
+ #isOutboundFlushScheduled = false;
188
+ #negotiatedWsProtocol: NegotiatedWsProtocol = V2_WS_PROTOCOL;
152
189
  #subscriptionManager = new SubscriptionManager<RemoteModule>();
153
190
  #remoteModule: RemoteModule;
154
191
  #reducerCallbacks = new Map<
@@ -158,6 +195,10 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
158
195
  #reducerCallInfo = new Map<number, { name: string; args: object }>();
159
196
  #procedureCallbacks = new Map<number, ProcedureCallback>();
160
197
  #rowDeserializers: Record<string, Deserializer<any>>;
198
+ #rowIdMetadata: Record<
199
+ string,
200
+ { primaryKeyColName?: string; primaryKeyColType?: AlgebraicType }
201
+ >;
161
202
  #reducerArgsSerializers: Record<
162
203
  string,
163
204
  { serialize: Serializer<any>; deserialize: Deserializer<any> }
@@ -166,15 +207,22 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
166
207
  string,
167
208
  { serializeArgs: Serializer<any>; deserializeReturn: Deserializer<any> }
168
209
  >;
210
+ #reducerNameBytes: Record<string, Uint8Array>;
211
+ #procedureNameBytes: Record<string, Uint8Array>;
169
212
  #sourceNameToTableDef: Record<string, Values<RemoteModule['tables']>>;
213
+ #messageReader = new BinaryReader(new Uint8Array());
214
+ #rowListReader = new BinaryReader(new Uint8Array());
215
+ #clientFrameEncoder = new BinaryWriter(1024);
216
+ #boundSubscriptionBuilder!: () => SubscriptionBuilderImpl<RemoteModule>;
217
+ #boundDisconnect!: () => void;
170
218
 
171
219
  // These fields are not part of the public API, but in a pinch you
172
220
  // could use JavaScript to access them by bypassing TypeScript's
173
221
  // private fields.
174
222
  // We use them in testing.
175
223
  private clientCache: ClientCache<RemoteModule>;
176
- private ws?: WebsocketAdapter;
177
- private wsPromise: Promise<WebsocketAdapter | undefined>;
224
+ private ws?: WebSocketAdapter;
225
+ private wsPromise: Promise<WebSocketAdapter | undefined>;
178
226
 
179
227
  constructor({
180
228
  uri,
@@ -203,8 +251,11 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
203
251
 
204
252
  this.#remoteModule = remoteModule;
205
253
  this.#emitter = emitter;
254
+ this.#boundSubscriptionBuilder = this.subscriptionBuilder.bind(this);
255
+ this.#boundDisconnect = this.disconnect.bind(this);
206
256
 
207
257
  this.#rowDeserializers = Object.create(null);
258
+ this.#rowIdMetadata = Object.create(null);
208
259
  this.#sourceNameToTableDef = Object.create(null);
209
260
  for (const table of Object.values(remoteModule.tables)) {
210
261
  this.#rowDeserializers[table.sourceName] = ProductType.makeDeserializer(
@@ -213,17 +264,29 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
213
264
  this.#sourceNameToTableDef[table.sourceName] = table as Values<
214
265
  RemoteModule['tables']
215
266
  >;
267
+ const primaryKeyColumn = Object.entries(table.columns).find(
268
+ ([, column]) => column.columnMetadata.isPrimaryKey
269
+ );
270
+ this.#rowIdMetadata[table.sourceName] = primaryKeyColumn
271
+ ? {
272
+ primaryKeyColName: primaryKeyColumn[0],
273
+ primaryKeyColType: primaryKeyColumn[1].typeBuilder.algebraicType,
274
+ }
275
+ : {};
216
276
  }
217
277
 
218
278
  this.#reducerArgsSerializers = Object.create(null);
279
+ this.#reducerNameBytes = Object.create(null);
219
280
  for (const reducer of remoteModule.reducers) {
220
281
  this.#reducerArgsSerializers[reducer.name] = {
221
282
  serialize: ProductType.makeSerializer(reducer.paramsType),
222
283
  deserialize: ProductType.makeDeserializer(reducer.paramsType),
223
284
  };
285
+ this.#reducerNameBytes[reducer.name] = TEXT_ENCODER.encode(reducer.name);
224
286
  }
225
287
 
226
288
  this.#procedureSerializers = Object.create(null);
289
+ this.#procedureNameBytes = Object.create(null);
227
290
  for (const procedure of remoteModule.procedures) {
228
291
  this.#procedureSerializers[procedure.name] = {
229
292
  serializeArgs: ProductType.makeSerializer(
@@ -233,10 +296,12 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
233
296
  procedure.returnType.algebraicType
234
297
  ),
235
298
  };
299
+ this.#procedureNameBytes[procedure.name] = TEXT_ENCODER.encode(
300
+ procedure.name
301
+ );
236
302
  }
237
303
 
238
- const connectionId = this.connectionId.toHexString();
239
- url.searchParams.set('connection_id', connectionId);
304
+ url.searchParams.set('connection_id', this.#connectionIdHex);
240
305
 
241
306
  this.clientCache = new ClientCache<RemoteModule>();
242
307
  this.db = this.#makeDbView();
@@ -246,7 +311,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
246
311
  this.wsPromise = createWSFn({
247
312
  url,
248
313
  nameOrAddress,
249
- wsProtocol: 'v2.bsatn.spacetimedb',
314
+ wsProtocol: [...PREFERRED_WS_PROTOCOLS],
250
315
  authToken: token,
251
316
  compression: compression,
252
317
  lightMode: lightMode,
@@ -302,20 +367,25 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
302
367
  #makeReducers(def: RemoteModule): ReducersView<RemoteModule> {
303
368
  const out: Record<string, unknown> = {};
304
369
 
305
- const writer = new BinaryWriter(1024);
306
-
307
370
  for (const reducer of def.reducers) {
308
371
  const reducerName = reducer.name;
372
+ const encodedReducerName = this.#reducerNameBytes[reducerName];
309
373
  const key = reducer.accessorName;
310
374
 
311
375
  const { serialize: serializeArgs } =
312
376
  this.#reducerArgsSerializers[reducerName];
313
377
 
314
378
  (out as any)[key] = (params: InferTypeOfRow<typeof reducer.params>) => {
379
+ const writer = this.#reducerArgsEncoder;
315
380
  writer.clear();
316
381
  serializeArgs(writer, params);
317
382
  const argsBuffer = writer.getBuffer();
318
- return this.callReducer(reducerName, argsBuffer, params);
383
+ return this.#callReducerWithEncodedName(
384
+ reducerName,
385
+ encodedReducerName,
386
+ argsBuffer,
387
+ params
388
+ );
319
389
  };
320
390
  }
321
391
 
@@ -329,6 +399,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
329
399
 
330
400
  for (const procedure of def.procedures) {
331
401
  const procedureName = procedure.name;
402
+ const encodedProcedureName = this.#procedureNameBytes[procedureName];
332
403
  const key = procedure.accessorName;
333
404
 
334
405
  const { serializeArgs, deserializeReturn } =
@@ -340,7 +411,11 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
340
411
  writer.clear();
341
412
  serializeArgs(writer, params);
342
413
  const argsBuffer = writer.getBuffer();
343
- return this.callProcedure(procedureName, argsBuffer).then(returnBuf => {
414
+ return this.#callProcedureWithEncodedName(
415
+ procedureName,
416
+ encodedProcedureName,
417
+ argsBuffer
418
+ ).then(returnBuf => {
344
419
  return deserializeReturn(new BinaryReader(returnBuf));
345
420
  });
346
421
  };
@@ -357,13 +432,12 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
357
432
  >
358
433
  >
359
434
  ): EventContextInterface<RemoteModule> {
360
- // Bind methods to preserve `this` (#private fields safe)
361
435
  return {
362
436
  db: this.db,
363
437
  reducers: this.reducers,
364
438
  isActive: this.isActive,
365
- subscriptionBuilder: this.subscriptionBuilder.bind(this),
366
- disconnect: this.disconnect.bind(this),
439
+ subscriptionBuilder: this.#boundSubscriptionBuilder,
440
+ disconnect: this.#boundDisconnect,
367
441
  event,
368
442
  };
369
443
  }
@@ -424,24 +498,18 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
424
498
  rowList: BsatnRowList
425
499
  ): Operation[] {
426
500
  const buffer = rowList.rowsData;
427
- const reader = new BinaryReader(buffer);
501
+ const reader = this.#rowListReader;
502
+ reader.reset(buffer);
428
503
  const rows: Operation[] = [];
429
504
 
430
505
  const deserializeRow = this.#rowDeserializers[tableName];
431
- const table = this.#sourceNameToTableDef[tableName];
432
- // TODO: performance
433
- const columnsArray = Object.entries(table.columns);
434
- const primaryKeyColumnEntry = columnsArray.find(
435
- col => col[1].columnMetadata.isPrimaryKey
436
- );
506
+ const { primaryKeyColName, primaryKeyColType } =
507
+ this.#rowIdMetadata[tableName];
437
508
  let previousOffset = 0;
438
509
  while (reader.remaining > 0) {
439
510
  const row = deserializeRow(reader);
440
511
  let rowId: ComparablePrimitive | undefined = undefined;
441
- if (primaryKeyColumnEntry !== undefined) {
442
- const primaryKeyColName = primaryKeyColumnEntry[0];
443
- const primaryKeyColType =
444
- primaryKeyColumnEntry[1].typeBuilder.algebraicType;
512
+ if (primaryKeyColName !== undefined && primaryKeyColType !== undefined) {
445
513
  rowId = AlgebraicType.intoMapKey(
446
514
  primaryKeyColType,
447
515
  row[primaryKeyColName]
@@ -541,47 +609,175 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
541
609
  return this.#mergeTableUpdates(updates);
542
610
  }
543
611
 
544
- #flushOutboundQueue(wsResolved: WebsocketAdapter): void {
612
+ #flushOutboundQueue(wsResolved: WebSocketAdapter): void {
613
+ if (this.#negotiatedWsProtocol === V3_WS_PROTOCOL) {
614
+ this.#flushOutboundQueueV3(wsResolved);
615
+ return;
616
+ }
617
+ this.#flushOutboundQueueV2(wsResolved);
618
+ }
619
+
620
+ #flushOutboundQueueV2(wsResolved: WebSocketAdapter): void {
545
621
  const pending = this.#outboundQueue.splice(0);
546
622
  for (const message of pending) {
547
623
  wsResolved.send(message);
548
624
  }
549
625
  }
550
626
 
627
+ #flushOutboundQueueV3(wsResolved: WebSocketAdapter): void {
628
+ if (this.#outboundQueue.length === 0) {
629
+ return;
630
+ }
631
+
632
+ // Emit at most one bounded frame per flush. If more encoded v2 messages
633
+ // remain in the queue, they are sent by a later scheduled flush so inbound
634
+ // traffic and other tasks get a chance to run between websocket writes.
635
+ const batchSize = countClientMessagesForV3Frame(
636
+ this.#outboundQueue,
637
+ MAX_V3_OUTBOUND_FRAME_BYTES
638
+ );
639
+ wsResolved.send(
640
+ encodeClientMessagesV3(
641
+ this.#clientFrameEncoder,
642
+ this.#outboundQueue,
643
+ batchSize
644
+ )
645
+ );
646
+
647
+ if (batchSize === this.#outboundQueue.length) {
648
+ this.#outboundQueue.length = 0;
649
+ return;
650
+ }
651
+
652
+ this.#outboundQueue.copyWithin(0, batchSize);
653
+ this.#outboundQueue.length -= batchSize;
654
+ if (this.#outboundQueue.length > 0) {
655
+ this.#scheduleDeferredOutboundFlush();
656
+ }
657
+ }
658
+
659
+ #scheduleOutboundFlush(): void {
660
+ this.#scheduleOutboundFlushWith('microtask');
661
+ }
662
+
663
+ #scheduleDeferredOutboundFlush(): void {
664
+ this.#scheduleOutboundFlushWith('next-task');
665
+ }
666
+
667
+ #scheduleOutboundFlushWith(schedule: 'microtask' | 'next-task'): void {
668
+ if (this.#isOutboundFlushScheduled) {
669
+ return;
670
+ }
671
+
672
+ this.#isOutboundFlushScheduled = true;
673
+ const flush = () => {
674
+ this.#isOutboundFlushScheduled = false;
675
+ if (this.ws && this.isActive) {
676
+ this.#flushOutboundQueue(this.ws);
677
+ }
678
+ };
679
+
680
+ // The first v3 flush stays on the current turn so same-tick sends coalesce.
681
+ // Follow-up flushes after a size-capped frame yield to the next task so we
682
+ // do not sit in a tight send loop while inbound websocket work is waiting.
683
+ if (schedule === 'next-task') {
684
+ setTimeout(flush, 0);
685
+ } else {
686
+ queueMicrotask(flush);
687
+ }
688
+ }
689
+
690
+ #reducerArgsEncoder = new BinaryWriter(1024);
551
691
  #clientMessageEncoder = new BinaryWriter(1024);
692
+ #sendEncodedMessage(
693
+ encoded: Uint8Array<ArrayBuffer>,
694
+ describe: () => string
695
+ ): void {
696
+ stdbLogger('trace', describe);
697
+ if (this.ws && this.isActive) {
698
+ if (this.#negotiatedWsProtocol === V2_WS_PROTOCOL) {
699
+ if (this.#outboundQueue.length) this.#flushOutboundQueue(this.ws);
700
+ this.ws.send(encoded);
701
+ return;
702
+ }
703
+
704
+ this.#outboundQueue.push(encoded.slice());
705
+ this.#scheduleOutboundFlush();
706
+ } else {
707
+ // Use slice() to copy, in case the clientMessageEncoder's buffer gets reused
708
+ // before the connection opens or before a v3 microbatch flush runs.
709
+ this.#outboundQueue.push(encoded.slice());
710
+ }
711
+ }
712
+
552
713
  #sendMessage(message: ClientMessage): void {
553
714
  const writer = this.#clientMessageEncoder;
554
715
  writer.clear();
555
716
  ClientMessage.serialize(writer, message);
556
717
  const encoded = writer.getBuffer();
718
+ const isLive = !!(this.ws && this.isActive);
719
+ this.#sendEncodedMessage(encoded, () =>
720
+ isLive
721
+ ? `Sending message to server: ${stringify(message)}`
722
+ : `Queuing message to server: ${stringify(message)}`
723
+ );
724
+ }
557
725
 
558
- if (this.ws && this.isActive) {
559
- if (this.#outboundQueue.length) this.#flushOutboundQueue(this.ws);
726
+ #sendCallReducerMessage(
727
+ requestId: number,
728
+ reducerNameBytes: Uint8Array,
729
+ argsBuffer: Uint8Array
730
+ ): void {
731
+ const writer = this.#clientMessageEncoder;
732
+ writer.clear();
733
+ writer.writeByte(CLIENT_MESSAGE_CALL_REDUCER_TAG);
734
+ writer.writeU32(requestId);
735
+ writer.writeU8(0);
736
+ writer.writeUInt8Array(reducerNameBytes);
737
+ writer.writeUInt8Array(argsBuffer);
738
+ const encoded = writer.getBuffer();
739
+ this.#sendEncodedMessage(
740
+ encoded,
741
+ () => `Sending reducer call message to server: requestId=${requestId}`
742
+ );
743
+ }
560
744
 
561
- stdbLogger(
562
- 'trace',
563
- () => `Sending message to server: ${stringify(message)}`
564
- );
565
- this.ws.send(encoded);
566
- } else {
567
- stdbLogger(
568
- 'trace',
569
- () => `Queuing message to server: ${stringify(message)}`
570
- );
571
- // use slice() to copy, in case the clientMessageEncoder's buffer gets used
572
- this.#outboundQueue.push(encoded.slice());
573
- }
745
+ #sendCallProcedureMessage(
746
+ requestId: number,
747
+ procedureNameBytes: Uint8Array,
748
+ argsBuffer: Uint8Array
749
+ ): void {
750
+ const writer = this.#clientMessageEncoder;
751
+ writer.clear();
752
+ writer.writeByte(CLIENT_MESSAGE_CALL_PROCEDURE_TAG);
753
+ writer.writeU32(requestId);
754
+ writer.writeU8(0);
755
+ writer.writeUInt8Array(procedureNameBytes);
756
+ writer.writeUInt8Array(argsBuffer);
757
+ const encoded = writer.getBuffer();
758
+ this.#sendEncodedMessage(
759
+ encoded,
760
+ () => `Sending procedure call message to server: requestId=${requestId}`
761
+ );
762
+ }
763
+
764
+ #setConnectionId(connectionId: ConnectionId): void {
765
+ this.connectionId = connectionId;
766
+ this.#connectionIdHex = connectionId.toHexString();
574
767
  }
575
768
 
576
769
  #nextEventId(): string {
577
770
  this.#eventId += 1;
578
- return `${this.connectionId.toHexString()}:${this.#eventId}`;
771
+ return `${this.#connectionIdHex}:${this.#eventId}`;
579
772
  }
580
773
 
581
774
  /**
582
775
  * Handles WebSocket onOpen event.
583
776
  */
584
777
  #handleOnOpen(): void {
778
+ if (this.ws) {
779
+ this.#negotiatedWsProtocol = normalizeWsProtocol(this.ws.protocol);
780
+ }
585
781
  this.isActive = true;
586
782
  if (this.ws) {
587
783
  this.#flushOutboundQueue(this.ws);
@@ -629,8 +825,17 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
629
825
  );
630
826
  }
631
827
 
632
- async #processMessage(data: Uint8Array): Promise<void> {
633
- const serverMessage = ServerMessage.deserialize(new BinaryReader(data));
828
+ #dispatchPendingCallbacks(callbacks: readonly PendingCallback[]): void {
829
+ stdbLogger(
830
+ 'trace',
831
+ () => `Calling ${callbacks.length} triggered row callbacks`
832
+ );
833
+ for (const callback of callbacks) {
834
+ callback.cb();
835
+ }
836
+ }
837
+
838
+ #processServerMessage(serverMessage: ServerMessage): void {
634
839
  stdbLogger(
635
840
  'trace',
636
841
  () => `Processing server message: ${stringify(serverMessage)}`
@@ -641,7 +846,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
641
846
  if (!this.token && serverMessage.value.token) {
642
847
  this.token = serverMessage.value.token;
643
848
  }
644
- this.connectionId = serverMessage.value.connectionId;
849
+ this.#setConnectionId(serverMessage.value.connectionId);
645
850
  this.#emitter.emit('connect', this, this.identity, this.token);
646
851
  break;
647
852
  }
@@ -668,13 +873,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
668
873
  const callbacks = this.#applyTableUpdates(tableUpdates, eventContext);
669
874
  const { event: _, ...subscriptionEventContext } = eventContext;
670
875
  subscription.emitter.emit('applied', subscriptionEventContext);
671
- stdbLogger(
672
- 'trace',
673
- () => `Calling ${callbacks.length} triggered row callbacks`
674
- );
675
- for (const callback of callbacks) {
676
- callback.cb();
677
- }
876
+ this.#dispatchPendingCallbacks(callbacks);
678
877
  break;
679
878
  }
680
879
  case 'UnsubscribeApplied': {
@@ -700,13 +899,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
700
899
  const { event: _, ...subscriptionEventContext } = eventContext;
701
900
  subscription.emitter.emit('end', subscriptionEventContext);
702
901
  this.#subscriptionManager.subscriptions.delete(querySetId);
703
- stdbLogger(
704
- 'trace',
705
- () => `Calling ${callbacks.length} triggered row callbacks`
706
- );
707
- for (const callback of callbacks) {
708
- callback.cb();
709
- }
902
+ this.#dispatchPendingCallbacks(callbacks);
710
903
  break;
711
904
  }
712
905
  case 'SubscriptionError': {
@@ -760,13 +953,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
760
953
  eventContext,
761
954
  serverMessage.value
762
955
  );
763
- stdbLogger(
764
- 'trace',
765
- () => `Calling ${callbacks.length} triggered row callbacks`
766
- );
767
- for (const callback of callbacks) {
768
- callback.cb();
769
- }
956
+ this.#dispatchPendingCallbacks(callbacks);
770
957
  break;
771
958
  }
772
959
  case 'ReducerResult': {
@@ -798,13 +985,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
798
985
  eventContext,
799
986
  result.value.transactionUpdate
800
987
  );
801
- stdbLogger(
802
- 'trace',
803
- () => `Calling ${callbacks.length} triggered row callbacks`
804
- );
805
- for (const callback of callbacks) {
806
- callback.cb();
807
- }
988
+ this.#dispatchPendingCallbacks(callbacks);
808
989
  }
809
990
  this.#reducerCallInfo.delete(requestId);
810
991
  const cb = this.#reducerCallbacks.get(requestId);
@@ -833,18 +1014,65 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
833
1014
  }
834
1015
  }
835
1016
 
1017
+ #processV2Message(data: Uint8Array): void {
1018
+ const reader = this.#messageReader;
1019
+ reader.reset(data);
1020
+ this.#processServerMessage(ServerMessage.deserialize(reader));
1021
+ }
1022
+
1023
+ #processMessage(data: Uint8Array): void {
1024
+ if (this.#negotiatedWsProtocol !== V3_WS_PROTOCOL) {
1025
+ this.#processV2Message(data);
1026
+ return;
1027
+ }
1028
+
1029
+ const messageCount = forEachServerMessageV3(
1030
+ this.#messageReader,
1031
+ data,
1032
+ serverMessage => {
1033
+ this.#processServerMessage(serverMessage);
1034
+ }
1035
+ );
1036
+ stdbLogger(
1037
+ 'trace',
1038
+ () => `Processing server v3 payload with ${messageCount} message(s)`
1039
+ );
1040
+ }
1041
+
836
1042
  /**
837
1043
  * Handles WebSocket onMessage event.
838
1044
  * @param wsMessage MessageEvent object.
839
1045
  */
840
1046
  #handleOnMessage(wsMessage: { data: Uint8Array }): void {
841
- // Utilize promise chaining to ensure that we process messages in order
842
- // even though we are processing them asyncronously. This will not begin
843
- // processing the next message until we await the processing of the
844
- // current message.
845
- this.#messageQueue = this.#messageQueue.then(() => {
846
- return this.#processMessage(wsMessage.data);
847
- });
1047
+ // Queue inbound messages so they are processed strictly in arrival order.
1048
+ // We deliberately drain synchronously instead of promise-chaining each
1049
+ // message, but this still guarantees that we do not begin processing the
1050
+ // next message until the current message has been fully handled.
1051
+ this.#inboundQueue.push(wsMessage.data);
1052
+ if (this.#isDrainingInboundQueue) {
1053
+ return;
1054
+ }
1055
+
1056
+ this.#isDrainingInboundQueue = true;
1057
+ try {
1058
+ // TODO: If this loop starts monopolizing the event loop under sustained
1059
+ // inbound traffic, switch to a chunked drain that periodically yields.
1060
+ while (this.#inboundQueueOffset < this.#inboundQueue.length) {
1061
+ const data = this.#inboundQueue[this.#inboundQueueOffset];
1062
+ this.#inboundQueueOffset += 1;
1063
+ if (data) {
1064
+ this.#processMessage(data);
1065
+ }
1066
+ }
1067
+ } finally {
1068
+ if (this.#inboundQueueOffset >= this.#inboundQueue.length) {
1069
+ this.#inboundQueue.length = 0;
1070
+ } else if (this.#inboundQueueOffset > 0) {
1071
+ this.#inboundQueue = this.#inboundQueue.slice(this.#inboundQueueOffset);
1072
+ }
1073
+ this.#inboundQueueOffset = 0;
1074
+ this.#isDrainingInboundQueue = false;
1075
+ }
848
1076
  }
849
1077
 
850
1078
  /**
@@ -857,6 +1085,59 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
857
1085
  reducerName: string,
858
1086
  argsBuffer: Uint8Array,
859
1087
  reducerArgs?: object
1088
+ ): Promise<void> {
1089
+ const encodedReducerName = this.#reducerNameBytes[reducerName];
1090
+ if (encodedReducerName) {
1091
+ return this.#callReducerWithEncodedName(
1092
+ reducerName,
1093
+ encodedReducerName,
1094
+ argsBuffer,
1095
+ reducerArgs
1096
+ );
1097
+ }
1098
+ return this.#callReducerGeneric(reducerName, argsBuffer, reducerArgs);
1099
+ }
1100
+
1101
+ #callReducerWithEncodedName(
1102
+ reducerName: string,
1103
+ encodedReducerName: Uint8Array,
1104
+ argsBuffer: Uint8Array,
1105
+ reducerArgs?: object
1106
+ ): Promise<void> {
1107
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
1108
+ const requestId = this.#getNextRequestId();
1109
+ this.#sendCallReducerMessage(requestId, encodedReducerName, argsBuffer);
1110
+ if (reducerArgs) {
1111
+ this.#reducerCallInfo.set(requestId, {
1112
+ name: reducerName,
1113
+ args: reducerArgs,
1114
+ });
1115
+ }
1116
+ this.#reducerCallbacks.set(requestId, result => {
1117
+ if (result.tag === 'Ok' || result.tag === 'OkEmpty') {
1118
+ resolve();
1119
+ } else {
1120
+ if (result.tag === 'Err') {
1121
+ /// Interpret the user-returned error as a string.
1122
+ const reader = new BinaryReader(result.value);
1123
+ const errorString = reader.readString();
1124
+ reject(new SenderError(errorString));
1125
+ } else if (result.tag === 'InternalError') {
1126
+ reject(new InternalError(result.value));
1127
+ } else {
1128
+ const unreachable: never = result;
1129
+ reject(new Error('Unexpected reducer result'));
1130
+ void unreachable;
1131
+ }
1132
+ }
1133
+ });
1134
+ return promise;
1135
+ }
1136
+
1137
+ #callReducerGeneric(
1138
+ reducerName: string,
1139
+ argsBuffer: Uint8Array,
1140
+ reducerArgs?: object
860
1141
  ): Promise<void> {
861
1142
  const { promise, resolve, reject } = Promise.withResolvers<void>();
862
1143
  const requestId = this.#getNextRequestId();
@@ -906,7 +1187,8 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
906
1187
  _paramsType: ProductType,
907
1188
  params: object
908
1189
  ): Promise<void> {
909
- const writer = new BinaryWriter(1024);
1190
+ const writer = this.#reducerArgsEncoder;
1191
+ writer.clear();
910
1192
  this.#reducerArgsSerializers[reducerName].serialize(writer, params);
911
1193
  const argsBuffer = writer.getBuffer();
912
1194
  return this.callReducer(reducerName, argsBuffer, params);
@@ -921,6 +1203,39 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
921
1203
  callProcedure(
922
1204
  procedureName: string,
923
1205
  argsBuffer: Uint8Array
1206
+ ): Promise<Uint8Array> {
1207
+ const encodedProcedureName = this.#procedureNameBytes[procedureName];
1208
+ if (encodedProcedureName) {
1209
+ return this.#callProcedureWithEncodedName(
1210
+ procedureName,
1211
+ encodedProcedureName,
1212
+ argsBuffer
1213
+ );
1214
+ }
1215
+ return this.#callProcedureGeneric(procedureName, argsBuffer);
1216
+ }
1217
+
1218
+ #callProcedureWithEncodedName(
1219
+ procedureName: string,
1220
+ encodedProcedureName: Uint8Array,
1221
+ argsBuffer: Uint8Array
1222
+ ): Promise<Uint8Array> {
1223
+ const { promise, resolve, reject } = Promise.withResolvers<Uint8Array>();
1224
+ const requestId = this.#getNextRequestId();
1225
+ this.#sendCallProcedureMessage(requestId, encodedProcedureName, argsBuffer);
1226
+ this.#procedureCallbacks.set(requestId, result => {
1227
+ if (result.tag === 'Ok') {
1228
+ resolve(result.value);
1229
+ } else {
1230
+ reject(result.value);
1231
+ }
1232
+ });
1233
+ return promise;
1234
+ }
1235
+
1236
+ #callProcedureGeneric(
1237
+ procedureName: string,
1238
+ argsBuffer: Uint8Array
924
1239
  ): Promise<Uint8Array> {
925
1240
  const { promise, resolve, reject } = Promise.withResolvers<Uint8Array>();
926
1241
  const requestId = this.#getNextRequestId();