From 98f6c0980e69daf6ed89bc49601abe9246bf0ebe Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 24 Sep 2026 13:49:23 -0400 Subject: [PATCH 1/9] Fail the connection instead of crashing Node when a server message cannot be applied Errors while processing an inbound message, whether an undecodable row (e.g. client bindings out of step with the module's schema) or a user callback that throws, escaped the WebSocket message listener. Browsers only report such errors, but Node rethrows them on the next tick, which killed the host process. The inbound drain now catches them, logs, drops the queued messages, and closes the socket, so `onDisconnect` receives the error the same way it does for a websocket error on an established connection. --- .../src/sdk/db_connection_impl.ts | 10 ++++++ .../tests/db_connection.test.ts | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 12dfca7afbf..ac3a1a223fd 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -1160,6 +1160,16 @@ export class DbConnectionImpl this.#processMessage(data); } } + } catch (e) { + // A message that fails to apply (undecodable, or a callback threw) + // leaves the client cache inconsistent, and rethrowing from a WebSocket + // listener crashes Node hosts. Fail the connection instead: drop what is + // queued and close, so `onDisconnect` receives the error. + stdbLogger('error', 'Failed to process a server message', e); + this.#connectionError = toError(e); + this.#inboundQueueOffset = this.#inboundQueue.length; + this.isActive = false; + this.ws?.close(); } finally { if (this.#inboundQueueOffset >= this.#inboundQueue.length) { this.#inboundQueue.length = 0; diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index 9e340637eb8..4bae98fe494 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -213,6 +213,40 @@ describe('DbConnection', () => { expect(client.isActive).toBe(false); }); + test('reports a server message it cannot apply through onDisconnect instead of throwing', async () => { + const onDisconnectPromise = new Deferred(); + const wsAdapter = new WebsocketTestAdapter(); + + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.openWebSocket) + .onConnect(() => { + throw new Error('callback failed'); + }) + .onDisconnect((_ctx, error) => onDisconnectPromise.resolve(error)) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + // Node rethrows errors from WebSocket listeners on the next tick, which + // crashes the host process. + expect(() => + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ) + ).not.toThrow(); + + expect((await onDisconnectPromise.promise)?.message).toBe( + 'callback failed' + ); + expect(client.isActive).toBe(false); + }); + test('marks disconnect as requested when disconnect() is called', async () => { const onDisconnectPromise = new Deferred(); const wsAdapter = new WebsocketTestAdapter(); From 73fd62e62352c8f288d9f02279e45270b591add6 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 24 Sep 2026 16:45:35 -0400 Subject: [PATCH 2/9] Type DbConnection.db with a merged interface instead of a declare field Generated TypeScript bindings narrowed `db` with `declare db: DbView;`. Babel rejects `declare` class fields unless `allowDeclareFields` is set, and babel-preset-expo does not set it, so React Native and Expo apps could not bundle generated bindings for any module with snake_case table accessor aliases. Declare the narrowed type on an interface merged into the class instead. It is type-only like before and emits nothing in any transpiler. Updated the codegen snapshot and the checked-in bindings to match. --- .../src/module_bindings/index.ts | 6 ++++-- .../test-app/src/module_bindings/index.ts | 6 ++++-- .../src/module_bindings/index.ts | 6 ++++-- .../test-solid-router/src/module_bindings/index.ts | 6 ++++-- crates/codegen/src/typescript.rs | 13 ++++++++++--- .../snapshots/codegen__codegen_typescript.snap | 6 ++++-- .../client-ts/src/module_bindings/index.ts | 6 ++++-- .../hangman-react-ts/src/module_bindings/index.ts | 6 ++++-- .../src/module_bindings/index.ts | 6 ++++-- 9 files changed, 42 insertions(+), 19 deletions(-) diff --git a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts index 8fbf73d58d4..82ddf0cb52f 100644 --- a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts +++ b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts @@ -229,10 +229,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/bindings-typescript/test-app/src/module_bindings/index.ts b/crates/bindings-typescript/test-app/src/module_bindings/index.ts index 5a5cc759b17..cf33870746c 100644 --- a/crates/bindings-typescript/test-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-app/src/module_bindings/index.ts @@ -249,10 +249,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts b/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts index 5a7a64d1e2b..dae611869c6 100644 --- a/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts @@ -227,10 +227,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts b/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts index 5a7a64d1e2b..dae611869c6 100644 --- a/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts @@ -227,10 +227,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index e2ee468eba4..78247aa03c2 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -754,6 +754,16 @@ impl Lang for TypeScript { ); writeln!(out); + if has_table_accessor_aliases { + // Narrow `db` by merging an interface into the class rather than with a + // `declare` field, which Babel (React Native, Expo) rejects by default. + writeln!(out, "export interface DbConnection {{"); + out.indent(1); + writeln!(out, "db: DbView;"); + out.dedent(1); + writeln!(out, "}}"); + writeln!(out); + } writeln!(out, "/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */"); writeln!( out, @@ -761,9 +771,6 @@ impl Lang for TypeScript { ); out.indent(1); if has_table_accessor_aliases { - writeln!(out, "declare db: DbView;"); - - writeln!(out); writeln!( out, "constructor(config: __DbConnectionConfig) {{" diff --git a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap index 80b88f79b73..e32448cbd40 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap @@ -409,10 +409,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/demo/Blackholio/client-ts/src/module_bindings/index.ts b/demo/Blackholio/client-ts/src/module_bindings/index.ts index 81afe450da0..0e7f3e8df74 100644 --- a/demo/Blackholio/client-ts/src/module_bindings/index.ts +++ b/demo/Blackholio/client-ts/src/module_bindings/index.ts @@ -219,10 +219,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/templates/hangman-react-ts/src/module_bindings/index.ts b/templates/hangman-react-ts/src/module_bindings/index.ts index 6852d40b6fe..625f44b14a7 100644 --- a/templates/hangman-react-ts/src/module_bindings/index.ts +++ b/templates/hangman-react-ts/src/module_bindings/index.ts @@ -259,10 +259,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/templates/money-exchange-react-ts/src/module_bindings/index.ts b/templates/money-exchange-react-ts/src/module_bindings/index.ts index c4bf96e2c56..ee0cd17aa2f 100644 --- a/templates/money-exchange-react-ts/src/module_bindings/index.ts +++ b/templates/money-exchange-react-ts/src/module_bindings/index.ts @@ -229,10 +229,12 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} +export interface DbConnection { + db: DbView; +} + /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { - declare db: DbView; - constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; From 4e9cfda2d8debe78e4739032ab1a552b617782e3 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 24 Sep 2026 17:14:55 -0400 Subject: [PATCH 3/9] Let short-lived auth tokens survive reconnects in the TypeScript SDK `withToken` only took a string, and the ConnectionManager re-applies the session's token on every automatic rebuild. For third-party OIDC tokens such as Clerk session JWTs (about a minute of lifetime) that token is the original JWT, so once it expired every reconnect failed with 401 and retried forever. React apps also had no way to switch identity while mounted: `retain()` ignores a new builder once a connection is live. `withToken` now also accepts a `TokenProvider`, a sync or async function that is called on every connection attempt: the initial build, automatic reconnects, resume and zombie revival, and `rebuild()`. Its value is that attempt's token (`undefined` connects anonymously) and `conn.token` reflects it. A throw or rejection reaches `onConnectError`, so the manager retries it with backoff. The manager no longer resumes the session token over a builder with a provider; string tokens resume as before. `TokenProvider` is exported from the package root. The React context gains `reconnect(builder)`, calling `ConnectionManager.rebuild()` like the Svelte provider does. The Clerk guide now passes a provider instead of a token fetched once. --- .../src/react/SpacetimeDBProvider.ts | 11 ++- .../src/react/connection_state.ts | 12 ++- .../src/sdk/connection_manager.ts | 6 +- .../src/sdk/db_connection_builder.ts | 25 +++++- .../src/sdk/db_connection_impl.ts | 40 ++++++--- .../tests/connection_manager_liveness.test.ts | 4 + .../connection_manager_reconnect.test.ts | 90 ++++++++++++++++++- .../tests/db_connection.test.ts | 46 ++++++++++ .../00500-authentication/00300-Clerk.md | 16 ++-- 9 files changed, 223 insertions(+), 27 deletions(-) diff --git a/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts b/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts index a574e21d4a8..74e1ec2c3f4 100644 --- a/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts +++ b/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts @@ -64,9 +64,16 @@ export function SpacetimeDBProvider< [key] ); + const reconnect = React.useCallback( + (builder: DbConnectionBuilder) => { + ConnectionManager.rebuild(key, builder); + }, + [key] + ); + const contextValue = React.useMemo( - () => ({ ...state, getConnection }), - [state, getConnection] + () => ({ ...state, getConnection, reconnect }), + [state, getConnection, reconnect] ); React.useEffect(() => { diff --git a/crates/bindings-typescript/src/react/connection_state.ts b/crates/bindings-typescript/src/react/connection_state.ts index 5ad565f9be6..714c507237a 100644 --- a/crates/bindings-typescript/src/react/connection_state.ts +++ b/crates/bindings-typescript/src/react/connection_state.ts @@ -1,6 +1,16 @@ -import type { DbConnectionImpl } from '../sdk/db_connection_impl'; +import type { + DbConnectionBuilder, + DbConnectionImpl, +} from '../sdk/db_connection_impl'; import type { ConnectionState as ManagerConnectionState } from '../sdk/connection_manager'; export type ConnectionState = ManagerConnectionState & { getConnection(): DbConnectionImpl | null; + /** + * Tear down the current connection and reconnect using a fresh builder, for + * example to switch identity after sign-in or sign-out. The builder should + * carry the new token and the same uri + database name. Hooks re-bind to the + * new connection automatically. + */ + reconnect(builder: DbConnectionBuilder): void; }; diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 2cf34ea2c36..32f8f18198c 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -315,6 +315,10 @@ class ConnectionManagerImpl { * * Pass `resumeSession: false` when the caller is deliberately changing * identity — see {@link rebuild} — so the builder's own token wins. + * + * A builder with a token provider is never overwritten: the provider is + * called for every attempt and already decides the identity, and resuming + * its last token would reuse a short-lived credential after it expired. */ #buildManagedConnection>( managed: ManagedConnection, @@ -322,7 +326,7 @@ class ConnectionManagerImpl { { resumeSession = true }: { resumeSession?: boolean } = {} ): T { managed.builder = builder; - if (resumeSession && managed.state.token) { + if (resumeSession && managed.state.token && !builder.hasTokenProvider()) { builder.withToken(managed.state.token); } const connection = builder.build(); diff --git a/crates/bindings-typescript/src/sdk/db_connection_builder.ts b/crates/bindings-typescript/src/sdk/db_connection_builder.ts index 282cab02d37..d9e196b65de 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_builder.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_builder.ts @@ -1,4 +1,8 @@ -import { DbConnectionImpl, type ConnectionEvent } from './db_connection_impl'; +import { + DbConnectionImpl, + type ConnectionEvent, + type TokenProvider, +} from './db_connection_impl'; import { EventEmitter } from './event_emitter'; import type { DbConnectionConfig, @@ -22,7 +26,7 @@ export class DbConnectionBuilder> { #uri?: URL; #nameOrAddress?: string; #identity?: Identity; - #token?: string; + #token?: string | TokenProvider; #emitter: EventEmitter = new EventEmitter(); #compression: 'gzip' | 'brotli' | 'none' = 'gzip'; #lightMode: boolean = false; @@ -76,13 +80,28 @@ export class DbConnectionBuilder> { * is optional. You can store the token returned by the `onConnect` callback * to use in future connections. * + * Pass a function instead of a string for short-lived tokens, such as OIDC + * session JWTs. It is called, and may return a promise, on every connection + * attempt, including automatic reconnects by the framework providers, so + * each attempt authenticates with a fresh token. Returning `undefined` + * connects anonymously. + * * @returns The `DbConnectionBuilder` instance. */ - withToken(token?: string): this { + withToken(token?: string | TokenProvider): this { this.#token = token; return this; } + /** + * Whether the token was set as a {@link TokenProvider}. The + * `ConnectionManager` does not resume a session's token over a provider, + * which already decides the identity for each attempt. + */ + hasTokenProvider(): boolean { + return typeof this.#token === 'function'; + } + withWSFn(createWSFn: WebSocketFactory): this { this.#createWSFn = createWSFn; return this; diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index ac3a1a223fd..65a0fd419c8 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -98,11 +98,20 @@ export type { export type ConnectionEvent = 'connect' | 'disconnect' | 'connectError'; +/** + * Supplies the auth token for one connection attempt. See + * {@link DbConnectionBuilder.withToken}. + */ +export type TokenProvider = () => + | string + | undefined + | Promise; + export type DbConnectionConfig = { uri: URL; nameOrAddress: string; identity?: Identity; - token?: string; + token?: string | TokenProvider; emitter: EventEmitter; createWSFn: WebSocketFactory; compression: 'gzip' | 'brotli' | 'none'; @@ -327,7 +336,6 @@ export class DbConnectionImpl } this.identity = identity; - this.token = token; this.#remoteModule = remoteModule; this.#emitter = emitter; @@ -388,15 +396,23 @@ export class DbConnectionImpl this.reducers = this.#makeReducers(remoteModule); this.procedures = this.#makeProcedures(remoteModule); - this.wsPromise = createWSFn({ - url, - nameOrAddress, - wsProtocol: [...PREFERRED_WS_PROTOCOLS], - authToken: token, - compression: compression, - lightMode: lightMode, - confirmedReads: confirmedReads, - }) + // A token provider is asked for a fresh token on every attempt, so + // short-lived credentials such as OIDC session JWTs survive reconnects. + // Resolving it inside the chain routes a throw or rejection to + // `onConnectError` rather than out of `build()`. A plain string token is + // assigned synchronously, as before. + this.wsPromise = (async () => { + this.token = typeof token === 'function' ? await token() : token; + return createWSFn({ + url, + nameOrAddress, + wsProtocol: [...PREFERRED_WS_PROTOCOLS], + authToken: this.token, + compression: compression, + lightMode: lightMode, + confirmedReads: confirmedReads, + }); + })() .then(v => { this.ws = v; @@ -423,7 +439,7 @@ export class DbConnectionImpl }) .catch(e => { stdbLogger('error', 'Error connecting to SpacetimeDB WS'); - this.#emitter.emit('connectError', this, e); + this.#emitter.emit('connectError', this, toError(e)); return undefined; }); diff --git a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts index 5c2e1e75c98..fabb9436824 100644 --- a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts @@ -99,6 +99,10 @@ class MockBuilder { return this; } + hasTokenProvider(): boolean { + return false; + } + build(): MockConnection { const connection = new MockConnection(); connection.token = this.token; diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index ee981ccf2f9..806119974eb 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { ConnectionId } from '../src'; +import { ConnectionId, type TokenProvider } from '../src'; +import { ServerMessage } from '../src/sdk/client_api/types.ts'; import { CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, connectionManagerReconnectDelayMs, ConnectionManager, } from '../src/sdk/connection_manager.ts'; +import WebsocketTestAdapter from '../src/sdk/websocket_test_adapter.ts'; +import { DbConnection } from '../test-app/src/module_bindings/index.ts'; +import { anIdentity } from './utils.ts'; type ErrorContextInterface = { isActive: boolean; @@ -144,6 +148,10 @@ class MockBuilder { return this; } + hasTokenProvider(): boolean { + return false; + } + build(): MockConnection { const connection = new MockConnection(this.token); this.buildCount += 1; @@ -809,3 +817,83 @@ describe('ConnectionManager session continuity across rebuilds', () => { ConnectionManager.release(key); }); }); + +// These use a real `DbConnection` because the provider is resolved inside it, +// per connection attempt. +describe('ConnectionManager with a token provider', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + function providerBuilder(provider: TokenProvider) { + const sockets: WebsocketTestAdapter[] = []; + const authTokens: (string | undefined)[] = []; + const builder = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withToken(provider) + .withWSFn(args => { + authTokens.push(args.authToken); + const socket = new WebsocketTestAdapter(); + sockets.push(socket); + return socket.openWebSocket(args); + }); + return { builder, sockets, authTokens }; + } + + test('asks the provider again on reconnect instead of resuming its last token', async () => { + const key = nextKey(); + let issued = 0; + const { builder, sockets, authTokens } = providerBuilder( + async () => `jwt-${++issued}` + ); + + ConnectionManager.retain(key, builder); + await vi.advanceTimersByTimeAsync(0); + sockets[0].acceptConnection(); + sockets[0].sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'jwt-1', + connectionId: ConnectionId.random(), + }) + ); + expect(ConnectionManager.getSnapshot(key)?.token).toBe('jwt-1'); + + // By now the short-lived token may have expired; the rebuild must not + // resume it. + sockets[0].close(); + await vi.advanceTimersByTimeAsync(connectionManagerReconnectDelayMs(0)); + + expect(authTokens).toEqual(['jwt-1', 'jwt-2']); + + ConnectionManager.release(key); + }); + + test('retries after the provider rejects', async () => { + const key = nextKey(); + const provider = vi + .fn() + .mockRejectedValueOnce(new Error('token fetch failed')) + .mockResolvedValue('jwt'); + const { builder, authTokens } = providerBuilder(provider); + + ConnectionManager.retain(key, builder); + await vi.advanceTimersByTimeAsync(0); + expect(ConnectionManager.getSnapshot(key)?.connectionError?.message).toBe( + 'token fetch failed' + ); + expect(authTokens).toEqual([]); + + await vi.advanceTimersByTimeAsync(connectionManagerReconnectDelayMs(0)); + expect(provider).toHaveBeenCalledTimes(2); + expect(authTokens).toEqual(['jwt']); + + ConnectionManager.release(key); + }); +}); diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index 4bae98fe494..6971525f4b8 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -372,6 +372,52 @@ describe('DbConnection', () => { expect(called).toBeTruthy(); }); + test.each([ + ['sync', (token: string) => token], + ['async', async (token: string) => token], + ])( + 'asks a %s token provider for a fresh token on every build', + async (_kind, provide) => { + let issued = 0; + const authTokens: (string | undefined)[] = []; + const builder = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withToken(() => provide(`token-${++issued}`)) + .withWSFn(args => { + authTokens.push(args.authToken); + return new WebsocketTestAdapter().openWebSocket(args); + }); + + const first = builder.build(); + await first['wsPromise']; + const second = builder.build(); + await second['wsPromise']; + + expect(authTokens).toEqual(['token-1', 'token-2']); + expect(first.token).toBe('token-1'); + expect(second.token).toBe('token-2'); + } + ); + + test('reports a throwing token provider through onConnectError', async () => { + let connectError: Error | undefined; + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withToken(() => { + throw new Error('token fetch failed'); + }) + .withWSFn(new WebsocketTestAdapter().openWebSocket) + .onConnectError((_ctx, error) => { + connectError = error; + }) + .build(); + + await client['wsPromise']; + expect(connectError?.message).toBe('token fetch failed'); + }); + test('batches same-tick reducer calls when v3 is negotiated', async () => { const wsAdapter = new WebsocketTestAdapter(); const client = DbConnection.builder() diff --git a/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md b/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md index 13df43bf929..f75228cedc3 100644 --- a/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md +++ b/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md @@ -211,8 +211,10 @@ createRoot(document.getElementById('root')!).render( Update your `App.tsx` file to: -1. Read the Clerk token via `useClerkToken`. -2. Pass it to the `DbConnection` builder using `.withToken(...)`. +1. Get Clerk's `getToken` function via `useAuth`. +2. Pass the `DbConnection` builder a function that calls it, using `.withToken(...)`. + +Clerk session tokens expire after about a minute, so pass a function rather than a token string: the SDK calls it on every connection attempt, including automatic reconnects, and so always authenticates with a fresh token. This mirrors the Auth0 flow: SpacetimeDB receives a bearer token (JWT) and can validate it server-side. @@ -220,10 +222,10 @@ This mirrors the Auth0 flow: SpacetimeDB receives a bearer token (JWT) and can v ```tsx import { useMemo } from 'react'; +import { useAuth } from '@clerk/clerk-react'; import { Identity } from 'spacetimedb'; import { SpacetimeDBProvider } from 'spacetimedb/react'; import { DbConnection, ErrorContext } from './module_bindings'; -import { useClerkToken } from './ClerkTokenProvider'; const onConnect = (_conn: DbConnection, identity: Identity) => { console.log( @@ -241,17 +243,17 @@ const onConnectError = (_ctx: ErrorContext, err: Error) => { }; export default function App() { - const token = useClerkToken(); + const { getToken } = useAuth(); const connectionBuilder = useMemo(() => { return DbConnection.builder() .withUri('') .withDatabaseName('') - .withToken(token) + .withToken(async () => (await getToken()) ?? undefined) .onConnect(onConnect) .onDisconnect(onDisconnect) .onConnectError(onConnectError); - }, [token]); + }, [getToken]); return ( @@ -296,7 +298,7 @@ export function Header() { You are now set up to use **Clerk** authentication in your React application. When users access your app, they will be redirected to Clerk for sign-in, a session token (JWT) will be retrieved in the browser, and that token will be used to authenticate your SpacetimeDB connection. -If you are using **Clerk JWT templates** (recommended for controlling claims/audience/issuer), update the token retrieval line to: +If you are using **Clerk JWT templates** (recommended for controlling claims/audience/issuer), update both `getToken()` calls to: ```ts await getToken({ template: '' }); From bd2657cc71d1ee46c1ac45fd1993c2fb9cb17261 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 24 Sep 2026 20:45:16 -0400 Subject: [PATCH 4/9] Negotiate HTTP/2 for procedure HTTPS requests Enable reqwest's `native-tls-alpn` feature so the native-tls connector offers ALPN `h2`. Without it every outbound `ctx.http.fetch` over HTTPS was pinned to HTTP/1.1, and HTTP/2-only APIs such as Apple's APNs (iOS push notifications) could not be reached from modules. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e679f9d8c4a..7db7d3a965d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -285,7 +285,7 @@ rand = "0.9" rayon = "1.8" rayon-core = "1.11.0" regex = "1" -reqwest = { version = "0.12", features = ["stream", "json", "gzip", "brotli"] } +reqwest = { version = "0.12", features = ["stream", "json", "gzip", "brotli", "native-tls-alpn"] } rolldown = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-rc.3" } rolldown_common = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-rc.3" } rolldown_error = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-rc.3" } From 9cfa6c9064a639d436f393d4dfbb149fe95593b9 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Sat, 26 Sep 2026 11:43:33 -0400 Subject: [PATCH 5/9] Stop re-signed websocket tokens from outliving the original /v1/identity/websocket-token re-signed any token it accepted, its own included, with a fresh iat and an exp 60 s out. Re-signing each copy before it expired renewed a token forever, and modules could never tell when a session token was issued. The copy now keeps the original iat and expires within 60 s but never after the original. --- crates/client-api/src/auth.rs | 43 ++++++++++++++++++++++-- crates/client-api/src/routes/identity.rs | 3 +- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index 729d001e2c3..9f40fd4f2ae 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -206,15 +206,23 @@ impl SpacetimeAuth { ) } - // Sign a new token with the same claims and a new expiry. + // Sign a short-lived copy of this token that we will be able to verify. // Note that this will not change the issuer, so the private_key might not match. - // We do this to create short-lived tokens that we will be able to verify. + // The copy keeps the original `iat` and expires within `expiry`, but never after the + // original: re-signing a copy can't renew a token forever, and modules can still tell + // when it was issued. pub fn re_sign_with_expiry( &self, signer: &impl TokenSigner, expiry: Duration, ) -> Result<(SpacetimeIdentityClaims, String), JwtError> { - TokenClaims::from(self.clone()).encode_and_sign_with_expiry(signer, Some(expiry)) + let cap = SystemTime::now() + expiry; + let claims = SpacetimeIdentityClaims { + exp: Some(self.claims.exp.map_or(cap, |exp| exp.min(cap))), + ..self.claims.clone() + }; + let token = signer.sign(&claims)?; + Ok((claims, token)) } } @@ -376,6 +384,35 @@ mod tests { Ok(()) } + // A re-signed copy keeps the original `iat` and never outlives the original token. + #[tokio::test] + async fn re_sign_never_extends_a_token() -> Result<(), anyhow::Error> { + let kp = JwtKeys::generate()?; + let claims = TokenClaims::new("localhost".into(), "test-subject".into()); + let re_sign = |token: String, claims| { + crate::auth::SpacetimeAuth::new(SpacetimeCreds::from_signed_token(token), claims)? + .re_sign_with_expiry(&kp.private, std::time::Duration::from_secs(60)) + .map_err(|e| anyhow!("{e}")) + }; + + let (_, token) = claims.encode_and_sign_with_expiry(&kp.private, Some(std::time::Duration::from_secs(30)))?; + let original = kp.public.validate_token(&token).await?; + let (_, copy) = re_sign(token, original.clone())?; + let copy = kp.public.validate_token(©).await?; + assert_eq!(copy.iat, original.iat); + assert_eq!(copy.exp, original.exp); + + // A token without an expiry gets one, 60 seconds out. + let (_, token) = claims.encode_and_sign(&kp.private)?; + let original = kp.public.validate_token(&token).await?; + let (_, copy) = re_sign(token, original.clone())?; + let copy = kp.public.validate_token(©).await?; + assert_eq!(copy.iat, original.iat); + let exp = copy.exp.ok_or_else(|| anyhow!("no exp"))?; + assert!(exp > std::time::SystemTime::now() + std::time::Duration::from_secs(50)); + Ok(()) + } + #[tokio::test] async fn authorization_rejection_custom_uses_display_message() -> Result<(), anyhow::Error> { let response = diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index fa42b460561..dfaf73ff779 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -96,7 +96,8 @@ pub struct WebsocketTokenResponse { pub token: String, } -// This endpoint takes a token from a client and sends a newly signed token with a 60s expiry. +// This endpoint takes a token from a client and sends a newly signed copy that expires within +// 60s, never after the original. // Note that even if the token has a different issuer, we will sign it with our key. // This is ok because `FullTokenValidator` checks if we signed the token before worrying about the issuer. pub async fn create_websocket_token( From f574b849147efe3c1b829b06201f654e3f5a9151 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Mon, 28 Sep 2026 16:11:31 -0400 Subject: [PATCH 6/9] Let optional constraint and index names accept undefined for exactOptionalPropertyTypes consumers --- crates/bindings-typescript/src/lib/constraints.ts | 2 +- crates/bindings-typescript/src/lib/indexes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/src/lib/constraints.ts b/crates/bindings-typescript/src/lib/constraints.ts index f779b49d309..561b26d52ef 100644 --- a/crates/bindings-typescript/src/lib/constraints.ts +++ b/crates/bindings-typescript/src/lib/constraints.ts @@ -44,5 +44,5 @@ export type ColumnIsUnique> = M extends * of constraint definitions. */ export type ConstraintOpts = { - name?: string; + name?: string | undefined; } & { constraint: 'unique'; columns: [AllowedCol] }; diff --git a/crates/bindings-typescript/src/lib/indexes.ts b/crates/bindings-typescript/src/lib/indexes.ts index 0b507ff3bae..39cb365a7d9 100644 --- a/crates/bindings-typescript/src/lib/indexes.ts +++ b/crates/bindings-typescript/src/lib/indexes.ts @@ -10,7 +10,7 @@ import type { ColumnIsUnique } from './constraints'; */ export type IndexOpts = { accessor: string; - name?: string; + name?: string | undefined; } & ( | { algorithm: 'btree'; columns: readonly AllowedCol[] } | { algorithm: 'hash'; columns: readonly AllowedCol[] } From 9f27053b1d00a187f7f68d8cdd316a87daac179b Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Mon, 28 Sep 2026 16:23:33 -0400 Subject: [PATCH 7/9] Revert "Let optional constraint and index names accept undefined for exactOptionalPropertyTypes consumers" This reverts commit f574b849147efe3c1b829b06201f654e3f5a9151. --- crates/bindings-typescript/src/lib/constraints.ts | 2 +- crates/bindings-typescript/src/lib/indexes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/src/lib/constraints.ts b/crates/bindings-typescript/src/lib/constraints.ts index 561b26d52ef..f779b49d309 100644 --- a/crates/bindings-typescript/src/lib/constraints.ts +++ b/crates/bindings-typescript/src/lib/constraints.ts @@ -44,5 +44,5 @@ export type ColumnIsUnique> = M extends * of constraint definitions. */ export type ConstraintOpts = { - name?: string | undefined; + name?: string; } & { constraint: 'unique'; columns: [AllowedCol] }; diff --git a/crates/bindings-typescript/src/lib/indexes.ts b/crates/bindings-typescript/src/lib/indexes.ts index 39cb365a7d9..0b507ff3bae 100644 --- a/crates/bindings-typescript/src/lib/indexes.ts +++ b/crates/bindings-typescript/src/lib/indexes.ts @@ -10,7 +10,7 @@ import type { ColumnIsUnique } from './constraints'; */ export type IndexOpts = { accessor: string; - name?: string | undefined; + name?: string; } & ( | { algorithm: 'btree'; columns: readonly AllowedCol[] } | { algorithm: 'hash'; columns: readonly AllowedCol[] } From 3c52bbd8544472577293a46d62ca6adeae0e51d2 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Mon, 28 Sep 2026 16:23:33 -0400 Subject: [PATCH 8/9] Revert "Stop re-signed websocket tokens from outliving the original" This reverts commit 9cfa6c9064a639d436f393d4dfbb149fe95593b9. --- crates/client-api/src/auth.rs | 43 ++---------------------- crates/client-api/src/routes/identity.rs | 3 +- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index 9f40fd4f2ae..729d001e2c3 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -206,23 +206,15 @@ impl SpacetimeAuth { ) } - // Sign a short-lived copy of this token that we will be able to verify. + // Sign a new token with the same claims and a new expiry. // Note that this will not change the issuer, so the private_key might not match. - // The copy keeps the original `iat` and expires within `expiry`, but never after the - // original: re-signing a copy can't renew a token forever, and modules can still tell - // when it was issued. + // We do this to create short-lived tokens that we will be able to verify. pub fn re_sign_with_expiry( &self, signer: &impl TokenSigner, expiry: Duration, ) -> Result<(SpacetimeIdentityClaims, String), JwtError> { - let cap = SystemTime::now() + expiry; - let claims = SpacetimeIdentityClaims { - exp: Some(self.claims.exp.map_or(cap, |exp| exp.min(cap))), - ..self.claims.clone() - }; - let token = signer.sign(&claims)?; - Ok((claims, token)) + TokenClaims::from(self.clone()).encode_and_sign_with_expiry(signer, Some(expiry)) } } @@ -384,35 +376,6 @@ mod tests { Ok(()) } - // A re-signed copy keeps the original `iat` and never outlives the original token. - #[tokio::test] - async fn re_sign_never_extends_a_token() -> Result<(), anyhow::Error> { - let kp = JwtKeys::generate()?; - let claims = TokenClaims::new("localhost".into(), "test-subject".into()); - let re_sign = |token: String, claims| { - crate::auth::SpacetimeAuth::new(SpacetimeCreds::from_signed_token(token), claims)? - .re_sign_with_expiry(&kp.private, std::time::Duration::from_secs(60)) - .map_err(|e| anyhow!("{e}")) - }; - - let (_, token) = claims.encode_and_sign_with_expiry(&kp.private, Some(std::time::Duration::from_secs(30)))?; - let original = kp.public.validate_token(&token).await?; - let (_, copy) = re_sign(token, original.clone())?; - let copy = kp.public.validate_token(©).await?; - assert_eq!(copy.iat, original.iat); - assert_eq!(copy.exp, original.exp); - - // A token without an expiry gets one, 60 seconds out. - let (_, token) = claims.encode_and_sign(&kp.private)?; - let original = kp.public.validate_token(&token).await?; - let (_, copy) = re_sign(token, original.clone())?; - let copy = kp.public.validate_token(©).await?; - assert_eq!(copy.iat, original.iat); - let exp = copy.exp.ok_or_else(|| anyhow!("no exp"))?; - assert!(exp > std::time::SystemTime::now() + std::time::Duration::from_secs(50)); - Ok(()) - } - #[tokio::test] async fn authorization_rejection_custom_uses_display_message() -> Result<(), anyhow::Error> { let response = diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index dfaf73ff779..fa42b460561 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -96,8 +96,7 @@ pub struct WebsocketTokenResponse { pub token: String, } -// This endpoint takes a token from a client and sends a newly signed copy that expires within -// 60s, never after the original. +// This endpoint takes a token from a client and sends a newly signed token with a 60s expiry. // Note that even if the token has a different issuer, we will sign it with our key. // This is ok because `FullTokenValidator` checks if we signed the token before worrying about the issuer. pub async fn create_websocket_token( From 92b60cfdaf32ebd86aaf78c2150e6d0d655dd944 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Mon, 28 Sep 2026 16:23:33 -0400 Subject: [PATCH 9/9] Revert "Type DbConnection.db with a merged interface instead of a declare field" This reverts commit 73fd62e62352c8f288d9f02279e45270b591add6. --- .../src/module_bindings/index.ts | 6 ++---- .../test-app/src/module_bindings/index.ts | 6 ++---- .../src/module_bindings/index.ts | 6 ++---- .../test-solid-router/src/module_bindings/index.ts | 6 ++---- crates/codegen/src/typescript.rs | 13 +++---------- .../snapshots/codegen__codegen_typescript.snap | 6 ++---- .../client-ts/src/module_bindings/index.ts | 6 ++---- .../hangman-react-ts/src/module_bindings/index.ts | 6 ++---- .../src/module_bindings/index.ts | 6 ++---- 9 files changed, 19 insertions(+), 42 deletions(-) diff --git a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts index 82ddf0cb52f..8fbf73d58d4 100644 --- a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts +++ b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/index.ts @@ -229,12 +229,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/bindings-typescript/test-app/src/module_bindings/index.ts b/crates/bindings-typescript/test-app/src/module_bindings/index.ts index cf33870746c..5a5cc759b17 100644 --- a/crates/bindings-typescript/test-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-app/src/module_bindings/index.ts @@ -249,12 +249,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts b/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts index dae611869c6..5a7a64d1e2b 100644 --- a/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-react-router-app/src/module_bindings/index.ts @@ -227,12 +227,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts b/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts index dae611869c6..5a7a64d1e2b 100644 --- a/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-solid-router/src/module_bindings/index.ts @@ -227,12 +227,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 78247aa03c2..e2ee468eba4 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -754,16 +754,6 @@ impl Lang for TypeScript { ); writeln!(out); - if has_table_accessor_aliases { - // Narrow `db` by merging an interface into the class rather than with a - // `declare` field, which Babel (React Native, Expo) rejects by default. - writeln!(out, "export interface DbConnection {{"); - out.indent(1); - writeln!(out, "db: DbView;"); - out.dedent(1); - writeln!(out, "}}"); - writeln!(out); - } writeln!(out, "/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */"); writeln!( out, @@ -771,6 +761,9 @@ impl Lang for TypeScript { ); out.indent(1); if has_table_accessor_aliases { + writeln!(out, "declare db: DbView;"); + + writeln!(out); writeln!( out, "constructor(config: __DbConnectionConfig) {{" diff --git a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap index e32448cbd40..80b88f79b73 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap @@ -409,12 +409,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/demo/Blackholio/client-ts/src/module_bindings/index.ts b/demo/Blackholio/client-ts/src/module_bindings/index.ts index 0e7f3e8df74..81afe450da0 100644 --- a/demo/Blackholio/client-ts/src/module_bindings/index.ts +++ b/demo/Blackholio/client-ts/src/module_bindings/index.ts @@ -219,12 +219,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/templates/hangman-react-ts/src/module_bindings/index.ts b/templates/hangman-react-ts/src/module_bindings/index.ts index 625f44b14a7..6852d40b6fe 100644 --- a/templates/hangman-react-ts/src/module_bindings/index.ts +++ b/templates/hangman-react-ts/src/module_bindings/index.ts @@ -259,12 +259,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView; diff --git a/templates/money-exchange-react-ts/src/module_bindings/index.ts b/templates/money-exchange-react-ts/src/module_bindings/index.ts index ee0cd17aa2f..c4bf96e2c56 100644 --- a/templates/money-exchange-react-ts/src/module_bindings/index.ts +++ b/templates/money-exchange-react-ts/src/module_bindings/index.ts @@ -229,12 +229,10 @@ export class SubscriptionBuilder extends __SubscriptionBuilderImpl< /** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ export class DbConnectionBuilder extends __DbConnectionBuilder {} -export interface DbConnection { - db: DbView; -} - /** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ export class DbConnection extends __DbConnectionImpl { + declare db: DbView; + constructor(config: __DbConnectionConfig) { super(config); this.db = __withTableAccessorAliases(this.db) as DbView;