spacetimedb 2.4.1 → 2.6.0

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