Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1ce785b
Auto-Reconnect: Connection replacement and batch subscribe
aasoni Aug 26, 2026
75f5b33
Merge branch 'master' into alessandro/auto-reconnect-server-side
aasoni Aug 26, 2026
7803b6a
Cargo.lock
aasoni Aug 26, 2026
508db01
Refactor to use the same function between subscribe and subscribe batch
aasoni Sep 1, 2026
c6f1780
Update doc comment wording
aasoni Sep 1, 2026
895f754
Serialize connection takeover
aasoni Sep 3, 2026
3f9d328
Refuse connection of existing session and start teardown instead
aasoni Sep 7, 2026
7ebdc8c
Add a drop safe async version of scope guard (#5910)
jsdt Sep 10, 2026
0ad2652
Typescript SDK: auto-reconnect
aasoni Sep 11, 2026
812ee9b
Docs update for reconnect
aasoni Sep 11, 2026
bdc2d01
re-ran code gen
aasoni Sep 15, 2026
7782c96
Merge remote-tracking branch 'origin/master' into alessandro/auto-rec…
aasoni Sep 16, 2026
3603a6d
Update crates/bindings-typescript/src/sdk/db_connection_impl.ts
aasoni Sep 16, 2026
26cd607
Update crates/bindings-typescript/src/sdk/db_connection_impl.ts
aasoni Sep 16, 2026
e2c0e9a
Performance improvement for deepEqual
aasoni Sep 16, 2026
1185628
Update agent skill files
aasoni Sep 17, 2026
dc09056
Implement reconnect options for min/max delay with limits
aasoni Sep 21, 2026
e215a77
Merge remote-tracking branch 'origin/master' into alessandro/auto-rec…
aasoni Sep 22, 2026
85ea91b
Fix canonical name vs accessor name bug in replay
aasoni Sep 22, 2026
1b0d7e5
Merge branch 'master' into alessandro/auto-reconnect-typescript-sdk
aasoni Oct 1, 2026
c3ef7d6
Add TypeScript onAutomaticReconnect lifecycle callback
aasoni Oct 2, 2026
fc42fdf
Handle automatic reconnects in TypeScript framework providers
aasoni Oct 2, 2026
0ace0ac
Document TypeScript automatic reconnect callbacks
aasoni Oct 2, 2026
abdbd6a
Preserve framework table subscriptions across automatic reconnects
aasoni Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 72 additions & 31 deletions codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,23 @@ Generated bindings convert snake_case names to camelCase, including row fields:
## React: main.tsx

```typescript
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import ReactDOM from 'react-dom/client';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection } from './module_bindings';
import { MODULE_NAME, SPACETIMEDB_URI } from './config';
import App from './App';

const TOKEN_KEY = `${SPACETIMEDB_URI}/${MODULE_NAME}/auth_token`;

function Root() {
const connectionBuilder = useMemo(() =>
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
.withToken(localStorage.getItem('auth_token') || undefined),
.withToken(localStorage.getItem(TOKEN_KEY) ?? undefined)
.withAutomaticReconnect()
.onConnect((_conn, _identity, token) => localStorage.setItem(TOKEN_KEY, token)),
[]
);
return (
Expand All @@ -49,42 +53,42 @@ ReactDOM.createRoot(document.getElementById('root')!).render(<Root />);
import { useTable, useSpacetimeDB } from 'spacetimedb/react';
import { DbConnection, tables } from './module_bindings';

function App() {
const { isActive, identity: myIdentity, token, getConnection } = useSpacetimeDB();
export default function App() {
const { isActive, identity: myIdentity, getConnection } = useSpacetimeDB();
const conn = getConnection() as DbConnection | null;

// Save auth token
useEffect(() => { if (token) localStorage.setItem('auth_token', token); }, [token]);

// Subscribe when connected. Prefer typed query builders over raw SQL
useEffect(() => {
if (!conn || !isActive) return;
conn.subscriptionBuilder()
.onApplied(() => setSubscribed(true))
.subscribe([tables.entity, tables.record]);
// Or with filters: tables.entity.where(r => r.active.eq(true))
// Or raw SQL: 'SELECT * FROM entity'
}, [conn, isActive]);

// Reactive data. Returns [rows, isReady]
// useTable owns subscriptions and reports readiness after replay.
const [entities, entitiesReady] = useTable(tables.entity);
const [records, recordsReady] = useTable(tables.record);

// useTable with row callbacks
const [onlineUsers] = useTable(
tables.entity.where(r => r.active.eq(true)),
{
onInsert: (user) => console.log('User connected:', user.name),
onDelete: (user) => console.log('User disconnected:', user.name),
onInsert: user => console.log('User connected:', user.name),
onDelete: user => console.log('User disconnected:', user.name),
onUpdate: (oldUser, newUser) => console.log('Updated:', newUser.name),
}
);

// Call reducers with object syntax
conn?.reducers.addRecord({ data }).catch(console.error);
const addRecord = (data: string) => {
if (!conn || !isActive) return;
void conn.reducers.addRecord({ data }).catch(console.error);
};
const ownsEntity = entities.some(
row => row.owner.toHexString() === myIdentity?.toHexString()
);

// Compare identities
const isMe = row.owner.toHexString() === myIdentity?.toHexString();
return (
<main>
<p>{isActive ? 'Connected' : 'Not connected'}</p>
<p>{entitiesReady && recordsReady ? 'Data ready' : 'Waiting for current data'}</p>
<p>{onlineUsers.length} users online, {records.length} records</p>
<p>{ownsEntity ? 'You own an entity' : 'No owned entity'}</p>
<button disabled={!isActive} onClick={() => addRecord('Hello!')}>
Add record
</button>
</main>
);
}
```

Expand All @@ -93,22 +97,59 @@ function App() {
```typescript
import { DbConnection, tables } from './module_bindings';

const HOST = 'wss://maincloud.spacetimedb.com';
const DATABASE = 'my_module';
const TOKEN_KEY = `${HOST}/${DATABASE}/auth_token`;

const conn = DbConnection.builder()
.withUri('wss://maincloud.spacetimedb.com')
.withDatabaseName('my_module')
.onConnect((ctx) => {
ctx.subscriptionBuilder()
.onApplied(() => console.log('Ready'))
.subscribe([tables.user, tables.message]);
.withUri(HOST)
.withDatabaseName(DATABASE)
.withToken(localStorage.getItem(TOKEN_KEY) ?? undefined)
.withAutomaticReconnect()
.onConnect((_conn, identity, token) => {
localStorage.setItem(TOKEN_KEY, token);
console.log('Connected as:', identity.toHexString());
})
.onDisconnect((_ctx, error, nextAttempt, delayMs) => {
if (nextAttempt !== undefined) {
console.warn(`Reconnect attempt ${nextAttempt} in ${delayMs} ms`, error);
} else {
console.log('Connection ended', error);
}
})
.onConnectError((_ctx, error, nextAttempt, delayMs) => {
console.error('Connection failed:', error);
if (nextAttempt !== undefined) {
console.log(`Retry ${nextAttempt} in ${delayMs} ms`);
}
})
.build();

// Register once; the SDK replays this subscription after reconnecting.
const subscription = conn.subscriptionBuilder()
.onApplied(() => console.log('Ready'))
.subscribe([tables.user, tables.message]);

// Row callbacks
conn.db.user.onInsert((ctx, user) => console.log('Joined:', user.name));
conn.db.user.onDelete((ctx, user) => console.log('Left:', user.name));
conn.db.user.onUpdate((ctx, oldUser, newUser) => console.log('Updated:', newUser.name));
```

## Automatic Reconnect and Token Refresh

The React provider's connection manager enables automatic reconnect; the explicit `.withAutomaticReconnect()` above also shows the setting to use for direct connections. Without it, a direct connection does not recover automatically. For direct connections, initial failures are not retried. After an established connection drops, retries use exponential backoff and jitter from `minDelayMs` (default 1 s) up to `maxDelayMs` (default 30 s), until `disconnect()` or a terminal failure. Tune these with `.withAutomaticReconnect({ minDelayMs, maxDelayMs })`; values below the 500 ms and 1 s floors are raised with a warning, so that retrying clients cannot overwhelm the database. While mounted, the React provider also preserves its separate connection-manager retries: it builds a replacement connection after an initial or terminal failure that the core connection will not retry. It respects an explicit `disconnect()`.

The same connection, identity, table handles, subscriptions, and row callbacks survive recovery. Each attempt gets a fresh connection ID. `onConnect` runs again before subscription replay, so register subscriptions and row callbacks once, outside that callback. The SDK replays subscriptions in one batch, retains readable but stale cached rows during outages, and emits net row changes after reconciliation. Subscription `onApplied` runs again after replay; keep one-time setup separate.

In React, let `useTable` manage its own subscriptions. Do not add a second subscription effect for the same queries or recreate subscriptions whenever `isActive` changes. Use the hook's `isReady` result for data readiness: a successful reconnect handshake does not mean replay has completed. Invoke reducers from event handlers, not during rendering. For a manually created subscription, retain its handle and call `unsubscribe()` when it is no longer needed, including during an outage.

For direct connections, `conn.isReconnecting` reports recovery before the next successful handshake. `onDisconnect` and `onConnectError` receive `(ctx, error, nextReconnectAttempt, nextReconnectDelayMs)`. The last two arguments are `undefined` when no retry is scheduled. Do not build a replacement connection or run your own retry timer while automatic recovery is pending. An explicit `conn.disconnect()` stops recovery, including a pending token refresh result.

For expiring credentials, also call `.withTokenProvider(() => refreshTokenAsync())`, where your authentication integration supplies `refreshTokenAsync(): Promise<string>`. Supply the initial token with `.withToken(initialToken)`; the provider is used only for reconnect attempts. It must return a non-empty token for the same identity. The SDK calls it when remaining validity is at most 30 seconds or 5% of the original lifetime, whichever is greater, when expiry cannot be read, or after a reused token is rejected. Provider failures retry; rejection of a freshly provided token is terminal. No periodic refresh runs while connected, and disconnecting does not cancel the provider's own asynchronous work.

Calls made while disconnected fail immediately. Pending reducer and procedure promises reject with `UnknownCallResultError` (exported from `spacetimedb`) when the connection is lost before a result arrives. The server may have executed the operation; the SDK never replays it. Do not automatically retry non-idempotent calls on that error.

## Gotchas

- **`useTable` rows are `readonly`.** Copy before sorting/mutating, or it fails to type-check:
Expand Down
31 changes: 22 additions & 9 deletions crates/bindings-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,31 @@ import { DbConnection, tables } from './module_bindings';
const connection = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('MODULE_NAME')
.onDisconnect(() => {
console.log('disconnected');
.withAutomaticReconnect()
.onConnect((_connection, identity) => {
console.log('Connected:', identity.toHexString());
})
.onConnectError(() => {
console.log('client_error');
.onAutomaticReconnect((_connection, identity) => {
console.log('Reconnected:', identity.toHexString());
})
.onConnect((connection, identity, _token) => {
.onDisconnect((_ctx, error, attempt, delayMs) => {
console.log(
'Connected to SpacetimeDB with identity:',
identity.toHexString()
attempt === undefined
? 'Disconnected'
: `Retry ${attempt} in ${delayMs} ms`,
error
);
})
.onConnectError((_ctx, error, attempt) => {
console.error(
attempt === undefined ? 'Connection failed' : 'Retry failed',
error
);

connection.subscriptionBuilder().subscribe(tables.player);
})
.withToken('TOKEN')
.build();

connection.subscriptionBuilder().subscribe(tables.player);
```

If you need to disconnect the client:
Expand All @@ -50,6 +59,10 @@ If you need to disconnect the client:
connection.disconnect();
```

Automatic reconnection preserves the connection, cache, handles, and callbacks. Register subscriptions and row callbacks once after `build()` or inside `onConnect`, which fires once per connection object. Successful automatic reconnects fire `onAutomaticReconnect(connection, identity, token)` instead. This callback runs before subscription replay; wait for subscription `onApplied` callbacks when you need refreshed data. Cache reads remain available during outages. Initial connection failures are not retried by the core SDK.

For expiring credentials, pass the initial token with `withToken` and add `withTokenProvider(() => auth.getAccessToken())`. The SDK asks for a fresh token before reconnecting when the retained token is near expiry. The provider must return a token for the same identity. If your application persists tokens, save the token from both `onConnect` and `onAutomaticReconnect` to include refreshed credentials.

Typically, you will use the SDK with types generated from SpacetimeDB module. For example, given a table named `Player` you can subscribe to player updates like this:

```ts
Expand Down
3 changes: 3 additions & 0 deletions crates/bindings-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
"@types/fast-text-encoding": "^1.0.3",
"@types/object-inspect": "^1.13.0",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.2.3",
"@types/statuses": "^2.0.6",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
Expand All @@ -240,6 +241,8 @@
"eslint": "^9.33.0",
"eslint-plugin-jsdoc": "^61.5.0",
"globals": "^15.14.0",
"jsdom": "^26.1.0",
"react-dom": "^18.3.1",
"size-limit": "^11.2.0",
"svelte": "^5.0.0",
"ts-node": "^10.9.2",
Expand Down
52 changes: 24 additions & 28 deletions crates/bindings-typescript/src/angular/injectors/inject-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
assertInInjectionContext,
inject,
signal,
computed,
effect,
type Signal,
} from '@angular/core';
Expand Down Expand Up @@ -98,29 +99,22 @@ export function injectTable<TableDef extends UntypedTableDef>(
const whereExpr = getQueryWhereClause(query);
const querySql = toSql(query);

const tableSignal = signal<TableRows<TableDef>>({
isLoading: true,
rows: [],
});
const rows = signal<readonly RowTypeDef<TableDef>[]>([]);

let latestTransactionEvent: any = null;
let subscribeApplied = false;
const appliedConnectionId = signal<string | null>(null);
const connection = computed(() => connState().getConnection());

// Note: this code is mostly derived from the React useTable implementation
// in order to keep behavior consistent across frameworks.

const computeSnapshot = (): readonly RowTypeDef<TableDef>[] => {
const state = connState();
if (!state.isActive) {
const currentConnection = connection();
if (!currentConnection) {
return [];
}

const connection = state.getConnection();
if (!connection) {
return [];
}

const table = connection.db[accessorName];
const table = currentConnection.db[accessorName];

if (whereExpr) {
return Array.from(table.iter()).filter(row =>
Expand All @@ -132,24 +126,18 @@ export function injectTable<TableDef extends UntypedTableDef>(
};

const updateSnapshot = () => {
tableSignal.set({
rows: computeSnapshot(),
isLoading: !subscribeApplied,
});
rows.set(computeSnapshot());
};

effect((onCleanup: (fn: () => void) => void) => {
const state = connState();
if (!state.isActive) {
appliedConnectionId.set(null);
const currentConnection = connection();
if (!currentConnection) {
updateSnapshot();
return;
}

const connection = state.getConnection();
if (!connection) {
return;
}

const table = connection.db[accessorName];
const table = currentConnection.db[accessorName];

const onInsert = (
ctx: EventContextInterface<UntypedRemoteModule>,
Expand Down Expand Up @@ -214,14 +202,17 @@ export function injectTable<TableDef extends UntypedTableDef>(
table.onDelete(onDelete);
table.onUpdate?.(onUpdate);

const subscription = connection
const subscription = currentConnection
.subscriptionBuilder()
.onApplied(() => {
subscribeApplied = true;
appliedConnectionId.set(currentConnection.connectionId.toHexString());
updateSnapshot();
})
.onError(() => appliedConnectionId.set(null))
.subscribe(querySql);

updateSnapshot();

onCleanup(() => {
table.removeOnInsert(onInsert);
table.removeOnDelete(onDelete);
Expand All @@ -230,5 +221,10 @@ export function injectTable<TableDef extends UntypedTableDef>(
});
});

return tableSignal.asReadonly();
return computed(() => ({
rows: rows(),
isLoading:
!connState().isActive ||
appliedConnectionId() !== connState().connectionId.toHexString(),
}));
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function provideSpacetimeDB<DbConnection extends DbConnectionImpl<any>>(
identity: conn.identity,
token: conn.token,
connectionId: conn.connectionId,
connectionError: undefined,
getConnection,
});
};
Expand All @@ -76,6 +77,7 @@ export function provideSpacetimeDB<DbConnection extends DbConnectionImpl<any>>(
};

connectionBuilder.onConnect(onConnect);
connectionBuilder.onAutomaticReconnect(onConnect);
connectionBuilder.onDisconnect(onDisconnect);
connectionBuilder.onConnectError(onConnectError);

Expand Down
34 changes: 34 additions & 0 deletions crates/bindings-typescript/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,37 @@ export class InternalError extends Error {
return 'InternalError';
}
}

/** The call was not sent because the connection was not established. */
export class DisconnectedError extends Error {
constructor(message: string = 'Not connected to SpacetimeDB') {
super(message);
}
get name(): string {
return 'DisconnectedError';
}
}

/** The connection dropped before acknowledgement; the call may have run. */
export class UnknownCallResultError extends Error {
constructor(
message: string = 'Connection lost before the call was acknowledged; it may or may not have run'
) {
super(message);
}
get name(): string {
return 'UnknownCallResultError';
}
}

/** The reconnect returned a different identity, ending automatic reconnection. */
export class IdentityChangedError extends Error {
constructor(
message: string = 'Reconnected with a different identity; the token was revoked or replaced'
) {
super(message);
}
get name(): string {
return 'IdentityChangedError';
}
}
Loading
Loading