spacetimedb 2.6.0 → 2.6.1

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 (172) hide show
  1. package/README.md +211 -211
  2. package/dist/angular/index.cjs.map +1 -1
  3. package/dist/angular/index.mjs.map +1 -1
  4. package/dist/browser/angular/index.mjs.map +1 -1
  5. package/dist/browser/react/index.mjs.map +1 -1
  6. package/dist/browser/solid/index.mjs.map +1 -1
  7. package/dist/browser/svelte/index.mjs.map +1 -1
  8. package/dist/browser/vue/index.mjs.map +1 -1
  9. package/dist/index.browser.mjs.map +1 -1
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/lib/type_builders.d.ts +18 -3
  13. package/dist/lib/type_builders.d.ts.map +1 -1
  14. package/dist/min/index.browser.mjs.map +1 -1
  15. package/dist/min/react/index.mjs.map +1 -1
  16. package/dist/min/sdk/index.browser.mjs.map +1 -1
  17. package/dist/react/index.cjs.map +1 -1
  18. package/dist/react/index.mjs.map +1 -1
  19. package/dist/sdk/db_connection_impl.d.ts.map +1 -1
  20. package/dist/sdk/event_context.d.ts +3 -3
  21. package/dist/sdk/event_context.d.ts.map +1 -1
  22. package/dist/sdk/index.browser.mjs.map +1 -1
  23. package/dist/sdk/index.cjs.map +1 -1
  24. package/dist/sdk/index.mjs.map +1 -1
  25. package/dist/sdk/procedures.d.ts +2 -2
  26. package/dist/sdk/procedures.d.ts.map +1 -1
  27. package/dist/sdk/reducers.d.ts +3 -4
  28. package/dist/sdk/reducers.d.ts.map +1 -1
  29. package/dist/sdk/table_cache.d.ts.map +1 -1
  30. package/dist/sdk/type_utils.d.ts +3 -3
  31. package/dist/sdk/type_utils.d.ts.map +1 -1
  32. package/dist/server/index.mjs.map +1 -1
  33. package/dist/server/runtime.d.ts.map +1 -1
  34. package/dist/server/views.d.ts +4 -2
  35. package/dist/server/views.d.ts.map +1 -1
  36. package/dist/solid/index.cjs.map +1 -1
  37. package/dist/solid/index.mjs.map +1 -1
  38. package/dist/svelte/index.cjs.map +1 -1
  39. package/dist/svelte/index.mjs.map +1 -1
  40. package/dist/svelte/useReducer.d.ts +2 -2
  41. package/dist/svelte/useReducer.d.ts.map +1 -1
  42. package/dist/tanstack/index.cjs.map +1 -1
  43. package/dist/tanstack/index.mjs.map +1 -1
  44. package/dist/vue/index.cjs.map +1 -1
  45. package/dist/vue/index.mjs.map +1 -1
  46. package/package.json +22 -21
  47. package/src/angular/connection_state.ts +19 -19
  48. package/src/angular/index.ts +3 -3
  49. package/src/angular/injectors/index.ts +4 -4
  50. package/src/angular/injectors/inject-reducer.ts +62 -62
  51. package/src/angular/injectors/inject-spacetimedb-connected.ts +13 -13
  52. package/src/angular/injectors/inject-spacetimedb.ts +10 -10
  53. package/src/angular/injectors/inject-table.ts +234 -234
  54. package/src/angular/providers/index.ts +1 -1
  55. package/src/angular/providers/provide-spacetimedb.ts +96 -96
  56. package/src/index.ts +16 -16
  57. package/src/lib/algebraic_type.ts +819 -819
  58. package/src/lib/algebraic_type_variants.ts +26 -26
  59. package/src/lib/algebraic_value.ts +10 -10
  60. package/src/lib/autogen/types.ts +746 -746
  61. package/src/lib/binary_reader.ts +188 -188
  62. package/src/lib/binary_writer.ts +213 -213
  63. package/src/lib/connection_id.ts +102 -102
  64. package/src/lib/constraints.ts +48 -48
  65. package/src/lib/errors.ts +26 -26
  66. package/src/lib/filter.ts +195 -195
  67. package/src/lib/identity.ts +83 -83
  68. package/src/lib/indexes.ts +251 -251
  69. package/src/lib/option.ts +34 -34
  70. package/src/lib/query.ts +1019 -1019
  71. package/src/lib/reducer_schema.ts +38 -38
  72. package/src/lib/reducers.ts +116 -116
  73. package/src/lib/result.ts +36 -36
  74. package/src/lib/schedule_at.ts +86 -86
  75. package/src/lib/schema.ts +420 -420
  76. package/src/lib/table.ts +548 -548
  77. package/src/lib/table_schema.ts +64 -64
  78. package/src/lib/time_duration.ts +77 -77
  79. package/src/lib/timestamp.ts +148 -148
  80. package/src/lib/type_builders.test-d.ts +130 -128
  81. package/src/lib/type_builders.ts +4050 -4014
  82. package/src/lib/type_util.ts +124 -124
  83. package/src/lib/util.ts +196 -196
  84. package/src/lib/uuid.ts +337 -337
  85. package/src/react/SpacetimeDBProvider.ts +84 -84
  86. package/src/react/connection_state.ts +6 -6
  87. package/src/react/index.ts +5 -5
  88. package/src/react/useProcedure.ts +60 -60
  89. package/src/react/useReducer.ts +53 -53
  90. package/src/react/useSpacetimeDB.ts +18 -18
  91. package/src/react/useTable.ts +256 -256
  92. package/src/sdk/client_api/index.ts +114 -114
  93. package/src/sdk/client_api/types/procedures.ts +8 -8
  94. package/src/sdk/client_api/types/reducers.ts +8 -8
  95. package/src/sdk/client_api/types.ts +288 -288
  96. package/src/sdk/client_cache.ts +129 -129
  97. package/src/sdk/client_table.ts +179 -179
  98. package/src/sdk/connection_manager.ts +352 -352
  99. package/src/sdk/db_connection_builder.ts +290 -290
  100. package/src/sdk/db_connection_impl.ts +1363 -1356
  101. package/src/sdk/db_context.ts +28 -28
  102. package/src/sdk/db_view.ts +12 -12
  103. package/src/sdk/decompress.ts +51 -51
  104. package/src/sdk/event.ts +18 -18
  105. package/src/sdk/event_context.ts +51 -51
  106. package/src/sdk/event_emitter.ts +32 -32
  107. package/src/sdk/index.ts +14 -14
  108. package/src/sdk/internal.ts +2 -2
  109. package/src/sdk/json_api.ts +46 -46
  110. package/src/sdk/logger.ts +134 -134
  111. package/src/sdk/message_types.ts +46 -46
  112. package/src/sdk/procedures.ts +87 -83
  113. package/src/sdk/reducer_event.ts +20 -20
  114. package/src/sdk/reducer_handle.ts +12 -12
  115. package/src/sdk/reducers.ts +158 -159
  116. package/src/sdk/schema.ts +45 -45
  117. package/src/sdk/spacetime_module.ts +28 -28
  118. package/src/sdk/subscription_builder_impl.ts +275 -275
  119. package/src/sdk/table_cache.ts +582 -581
  120. package/src/sdk/type_utils.ts +19 -19
  121. package/src/sdk/version.ts +133 -133
  122. package/src/sdk/websocket_decompress_adapter.ts +63 -63
  123. package/src/sdk/websocket_protocols.ts +25 -25
  124. package/src/sdk/websocket_test_adapter.ts +107 -107
  125. package/src/sdk/websocket_v3_frames.ts +126 -126
  126. package/src/sdk/ws.ts +105 -105
  127. package/src/server/console.ts +81 -81
  128. package/src/server/db_view.ts +21 -21
  129. package/src/server/errors.ts +138 -138
  130. package/src/server/http.test-d.ts +80 -80
  131. package/src/server/http.ts +14 -14
  132. package/src/server/http_handlers.ts +413 -413
  133. package/src/server/http_internal.ts +79 -79
  134. package/src/server/http_shared.ts +186 -186
  135. package/src/server/index.ts +37 -37
  136. package/src/server/polyfills.ts +4 -4
  137. package/src/server/procedures.ts +239 -239
  138. package/src/server/query.ts +1 -1
  139. package/src/server/range.ts +53 -53
  140. package/src/server/reducers.ts +113 -113
  141. package/src/server/rng.ts +113 -113
  142. package/src/server/runtime.ts +1102 -1102
  143. package/src/server/schema.test-d.ts +99 -99
  144. package/src/server/schema.ts +663 -663
  145. package/src/server/sys.d.ts +125 -125
  146. package/src/server/view.test-d.ts +194 -194
  147. package/src/server/views.ts +343 -340
  148. package/src/solid/SpacetimeDBProvider.ts +97 -97
  149. package/src/solid/connection_state.ts +6 -6
  150. package/src/solid/index.ts +5 -5
  151. package/src/solid/useProcedure.ts +57 -57
  152. package/src/solid/useReducer.ts +50 -50
  153. package/src/solid/useSpacetimeDB.ts +18 -18
  154. package/src/solid/useTable.ts +203 -203
  155. package/src/svelte/SpacetimeDBProvider.ts +101 -101
  156. package/src/svelte/connection_state.ts +16 -16
  157. package/src/svelte/index.ts +4 -4
  158. package/src/svelte/useReducer.ts +61 -61
  159. package/src/svelte/useSpacetimeDB.ts +22 -22
  160. package/src/svelte/useTable.ts +218 -218
  161. package/src/tanstack/SpacetimeDBQueryClient.ts +330 -330
  162. package/src/tanstack/hooks.ts +83 -83
  163. package/src/tanstack/index.ts +16 -16
  164. package/src/util-stub.ts +1 -1
  165. package/src/vue/SpacetimeDBProvider.ts +157 -157
  166. package/src/vue/connection_state.ts +19 -19
  167. package/src/vue/index.ts +5 -5
  168. package/src/vue/useProcedure.ts +62 -62
  169. package/src/vue/useReducer.ts +55 -55
  170. package/src/vue/useSpacetimeDB.ts +18 -18
  171. package/src/vue/useTable.ts +229 -229
  172. package/LICENSE.txt +0 -759
@@ -1,1356 +1,1363 @@
1
- import { ConnectionId, ProductBuilder, ProductType } from '../';
2
- import { AlgebraicType, type ComparablePrimitive } from '../';
3
- import BinaryReader from '../lib/binary_reader.ts';
4
- import BinaryWriter from '../lib/binary_writer.ts';
5
- import {
6
- BsatnRowList,
7
- ClientMessage,
8
- QueryRows,
9
- QuerySetUpdate,
10
- ServerMessage,
11
- TableUpdateRows,
12
- UnsubscribeFlags,
13
- } from './client_api/types';
14
- import { ClientCache } from './client_cache.ts';
15
- import { DbConnectionBuilder } from './db_connection_builder.ts';
16
- import { INTERNAL_REMOTE_MODULE } from './internal.ts';
17
- import { type DbContext } from './db_context.ts';
18
- import type { Event } from './event.ts';
19
- import {
20
- type ErrorContextInterface,
21
- type EventContextInterface,
22
- type ReducerEventContextInterface,
23
- type SubscriptionEventContextInterface,
24
- } from './event_context.ts';
25
- import { EventEmitter } from './event_emitter.ts';
26
- import type { Deserializer, Identity, InferTypeOfRow, Serializer } from '../';
27
- import type {
28
- ProcedureResultMessage,
29
- ReducerResultMessage,
30
- } from './message_types.ts';
31
- import type { ReducerEvent } from './reducer_event.ts';
32
- import { type UntypedRemoteModule } from './spacetime_module.ts';
33
- import { makeQueryBuilder } from '../lib/query';
34
- import {
35
- type TableCache,
36
- type Operation,
37
- type PendingCallback,
38
- type TableUpdate as CacheTableUpdate,
39
- } from './table_cache.ts';
40
- import {
41
- SubscriptionBuilderImpl,
42
- SubscriptionHandleImpl,
43
- SubscriptionManager,
44
- type SubscribeEvent,
45
- } from './subscription_builder_impl.ts';
46
- import { stdbLogger, stringify } from './logger.ts';
47
- import { fromByteArray } from 'base64-js';
48
- import type {
49
- ReducerEventInfo,
50
- ReducersView,
51
- SubscriptionEventCallback,
52
- } from './reducers.ts';
53
- import type { ClientDbView } from './db_view.ts';
54
- import type { RowType, UntypedTableDef } from '../lib/table.ts';
55
- import type { ProceduresView } from './procedures.ts';
56
- import type { Values } from '../lib/type_util.ts';
57
- import type { TransactionUpdate } from './client_api/types.ts';
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';
72
-
73
- export {
74
- DbConnectionBuilder,
75
- SubscriptionBuilderImpl,
76
- SubscriptionHandleImpl,
77
- type TableCache,
78
- type Event,
79
- };
80
-
81
- export type RemoteModuleOf<C> =
82
- C extends DbConnectionImpl<infer RM> ? RM : never;
83
-
84
- export type {
85
- DbContext,
86
- EventContextInterface,
87
- ReducerEventContextInterface,
88
- SubscriptionEventContextInterface,
89
- ErrorContextInterface,
90
- ReducerEvent,
91
- };
92
-
93
- export type ConnectionEvent = 'connect' | 'disconnect' | 'connectError';
94
-
95
- export type DbConnectionConfig<RemoteModule extends UntypedRemoteModule> = {
96
- uri: URL;
97
- nameOrAddress: string;
98
- identity?: Identity;
99
- token?: string;
100
- emitter: EventEmitter<ConnectionEvent>;
101
- createWSFn: WebSocketFactory;
102
- compression: 'gzip' | 'brotli' | 'none';
103
- lightMode: boolean;
104
- confirmedReads?: boolean;
105
- remoteModule: RemoteModule;
106
- };
107
-
108
- type ProcedureCallback = (result: ProcedureResultMessage['result']) => void;
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
-
133
- export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
134
- implements DbContext<RemoteModule>
135
- {
136
- /**
137
- * Whether or not the connection is active.
138
- */
139
- isActive = false;
140
-
141
- /**
142
- * Whether `disconnect()` has been called on this connection.
143
- * Once requested, the connection will not be reused: managed environments
144
- * (such as the React `SpacetimeDBProvider`) use this to avoid reconnecting
145
- * after an intentional disconnect.
146
- */
147
- isDisconnectRequested = false;
148
-
149
- /**
150
- * This connection's public identity.
151
- */
152
- identity?: Identity = undefined;
153
-
154
- /**
155
- * This connection's private authentication token.
156
- */
157
- token?: string = undefined;
158
-
159
- /** @internal */
160
- [INTERNAL_REMOTE_MODULE](): RemoteModule {
161
- return this.#remoteModule;
162
- }
163
-
164
- /**
165
- * The accessor field to access the tables in the database and associated
166
- * callback functions.
167
- */
168
- db: ClientDbView<RemoteModule>;
169
-
170
- /**
171
- * The accessor field to access the reducers in the database.
172
- */
173
- reducers: ReducersView<RemoteModule>;
174
-
175
- /**
176
- * The accessor field to access the procedures in the database.
177
- */
178
- procedures: ProceduresView<RemoteModule>;
179
-
180
- /**
181
- * The `ConnectionId` of the connection to to the database.
182
- */
183
- connectionId: ConnectionId = ConnectionId.random();
184
- #connectionIdHex = this.connectionId.toHexString();
185
-
186
- // These fields are meant to be strictly private.
187
- #queryId = 0;
188
- #requestId = 0;
189
- #eventId = 0;
190
- #emitter: EventEmitter<ConnectionEvent>;
191
- #inboundQueue: Uint8Array[] = [];
192
- #inboundQueueOffset = 0;
193
- #isDrainingInboundQueue = false;
194
- #outboundQueue: Uint8Array<ArrayBuffer>[] = [];
195
- #isOutboundFlushScheduled = false;
196
- #negotiatedWsProtocol: NegotiatedWsProtocol = V2_WS_PROTOCOL;
197
- #subscriptionManager = new SubscriptionManager<RemoteModule>();
198
- #remoteModule: RemoteModule;
199
- #reducerCallbacks = new Map<
200
- number,
201
- (result: ReducerResultMessage['result']) => void
202
- >();
203
- #reducerCallInfo = new Map<number, { name: string; args: object }>();
204
- #procedureCallbacks = new Map<number, ProcedureCallback>();
205
- #rowDeserializers: Record<string, Deserializer<any>>;
206
- #rowIdMetadata: Record<
207
- string,
208
- { primaryKeyColName?: string; primaryKeyColType?: AlgebraicType }
209
- >;
210
- #reducerArgsSerializers: Record<
211
- string,
212
- { serialize: Serializer<any>; deserialize: Deserializer<any> }
213
- >;
214
- #procedureSerializers: Record<
215
- string,
216
- { serializeArgs: Serializer<any>; deserializeReturn: Deserializer<any> }
217
- >;
218
- #reducerNameBytes: Record<string, Uint8Array>;
219
- #procedureNameBytes: Record<string, Uint8Array>;
220
- #sourceNameToTableDef: Record<string, Values<RemoteModule['tables']>>;
221
- #messageReader = new BinaryReader(new Uint8Array());
222
- #rowListReader = new BinaryReader(new Uint8Array());
223
- #clientFrameEncoder = new BinaryWriter(1024);
224
- #boundSubscriptionBuilder!: () => SubscriptionBuilderImpl<RemoteModule>;
225
- #boundDisconnect!: () => void;
226
-
227
- // These fields are not part of the public API, but in a pinch you
228
- // could use JavaScript to access them by bypassing TypeScript's
229
- // private fields.
230
- // We use them in testing.
231
- private clientCache: ClientCache<RemoteModule>;
232
- private ws?: WebSocketAdapter;
233
- private wsPromise: Promise<WebSocketAdapter | undefined>;
234
-
235
- constructor({
236
- uri,
237
- nameOrAddress,
238
- identity,
239
- token,
240
- emitter,
241
- remoteModule,
242
- createWSFn,
243
- compression,
244
- lightMode,
245
- confirmedReads,
246
- }: DbConnectionConfig<RemoteModule>) {
247
- stdbLogger('info', 'Connecting to SpacetimeDB WS...');
248
-
249
- // We use .toString() here because some versions of React Native contain a bug where the URL constructor
250
- // incorrectly treats a URL instance as a plain string.
251
- // This results in an attempt to call .endsWith() on it, leading to an error.
252
- const url = new URL(uri.toString());
253
- if (!/^wss?:/.test(uri.protocol)) {
254
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
255
- }
256
-
257
- this.identity = identity;
258
- this.token = token;
259
-
260
- this.#remoteModule = remoteModule;
261
- this.#emitter = emitter;
262
- this.#boundSubscriptionBuilder = this.subscriptionBuilder.bind(this);
263
- this.#boundDisconnect = this.disconnect.bind(this);
264
-
265
- this.#rowDeserializers = Object.create(null);
266
- this.#rowIdMetadata = Object.create(null);
267
- this.#sourceNameToTableDef = Object.create(null);
268
- for (const table of Object.values(remoteModule.tables)) {
269
- this.#rowDeserializers[table.sourceName] = ProductType.makeDeserializer(
270
- table.rowType
271
- );
272
- this.#sourceNameToTableDef[table.sourceName] = table as Values<
273
- RemoteModule['tables']
274
- >;
275
- const primaryKeyColumn = Object.entries(table.columns).find(
276
- ([, column]) => column.columnMetadata.isPrimaryKey
277
- );
278
- this.#rowIdMetadata[table.sourceName] = primaryKeyColumn
279
- ? {
280
- primaryKeyColName: primaryKeyColumn[0],
281
- primaryKeyColType: primaryKeyColumn[1].typeBuilder.algebraicType,
282
- }
283
- : {};
284
- }
285
-
286
- this.#reducerArgsSerializers = Object.create(null);
287
- this.#reducerNameBytes = Object.create(null);
288
- for (const reducer of remoteModule.reducers) {
289
- this.#reducerArgsSerializers[reducer.name] = {
290
- serialize: ProductType.makeSerializer(reducer.paramsType),
291
- deserialize: ProductType.makeDeserializer(reducer.paramsType),
292
- };
293
- this.#reducerNameBytes[reducer.name] = TEXT_ENCODER.encode(reducer.name);
294
- }
295
-
296
- this.#procedureSerializers = Object.create(null);
297
- this.#procedureNameBytes = Object.create(null);
298
- for (const procedure of remoteModule.procedures) {
299
- this.#procedureSerializers[procedure.name] = {
300
- serializeArgs: ProductType.makeSerializer(
301
- new ProductBuilder(procedure.params).algebraicType.value
302
- ),
303
- deserializeReturn: AlgebraicType.makeDeserializer(
304
- procedure.returnType.algebraicType
305
- ),
306
- };
307
- this.#procedureNameBytes[procedure.name] = TEXT_ENCODER.encode(
308
- procedure.name
309
- );
310
- }
311
-
312
- url.searchParams.set('connection_id', this.#connectionIdHex);
313
-
314
- this.clientCache = new ClientCache<RemoteModule>();
315
- this.db = this.#makeDbView();
316
- this.reducers = this.#makeReducers(remoteModule);
317
- this.procedures = this.#makeProcedures(remoteModule);
318
-
319
- this.wsPromise = createWSFn({
320
- url,
321
- nameOrAddress,
322
- wsProtocol: [...PREFERRED_WS_PROTOCOLS],
323
- authToken: token,
324
- compression: compression,
325
- lightMode: lightMode,
326
- confirmedReads: confirmedReads,
327
- })
328
- .then(v => {
329
- this.ws = v;
330
-
331
- this.ws.onclose = () => {
332
- this.isActive = false;
333
- this.#emitter.emit('disconnect', this);
334
- };
335
- this.ws.onerror = (e: ErrorEvent) => {
336
- this.isActive = false;
337
- this.#emitter.emit('connectError', this, e);
338
- };
339
- this.ws.onopen = this.#handleOnOpen.bind(this);
340
- this.ws.onmessage = this.#handleOnMessage.bind(this);
341
- return v;
342
- })
343
- .catch(e => {
344
- stdbLogger('error', 'Error connecting to SpacetimeDB WS');
345
- this.#emitter.emit('connectError', this, e);
346
-
347
- return undefined;
348
- });
349
- }
350
-
351
- #getNextQueryId = () => {
352
- const queryId = this.#queryId;
353
- this.#queryId += 1;
354
- return queryId;
355
- };
356
-
357
- #getNextRequestId = () => this.#requestId++;
358
-
359
- #makeDbView(): ClientDbView<RemoteModule> {
360
- const view = Object.create(null) as ClientDbView<RemoteModule>;
361
-
362
- for (const tbl of Object.values(this.#sourceNameToTableDef)) {
363
- // ClientDbView uses this name verbatim
364
- const key = tbl.accessorName;
365
- Object.defineProperty(view, key, {
366
- enumerable: true,
367
- configurable: false,
368
- get: () => this.clientCache.getOrCreateTable(tbl),
369
- });
370
- }
371
-
372
- return view;
373
- }
374
-
375
- #makeReducers(def: RemoteModule): ReducersView<RemoteModule> {
376
- const out: Record<string, unknown> = {};
377
-
378
- for (const reducer of def.reducers) {
379
- const reducerName = reducer.name;
380
- const encodedReducerName = this.#reducerNameBytes[reducerName];
381
- const key = reducer.accessorName;
382
-
383
- const { serialize: serializeArgs } =
384
- this.#reducerArgsSerializers[reducerName];
385
-
386
- (out as any)[key] = (params: InferTypeOfRow<typeof reducer.params>) => {
387
- const writer = this.#reducerArgsEncoder;
388
- writer.clear();
389
- serializeArgs(writer, params);
390
- const argsBuffer = writer.getBuffer();
391
- return this.#callReducerWithEncodedName(
392
- reducerName,
393
- encodedReducerName,
394
- argsBuffer,
395
- params
396
- );
397
- };
398
- }
399
-
400
- return out as ReducersView<RemoteModule>;
401
- }
402
-
403
- #makeProcedures(def: RemoteModule): ProceduresView<RemoteModule> {
404
- const out: Record<string, unknown> = {};
405
-
406
- const writer = new BinaryWriter(1024);
407
-
408
- for (const procedure of def.procedures) {
409
- const procedureName = procedure.name;
410
- const encodedProcedureName = this.#procedureNameBytes[procedureName];
411
- const key = procedure.accessorName;
412
-
413
- const { serializeArgs, deserializeReturn } =
414
- this.#procedureSerializers[procedureName];
415
-
416
- (out as any)[key] = (
417
- params: InferTypeOfRow<typeof procedure.params>
418
- ): Promise<any> => {
419
- writer.clear();
420
- serializeArgs(writer, params);
421
- const argsBuffer = writer.getBuffer();
422
- return this.#callProcedureWithEncodedName(
423
- procedureName,
424
- encodedProcedureName,
425
- argsBuffer
426
- ).then(returnBuf => {
427
- return deserializeReturn(new BinaryReader(returnBuf));
428
- });
429
- };
430
- }
431
-
432
- return out as ProceduresView<RemoteModule>;
433
- }
434
-
435
- #makeEventContext(
436
- event: Event<
437
- ReducerEventInfo<
438
- RemoteModule['reducers'][number]['name'],
439
- InferTypeOfRow<RemoteModule['reducers'][number]['params']>
440
- >
441
- >
442
- ): EventContextInterface<RemoteModule> {
443
- return {
444
- db: this.db,
445
- reducers: this.reducers,
446
- isActive: this.isActive,
447
- subscriptionBuilder: this.#boundSubscriptionBuilder,
448
- disconnect: this.#boundDisconnect,
449
- event,
450
- };
451
- }
452
-
453
- // NOTE: This is very important!!! This is the actual function that
454
- // gets called when you call `connection.subscriptionBuilder()`.
455
- // The `subscriptionBuilder` function which is generated, just shadows
456
- // this function in the type system, but not the actual implementation!
457
- // Do not remove this function, or shoot yourself in the foot please.
458
- // It's not clear what would be a better way to do this at this exact
459
- // moment.
460
- subscriptionBuilder = (): SubscriptionBuilderImpl<RemoteModule> => {
461
- return new SubscriptionBuilderImpl(this);
462
- };
463
-
464
- getTablesMap(): any {
465
- return makeQueryBuilder({ tables: this.#remoteModule.tables } as any);
466
- }
467
-
468
- registerSubscription(
469
- handle: SubscriptionHandleImpl<RemoteModule>,
470
- handleEmitter: EventEmitter<
471
- SubscribeEvent,
472
- SubscriptionEventCallback<RemoteModule>
473
- >,
474
- querySql: string[]
475
- ): number {
476
- const querySetId = this.#getNextQueryId();
477
- this.#subscriptionManager.subscriptions.set(querySetId, {
478
- handle,
479
- emitter: handleEmitter,
480
- });
481
- const requestId = this.#getNextRequestId();
482
- this.#sendMessage(
483
- ClientMessage.Subscribe({
484
- queryStrings: querySql,
485
- querySetId: { id: querySetId },
486
- requestId,
487
- })
488
- );
489
- return querySetId;
490
- }
491
-
492
- unregisterSubscription(querySetId: number): void {
493
- const requestId = this.#getNextRequestId();
494
- this.#sendMessage(
495
- ClientMessage.Unsubscribe({
496
- querySetId: { id: querySetId },
497
- requestId,
498
- flags: UnsubscribeFlags.SendDroppedRows,
499
- })
500
- );
501
- }
502
-
503
- #parseRowList(
504
- type: 'insert' | 'delete',
505
- tableName: string,
506
- rowList: BsatnRowList
507
- ): Operation[] {
508
- const buffer = rowList.rowsData;
509
- const reader = this.#rowListReader;
510
- reader.reset(buffer);
511
- const rows: Operation[] = [];
512
-
513
- const deserializeRow = this.#rowDeserializers[tableName];
514
- const { primaryKeyColName, primaryKeyColType } =
515
- this.#rowIdMetadata[tableName];
516
- let previousOffset = 0;
517
- while (reader.remaining > 0) {
518
- const row = deserializeRow(reader);
519
- let rowId: ComparablePrimitive | undefined = undefined;
520
- if (primaryKeyColName !== undefined && primaryKeyColType !== undefined) {
521
- rowId = AlgebraicType.intoMapKey(
522
- primaryKeyColType,
523
- row[primaryKeyColName]
524
- );
525
- } else {
526
- // Get a view of the bytes for this row.
527
- const rowBytes = buffer.subarray(previousOffset, reader.offset);
528
- // Convert it to a base64 string, so we can use it as a map key.
529
- const asBase64 = fromByteArray(rowBytes);
530
- rowId = asBase64;
531
- }
532
- previousOffset = reader.offset;
533
-
534
- rows.push({
535
- type,
536
- rowId,
537
- row,
538
- });
539
- }
540
- return rows;
541
- }
542
-
543
- // Take a bunch of table updates and ensure that there is at most one update per table.
544
- #mergeTableUpdates(
545
- updates: CacheTableUpdate<UntypedTableDef>[]
546
- ): CacheTableUpdate<UntypedTableDef>[] {
547
- const merged = new Map<string, Operation[]>();
548
- for (const update of updates) {
549
- const ops = merged.get(update.tableName);
550
- if (ops) {
551
- for (const op of update.operations) ops.push(op);
552
- } else {
553
- merged.set(update.tableName, update.operations.slice());
554
- }
555
- }
556
- return Array.from(merged, ([tableName, operations]) => ({
557
- tableName,
558
- operations,
559
- }));
560
- }
561
-
562
- #queryRowsToTableUpdates(
563
- rows: QueryRows,
564
- opType: 'insert' | 'delete'
565
- ): CacheTableUpdate<UntypedTableDef>[] {
566
- const updates: CacheTableUpdate<UntypedTableDef>[] = [];
567
- for (const tableRows of rows.tables) {
568
- updates.push({
569
- tableName: tableRows.table,
570
- operations: this.#parseRowList(opType, tableRows.table, tableRows.rows),
571
- });
572
- }
573
- return this.#mergeTableUpdates(updates);
574
- }
575
-
576
- #tableUpdateRowsToOperations(
577
- tableName: string,
578
- rows: TableUpdateRows
579
- ): Operation[] {
580
- if (rows.tag === 'PersistentTable') {
581
- const inserts = this.#parseRowList(
582
- 'insert',
583
- tableName,
584
- rows.value.inserts
585
- );
586
- const deletes = this.#parseRowList(
587
- 'delete',
588
- tableName,
589
- rows.value.deletes
590
- );
591
- return inserts.concat(deletes);
592
- }
593
- if (rows.tag === 'EventTable') {
594
- // Event table rows are insert-only. The table cache handles skipping
595
- // storage for event tables and only firing on_insert callbacks.
596
- return this.#parseRowList('insert', tableName, rows.value.events);
597
- }
598
- return [];
599
- }
600
-
601
- #querySetUpdateToTableUpdates(
602
- querySetUpdate: QuerySetUpdate
603
- ): CacheTableUpdate<UntypedTableDef>[] {
604
- const updates: CacheTableUpdate<UntypedTableDef>[] = [];
605
- for (const tableUpdate of querySetUpdate.tables) {
606
- let operations: Operation[] = [];
607
- for (const rows of tableUpdate.rows) {
608
- operations = operations.concat(
609
- this.#tableUpdateRowsToOperations(tableUpdate.tableName, rows)
610
- );
611
- }
612
- updates.push({
613
- tableName: tableUpdate.tableName,
614
- operations,
615
- });
616
- }
617
- return this.#mergeTableUpdates(updates);
618
- }
619
-
620
- #flushOutboundQueue(wsResolved: WebSocketAdapter): void {
621
- if (this.#negotiatedWsProtocol === V3_WS_PROTOCOL) {
622
- this.#flushOutboundQueueV3(wsResolved);
623
- return;
624
- }
625
- this.#flushOutboundQueueV2(wsResolved);
626
- }
627
-
628
- #flushOutboundQueueV2(wsResolved: WebSocketAdapter): void {
629
- const pending = this.#outboundQueue.splice(0);
630
- for (const message of pending) {
631
- wsResolved.send(message);
632
- }
633
- }
634
-
635
- #flushOutboundQueueV3(wsResolved: WebSocketAdapter): void {
636
- if (this.#outboundQueue.length === 0) {
637
- return;
638
- }
639
-
640
- // Emit at most one bounded frame per flush. If more encoded v2 messages
641
- // remain in the queue, they are sent by a later scheduled flush so inbound
642
- // traffic and other tasks get a chance to run between websocket writes.
643
- const batchSize = countClientMessagesForV3Frame(
644
- this.#outboundQueue,
645
- MAX_V3_OUTBOUND_FRAME_BYTES
646
- );
647
- wsResolved.send(
648
- encodeClientMessagesV3(
649
- this.#clientFrameEncoder,
650
- this.#outboundQueue,
651
- batchSize
652
- )
653
- );
654
-
655
- if (batchSize === this.#outboundQueue.length) {
656
- this.#outboundQueue.length = 0;
657
- return;
658
- }
659
-
660
- this.#outboundQueue.copyWithin(0, batchSize);
661
- this.#outboundQueue.length -= batchSize;
662
- if (this.#outboundQueue.length > 0) {
663
- this.#scheduleDeferredOutboundFlush();
664
- }
665
- }
666
-
667
- #scheduleOutboundFlush(): void {
668
- this.#scheduleOutboundFlushWith('microtask');
669
- }
670
-
671
- #scheduleDeferredOutboundFlush(): void {
672
- this.#scheduleOutboundFlushWith('next-task');
673
- }
674
-
675
- #scheduleOutboundFlushWith(schedule: 'microtask' | 'next-task'): void {
676
- if (this.#isOutboundFlushScheduled) {
677
- return;
678
- }
679
-
680
- this.#isOutboundFlushScheduled = true;
681
- const flush = () => {
682
- this.#isOutboundFlushScheduled = false;
683
- if (this.ws && this.isActive) {
684
- this.#flushOutboundQueue(this.ws);
685
- }
686
- };
687
-
688
- // The first v3 flush stays on the current turn so same-tick sends coalesce.
689
- // Follow-up flushes after a size-capped frame yield to the next task so we
690
- // do not sit in a tight send loop while inbound websocket work is waiting.
691
- if (schedule === 'next-task') {
692
- setTimeout(flush, 0);
693
- } else {
694
- queueMicrotask(flush);
695
- }
696
- }
697
-
698
- #reducerArgsEncoder = new BinaryWriter(1024);
699
- #clientMessageEncoder = new BinaryWriter(1024);
700
- #sendEncodedMessage(
701
- encoded: Uint8Array<ArrayBuffer>,
702
- describe: () => string
703
- ): void {
704
- stdbLogger('trace', describe);
705
- if (this.ws && this.isActive) {
706
- if (this.#negotiatedWsProtocol === V2_WS_PROTOCOL) {
707
- if (this.#outboundQueue.length) this.#flushOutboundQueue(this.ws);
708
- this.ws.send(encoded);
709
- return;
710
- }
711
-
712
- this.#outboundQueue.push(encoded.slice());
713
- this.#scheduleOutboundFlush();
714
- } else {
715
- // Use slice() to copy, in case the clientMessageEncoder's buffer gets reused
716
- // before the connection opens or before a v3 microbatch flush runs.
717
- this.#outboundQueue.push(encoded.slice());
718
- }
719
- }
720
-
721
- #sendMessage(message: ClientMessage): void {
722
- const writer = this.#clientMessageEncoder;
723
- writer.clear();
724
- ClientMessage.serialize(writer, message);
725
- const encoded = writer.getBuffer();
726
- const isLive = !!(this.ws && this.isActive);
727
- this.#sendEncodedMessage(encoded, () =>
728
- isLive
729
- ? `Sending message to server: ${stringify(message)}`
730
- : `Queuing message to server: ${stringify(message)}`
731
- );
732
- }
733
-
734
- #sendCallReducerMessage(
735
- requestId: number,
736
- reducerNameBytes: Uint8Array,
737
- argsBuffer: Uint8Array
738
- ): void {
739
- const writer = this.#clientMessageEncoder;
740
- writer.clear();
741
- writer.writeByte(CLIENT_MESSAGE_CALL_REDUCER_TAG);
742
- writer.writeU32(requestId);
743
- writer.writeU8(0);
744
- writer.writeUInt8Array(reducerNameBytes);
745
- writer.writeUInt8Array(argsBuffer);
746
- const encoded = writer.getBuffer();
747
- this.#sendEncodedMessage(
748
- encoded,
749
- () => `Sending reducer call message to server: requestId=${requestId}`
750
- );
751
- }
752
-
753
- #sendCallProcedureMessage(
754
- requestId: number,
755
- procedureNameBytes: Uint8Array,
756
- argsBuffer: Uint8Array
757
- ): void {
758
- const writer = this.#clientMessageEncoder;
759
- writer.clear();
760
- writer.writeByte(CLIENT_MESSAGE_CALL_PROCEDURE_TAG);
761
- writer.writeU32(requestId);
762
- writer.writeU8(0);
763
- writer.writeUInt8Array(procedureNameBytes);
764
- writer.writeUInt8Array(argsBuffer);
765
- const encoded = writer.getBuffer();
766
- this.#sendEncodedMessage(
767
- encoded,
768
- () => `Sending procedure call message to server: requestId=${requestId}`
769
- );
770
- }
771
-
772
- #setConnectionId(connectionId: ConnectionId): void {
773
- this.connectionId = connectionId;
774
- this.#connectionIdHex = connectionId.toHexString();
775
- }
776
-
777
- #nextEventId(): string {
778
- this.#eventId += 1;
779
- return `${this.#connectionIdHex}:${this.#eventId}`;
780
- }
781
-
782
- /**
783
- * Handles WebSocket onOpen event.
784
- */
785
- #handleOnOpen(): void {
786
- if (this.ws) {
787
- this.#negotiatedWsProtocol = normalizeWsProtocol(this.ws.protocol);
788
- }
789
- this.isActive = true;
790
- if (this.ws) {
791
- this.#flushOutboundQueue(this.ws);
792
- }
793
- }
794
-
795
- #applyTableUpdates(
796
- tableUpdates: CacheTableUpdate<UntypedTableDef>[],
797
- eventContext: EventContextInterface<RemoteModule>
798
- ): PendingCallback[] {
799
- const pendingCallbacks: PendingCallback[] = [];
800
- for (const tableUpdate of tableUpdates) {
801
- // Get table information for the table being updated
802
- const tableName = tableUpdate.tableName;
803
- const tableDef = this.#sourceNameToTableDef[tableName];
804
- const table = this.clientCache.getOrCreateTable(tableDef);
805
- const newCallbacks = table.applyOperations(
806
- tableUpdate.operations as Operation<
807
- RowType<Values<RemoteModule['tables']>>
808
- >[],
809
- eventContext
810
- );
811
- for (const callback of newCallbacks) {
812
- pendingCallbacks.push(callback);
813
- }
814
- }
815
- return pendingCallbacks;
816
- }
817
-
818
- #applyTransactionUpdates(
819
- eventContext: EventContextInterface<RemoteModule>,
820
- tu: TransactionUpdate
821
- ): PendingCallback[] {
822
- const allUpdates: CacheTableUpdate<UntypedTableDef>[] = [];
823
- for (const querySetUpdate of tu.querySets) {
824
- const tableUpdates = this.#querySetUpdateToTableUpdates(querySetUpdate);
825
- for (const update of tableUpdates) {
826
- allUpdates.push(update);
827
- }
828
- // TODO: When we have per-query storage, we will want to apply the per-query events here.
829
- }
830
- return this.#applyTableUpdates(
831
- this.#mergeTableUpdates(allUpdates),
832
- eventContext
833
- );
834
- }
835
-
836
- #dispatchPendingCallbacks(callbacks: readonly PendingCallback[]): void {
837
- stdbLogger(
838
- 'trace',
839
- () => `Calling ${callbacks.length} triggered row callbacks`
840
- );
841
- for (const callback of callbacks) {
842
- callback.cb();
843
- }
844
- }
845
-
846
- #processServerMessage(serverMessage: ServerMessage): void {
847
- stdbLogger(
848
- 'trace',
849
- () => `Processing server message: ${stringify(serverMessage)}`
850
- );
851
- switch (serverMessage.tag) {
852
- case 'InitialConnection': {
853
- this.identity = serverMessage.value.identity;
854
- if (!this.token && serverMessage.value.token) {
855
- this.token = serverMessage.value.token;
856
- }
857
- this.#setConnectionId(serverMessage.value.connectionId);
858
- this.#emitter.emit('connect', this, this.identity, this.token);
859
- break;
860
- }
861
- case 'SubscribeApplied': {
862
- const querySetId = serverMessage.value.querySetId.id;
863
- const subscription =
864
- this.#subscriptionManager.subscriptions.get(querySetId);
865
- if (!subscription) {
866
- stdbLogger(
867
- 'error',
868
- `Received SubscribeApplied for unknown querySetId ${querySetId}.`
869
- );
870
- return;
871
- }
872
- const event: Event<never> = {
873
- id: this.#nextEventId(),
874
- tag: 'SubscribeApplied',
875
- };
876
- const eventContext = this.#makeEventContext(event);
877
- const tableUpdates = this.#queryRowsToTableUpdates(
878
- serverMessage.value.rows,
879
- 'insert'
880
- );
881
- const callbacks = this.#applyTableUpdates(tableUpdates, eventContext);
882
- const { event: _, ...subscriptionEventContext } = eventContext;
883
- subscription.emitter.emit('applied', subscriptionEventContext);
884
- this.#dispatchPendingCallbacks(callbacks);
885
- break;
886
- }
887
- case 'UnsubscribeApplied': {
888
- const querySetId = serverMessage.value.querySetId.id;
889
- const subscription =
890
- this.#subscriptionManager.subscriptions.get(querySetId);
891
- if (!subscription) {
892
- stdbLogger(
893
- 'error',
894
- `Received UnsubscribeApplied for unknown querySetId ${querySetId}.`
895
- );
896
- return;
897
- }
898
- const event: Event<never> = {
899
- id: this.#nextEventId(),
900
- tag: 'UnsubscribeApplied',
901
- };
902
- const eventContext = this.#makeEventContext(event);
903
- const tableUpdates = serverMessage.value.rows
904
- ? this.#queryRowsToTableUpdates(serverMessage.value.rows, 'delete')
905
- : [];
906
- const callbacks = this.#applyTableUpdates(tableUpdates, eventContext);
907
- const { event: _, ...subscriptionEventContext } = eventContext;
908
- subscription.emitter.emit('end', subscriptionEventContext);
909
- this.#subscriptionManager.subscriptions.delete(querySetId);
910
- this.#dispatchPendingCallbacks(callbacks);
911
- break;
912
- }
913
- case 'SubscriptionError': {
914
- const querySetId = serverMessage.value.querySetId.id;
915
- const requestId = serverMessage.value.requestId;
916
- const error = Error(serverMessage.value.error);
917
- const event: Event<never> = {
918
- id: this.#nextEventId(),
919
- tag: 'Error',
920
- value: error,
921
- };
922
- const eventContext = this.#makeEventContext(event);
923
- const errorContext = {
924
- ...eventContext,
925
- event: error,
926
- };
927
-
928
- // If the requestId isn't set, that means we already applied the subscription.
929
- // Since we don't know how to remove the relevant rows from our table cache, we need
930
- // to kill the connection. Once we have per-query storage, this won't be fatal.
931
- if (requestId == null) {
932
- stdbLogger(
933
- 'error',
934
- `Disconnecting due to error for a previously applied subscription: ${serverMessage.value.error}`
935
- );
936
- this.disconnect();
937
- break;
938
- }
939
-
940
- const subscription =
941
- this.#subscriptionManager.subscriptions.get(querySetId);
942
- if (subscription) {
943
- subscription.emitter.emit('error', errorContext, error);
944
- this.#subscriptionManager.subscriptions.delete(querySetId);
945
- } else {
946
- stdbLogger(
947
- 'error',
948
- `Received SubscriptionError for unknown querySetId ${querySetId}:`,
949
- error
950
- );
951
- }
952
- break;
953
- }
954
- case 'TransactionUpdate': {
955
- const event: Event<never> = {
956
- id: this.#nextEventId(),
957
- tag: 'Transaction',
958
- };
959
- const eventContext = this.#makeEventContext(event);
960
- const callbacks = this.#applyTransactionUpdates(
961
- eventContext,
962
- serverMessage.value
963
- );
964
- this.#dispatchPendingCallbacks(callbacks);
965
- break;
966
- }
967
- case 'ReducerResult': {
968
- const { requestId, result } = serverMessage.value;
969
-
970
- if (result.tag === 'Ok') {
971
- const reducerInfo = this.#reducerCallInfo.get(requestId);
972
- const eventId: string = this.#nextEventId();
973
- const event: Event<any> = reducerInfo
974
- ? {
975
- id: eventId,
976
- tag: 'Reducer',
977
- value: {
978
- timestamp: serverMessage.value.timestamp,
979
- outcome: result,
980
- reducer: {
981
- name: reducerInfo.name,
982
- args: reducerInfo.args,
983
- },
984
- },
985
- }
986
- : {
987
- id: eventId,
988
- tag: 'Transaction',
989
- };
990
- const eventContext = this.#makeEventContext(event as any);
991
-
992
- const callbacks = this.#applyTransactionUpdates(
993
- eventContext,
994
- result.value.transactionUpdate
995
- );
996
- this.#dispatchPendingCallbacks(callbacks);
997
- }
998
- this.#reducerCallInfo.delete(requestId);
999
- const cb = this.#reducerCallbacks.get(requestId);
1000
- this.#reducerCallbacks.delete(requestId);
1001
- cb?.(result);
1002
- break;
1003
- }
1004
- case 'ProcedureResult': {
1005
- const { status, requestId } = serverMessage.value;
1006
- const result: ProcedureResultMessage['result'] =
1007
- status.tag === 'Returned'
1008
- ? { tag: 'Ok', value: status.value }
1009
- : { tag: 'Err', value: status.value };
1010
- const cb = this.#procedureCallbacks.get(requestId);
1011
- this.#procedureCallbacks.delete(requestId);
1012
- cb?.(result);
1013
- break;
1014
- }
1015
- case 'OneOffQueryResult': {
1016
- stdbLogger(
1017
- 'warn',
1018
- 'Received OneOffQueryResult but SDK does not expose one-off query APIs yet.'
1019
- );
1020
- break;
1021
- }
1022
- }
1023
- }
1024
-
1025
- #processV2Message(data: Uint8Array): void {
1026
- const reader = this.#messageReader;
1027
- reader.reset(data);
1028
- this.#processServerMessage(ServerMessage.deserialize(reader));
1029
- }
1030
-
1031
- #processMessage(data: Uint8Array): void {
1032
- if (this.#negotiatedWsProtocol !== V3_WS_PROTOCOL) {
1033
- this.#processV2Message(data);
1034
- return;
1035
- }
1036
-
1037
- const messageCount = forEachServerMessageV3(
1038
- this.#messageReader,
1039
- data,
1040
- serverMessage => {
1041
- this.#processServerMessage(serverMessage);
1042
- }
1043
- );
1044
- stdbLogger(
1045
- 'trace',
1046
- () => `Processing server v3 payload with ${messageCount} message(s)`
1047
- );
1048
- }
1049
-
1050
- /**
1051
- * Handles WebSocket onMessage event.
1052
- * @param wsMessage MessageEvent object.
1053
- */
1054
- #handleOnMessage(wsMessage: { data: Uint8Array }): void {
1055
- // Queue inbound messages so they are processed strictly in arrival order.
1056
- // We deliberately drain synchronously instead of promise-chaining each
1057
- // message, but this still guarantees that we do not begin processing the
1058
- // next message until the current message has been fully handled.
1059
- this.#inboundQueue.push(wsMessage.data);
1060
- if (this.#isDrainingInboundQueue) {
1061
- return;
1062
- }
1063
-
1064
- this.#isDrainingInboundQueue = true;
1065
- try {
1066
- // TODO: If this loop starts monopolizing the event loop under sustained
1067
- // inbound traffic, switch to a chunked drain that periodically yields.
1068
- while (this.#inboundQueueOffset < this.#inboundQueue.length) {
1069
- const data = this.#inboundQueue[this.#inboundQueueOffset];
1070
- this.#inboundQueueOffset += 1;
1071
- if (data) {
1072
- this.#processMessage(data);
1073
- }
1074
- }
1075
- } finally {
1076
- if (this.#inboundQueueOffset >= this.#inboundQueue.length) {
1077
- this.#inboundQueue.length = 0;
1078
- } else if (this.#inboundQueueOffset > 0) {
1079
- this.#inboundQueue = this.#inboundQueue.slice(this.#inboundQueueOffset);
1080
- }
1081
- this.#inboundQueueOffset = 0;
1082
- this.#isDrainingInboundQueue = false;
1083
- }
1084
- }
1085
-
1086
- /**
1087
- * Call a reducer on your SpacetimeDB module.
1088
- *
1089
- * @param reducerName The name of the reducer to call
1090
- * @param argsSerializer The arguments to pass to the reducer
1091
- */
1092
- callReducer(
1093
- reducerName: string,
1094
- argsBuffer: Uint8Array,
1095
- reducerArgs?: object
1096
- ): Promise<void> {
1097
- const encodedReducerName = this.#reducerNameBytes[reducerName];
1098
- if (encodedReducerName) {
1099
- return this.#callReducerWithEncodedName(
1100
- reducerName,
1101
- encodedReducerName,
1102
- argsBuffer,
1103
- reducerArgs
1104
- );
1105
- }
1106
- return this.#callReducerGeneric(reducerName, argsBuffer, reducerArgs);
1107
- }
1108
-
1109
- #callReducerWithEncodedName(
1110
- reducerName: string,
1111
- encodedReducerName: Uint8Array,
1112
- argsBuffer: Uint8Array,
1113
- reducerArgs?: object
1114
- ): Promise<void> {
1115
- const { promise, resolve, reject } = Promise.withResolvers<void>();
1116
- const requestId = this.#getNextRequestId();
1117
- this.#sendCallReducerMessage(requestId, encodedReducerName, argsBuffer);
1118
- if (reducerArgs) {
1119
- this.#reducerCallInfo.set(requestId, {
1120
- name: reducerName,
1121
- args: reducerArgs,
1122
- });
1123
- }
1124
- this.#reducerCallbacks.set(requestId, result => {
1125
- if (result.tag === 'Ok' || result.tag === 'OkEmpty') {
1126
- resolve();
1127
- } else {
1128
- if (result.tag === 'Err') {
1129
- /// Interpret the user-returned error as a string.
1130
- const reader = new BinaryReader(result.value);
1131
- const errorString = reader.readString();
1132
- reject(new SenderError(errorString));
1133
- } else if (result.tag === 'InternalError') {
1134
- reject(new InternalError(result.value));
1135
- } else {
1136
- const unreachable: never = result;
1137
- reject(new Error('Unexpected reducer result'));
1138
- void unreachable;
1139
- }
1140
- }
1141
- });
1142
- return promise;
1143
- }
1144
-
1145
- #callReducerGeneric(
1146
- reducerName: string,
1147
- argsBuffer: Uint8Array,
1148
- reducerArgs?: object
1149
- ): Promise<void> {
1150
- const { promise, resolve, reject } = Promise.withResolvers<void>();
1151
- const requestId = this.#getNextRequestId();
1152
- const message = ClientMessage.CallReducer({
1153
- reducer: reducerName,
1154
- args: argsBuffer,
1155
- requestId,
1156
- flags: 0,
1157
- });
1158
- this.#sendMessage(message);
1159
- if (reducerArgs) {
1160
- this.#reducerCallInfo.set(requestId, {
1161
- name: reducerName,
1162
- args: reducerArgs,
1163
- });
1164
- }
1165
- this.#reducerCallbacks.set(requestId, result => {
1166
- if (result.tag === 'Ok' || result.tag === 'OkEmpty') {
1167
- resolve();
1168
- } else {
1169
- if (result.tag === 'Err') {
1170
- /// Interpret the user-returned error as a string.
1171
- const reader = new BinaryReader(result.value);
1172
- const errorString = reader.readString();
1173
- reject(new SenderError(errorString));
1174
- } else if (result.tag === 'InternalError') {
1175
- reject(new InternalError(result.value));
1176
- } else {
1177
- const unreachable: never = result;
1178
- reject(new Error('Unexpected reducer result'));
1179
- void unreachable;
1180
- }
1181
- }
1182
- });
1183
- return promise;
1184
- }
1185
-
1186
- /**
1187
- * Call a reducer on your SpacetimeDB module with typed arguments.
1188
- * @param reducerSchema The schema of the reducer to call
1189
- * @param callReducerFlags The flags for the reducer call
1190
- * @param params The arguments to pass to the reducer
1191
- */
1192
- callReducerWithParams(
1193
- reducerName: string,
1194
- // TODO: remove
1195
- _paramsType: ProductType,
1196
- params: object
1197
- ): Promise<void> {
1198
- const writer = this.#reducerArgsEncoder;
1199
- writer.clear();
1200
- this.#reducerArgsSerializers[reducerName].serialize(writer, params);
1201
- const argsBuffer = writer.getBuffer();
1202
- return this.callReducer(reducerName, argsBuffer, params);
1203
- }
1204
-
1205
- /**
1206
- * Call a reducer on your SpacetimeDB module.
1207
- *
1208
- * @param procedureName The name of the reducer to call
1209
- * @param argsBuffer The arguments to pass to the reducer
1210
- */
1211
- callProcedure(
1212
- procedureName: string,
1213
- argsBuffer: Uint8Array
1214
- ): Promise<Uint8Array> {
1215
- const encodedProcedureName = this.#procedureNameBytes[procedureName];
1216
- if (encodedProcedureName) {
1217
- return this.#callProcedureWithEncodedName(
1218
- procedureName,
1219
- encodedProcedureName,
1220
- argsBuffer
1221
- );
1222
- }
1223
- return this.#callProcedureGeneric(procedureName, argsBuffer);
1224
- }
1225
-
1226
- #callProcedureWithEncodedName(
1227
- procedureName: string,
1228
- encodedProcedureName: Uint8Array,
1229
- argsBuffer: Uint8Array
1230
- ): Promise<Uint8Array> {
1231
- const { promise, resolve, reject } = Promise.withResolvers<Uint8Array>();
1232
- const requestId = this.#getNextRequestId();
1233
- this.#sendCallProcedureMessage(requestId, encodedProcedureName, argsBuffer);
1234
- this.#procedureCallbacks.set(requestId, result => {
1235
- if (result.tag === 'Ok') {
1236
- resolve(result.value);
1237
- } else {
1238
- reject(result.value);
1239
- }
1240
- });
1241
- return promise;
1242
- }
1243
-
1244
- #callProcedureGeneric(
1245
- procedureName: string,
1246
- argsBuffer: Uint8Array
1247
- ): Promise<Uint8Array> {
1248
- const { promise, resolve, reject } = Promise.withResolvers<Uint8Array>();
1249
- const requestId = this.#getNextRequestId();
1250
- const message = ClientMessage.CallProcedure({
1251
- procedure: procedureName,
1252
- args: argsBuffer,
1253
- requestId,
1254
- // reserved for future use - 0 is the only valid value
1255
- flags: 0,
1256
- });
1257
- this.#sendMessage(message);
1258
- this.#procedureCallbacks.set(requestId, result => {
1259
- if (result.tag === 'Ok') {
1260
- resolve(result.value);
1261
- } else {
1262
- reject(result.value);
1263
- }
1264
- });
1265
- return promise;
1266
- }
1267
-
1268
- /**
1269
- * Call a reducer on your SpacetimeDB module with typed arguments.
1270
- * @param reducerSchema The schema of the reducer to call
1271
- * @param callReducerFlags The flags for the reducer call
1272
- * @param params The arguments to pass to the reducer
1273
- */
1274
- callProcedureWithParams(
1275
- procedureName: string,
1276
- // TODO: remove
1277
- _paramsType: ProductType,
1278
- params: object,
1279
- // TODO: remove
1280
- _returnType: AlgebraicType
1281
- ): Promise<any> {
1282
- const writer = new BinaryWriter(1024);
1283
- const { serializeArgs, deserializeReturn } =
1284
- this.#procedureSerializers[procedureName];
1285
- serializeArgs(writer, params);
1286
- const argsBuffer = writer.getBuffer();
1287
- return this.callProcedure(procedureName, argsBuffer).then(returnBuf => {
1288
- return deserializeReturn(new BinaryReader(returnBuf));
1289
- });
1290
- }
1291
-
1292
- /**
1293
- * Close the current connection.
1294
- *
1295
- * @example
1296
- *
1297
- * ```ts
1298
- * const connection = DbConnection.builder().build();
1299
- * connection.disconnect()
1300
- * ```
1301
- */
1302
- disconnect(): void {
1303
- this.isDisconnectRequested = true;
1304
- this.wsPromise.then(ws => ws?.close());
1305
- }
1306
-
1307
- private on(
1308
- eventName: ConnectionEvent,
1309
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1310
- ): void {
1311
- this.#emitter.on(eventName, callback);
1312
- }
1313
-
1314
- private off(
1315
- eventName: ConnectionEvent,
1316
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1317
- ): void {
1318
- this.#emitter.off(eventName, callback);
1319
- }
1320
-
1321
- private onConnect(
1322
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1323
- ): void {
1324
- this.#emitter.on('connect', callback);
1325
- }
1326
-
1327
- private onDisconnect(
1328
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1329
- ): void {
1330
- this.#emitter.on('disconnect', callback);
1331
- }
1332
-
1333
- private onConnectError(
1334
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1335
- ): void {
1336
- this.#emitter.on('connectError', callback);
1337
- }
1338
-
1339
- removeOnConnect(
1340
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1341
- ): void {
1342
- this.#emitter.off('connect', callback);
1343
- }
1344
-
1345
- removeOnDisconnect(
1346
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1347
- ): void {
1348
- this.#emitter.off('disconnect', callback);
1349
- }
1350
-
1351
- removeOnConnectError(
1352
- callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1353
- ): void {
1354
- this.#emitter.off('connectError', callback);
1355
- }
1356
- }
1
+ import { ConnectionId, ProductBuilder, ProductType } from '../';
2
+ import { AlgebraicType, type ComparablePrimitive } from '../';
3
+ import BinaryReader from '../lib/binary_reader.ts';
4
+ import BinaryWriter from '../lib/binary_writer.ts';
5
+ import {
6
+ BsatnRowList,
7
+ ClientMessage,
8
+ QueryRows,
9
+ QuerySetUpdate,
10
+ ServerMessage,
11
+ TableUpdateRows,
12
+ UnsubscribeFlags,
13
+ } from './client_api/types';
14
+ import { ClientCache } from './client_cache.ts';
15
+ import { DbConnectionBuilder } from './db_connection_builder.ts';
16
+ import { INTERNAL_REMOTE_MODULE } from './internal.ts';
17
+ import { type DbContext } from './db_context.ts';
18
+ import type { Event } from './event.ts';
19
+ import {
20
+ type ErrorContextInterface,
21
+ type EventContextInterface,
22
+ type ReducerEventContextInterface,
23
+ type SubscriptionEventContextInterface,
24
+ } from './event_context.ts';
25
+ import { EventEmitter } from './event_emitter.ts';
26
+ import type {
27
+ Deserializer,
28
+ Identity,
29
+ InferTypeOfParams,
30
+ Serializer,
31
+ } from '../';
32
+ import type {
33
+ ProcedureResultMessage,
34
+ ReducerResultMessage,
35
+ } from './message_types.ts';
36
+ import type { ReducerEvent } from './reducer_event.ts';
37
+ import { type UntypedRemoteModule } from './spacetime_module.ts';
38
+ import { makeQueryBuilder } from '../lib/query';
39
+ import {
40
+ type TableCache,
41
+ type Operation,
42
+ type PendingCallback,
43
+ type TableUpdate as CacheTableUpdate,
44
+ } from './table_cache.ts';
45
+ import {
46
+ SubscriptionBuilderImpl,
47
+ SubscriptionHandleImpl,
48
+ SubscriptionManager,
49
+ type SubscribeEvent,
50
+ } from './subscription_builder_impl.ts';
51
+ import { stdbLogger, stringify } from './logger.ts';
52
+ import { fromByteArray } from 'base64-js';
53
+ import type {
54
+ ReducerEventInfo,
55
+ ReducersView,
56
+ SubscriptionEventCallback,
57
+ } from './reducers.ts';
58
+ import type { ClientDbView } from './db_view.ts';
59
+ import type { RowType, UntypedTableDef } from '../lib/table.ts';
60
+ import type { ProceduresView } from './procedures.ts';
61
+ import type { Values } from '../lib/type_util.ts';
62
+ import type { TransactionUpdate } from './client_api/types.ts';
63
+ import { InternalError, SenderError } from '../lib/errors.ts';
64
+ import type { WebSocketAdapter, WebSocketFactory } from './ws.ts';
65
+ import {
66
+ normalizeWsProtocol,
67
+ PREFERRED_WS_PROTOCOLS,
68
+ V2_WS_PROTOCOL,
69
+ V3_WS_PROTOCOL,
70
+ type NegotiatedWsProtocol,
71
+ } from './websocket_protocols';
72
+ import {
73
+ countClientMessagesForV3Frame,
74
+ encodeClientMessagesV3,
75
+ forEachServerMessageV3,
76
+ } from './websocket_v3_frames.ts';
77
+
78
+ export {
79
+ DbConnectionBuilder,
80
+ SubscriptionBuilderImpl,
81
+ SubscriptionHandleImpl,
82
+ type TableCache,
83
+ type Event,
84
+ };
85
+
86
+ export type RemoteModuleOf<C> =
87
+ C extends DbConnectionImpl<infer RM> ? RM : never;
88
+
89
+ export type {
90
+ DbContext,
91
+ EventContextInterface,
92
+ ReducerEventContextInterface,
93
+ SubscriptionEventContextInterface,
94
+ ErrorContextInterface,
95
+ ReducerEvent,
96
+ };
97
+
98
+ export type ConnectionEvent = 'connect' | 'disconnect' | 'connectError';
99
+
100
+ export type DbConnectionConfig<RemoteModule extends UntypedRemoteModule> = {
101
+ uri: URL;
102
+ nameOrAddress: string;
103
+ identity?: Identity;
104
+ token?: string;
105
+ emitter: EventEmitter<ConnectionEvent>;
106
+ createWSFn: WebSocketFactory;
107
+ compression: 'gzip' | 'brotli' | 'none';
108
+ lightMode: boolean;
109
+ confirmedReads?: boolean;
110
+ remoteModule: RemoteModule;
111
+ };
112
+
113
+ type ProcedureCallback = (result: ProcedureResultMessage['result']) => void;
114
+
115
+ const TEXT_ENCODER = new TextEncoder();
116
+
117
+ function getClientMessageVariantTag(name: string): number {
118
+ if (ClientMessage.algebraicType.tag !== 'Sum') {
119
+ throw new TypeError('ClientMessage must be a sum type');
120
+ }
121
+ const tag = ClientMessage.algebraicType.value.variants.findIndex(
122
+ variant => variant.name === name
123
+ );
124
+ if (tag === -1) {
125
+ throw new RangeError(`Unknown ClientMessage variant: ${name}`);
126
+ }
127
+ return tag;
128
+ }
129
+
130
+ const CLIENT_MESSAGE_CALL_REDUCER_TAG =
131
+ getClientMessageVariantTag('CallReducer');
132
+ const CLIENT_MESSAGE_CALL_PROCEDURE_TAG =
133
+ getClientMessageVariantTag('CallProcedure');
134
+ // Keep individual v3 frames bounded so one burst does not monopolize the send
135
+ // path or create very large websocket writes.
136
+ const MAX_V3_OUTBOUND_FRAME_BYTES = 256 * 1024;
137
+
138
+ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
139
+ implements DbContext<RemoteModule>
140
+ {
141
+ /**
142
+ * Whether or not the connection is active.
143
+ */
144
+ isActive = false;
145
+
146
+ /**
147
+ * Whether `disconnect()` has been called on this connection.
148
+ * Once requested, the connection will not be reused: managed environments
149
+ * (such as the React `SpacetimeDBProvider`) use this to avoid reconnecting
150
+ * after an intentional disconnect.
151
+ */
152
+ isDisconnectRequested = false;
153
+
154
+ /**
155
+ * This connection's public identity.
156
+ */
157
+ identity?: Identity = undefined;
158
+
159
+ /**
160
+ * This connection's private authentication token.
161
+ */
162
+ token?: string = undefined;
163
+
164
+ /** @internal */
165
+ [INTERNAL_REMOTE_MODULE](): RemoteModule {
166
+ return this.#remoteModule;
167
+ }
168
+
169
+ /**
170
+ * The accessor field to access the tables in the database and associated
171
+ * callback functions.
172
+ */
173
+ db: ClientDbView<RemoteModule>;
174
+
175
+ /**
176
+ * The accessor field to access the reducers in the database.
177
+ */
178
+ reducers: ReducersView<RemoteModule>;
179
+
180
+ /**
181
+ * The accessor field to access the procedures in the database.
182
+ */
183
+ procedures: ProceduresView<RemoteModule>;
184
+
185
+ /**
186
+ * The `ConnectionId` of the connection to to the database.
187
+ */
188
+ connectionId: ConnectionId = ConnectionId.random();
189
+ #connectionIdHex = this.connectionId.toHexString();
190
+
191
+ // These fields are meant to be strictly private.
192
+ #queryId = 0;
193
+ #requestId = 0;
194
+ #eventId = 0;
195
+ #emitter: EventEmitter<ConnectionEvent>;
196
+ #inboundQueue: Uint8Array[] = [];
197
+ #inboundQueueOffset = 0;
198
+ #isDrainingInboundQueue = false;
199
+ #outboundQueue: Uint8Array<ArrayBuffer>[] = [];
200
+ #isOutboundFlushScheduled = false;
201
+ #negotiatedWsProtocol: NegotiatedWsProtocol = V2_WS_PROTOCOL;
202
+ #subscriptionManager = new SubscriptionManager<RemoteModule>();
203
+ #remoteModule: RemoteModule;
204
+ #reducerCallbacks = new Map<
205
+ number,
206
+ (result: ReducerResultMessage['result']) => void
207
+ >();
208
+ #reducerCallInfo = new Map<number, { name: string; args: object }>();
209
+ #procedureCallbacks = new Map<number, ProcedureCallback>();
210
+ #rowDeserializers: Record<string, Deserializer<any>>;
211
+ #rowIdMetadata: Record<
212
+ string,
213
+ { primaryKeyColName?: string; primaryKeyColType?: AlgebraicType }
214
+ >;
215
+ #reducerArgsSerializers: Record<
216
+ string,
217
+ { serialize: Serializer<any>; deserialize: Deserializer<any> }
218
+ >;
219
+ #procedureSerializers: Record<
220
+ string,
221
+ { serializeArgs: Serializer<any>; deserializeReturn: Deserializer<any> }
222
+ >;
223
+ #reducerNameBytes: Record<string, Uint8Array>;
224
+ #procedureNameBytes: Record<string, Uint8Array>;
225
+ #sourceNameToTableDef: Record<string, Values<RemoteModule['tables']>>;
226
+ #messageReader = new BinaryReader(new Uint8Array());
227
+ #rowListReader = new BinaryReader(new Uint8Array());
228
+ #clientFrameEncoder = new BinaryWriter(1024);
229
+ #boundSubscriptionBuilder!: () => SubscriptionBuilderImpl<RemoteModule>;
230
+ #boundDisconnect!: () => void;
231
+
232
+ // These fields are not part of the public API, but in a pinch you
233
+ // could use JavaScript to access them by bypassing TypeScript's
234
+ // private fields.
235
+ // We use them in testing.
236
+ private clientCache: ClientCache<RemoteModule>;
237
+ private ws?: WebSocketAdapter;
238
+ private wsPromise: Promise<WebSocketAdapter | undefined>;
239
+
240
+ constructor({
241
+ uri,
242
+ nameOrAddress,
243
+ identity,
244
+ token,
245
+ emitter,
246
+ remoteModule,
247
+ createWSFn,
248
+ compression,
249
+ lightMode,
250
+ confirmedReads,
251
+ }: DbConnectionConfig<RemoteModule>) {
252
+ stdbLogger('info', 'Connecting to SpacetimeDB WS...');
253
+
254
+ // We use .toString() here because some versions of React Native contain a bug where the URL constructor
255
+ // incorrectly treats a URL instance as a plain string.
256
+ // This results in an attempt to call .endsWith() on it, leading to an error.
257
+ const url = new URL(uri.toString());
258
+ if (!/^wss?:/.test(uri.protocol)) {
259
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
260
+ }
261
+
262
+ this.identity = identity;
263
+ this.token = token;
264
+
265
+ this.#remoteModule = remoteModule;
266
+ this.#emitter = emitter;
267
+ this.#boundSubscriptionBuilder = this.subscriptionBuilder.bind(this);
268
+ this.#boundDisconnect = this.disconnect.bind(this);
269
+
270
+ this.#rowDeserializers = Object.create(null);
271
+ this.#rowIdMetadata = Object.create(null);
272
+ this.#sourceNameToTableDef = Object.create(null);
273
+ for (const table of Object.values(remoteModule.tables)) {
274
+ this.#rowDeserializers[table.sourceName] = ProductType.makeDeserializer(
275
+ table.rowType
276
+ );
277
+ this.#sourceNameToTableDef[table.sourceName] = table as Values<
278
+ RemoteModule['tables']
279
+ >;
280
+ const primaryKeyColumn = Object.entries(table.columns).find(
281
+ ([, column]) => column.columnMetadata.isPrimaryKey
282
+ );
283
+ this.#rowIdMetadata[table.sourceName] = primaryKeyColumn
284
+ ? {
285
+ primaryKeyColName: primaryKeyColumn[0],
286
+ primaryKeyColType: primaryKeyColumn[1].typeBuilder.algebraicType,
287
+ }
288
+ : {};
289
+ }
290
+
291
+ this.#reducerArgsSerializers = Object.create(null);
292
+ this.#reducerNameBytes = Object.create(null);
293
+ for (const reducer of remoteModule.reducers) {
294
+ this.#reducerArgsSerializers[reducer.name] = {
295
+ serialize: ProductType.makeSerializer(reducer.paramsType),
296
+ deserialize: ProductType.makeDeserializer(reducer.paramsType),
297
+ };
298
+ this.#reducerNameBytes[reducer.name] = TEXT_ENCODER.encode(reducer.name);
299
+ }
300
+
301
+ this.#procedureSerializers = Object.create(null);
302
+ this.#procedureNameBytes = Object.create(null);
303
+ for (const procedure of remoteModule.procedures) {
304
+ this.#procedureSerializers[procedure.name] = {
305
+ serializeArgs: ProductType.makeSerializer(
306
+ new ProductBuilder(procedure.params).algebraicType.value
307
+ ),
308
+ deserializeReturn: AlgebraicType.makeDeserializer(
309
+ procedure.returnType.algebraicType
310
+ ),
311
+ };
312
+ this.#procedureNameBytes[procedure.name] = TEXT_ENCODER.encode(
313
+ procedure.name
314
+ );
315
+ }
316
+
317
+ url.searchParams.set('connection_id', this.#connectionIdHex);
318
+
319
+ this.clientCache = new ClientCache<RemoteModule>();
320
+ this.db = this.#makeDbView();
321
+ this.reducers = this.#makeReducers(remoteModule);
322
+ this.procedures = this.#makeProcedures(remoteModule);
323
+
324
+ this.wsPromise = createWSFn({
325
+ url,
326
+ nameOrAddress,
327
+ wsProtocol: [...PREFERRED_WS_PROTOCOLS],
328
+ authToken: token,
329
+ compression: compression,
330
+ lightMode: lightMode,
331
+ confirmedReads: confirmedReads,
332
+ })
333
+ .then(v => {
334
+ this.ws = v;
335
+
336
+ this.ws.onclose = () => {
337
+ this.isActive = false;
338
+ this.#emitter.emit('disconnect', this);
339
+ };
340
+ this.ws.onerror = (e: ErrorEvent) => {
341
+ this.isActive = false;
342
+ this.#emitter.emit('connectError', this, e);
343
+ };
344
+ this.ws.onopen = this.#handleOnOpen.bind(this);
345
+ this.ws.onmessage = this.#handleOnMessage.bind(this);
346
+ return v;
347
+ })
348
+ .catch(e => {
349
+ stdbLogger('error', 'Error connecting to SpacetimeDB WS');
350
+ this.#emitter.emit('connectError', this, e);
351
+
352
+ return undefined;
353
+ });
354
+ }
355
+
356
+ #getNextQueryId = () => {
357
+ const queryId = this.#queryId;
358
+ this.#queryId += 1;
359
+ return queryId;
360
+ };
361
+
362
+ #getNextRequestId = () => this.#requestId++;
363
+
364
+ #makeDbView(): ClientDbView<RemoteModule> {
365
+ const view = Object.create(null) as ClientDbView<RemoteModule>;
366
+
367
+ for (const tbl of Object.values(this.#sourceNameToTableDef)) {
368
+ // ClientDbView uses this name verbatim
369
+ const key = tbl.accessorName;
370
+ Object.defineProperty(view, key, {
371
+ enumerable: true,
372
+ configurable: false,
373
+ get: () => this.clientCache.getOrCreateTable(tbl),
374
+ });
375
+ }
376
+
377
+ return view;
378
+ }
379
+
380
+ #makeReducers(def: RemoteModule): ReducersView<RemoteModule> {
381
+ const out: Record<string, unknown> = {};
382
+
383
+ for (const reducer of def.reducers) {
384
+ const reducerName = reducer.name;
385
+ const encodedReducerName = this.#reducerNameBytes[reducerName];
386
+ const key = reducer.accessorName;
387
+
388
+ const { serialize: serializeArgs } =
389
+ this.#reducerArgsSerializers[reducerName];
390
+
391
+ (out as any)[key] = (
392
+ params: InferTypeOfParams<typeof reducer.params>
393
+ ) => {
394
+ const writer = this.#reducerArgsEncoder;
395
+ writer.clear();
396
+ serializeArgs(writer, params);
397
+ const argsBuffer = writer.getBuffer();
398
+ return this.#callReducerWithEncodedName(
399
+ reducerName,
400
+ encodedReducerName,
401
+ argsBuffer,
402
+ params
403
+ );
404
+ };
405
+ }
406
+
407
+ return out as ReducersView<RemoteModule>;
408
+ }
409
+
410
+ #makeProcedures(def: RemoteModule): ProceduresView<RemoteModule> {
411
+ const out: Record<string, unknown> = {};
412
+
413
+ const writer = new BinaryWriter(1024);
414
+
415
+ for (const procedure of def.procedures) {
416
+ const procedureName = procedure.name;
417
+ const encodedProcedureName = this.#procedureNameBytes[procedureName];
418
+ const key = procedure.accessorName;
419
+
420
+ const { serializeArgs, deserializeReturn } =
421
+ this.#procedureSerializers[procedureName];
422
+
423
+ (out as any)[key] = (
424
+ params: InferTypeOfParams<typeof procedure.params>
425
+ ): Promise<any> => {
426
+ writer.clear();
427
+ serializeArgs(writer, params);
428
+ const argsBuffer = writer.getBuffer();
429
+ return this.#callProcedureWithEncodedName(
430
+ procedureName,
431
+ encodedProcedureName,
432
+ argsBuffer
433
+ ).then(returnBuf => {
434
+ return deserializeReturn(new BinaryReader(returnBuf));
435
+ });
436
+ };
437
+ }
438
+
439
+ return out as ProceduresView<RemoteModule>;
440
+ }
441
+
442
+ #makeEventContext(
443
+ event: Event<
444
+ ReducerEventInfo<
445
+ RemoteModule['reducers'][number]['name'],
446
+ InferTypeOfParams<RemoteModule['reducers'][number]['params']>
447
+ >
448
+ >
449
+ ): EventContextInterface<RemoteModule> {
450
+ return {
451
+ db: this.db,
452
+ reducers: this.reducers,
453
+ isActive: this.isActive,
454
+ subscriptionBuilder: this.#boundSubscriptionBuilder,
455
+ disconnect: this.#boundDisconnect,
456
+ event,
457
+ };
458
+ }
459
+
460
+ // NOTE: This is very important!!! This is the actual function that
461
+ // gets called when you call `connection.subscriptionBuilder()`.
462
+ // The `subscriptionBuilder` function which is generated, just shadows
463
+ // this function in the type system, but not the actual implementation!
464
+ // Do not remove this function, or shoot yourself in the foot please.
465
+ // It's not clear what would be a better way to do this at this exact
466
+ // moment.
467
+ subscriptionBuilder = (): SubscriptionBuilderImpl<RemoteModule> => {
468
+ return new SubscriptionBuilderImpl(this);
469
+ };
470
+
471
+ getTablesMap(): any {
472
+ return makeQueryBuilder({ tables: this.#remoteModule.tables } as any);
473
+ }
474
+
475
+ registerSubscription(
476
+ handle: SubscriptionHandleImpl<RemoteModule>,
477
+ handleEmitter: EventEmitter<
478
+ SubscribeEvent,
479
+ SubscriptionEventCallback<RemoteModule>
480
+ >,
481
+ querySql: string[]
482
+ ): number {
483
+ const querySetId = this.#getNextQueryId();
484
+ this.#subscriptionManager.subscriptions.set(querySetId, {
485
+ handle,
486
+ emitter: handleEmitter,
487
+ });
488
+ const requestId = this.#getNextRequestId();
489
+ this.#sendMessage(
490
+ ClientMessage.Subscribe({
491
+ queryStrings: querySql,
492
+ querySetId: { id: querySetId },
493
+ requestId,
494
+ })
495
+ );
496
+ return querySetId;
497
+ }
498
+
499
+ unregisterSubscription(querySetId: number): void {
500
+ const requestId = this.#getNextRequestId();
501
+ this.#sendMessage(
502
+ ClientMessage.Unsubscribe({
503
+ querySetId: { id: querySetId },
504
+ requestId,
505
+ flags: UnsubscribeFlags.SendDroppedRows,
506
+ })
507
+ );
508
+ }
509
+
510
+ #parseRowList(
511
+ type: 'insert' | 'delete',
512
+ tableName: string,
513
+ rowList: BsatnRowList
514
+ ): Operation[] {
515
+ const buffer = rowList.rowsData;
516
+ const reader = this.#rowListReader;
517
+ reader.reset(buffer);
518
+ const rows: Operation[] = [];
519
+
520
+ const deserializeRow = this.#rowDeserializers[tableName];
521
+ const { primaryKeyColName, primaryKeyColType } =
522
+ this.#rowIdMetadata[tableName];
523
+ let previousOffset = 0;
524
+ while (reader.remaining > 0) {
525
+ const row = deserializeRow(reader);
526
+ let rowId: ComparablePrimitive | undefined = undefined;
527
+ if (primaryKeyColName !== undefined && primaryKeyColType !== undefined) {
528
+ rowId = AlgebraicType.intoMapKey(
529
+ primaryKeyColType,
530
+ row[primaryKeyColName]
531
+ );
532
+ } else {
533
+ // Get a view of the bytes for this row.
534
+ const rowBytes = buffer.subarray(previousOffset, reader.offset);
535
+ // Convert it to a base64 string, so we can use it as a map key.
536
+ const asBase64 = fromByteArray(rowBytes);
537
+ rowId = asBase64;
538
+ }
539
+ previousOffset = reader.offset;
540
+
541
+ rows.push({
542
+ type,
543
+ rowId,
544
+ row,
545
+ });
546
+ }
547
+ return rows;
548
+ }
549
+
550
+ // Take a bunch of table updates and ensure that there is at most one update per table.
551
+ #mergeTableUpdates(
552
+ updates: CacheTableUpdate<UntypedTableDef>[]
553
+ ): CacheTableUpdate<UntypedTableDef>[] {
554
+ const merged = new Map<string, Operation[]>();
555
+ for (const update of updates) {
556
+ const ops = merged.get(update.tableName);
557
+ if (ops) {
558
+ for (const op of update.operations) ops.push(op);
559
+ } else {
560
+ merged.set(update.tableName, update.operations.slice());
561
+ }
562
+ }
563
+ return Array.from(merged, ([tableName, operations]) => ({
564
+ tableName,
565
+ operations,
566
+ }));
567
+ }
568
+
569
+ #queryRowsToTableUpdates(
570
+ rows: QueryRows,
571
+ opType: 'insert' | 'delete'
572
+ ): CacheTableUpdate<UntypedTableDef>[] {
573
+ const updates: CacheTableUpdate<UntypedTableDef>[] = [];
574
+ for (const tableRows of rows.tables) {
575
+ updates.push({
576
+ tableName: tableRows.table,
577
+ operations: this.#parseRowList(opType, tableRows.table, tableRows.rows),
578
+ });
579
+ }
580
+ return this.#mergeTableUpdates(updates);
581
+ }
582
+
583
+ #tableUpdateRowsToOperations(
584
+ tableName: string,
585
+ rows: TableUpdateRows
586
+ ): Operation[] {
587
+ if (rows.tag === 'PersistentTable') {
588
+ const inserts = this.#parseRowList(
589
+ 'insert',
590
+ tableName,
591
+ rows.value.inserts
592
+ );
593
+ const deletes = this.#parseRowList(
594
+ 'delete',
595
+ tableName,
596
+ rows.value.deletes
597
+ );
598
+ return inserts.concat(deletes);
599
+ }
600
+ if (rows.tag === 'EventTable') {
601
+ // Event table rows are insert-only. The table cache handles skipping
602
+ // storage for event tables and only firing on_insert callbacks.
603
+ return this.#parseRowList('insert', tableName, rows.value.events);
604
+ }
605
+ return [];
606
+ }
607
+
608
+ #querySetUpdateToTableUpdates(
609
+ querySetUpdate: QuerySetUpdate
610
+ ): CacheTableUpdate<UntypedTableDef>[] {
611
+ const updates: CacheTableUpdate<UntypedTableDef>[] = [];
612
+ for (const tableUpdate of querySetUpdate.tables) {
613
+ let operations: Operation[] = [];
614
+ for (const rows of tableUpdate.rows) {
615
+ operations = operations.concat(
616
+ this.#tableUpdateRowsToOperations(tableUpdate.tableName, rows)
617
+ );
618
+ }
619
+ updates.push({
620
+ tableName: tableUpdate.tableName,
621
+ operations,
622
+ });
623
+ }
624
+ return this.#mergeTableUpdates(updates);
625
+ }
626
+
627
+ #flushOutboundQueue(wsResolved: WebSocketAdapter): void {
628
+ if (this.#negotiatedWsProtocol === V3_WS_PROTOCOL) {
629
+ this.#flushOutboundQueueV3(wsResolved);
630
+ return;
631
+ }
632
+ this.#flushOutboundQueueV2(wsResolved);
633
+ }
634
+
635
+ #flushOutboundQueueV2(wsResolved: WebSocketAdapter): void {
636
+ const pending = this.#outboundQueue.splice(0);
637
+ for (const message of pending) {
638
+ wsResolved.send(message);
639
+ }
640
+ }
641
+
642
+ #flushOutboundQueueV3(wsResolved: WebSocketAdapter): void {
643
+ if (this.#outboundQueue.length === 0) {
644
+ return;
645
+ }
646
+
647
+ // Emit at most one bounded frame per flush. If more encoded v2 messages
648
+ // remain in the queue, they are sent by a later scheduled flush so inbound
649
+ // traffic and other tasks get a chance to run between websocket writes.
650
+ const batchSize = countClientMessagesForV3Frame(
651
+ this.#outboundQueue,
652
+ MAX_V3_OUTBOUND_FRAME_BYTES
653
+ );
654
+ wsResolved.send(
655
+ encodeClientMessagesV3(
656
+ this.#clientFrameEncoder,
657
+ this.#outboundQueue,
658
+ batchSize
659
+ )
660
+ );
661
+
662
+ if (batchSize === this.#outboundQueue.length) {
663
+ this.#outboundQueue.length = 0;
664
+ return;
665
+ }
666
+
667
+ this.#outboundQueue.copyWithin(0, batchSize);
668
+ this.#outboundQueue.length -= batchSize;
669
+ if (this.#outboundQueue.length > 0) {
670
+ this.#scheduleDeferredOutboundFlush();
671
+ }
672
+ }
673
+
674
+ #scheduleOutboundFlush(): void {
675
+ this.#scheduleOutboundFlushWith('microtask');
676
+ }
677
+
678
+ #scheduleDeferredOutboundFlush(): void {
679
+ this.#scheduleOutboundFlushWith('next-task');
680
+ }
681
+
682
+ #scheduleOutboundFlushWith(schedule: 'microtask' | 'next-task'): void {
683
+ if (this.#isOutboundFlushScheduled) {
684
+ return;
685
+ }
686
+
687
+ this.#isOutboundFlushScheduled = true;
688
+ const flush = () => {
689
+ this.#isOutboundFlushScheduled = false;
690
+ if (this.ws && this.isActive) {
691
+ this.#flushOutboundQueue(this.ws);
692
+ }
693
+ };
694
+
695
+ // The first v3 flush stays on the current turn so same-tick sends coalesce.
696
+ // Follow-up flushes after a size-capped frame yield to the next task so we
697
+ // do not sit in a tight send loop while inbound websocket work is waiting.
698
+ if (schedule === 'next-task') {
699
+ setTimeout(flush, 0);
700
+ } else {
701
+ queueMicrotask(flush);
702
+ }
703
+ }
704
+
705
+ #reducerArgsEncoder = new BinaryWriter(1024);
706
+ #clientMessageEncoder = new BinaryWriter(1024);
707
+ #sendEncodedMessage(
708
+ encoded: Uint8Array<ArrayBuffer>,
709
+ describe: () => string
710
+ ): void {
711
+ stdbLogger('trace', describe);
712
+ if (this.ws && this.isActive) {
713
+ if (this.#negotiatedWsProtocol === V2_WS_PROTOCOL) {
714
+ if (this.#outboundQueue.length) this.#flushOutboundQueue(this.ws);
715
+ this.ws.send(encoded);
716
+ return;
717
+ }
718
+
719
+ this.#outboundQueue.push(encoded.slice());
720
+ this.#scheduleOutboundFlush();
721
+ } else {
722
+ // Use slice() to copy, in case the clientMessageEncoder's buffer gets reused
723
+ // before the connection opens or before a v3 microbatch flush runs.
724
+ this.#outboundQueue.push(encoded.slice());
725
+ }
726
+ }
727
+
728
+ #sendMessage(message: ClientMessage): void {
729
+ const writer = this.#clientMessageEncoder;
730
+ writer.clear();
731
+ ClientMessage.serialize(writer, message);
732
+ const encoded = writer.getBuffer();
733
+ const isLive = !!(this.ws && this.isActive);
734
+ this.#sendEncodedMessage(encoded, () =>
735
+ isLive
736
+ ? `Sending message to server: ${stringify(message)}`
737
+ : `Queuing message to server: ${stringify(message)}`
738
+ );
739
+ }
740
+
741
+ #sendCallReducerMessage(
742
+ requestId: number,
743
+ reducerNameBytes: Uint8Array,
744
+ argsBuffer: Uint8Array
745
+ ): void {
746
+ const writer = this.#clientMessageEncoder;
747
+ writer.clear();
748
+ writer.writeByte(CLIENT_MESSAGE_CALL_REDUCER_TAG);
749
+ writer.writeU32(requestId);
750
+ writer.writeU8(0);
751
+ writer.writeUInt8Array(reducerNameBytes);
752
+ writer.writeUInt8Array(argsBuffer);
753
+ const encoded = writer.getBuffer();
754
+ this.#sendEncodedMessage(
755
+ encoded,
756
+ () => `Sending reducer call message to server: requestId=${requestId}`
757
+ );
758
+ }
759
+
760
+ #sendCallProcedureMessage(
761
+ requestId: number,
762
+ procedureNameBytes: Uint8Array,
763
+ argsBuffer: Uint8Array
764
+ ): void {
765
+ const writer = this.#clientMessageEncoder;
766
+ writer.clear();
767
+ writer.writeByte(CLIENT_MESSAGE_CALL_PROCEDURE_TAG);
768
+ writer.writeU32(requestId);
769
+ writer.writeU8(0);
770
+ writer.writeUInt8Array(procedureNameBytes);
771
+ writer.writeUInt8Array(argsBuffer);
772
+ const encoded = writer.getBuffer();
773
+ this.#sendEncodedMessage(
774
+ encoded,
775
+ () => `Sending procedure call message to server: requestId=${requestId}`
776
+ );
777
+ }
778
+
779
+ #setConnectionId(connectionId: ConnectionId): void {
780
+ this.connectionId = connectionId;
781
+ this.#connectionIdHex = connectionId.toHexString();
782
+ }
783
+
784
+ #nextEventId(): string {
785
+ this.#eventId += 1;
786
+ return `${this.#connectionIdHex}:${this.#eventId}`;
787
+ }
788
+
789
+ /**
790
+ * Handles WebSocket onOpen event.
791
+ */
792
+ #handleOnOpen(): void {
793
+ if (this.ws) {
794
+ this.#negotiatedWsProtocol = normalizeWsProtocol(this.ws.protocol);
795
+ }
796
+ this.isActive = true;
797
+ if (this.ws) {
798
+ this.#flushOutboundQueue(this.ws);
799
+ }
800
+ }
801
+
802
+ #applyTableUpdates(
803
+ tableUpdates: CacheTableUpdate<UntypedTableDef>[],
804
+ eventContext: EventContextInterface<RemoteModule>
805
+ ): PendingCallback[] {
806
+ const pendingCallbacks: PendingCallback[] = [];
807
+ for (const tableUpdate of tableUpdates) {
808
+ // Get table information for the table being updated
809
+ const tableName = tableUpdate.tableName;
810
+ const tableDef = this.#sourceNameToTableDef[tableName];
811
+ const table = this.clientCache.getOrCreateTable(tableDef);
812
+ const newCallbacks = table.applyOperations(
813
+ tableUpdate.operations as Operation<
814
+ RowType<Values<RemoteModule['tables']>>
815
+ >[],
816
+ eventContext
817
+ );
818
+ for (const callback of newCallbacks) {
819
+ pendingCallbacks.push(callback);
820
+ }
821
+ }
822
+ return pendingCallbacks;
823
+ }
824
+
825
+ #applyTransactionUpdates(
826
+ eventContext: EventContextInterface<RemoteModule>,
827
+ tu: TransactionUpdate
828
+ ): PendingCallback[] {
829
+ const allUpdates: CacheTableUpdate<UntypedTableDef>[] = [];
830
+ for (const querySetUpdate of tu.querySets) {
831
+ const tableUpdates = this.#querySetUpdateToTableUpdates(querySetUpdate);
832
+ for (const update of tableUpdates) {
833
+ allUpdates.push(update);
834
+ }
835
+ // TODO: When we have per-query storage, we will want to apply the per-query events here.
836
+ }
837
+ return this.#applyTableUpdates(
838
+ this.#mergeTableUpdates(allUpdates),
839
+ eventContext
840
+ );
841
+ }
842
+
843
+ #dispatchPendingCallbacks(callbacks: readonly PendingCallback[]): void {
844
+ stdbLogger(
845
+ 'trace',
846
+ () => `Calling ${callbacks.length} triggered row callbacks`
847
+ );
848
+ for (const callback of callbacks) {
849
+ callback.cb();
850
+ }
851
+ }
852
+
853
+ #processServerMessage(serverMessage: ServerMessage): void {
854
+ stdbLogger(
855
+ 'trace',
856
+ () => `Processing server message: ${stringify(serverMessage)}`
857
+ );
858
+ switch (serverMessage.tag) {
859
+ case 'InitialConnection': {
860
+ this.identity = serverMessage.value.identity;
861
+ if (!this.token && serverMessage.value.token) {
862
+ this.token = serverMessage.value.token;
863
+ }
864
+ this.#setConnectionId(serverMessage.value.connectionId);
865
+ this.#emitter.emit('connect', this, this.identity, this.token);
866
+ break;
867
+ }
868
+ case 'SubscribeApplied': {
869
+ const querySetId = serverMessage.value.querySetId.id;
870
+ const subscription =
871
+ this.#subscriptionManager.subscriptions.get(querySetId);
872
+ if (!subscription) {
873
+ stdbLogger(
874
+ 'error',
875
+ `Received SubscribeApplied for unknown querySetId ${querySetId}.`
876
+ );
877
+ return;
878
+ }
879
+ const event: Event<never> = {
880
+ id: this.#nextEventId(),
881
+ tag: 'SubscribeApplied',
882
+ };
883
+ const eventContext = this.#makeEventContext(event);
884
+ const tableUpdates = this.#queryRowsToTableUpdates(
885
+ serverMessage.value.rows,
886
+ 'insert'
887
+ );
888
+ const callbacks = this.#applyTableUpdates(tableUpdates, eventContext);
889
+ const { event: _, ...subscriptionEventContext } = eventContext;
890
+ subscription.emitter.emit('applied', subscriptionEventContext);
891
+ this.#dispatchPendingCallbacks(callbacks);
892
+ break;
893
+ }
894
+ case 'UnsubscribeApplied': {
895
+ const querySetId = serverMessage.value.querySetId.id;
896
+ const subscription =
897
+ this.#subscriptionManager.subscriptions.get(querySetId);
898
+ if (!subscription) {
899
+ stdbLogger(
900
+ 'error',
901
+ `Received UnsubscribeApplied for unknown querySetId ${querySetId}.`
902
+ );
903
+ return;
904
+ }
905
+ const event: Event<never> = {
906
+ id: this.#nextEventId(),
907
+ tag: 'UnsubscribeApplied',
908
+ };
909
+ const eventContext = this.#makeEventContext(event);
910
+ const tableUpdates = serverMessage.value.rows
911
+ ? this.#queryRowsToTableUpdates(serverMessage.value.rows, 'delete')
912
+ : [];
913
+ const callbacks = this.#applyTableUpdates(tableUpdates, eventContext);
914
+ const { event: _, ...subscriptionEventContext } = eventContext;
915
+ subscription.emitter.emit('end', subscriptionEventContext);
916
+ this.#subscriptionManager.subscriptions.delete(querySetId);
917
+ this.#dispatchPendingCallbacks(callbacks);
918
+ break;
919
+ }
920
+ case 'SubscriptionError': {
921
+ const querySetId = serverMessage.value.querySetId.id;
922
+ const requestId = serverMessage.value.requestId;
923
+ const error = Error(serverMessage.value.error);
924
+ const event: Event<never> = {
925
+ id: this.#nextEventId(),
926
+ tag: 'Error',
927
+ value: error,
928
+ };
929
+ const eventContext = this.#makeEventContext(event);
930
+ const errorContext = {
931
+ ...eventContext,
932
+ event: error,
933
+ };
934
+
935
+ // If the requestId isn't set, that means we already applied the subscription.
936
+ // Since we don't know how to remove the relevant rows from our table cache, we need
937
+ // to kill the connection. Once we have per-query storage, this won't be fatal.
938
+ if (requestId == null) {
939
+ stdbLogger(
940
+ 'error',
941
+ `Disconnecting due to error for a previously applied subscription: ${serverMessage.value.error}`
942
+ );
943
+ this.disconnect();
944
+ break;
945
+ }
946
+
947
+ const subscription =
948
+ this.#subscriptionManager.subscriptions.get(querySetId);
949
+ if (subscription) {
950
+ subscription.emitter.emit('error', errorContext, error);
951
+ this.#subscriptionManager.subscriptions.delete(querySetId);
952
+ } else {
953
+ stdbLogger(
954
+ 'error',
955
+ `Received SubscriptionError for unknown querySetId ${querySetId}:`,
956
+ error
957
+ );
958
+ }
959
+ break;
960
+ }
961
+ case 'TransactionUpdate': {
962
+ const event: Event<never> = {
963
+ id: this.#nextEventId(),
964
+ tag: 'Transaction',
965
+ };
966
+ const eventContext = this.#makeEventContext(event);
967
+ const callbacks = this.#applyTransactionUpdates(
968
+ eventContext,
969
+ serverMessage.value
970
+ );
971
+ this.#dispatchPendingCallbacks(callbacks);
972
+ break;
973
+ }
974
+ case 'ReducerResult': {
975
+ const { requestId, result } = serverMessage.value;
976
+
977
+ if (result.tag === 'Ok') {
978
+ const reducerInfo = this.#reducerCallInfo.get(requestId);
979
+ const eventId: string = this.#nextEventId();
980
+ const event: Event<any> = reducerInfo
981
+ ? {
982
+ id: eventId,
983
+ tag: 'Reducer',
984
+ value: {
985
+ timestamp: serverMessage.value.timestamp,
986
+ outcome: result,
987
+ reducer: {
988
+ name: reducerInfo.name,
989
+ args: reducerInfo.args,
990
+ },
991
+ },
992
+ }
993
+ : {
994
+ id: eventId,
995
+ tag: 'Transaction',
996
+ };
997
+ const eventContext = this.#makeEventContext(event as any);
998
+
999
+ const callbacks = this.#applyTransactionUpdates(
1000
+ eventContext,
1001
+ result.value.transactionUpdate
1002
+ );
1003
+ this.#dispatchPendingCallbacks(callbacks);
1004
+ }
1005
+ this.#reducerCallInfo.delete(requestId);
1006
+ const cb = this.#reducerCallbacks.get(requestId);
1007
+ this.#reducerCallbacks.delete(requestId);
1008
+ cb?.(result);
1009
+ break;
1010
+ }
1011
+ case 'ProcedureResult': {
1012
+ const { status, requestId } = serverMessage.value;
1013
+ const result: ProcedureResultMessage['result'] =
1014
+ status.tag === 'Returned'
1015
+ ? { tag: 'Ok', value: status.value }
1016
+ : { tag: 'Err', value: status.value };
1017
+ const cb = this.#procedureCallbacks.get(requestId);
1018
+ this.#procedureCallbacks.delete(requestId);
1019
+ cb?.(result);
1020
+ break;
1021
+ }
1022
+ case 'OneOffQueryResult': {
1023
+ stdbLogger(
1024
+ 'warn',
1025
+ 'Received OneOffQueryResult but SDK does not expose one-off query APIs yet.'
1026
+ );
1027
+ break;
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ #processV2Message(data: Uint8Array): void {
1033
+ const reader = this.#messageReader;
1034
+ reader.reset(data);
1035
+ this.#processServerMessage(ServerMessage.deserialize(reader));
1036
+ }
1037
+
1038
+ #processMessage(data: Uint8Array): void {
1039
+ if (this.#negotiatedWsProtocol !== V3_WS_PROTOCOL) {
1040
+ this.#processV2Message(data);
1041
+ return;
1042
+ }
1043
+
1044
+ const messageCount = forEachServerMessageV3(
1045
+ this.#messageReader,
1046
+ data,
1047
+ serverMessage => {
1048
+ this.#processServerMessage(serverMessage);
1049
+ }
1050
+ );
1051
+ stdbLogger(
1052
+ 'trace',
1053
+ () => `Processing server v3 payload with ${messageCount} message(s)`
1054
+ );
1055
+ }
1056
+
1057
+ /**
1058
+ * Handles WebSocket onMessage event.
1059
+ * @param wsMessage MessageEvent object.
1060
+ */
1061
+ #handleOnMessage(wsMessage: { data: Uint8Array }): void {
1062
+ // Queue inbound messages so they are processed strictly in arrival order.
1063
+ // We deliberately drain synchronously instead of promise-chaining each
1064
+ // message, but this still guarantees that we do not begin processing the
1065
+ // next message until the current message has been fully handled.
1066
+ this.#inboundQueue.push(wsMessage.data);
1067
+ if (this.#isDrainingInboundQueue) {
1068
+ return;
1069
+ }
1070
+
1071
+ this.#isDrainingInboundQueue = true;
1072
+ try {
1073
+ // TODO: If this loop starts monopolizing the event loop under sustained
1074
+ // inbound traffic, switch to a chunked drain that periodically yields.
1075
+ while (this.#inboundQueueOffset < this.#inboundQueue.length) {
1076
+ const data = this.#inboundQueue[this.#inboundQueueOffset];
1077
+ this.#inboundQueueOffset += 1;
1078
+ if (data) {
1079
+ this.#processMessage(data);
1080
+ }
1081
+ }
1082
+ } finally {
1083
+ if (this.#inboundQueueOffset >= this.#inboundQueue.length) {
1084
+ this.#inboundQueue.length = 0;
1085
+ } else if (this.#inboundQueueOffset > 0) {
1086
+ this.#inboundQueue = this.#inboundQueue.slice(this.#inboundQueueOffset);
1087
+ }
1088
+ this.#inboundQueueOffset = 0;
1089
+ this.#isDrainingInboundQueue = false;
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Call a reducer on your SpacetimeDB module.
1095
+ *
1096
+ * @param reducerName The name of the reducer to call
1097
+ * @param argsSerializer The arguments to pass to the reducer
1098
+ */
1099
+ callReducer(
1100
+ reducerName: string,
1101
+ argsBuffer: Uint8Array,
1102
+ reducerArgs?: object
1103
+ ): Promise<void> {
1104
+ const encodedReducerName = this.#reducerNameBytes[reducerName];
1105
+ if (encodedReducerName) {
1106
+ return this.#callReducerWithEncodedName(
1107
+ reducerName,
1108
+ encodedReducerName,
1109
+ argsBuffer,
1110
+ reducerArgs
1111
+ );
1112
+ }
1113
+ return this.#callReducerGeneric(reducerName, argsBuffer, reducerArgs);
1114
+ }
1115
+
1116
+ #callReducerWithEncodedName(
1117
+ reducerName: string,
1118
+ encodedReducerName: Uint8Array,
1119
+ argsBuffer: Uint8Array,
1120
+ reducerArgs?: object
1121
+ ): Promise<void> {
1122
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
1123
+ const requestId = this.#getNextRequestId();
1124
+ this.#sendCallReducerMessage(requestId, encodedReducerName, argsBuffer);
1125
+ if (reducerArgs) {
1126
+ this.#reducerCallInfo.set(requestId, {
1127
+ name: reducerName,
1128
+ args: reducerArgs,
1129
+ });
1130
+ }
1131
+ this.#reducerCallbacks.set(requestId, result => {
1132
+ if (result.tag === 'Ok' || result.tag === 'OkEmpty') {
1133
+ resolve();
1134
+ } else {
1135
+ if (result.tag === 'Err') {
1136
+ /// Interpret the user-returned error as a string.
1137
+ const reader = new BinaryReader(result.value);
1138
+ const errorString = reader.readString();
1139
+ reject(new SenderError(errorString));
1140
+ } else if (result.tag === 'InternalError') {
1141
+ reject(new InternalError(result.value));
1142
+ } else {
1143
+ const unreachable: never = result;
1144
+ reject(new Error('Unexpected reducer result'));
1145
+ void unreachable;
1146
+ }
1147
+ }
1148
+ });
1149
+ return promise;
1150
+ }
1151
+
1152
+ #callReducerGeneric(
1153
+ reducerName: string,
1154
+ argsBuffer: Uint8Array,
1155
+ reducerArgs?: object
1156
+ ): Promise<void> {
1157
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
1158
+ const requestId = this.#getNextRequestId();
1159
+ const message = ClientMessage.CallReducer({
1160
+ reducer: reducerName,
1161
+ args: argsBuffer,
1162
+ requestId,
1163
+ flags: 0,
1164
+ });
1165
+ this.#sendMessage(message);
1166
+ if (reducerArgs) {
1167
+ this.#reducerCallInfo.set(requestId, {
1168
+ name: reducerName,
1169
+ args: reducerArgs,
1170
+ });
1171
+ }
1172
+ this.#reducerCallbacks.set(requestId, result => {
1173
+ if (result.tag === 'Ok' || result.tag === 'OkEmpty') {
1174
+ resolve();
1175
+ } else {
1176
+ if (result.tag === 'Err') {
1177
+ /// Interpret the user-returned error as a string.
1178
+ const reader = new BinaryReader(result.value);
1179
+ const errorString = reader.readString();
1180
+ reject(new SenderError(errorString));
1181
+ } else if (result.tag === 'InternalError') {
1182
+ reject(new InternalError(result.value));
1183
+ } else {
1184
+ const unreachable: never = result;
1185
+ reject(new Error('Unexpected reducer result'));
1186
+ void unreachable;
1187
+ }
1188
+ }
1189
+ });
1190
+ return promise;
1191
+ }
1192
+
1193
+ /**
1194
+ * Call a reducer on your SpacetimeDB module with typed arguments.
1195
+ * @param reducerSchema The schema of the reducer to call
1196
+ * @param callReducerFlags The flags for the reducer call
1197
+ * @param params The arguments to pass to the reducer
1198
+ */
1199
+ callReducerWithParams(
1200
+ reducerName: string,
1201
+ // TODO: remove
1202
+ _paramsType: ProductType,
1203
+ params: object
1204
+ ): Promise<void> {
1205
+ const writer = this.#reducerArgsEncoder;
1206
+ writer.clear();
1207
+ this.#reducerArgsSerializers[reducerName].serialize(writer, params);
1208
+ const argsBuffer = writer.getBuffer();
1209
+ return this.callReducer(reducerName, argsBuffer, params);
1210
+ }
1211
+
1212
+ /**
1213
+ * Call a reducer on your SpacetimeDB module.
1214
+ *
1215
+ * @param procedureName The name of the reducer to call
1216
+ * @param argsBuffer The arguments to pass to the reducer
1217
+ */
1218
+ callProcedure(
1219
+ procedureName: string,
1220
+ argsBuffer: Uint8Array
1221
+ ): Promise<Uint8Array> {
1222
+ const encodedProcedureName = this.#procedureNameBytes[procedureName];
1223
+ if (encodedProcedureName) {
1224
+ return this.#callProcedureWithEncodedName(
1225
+ procedureName,
1226
+ encodedProcedureName,
1227
+ argsBuffer
1228
+ );
1229
+ }
1230
+ return this.#callProcedureGeneric(procedureName, argsBuffer);
1231
+ }
1232
+
1233
+ #callProcedureWithEncodedName(
1234
+ procedureName: string,
1235
+ encodedProcedureName: Uint8Array,
1236
+ argsBuffer: Uint8Array
1237
+ ): Promise<Uint8Array> {
1238
+ const { promise, resolve, reject } = Promise.withResolvers<Uint8Array>();
1239
+ const requestId = this.#getNextRequestId();
1240
+ this.#sendCallProcedureMessage(requestId, encodedProcedureName, argsBuffer);
1241
+ this.#procedureCallbacks.set(requestId, result => {
1242
+ if (result.tag === 'Ok') {
1243
+ resolve(result.value);
1244
+ } else {
1245
+ reject(result.value);
1246
+ }
1247
+ });
1248
+ return promise;
1249
+ }
1250
+
1251
+ #callProcedureGeneric(
1252
+ procedureName: string,
1253
+ argsBuffer: Uint8Array
1254
+ ): Promise<Uint8Array> {
1255
+ const { promise, resolve, reject } = Promise.withResolvers<Uint8Array>();
1256
+ const requestId = this.#getNextRequestId();
1257
+ const message = ClientMessage.CallProcedure({
1258
+ procedure: procedureName,
1259
+ args: argsBuffer,
1260
+ requestId,
1261
+ // reserved for future use - 0 is the only valid value
1262
+ flags: 0,
1263
+ });
1264
+ this.#sendMessage(message);
1265
+ this.#procedureCallbacks.set(requestId, result => {
1266
+ if (result.tag === 'Ok') {
1267
+ resolve(result.value);
1268
+ } else {
1269
+ reject(result.value);
1270
+ }
1271
+ });
1272
+ return promise;
1273
+ }
1274
+
1275
+ /**
1276
+ * Call a reducer on your SpacetimeDB module with typed arguments.
1277
+ * @param reducerSchema The schema of the reducer to call
1278
+ * @param callReducerFlags The flags for the reducer call
1279
+ * @param params The arguments to pass to the reducer
1280
+ */
1281
+ callProcedureWithParams(
1282
+ procedureName: string,
1283
+ // TODO: remove
1284
+ _paramsType: ProductType,
1285
+ params: object,
1286
+ // TODO: remove
1287
+ _returnType: AlgebraicType
1288
+ ): Promise<any> {
1289
+ const writer = new BinaryWriter(1024);
1290
+ const { serializeArgs, deserializeReturn } =
1291
+ this.#procedureSerializers[procedureName];
1292
+ serializeArgs(writer, params);
1293
+ const argsBuffer = writer.getBuffer();
1294
+ return this.callProcedure(procedureName, argsBuffer).then(returnBuf => {
1295
+ return deserializeReturn(new BinaryReader(returnBuf));
1296
+ });
1297
+ }
1298
+
1299
+ /**
1300
+ * Close the current connection.
1301
+ *
1302
+ * @example
1303
+ *
1304
+ * ```ts
1305
+ * const connection = DbConnection.builder().build();
1306
+ * connection.disconnect()
1307
+ * ```
1308
+ */
1309
+ disconnect(): void {
1310
+ this.isDisconnectRequested = true;
1311
+ this.wsPromise.then(ws => ws?.close());
1312
+ }
1313
+
1314
+ private on(
1315
+ eventName: ConnectionEvent,
1316
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1317
+ ): void {
1318
+ this.#emitter.on(eventName, callback);
1319
+ }
1320
+
1321
+ private off(
1322
+ eventName: ConnectionEvent,
1323
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1324
+ ): void {
1325
+ this.#emitter.off(eventName, callback);
1326
+ }
1327
+
1328
+ private onConnect(
1329
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1330
+ ): void {
1331
+ this.#emitter.on('connect', callback);
1332
+ }
1333
+
1334
+ private onDisconnect(
1335
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1336
+ ): void {
1337
+ this.#emitter.on('disconnect', callback);
1338
+ }
1339
+
1340
+ private onConnectError(
1341
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1342
+ ): void {
1343
+ this.#emitter.on('connectError', callback);
1344
+ }
1345
+
1346
+ removeOnConnect(
1347
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1348
+ ): void {
1349
+ this.#emitter.off('connect', callback);
1350
+ }
1351
+
1352
+ removeOnDisconnect(
1353
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1354
+ ): void {
1355
+ this.#emitter.off('disconnect', callback);
1356
+ }
1357
+
1358
+ removeOnConnectError(
1359
+ callback: (ctx: DbConnectionImpl<RemoteModule>, ...args: any[]) => void
1360
+ ): void {
1361
+ this.#emitter.off('connectError', callback);
1362
+ }
1363
+ }