Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 with a 30-second cap, until `disconnect()` or a terminal failure. 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
30 changes: 20 additions & 10 deletions crates/bindings-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,28 @@ 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');
})
.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 +56,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, outside `onConnect`, which runs again after every reconnect. 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.

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
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';
}
}
30 changes: 19 additions & 11 deletions crates/bindings-typescript/src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ import type { ParamsObj } from './reducers';
import type { ColumnBuilder, TypeBuilder } from './type_builders';
import type { CamelCase, SnakeCase } from './type_util';

export function deepEqual(obj1: any, obj2: any): boolean {
// If both are strictly equal (covers primitives and reference equality), return true
export function deepEqual(obj1: unknown, obj2: unknown): boolean {
if (obj1 === obj2) return true;

// If either is a primitive type or one is null, return false since we already checked for strict equality
if (
typeof obj1 !== 'object' ||
obj1 === null ||
Expand All @@ -20,20 +17,31 @@ export function deepEqual(obj1: any, obj2: any): boolean {
return false;
}

// Get keys of both objects
let firstKey = 0;
if (obj1 instanceof Uint8Array && obj2 instanceof Uint8Array) {
if (obj1.length !== obj2.length) return false;
for (let i = 0; i < obj1.length; i++) {
if (obj1[i] !== obj2[i]) return false;
}
// Typed-array indices precede other enumerable keys and are already equal.
firstKey = obj1.length;
}

const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);

// If number of keys is different, return false
if (keys1.length !== keys2.length) return false;

// Check all keys and compare values recursively
for (const key of keys1) {
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
const values1 = obj1 as Record<string, unknown>;
const values2 = obj2 as Record<string, unknown>;
for (let i = firstKey; i < keys1.length; i++) {
const key = keys1[i];
if (
!Object.prototype.propertyIsEnumerable.call(obj2, key) ||
!deepEqual(values1[key], values2[key])
) {
return false;
}
}

return true;
}

Expand Down
7 changes: 5 additions & 2 deletions crates/bindings-typescript/src/sdk/client_api/index.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions crates/bindings-typescript/src/sdk/client_api/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading