diff --git a/crates/bindings-typescript/src/sdk/client_api/index.ts b/crates/bindings-typescript/src/sdk/client_api/index.ts index 673f89b6030..7bc57c79a6b 100644 --- a/crates/bindings-typescript/src/sdk/client_api/index.ts +++ b/crates/bindings-typescript/src/sdk/client_api/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 901ee64ccec4620a9bbf1090e9cd868040063661). +// This was generated using spacetimedb cli version 2.0.0 (commit 98cb84ff20f3d37dc683c3d3c13ad2cb2bb34fc2). /* eslint-disable */ /* tslint:disable */ diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 344a9dbc4a9..8866438905f 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -61,6 +61,7 @@ import type { RowType, UntypedTableDef } from '../lib/table.ts'; import { toCamelCase } from '../lib/util.ts'; import type { ProceduresView } from './procedures.ts'; import type { Values } from '../lib/type_util.ts'; +import type { TransactionUpdate } from './client_api/types.ts'; export { DbConnectionBuilder, @@ -154,6 +155,7 @@ export class DbConnectionImpl number, (result: ReducerResultMessage['result']) => void >(); + #reducerCallInfo = new Map(); #procedureCallbacks = new Map(); #rowDeserializers: Record>; #reducerArgsSerializers: Record< @@ -313,7 +315,7 @@ export class DbConnectionImpl const writer = new BinaryWriter(1024); serializeArgs(writer, params); const argsBuffer = writer.getBuffer(); - return this.callReducer(reducerName, argsBuffer); + return this.callReducer(reducerName, argsBuffer, params); }; } @@ -409,7 +411,7 @@ export class DbConnectionImpl ClientMessage.Unsubscribe({ querySetId: { id: querySetId }, requestId, - flags: UnsubscribeFlags.Default, + flags: UnsubscribeFlags.SendDroppedRows, }) ); } @@ -460,6 +462,7 @@ export class DbConnectionImpl return rows; } + // Take a bunch of table updates and ensure that there is at most one update per table. #mergeTableUpdates( updates: CacheTableUpdate[] ): CacheTableUpdate[] { @@ -467,9 +470,9 @@ export class DbConnectionImpl for (const update of updates) { const ops = merged.get(update.tableName); if (ops) { - ops.push(...update.operations); + for (const op of update.operations) ops.push(op); } else { - merged.set(update.tableName, [...update.operations]); + merged.set(update.tableName, update.operations.slice()); } } return Array.from(merged, ([tableName, operations]) => ({ @@ -577,6 +580,24 @@ export class DbConnectionImpl return pendingCallbacks; } + #applyTransactionUpdates( + eventContext: EventContextInterface, + tu: TransactionUpdate + ): PendingCallback[] { + const all_updates: CacheTableUpdate[] = []; + for (const querySetUpdate of tu.querySets) { + const tableUpdates = this.#querySetUpdateToTableUpdates(querySetUpdate); + for (const update of tableUpdates) { + all_updates.push(update); + } + // TODO: When we have per-query storage, we will want to apply the per-query events here. + } + return this.#applyTableUpdates( + this.#mergeTableUpdates(all_updates), + eventContext + ); + } + async #processMessage(data: Uint8Array): Promise { const serverMessage = ServerMessage.deserialize(new BinaryReader(data)); switch (serverMessage.tag) { @@ -664,13 +685,12 @@ export class DbConnectionImpl case 'TransactionUpdate': { const event: Event = { tag: 'UnknownTransaction' }; const eventContext = this.#makeEventContext(event); - for (const querySetUpdate of serverMessage.value.querySets) { - const tableUpdates = - this.#querySetUpdateToTableUpdates(querySetUpdate); - const callbacks = this.#applyTableUpdates(tableUpdates, eventContext); - for (const callback of callbacks) { - callback.cb(); - } + const callbacks = this.#applyTransactionUpdates( + eventContext, + serverMessage.value + ); + for (const callback of callbacks) { + callback.cb(); } break; } @@ -678,16 +698,31 @@ export class DbConnectionImpl const { requestId, result } = serverMessage.value; if (result.tag === 'Ok') { - const tableUpdates = result.value.transactionUpdate.querySets.flatMap( - qs => this.#querySetUpdateToTableUpdates(qs) + const reducerInfo = this.#reducerCallInfo.get(requestId); + const event: Event = reducerInfo + ? { + tag: 'Reducer', + value: { + timestamp: serverMessage.value.timestamp, + outcome: result, + reducer: { + name: reducerInfo.name, + args: reducerInfo.args, + }, + }, + } + : { tag: 'UnknownTransaction' }; + const eventContext = this.#makeEventContext(event as any); + + const callbacks = this.#applyTransactionUpdates( + eventContext, + result.value.transactionUpdate ); - const event: Event = { tag: 'UnknownTransaction' }; - const eventContext = this.#makeEventContext(event); - const callbacks = this.#applyTableUpdates(tableUpdates, eventContext); for (const callback of callbacks) { callback.cb(); } } + this.#reducerCallInfo.delete(requestId); const cb = this.#reducerCallbacks.get(requestId); this.#reducerCallbacks.delete(requestId); cb?.(result); @@ -733,7 +768,11 @@ export class DbConnectionImpl * @param reducerName The name of the reducer to call * @param argsSerializer The arguments to pass to the reducer */ - callReducer(reducerName: string, argsBuffer: Uint8Array): Promise { + callReducer( + reducerName: string, + argsBuffer: Uint8Array, + reducerArgs?: object + ): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const requestId = this.#getNextRequestId(); const message = ClientMessage.CallReducer({ @@ -743,11 +782,24 @@ export class DbConnectionImpl flags: 0, }); this.#sendMessage(message); + if (reducerArgs) { + this.#reducerCallInfo.set(requestId, { + name: reducerName, + args: reducerArgs, + }); + } this.#reducerCallbacks.set(requestId, result => { if (result.tag === 'Ok' || result.tag === 'OkEmpty') { resolve(); } else { - reject(result.value); + if (result.tag === 'Err') { + /// Interpret the user-returned error as a string. + const reader = new BinaryReader(result.value); + const errorString = reader.readString(); + reject(errorString); + } else { + reject(result.value); + } } }); return promise; @@ -768,7 +820,7 @@ export class DbConnectionImpl const writer = new BinaryWriter(1024); this.#reducerArgsSerializers[reducerName].serialize(writer, params); const argsBuffer = writer.getBuffer(); - return this.callReducer(reducerName, argsBuffer); + return this.callReducer(reducerName, argsBuffer, params); } /** diff --git a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts index a63daccbe11..35eb415652c 100644 --- a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts +++ b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts @@ -1,5 +1,7 @@ -import { BinaryWriter, type Infer } from '../'; +import { BinaryReader, BinaryWriter, type Infer } from '../'; +import ClientMessageSerde from './client_api/client_message_type'; import ServerMessage from './client_api/server_message_type'; +import type { ClientMessage } from './client_api/types'; class WebsocketTestAdapter { onclose: any; @@ -9,14 +11,21 @@ class WebsocketTestAdapter { onerror: any; messageQueue: any[]; + outgoingMessages: ClientMessage[]; closed: boolean; constructor() { this.messageQueue = []; + this.outgoingMessages = []; this.closed = false; } send(message: any): void { + const parsedMessage = ClientMessageSerde.deserialize( + new BinaryReader(message) + ); + this.outgoingMessages.push(parsedMessage); + // console.ClientMessageSerde.deserialize(message); this.messageQueue.push(message); } 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 430ed677d7a..a799af68562 100644 --- a/crates/bindings-typescript/test-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-app/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 901ee64ccec4620a9bbf1090e9cd868040063661). +// This was generated using spacetimedb cli version 2.0.0 (commit 98cb84ff20f3d37dc683c3d3c13ad2cb2bb34fc2). /* eslint-disable */ /* tslint:disable */ @@ -50,7 +50,9 @@ const tablesSchema = __schema({ player: __table( { name: 'player', - indexes: [{ name: 'id', algorithm: 'btree', columns: ['id'] }], + indexes: [ + { name: 'player_id_idx_btree', algorithm: 'btree', columns: ['id'] }, + ], constraints: [ { name: 'player_id_key', constraint: 'unique', columns: ['id'] }, ], @@ -60,7 +62,13 @@ const tablesSchema = __schema({ unindexed_player: __table( { name: 'unindexed_player', - indexes: [{ name: 'id', algorithm: 'btree', columns: ['id'] }], + indexes: [ + { + name: 'unindexed_player_id_idx_btree', + algorithm: 'btree', + columns: ['id'], + }, + ], constraints: [ { name: 'unindexed_player_id_key', @@ -75,7 +83,11 @@ const tablesSchema = __schema({ { name: 'user', indexes: [ - { name: 'identity', algorithm: 'btree', columns: ['identity'] }, + { + name: 'user_identity_idx_btree', + algorithm: 'btree', + columns: ['identity'], + }, ], constraints: [ { diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index 742b1e4fef2..4c0d0962417 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -1,13 +1,14 @@ import { DbConnection } from '../test-app/src/module_bindings'; import User from '../test-app/src/module_bindings/user_table'; import { beforeEach, describe, expect, test } from 'vitest'; -import { ConnectionId, type Infer } from '../src'; +import { BinaryWriter, ConnectionId, Timestamp, type Infer } from '../src'; import ServerMessage from '../src/sdk/client_api/server_message_type'; import { Identity } from '../src'; import WebsocketTestAdapter from '../src/sdk/websocket_test_adapter'; import { anIdentity, bobIdentity, + encodePlayer, encodeUser, makeQuerySetUpdate, sallyIdentity, @@ -56,6 +57,52 @@ class Deferred { beforeEach(() => {}); +function getLastCallReducerRequestId(wsAdapter: WebsocketTestAdapter): number { + for (let i = wsAdapter.outgoingMessages.length - 1; i >= 0; i--) { + const message = wsAdapter.outgoingMessages[i]; + if (message.tag === 'CallReducer') { + return message.value.requestId; + } + + console.log('Message: ', JSON.stringify(message)); + } + console.log('Outgoing messages length: ', wsAdapter.outgoingMessages.length); + throw new Error('No CallReducer message found in messageQueue.'); +} + +function makeReducerResult( + requestId: number, + reducerQuerySetUpdate: ReturnType +) { + return ServerMessage.ReducerResult({ + requestId, + timestamp: new Timestamp(0n), + result: { + tag: 'Ok', + value: { + retValue: new Uint8Array(), + transactionUpdate: { + querySets: [reducerQuerySetUpdate], + }, + }, + }, + }); +} + +function makeReducerErrorResult(requestId: number, error: string) { + const errorWriter = new BinaryWriter(64); + errorWriter.writeString(error); + const errorPayload = errorWriter.getBuffer(); + return ServerMessage.ReducerResult({ + requestId, + timestamp: new Timestamp(0n), + result: { + tag: 'Err', + value: errorPayload, + }, + }); +} + describe('DbConnection', () => { test('call onConnectError callback after websocket connection failed to be established', async () => { const onConnectErrorPromise = new Deferred(); @@ -113,6 +160,113 @@ describe('DbConnection', () => { expect(called).toBeTruthy(); }); + test('fires row callbacks after reducer resolution in ReducerResult', async () => { + const wsAdapter = new WebsocketTestAdapter(); + const onConnectPromise = new Deferred(); + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.createWebSocketFn.bind(wsAdapter) as any) + .onConnect(() => { + onConnectPromise.resolve(); + }) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ); + await onConnectPromise.promise; + + let reducerResolved = false; + + const rowCallbackPromise = new Deferred(); + client.db.player.onInsert(ctx => { + expect(reducerResolved).toBeFalsy(); + expect(ctx.event.tag).toEqual('Reducer'); + if (ctx.event.tag === 'Reducer') { + expect(ctx.event.value.reducer.name).toEqual('create_player'); + expect(ctx.event.value.reducer.args).toEqual({ + name: 'A Player', + location: { x: 1, y: 2 }, + }); + } + rowCallbackPromise.resolve(); + }); + + const reducerPromise = client.reducers.createPlayer({ + name: 'A Player', + location: { x: 1, y: 2 }, + }); + reducerPromise.then(() => { + reducerResolved = true; + }); + // Hack to get the request sent from the client. + await Promise.resolve(); + const requestId = getLastCallReducerRequestId(wsAdapter); + const reducerQuerySetUpdate = makeQuerySetUpdate( + 0, + 'player', + encodePlayer({ + id: 1, + userId: anIdentity, + name: 'A Player', + location: { x: 1, y: 2 }, + }) + ); + wsAdapter.sendToClient(makeReducerResult(requestId, reducerQuerySetUpdate)); + + await rowCallbackPromise.promise; + await reducerPromise; + expect(reducerResolved).toBeTruthy(); + }); + + test('reducer error rejects and does not fire row callbacks', async () => { + const wsAdapter = new WebsocketTestAdapter(); + const onConnectPromise = new Deferred(); + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.createWebSocketFn.bind(wsAdapter) as any) + .onConnect(() => { + onConnectPromise.resolve(); + }) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ); + await onConnectPromise.promise; + + let insertCalled = false; + client.db.player.onInsert(() => { + insertCalled = true; + }); + + const reducerPromise = client.reducers.createPlayer({ + name: 'A Player', + location: { x: 1, y: 2 }, + }); + + await Promise.resolve(); + const requestId = getLastCallReducerRequestId(wsAdapter); + wsAdapter.sendToClient(makeReducerErrorResult(requestId, 'test error')); + + await expect(reducerPromise).rejects.toBe('test error'); + expect(insertCalled).toBeFalsy(); + }); + /* test('it calls onInsert callback when a record is added with a subscription update and then with a transaction update', async () => { const wsAdapter = new WebsocketTestAdapter(); diff --git a/templates/angular-ts/package.json b/templates/angular-ts/package.json index 4f8e35ba3b2..2f47cf0e5e4 100644 --- a/templates/angular-ts/package.json +++ b/templates/angular-ts/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "ng serve", "build": "ng build", - "generate": "pnpm --dir spacetimedb install --ignore-workspace && cargo run -p gen-bindings -- --out-dir src/module_bindings --project-path spacetimedb && prettier --write src/module_bindings", + "generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings", "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb", "spacetime:publish:local": "spacetime publish --project-path spacetimedb --server local", "spacetime:publish": "spacetime publish --project-path spacetimedb --server maincloud" diff --git a/templates/angular-ts/src/module_bindings/index.ts b/templates/angular-ts/src/module_bindings/index.ts index 7b098247c45..cbc5d6b3eba 100644 --- a/templates/angular-ts/src/module_bindings/index.ts +++ b/templates/angular-ts/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 4cf57e2fe6ba480834ee0bb2f6aefa4482550164). +// This was generated using spacetimedb cli version 2.0.0 (commit 98cb84ff20f3d37dc683c3d3c13ad2cb2bb34fc2). /* eslint-disable */ /* tslint:disable */ diff --git a/templates/angular-ts/src/module_bindings/types/reducers.ts b/templates/angular-ts/src/module_bindings/types/reducers.ts index 7b434947645..d8ceaeb2709 100644 --- a/templates/angular-ts/src/module_bindings/types/reducers.ts +++ b/templates/angular-ts/src/module_bindings/types/reducers.ts @@ -7,11 +7,7 @@ import { type Infer as __Infer } from 'spacetimedb'; // Import all reducer arg schemas import AddReducer from '../add_reducer'; -import OnConnectReducer from '../on_connect_reducer'; -import OnDisconnectReducer from '../on_disconnect_reducer'; import SayHelloReducer from '../say_hello_reducer'; export type AddParams = __Infer; -export type OnConnectParams = __Infer; -export type OnDisconnectParams = __Infer; export type SayHelloParams = __Infer; diff --git a/templates/basic-ts/src/module_bindings/index.ts b/templates/basic-ts/src/module_bindings/index.ts index 0ca8d4f8886..0710daad4fc 100644 --- a/templates/basic-ts/src/module_bindings/index.ts +++ b/templates/basic-ts/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 901ee64ccec4620a9bbf1090e9cd868040063661). +// This was generated using spacetimedb cli version 2.0.0 (commit 98cb84ff20f3d37dc683c3d3c13ad2cb2bb34fc2). /* eslint-disable */ /* tslint:disable */ diff --git a/templates/basic-ts/src/module_bindings/types/reducers.ts b/templates/basic-ts/src/module_bindings/types/reducers.ts index 7b434947645..d8ceaeb2709 100644 --- a/templates/basic-ts/src/module_bindings/types/reducers.ts +++ b/templates/basic-ts/src/module_bindings/types/reducers.ts @@ -7,11 +7,7 @@ import { type Infer as __Infer } from 'spacetimedb'; // Import all reducer arg schemas import AddReducer from '../add_reducer'; -import OnConnectReducer from '../on_connect_reducer'; -import OnDisconnectReducer from '../on_disconnect_reducer'; import SayHelloReducer from '../say_hello_reducer'; export type AddParams = __Infer; -export type OnConnectParams = __Infer; -export type OnDisconnectParams = __Infer; export type SayHelloParams = __Infer; diff --git a/templates/chat-react-ts/src/module_bindings/index.ts b/templates/chat-react-ts/src/module_bindings/index.ts index 460ced69795..678713c804a 100644 --- a/templates/chat-react-ts/src/module_bindings/index.ts +++ b/templates/chat-react-ts/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 901ee64ccec4620a9bbf1090e9cd868040063661). +// This was generated using spacetimedb cli version 2.0.0 (commit 98cb84ff20f3d37dc683c3d3c13ad2cb2bb34fc2). /* eslint-disable */ /* tslint:disable */ diff --git a/templates/react-ts/package.json b/templates/react-ts/package.json index a49c2f23238..bd51ae129ae 100644 --- a/templates/react-ts/package.json +++ b/templates/react-ts/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "generate": "pnpm --dir spacetimedb install --ignore-workspace && cargo run -p gen-bindings -- --out-dir src/module_bindings --project-path spacetimedb && prettier --write src/module_bindings", + "generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings", "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb", "spacetime:publish:local": "spacetime publish --project-path server --server local", "spacetime:publish": "spacetime publish --project-path server --server maincloud" diff --git a/templates/react-ts/src/module_bindings/index.ts b/templates/react-ts/src/module_bindings/index.ts index 8751d04a776..cbc5d6b3eba 100644 --- a/templates/react-ts/src/module_bindings/index.ts +++ b/templates/react-ts/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 901ee64ccec4620a9bbf1090e9cd868040063661). +// This was generated using spacetimedb cli version 2.0.0 (commit 98cb84ff20f3d37dc683c3d3c13ad2cb2bb34fc2). /* eslint-disable */ /* tslint:disable */ diff --git a/templates/react-ts/src/module_bindings/types/reducers.ts b/templates/react-ts/src/module_bindings/types/reducers.ts index 7b434947645..d8ceaeb2709 100644 --- a/templates/react-ts/src/module_bindings/types/reducers.ts +++ b/templates/react-ts/src/module_bindings/types/reducers.ts @@ -7,11 +7,7 @@ import { type Infer as __Infer } from 'spacetimedb'; // Import all reducer arg schemas import AddReducer from '../add_reducer'; -import OnConnectReducer from '../on_connect_reducer'; -import OnDisconnectReducer from '../on_disconnect_reducer'; import SayHelloReducer from '../say_hello_reducer'; export type AddParams = __Infer; -export type OnConnectParams = __Infer; -export type OnDisconnectParams = __Infer; export type SayHelloParams = __Infer;