From 45bc0451ac7e025cc5adf06fa279dec9a3692ecd Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 18 Feb 2026 19:00:16 -0800 Subject: [PATCH] Add more tests for typescript client and fix some bugs (#4307) # Description of Changes This fixes a couple issues: - We are now using the `SendDroppedRows` flag for unsubscribe messages. - We parse reducer errors as strings now when throwing errors. - `useTable` was not doing client-side filtering correctly for object types (Timestamp, ConnectionId, Identity) - `useTable` was not always recomputing snapshots when it needed to, because it couldn't distinguish between two events if they were caused by reducers called by other connections (or two subscription applied events). All events how have a client-generated id attached, so we can tell if there was actually a new event processed. Once we have per-query storage, we can purge the client-side filtering code. This also adds some tests to cover these cases, and includes a bit of refactoring. Another change added here is to use `SenderError` in the typescript SDK for errors that were returned by reducers (which throw `SenderError` in typescript modules). # Expected complexity level and risk 1 # Testing This has some new tests added. Much of the `useTable` logic was tested manually. --- crates/bindings-typescript/src/index.ts | 1 + crates/bindings-typescript/src/lib/errors.ts | 26 +++ crates/bindings-typescript/src/lib/query.ts | 51 +++- .../bindings-typescript/src/lib/timestamp.ts | 32 +++ .../bindings-typescript/src/react/useTable.ts | 23 +- .../src/sdk/client_api/index.ts | 2 +- .../src/sdk/db_connection_impl.ts | 127 ++++++++-- crates/bindings-typescript/src/sdk/event.ts | 21 +- crates/bindings-typescript/src/sdk/index.ts | 1 + .../src/sdk/websocket_test_adapter.ts | 11 +- .../bindings-typescript/src/server/errors.ts | 17 +- .../test-app/src/module_bindings/index.ts | 20 +- .../tests/db_connection.test.ts | 219 +++++++++++++++++- .../bindings-typescript/tests/query.test.ts | 22 +- templates/angular-ts/package.json | 2 +- .../angular-ts/src/module_bindings/index.ts | 2 +- .../src/module_bindings/types/reducers.ts | 4 - .../basic-ts/src/module_bindings/index.ts | 2 +- .../src/module_bindings/types/reducers.ts | 4 - .../src/module_bindings/index.ts | 2 +- templates/react-ts/package.json | 2 +- .../react-ts/src/module_bindings/index.ts | 2 +- .../src/module_bindings/types/reducers.ts | 4 - 23 files changed, 510 insertions(+), 87 deletions(-) create mode 100644 crates/bindings-typescript/src/lib/errors.ts diff --git a/crates/bindings-typescript/src/index.ts b/crates/bindings-typescript/src/index.ts index 7061d1448..41674612e 100644 --- a/crates/bindings-typescript/src/index.ts +++ b/crates/bindings-typescript/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/connection_id'; +export * from './lib/errors'; export * from './lib/algebraic_type'; export * from './lib/algebraic_value'; export { default as BinaryReader } from './lib/binary_reader'; diff --git a/crates/bindings-typescript/src/lib/errors.ts b/crates/bindings-typescript/src/lib/errors.ts new file mode 100644 index 000000000..c8ec99133 --- /dev/null +++ b/crates/bindings-typescript/src/lib/errors.ts @@ -0,0 +1,26 @@ +/** + * An error thrown by a reducer that indicates a problem to the sender. + * + * When this error is thrown by a reducer, the sender will be notified + * that the reducer failed gracefully with the given message. + */ +export class SenderError extends Error { + constructor(message: string) { + super(message); + } + get name(): string { + return 'SenderError'; + } +} + +/** + * An internal reducer error returned by the server runtime. + */ +export class InternalError extends Error { + constructor(message: string) { + super(message); + } + get name(): string { + return 'InternalError'; + } +} diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts index 6bdaf9edb..451938190 100644 --- a/crates/bindings-typescript/src/lib/query.ts +++ b/crates/bindings-typescript/src/lib/query.ts @@ -3,6 +3,7 @@ import { Identity } from './identity'; import type { ColumnIndex, IndexColumns, IndexOpts } from './indexes'; import type { UntypedSchemaDef } from './schema'; import type { UntypedTableSchema } from './table_schema'; +import { Timestamp } from './timestamp'; import type { ColumnBuilder, ColumnMetadata, @@ -615,6 +616,7 @@ type LiteralValue = | bigint | boolean | Identity + | Timestamp | ConnectionId; type ValueLike = LiteralValue | ColumnExpr | LiteralExpr; @@ -788,6 +790,9 @@ function literalValueToSql(value: unknown): string { // We use this hex string syntax. return `0x${value.toHexString()}`; } + if (value instanceof Timestamp) { + return `'${value.toISOString()}'`; + } switch (typeof value) { case 'number': case 'bigint': @@ -853,9 +858,51 @@ function resolveValue( row: Record ): any { if (isLiteralExpr(expr)) { - return expr.value; + return toComparableValue(expr.value); } - return row[expr.column]; + return toComparableValue(row[expr.column]); +} + +type TimestampLike = { + __timestamp_micros_since_unix_epoch__: bigint; +}; + +type HexSerializableLike = { + toHexString: () => string; +}; + +function isHexSerializableLike(value: unknown): value is HexSerializableLike { + return ( + !!value && + typeof value === 'object' && + typeof (value as { toHexString?: unknown }).toHexString === 'function' + ); +} + +// Check if this value is a Timestamp-like object. This is here because +// running locally can end up with different versions of the Timestamp class, +// which breaks the simple instanceof version. +function isTimestampLike(value: unknown): value is TimestampLike { + if (!value || typeof value !== 'object') return false; + + if (value instanceof Timestamp) return true; + + const micros = (value as Record)[ + '__timestamp_micros_since_unix_epoch__' + ]; + return typeof micros === 'bigint'; +} + +// Exported for tests. +export function toComparableValue(value: any): any { + // Handle `ConnectionId` and `Identity`. + if (isHexSerializableLike(value)) { + return value.toHexString(); + } + if (isTimestampLike(value)) { + return value.__timestamp_micros_since_unix_epoch__; + } + return value; } /** diff --git a/crates/bindings-typescript/src/lib/timestamp.ts b/crates/bindings-typescript/src/lib/timestamp.ts index d9c7ad355..adc099bfe 100644 --- a/crates/bindings-typescript/src/lib/timestamp.ts +++ b/crates/bindings-typescript/src/lib/timestamp.ts @@ -107,6 +107,38 @@ export class Timestamp { return new Date(Number(millis)); } + /** + * Get an ISO 8601 / RFC 3339 formatted string representation of this timestamp with microsecond precision. + * + * This method preserves the full microsecond precision of the timestamp, + * and throws `RangeError` if the `Timestamp` is outside the range representable in ISO format. + * + * @returns ISO 8601 formatted string with microsecond precision (e.g., '2025-02-17T10:30:45.123456Z') + */ + toISOString(): string { + const micros = this.__timestamp_micros_since_unix_epoch__; + const millis = micros / Timestamp.MICROS_PER_MILLIS; + + if ( + millis > BigInt(Number.MAX_SAFE_INTEGER) || + millis < BigInt(Number.MIN_SAFE_INTEGER) + ) { + throw new RangeError( + 'Timestamp is outside of the representable range for ISO string formatting' + ); + } + + const date = new Date(Number(millis)); + const isoBase = date.toISOString(); // Format: '2025-02-17T10:30:45.123Z' + + // Extract the full 6 decimal places of microseconds + const microsRemainder = Math.abs(Number(micros % 1000000n)); + const fractionalPart = String(microsRemainder).padStart(6, '0'); + + // Replace the 3-digit millisecond part with the full 6-digit microsecond part + return isoBase.replace(/\.\d{3}Z$/, `.${fractionalPart}Z`); + } + since(other: Timestamp): TimeDuration { return new TimeDuration( this.__timestamp_micros_since_unix_epoch__ - diff --git a/crates/bindings-typescript/src/react/useTable.ts b/crates/bindings-typescript/src/react/useTable.ts index 2165009ce..1831bd0b3 100644 --- a/crates/bindings-typescript/src/react/useTable.ts +++ b/crates/bindings-typescript/src/react/useTable.ts @@ -84,7 +84,7 @@ export function useTable( const querySql = toSql(query); - const latestTransactionEvent = useRef(null); + const latestTransactionEventId = useRef(null); const lastSnapshotRef = useRef< [readonly Prettify[], boolean] | null >(null); @@ -132,11 +132,8 @@ export function useTable( return; } callbacks?.onInsert?.(row); - if ( - ctx.event !== latestTransactionEvent.current || - !latestTransactionEvent.current - ) { - latestTransactionEvent.current = ctx.event; + if (ctx.event.id !== latestTransactionEventId.current) { + latestTransactionEventId.current = ctx.event.id; lastSnapshotRef.current = computeSnapshot(); onStoreChange(); } @@ -150,11 +147,8 @@ export function useTable( return; } callbacks?.onDelete?.(row); - if ( - ctx.event !== latestTransactionEvent.current || - !latestTransactionEvent.current - ) { - latestTransactionEvent.current = ctx.event; + if (ctx.event.id !== latestTransactionEventId.current) { + latestTransactionEventId.current = ctx.event.id; lastSnapshotRef.current = computeSnapshot(); onStoreChange(); } @@ -181,11 +175,8 @@ export function useTable( return; // no-op } - if ( - ctx.event !== latestTransactionEvent.current || - !latestTransactionEvent.current - ) { - latestTransactionEvent.current = ctx.event; + if (ctx.event.id !== latestTransactionEventId.current) { + latestTransactionEventId.current = ctx.event.id; lastSnapshotRef.current = computeSnapshot(); onStoreChange(); } diff --git a/crates/bindings-typescript/src/sdk/client_api/index.ts b/crates/bindings-typescript/src/sdk/client_api/index.ts index 673f89b60..7bc57c79a 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 344a9dbc4..db14114f3 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -61,6 +61,8 @@ 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'; +import { InternalError, SenderError } from '../lib/errors.ts'; export { DbConnectionBuilder, @@ -146,6 +148,7 @@ export class DbConnectionImpl // These fields are meant to be strictly private. #queryId = 0; #requestId = 0; + #eventId = 0; #emitter: EventEmitter; #messageQueue = Promise.resolve(); #subscriptionManager = new SubscriptionManager(); @@ -154,6 +157,7 @@ export class DbConnectionImpl number, (result: ReducerResultMessage['result']) => void >(); + #reducerCallInfo = new Map(); #procedureCallbacks = new Map(); #rowDeserializers: Record>; #reducerArgsSerializers: Record< @@ -313,7 +317,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 +413,7 @@ export class DbConnectionImpl ClientMessage.Unsubscribe({ querySetId: { id: querySetId }, requestId, - flags: UnsubscribeFlags.Default, + flags: UnsubscribeFlags.SendDroppedRows, }) ); } @@ -460,6 +464,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 +472,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]) => ({ @@ -547,6 +552,11 @@ export class DbConnectionImpl }); } + #nextEventId(): string { + this.#eventId += 1; + return `${this.connectionId.toHexString()}:${this.#eventId}`; + } + /** * Handles WebSocket onOpen event. */ @@ -577,6 +587,24 @@ export class DbConnectionImpl return pendingCallbacks; } + #applyTransactionUpdates( + eventContext: EventContextInterface, + tu: TransactionUpdate + ): PendingCallback[] { + const allUpdates: CacheTableUpdate[] = []; + for (const querySetUpdate of tu.querySets) { + const tableUpdates = this.#querySetUpdateToTableUpdates(querySetUpdate); + for (const update of tableUpdates) { + allUpdates.push(update); + } + // TODO: When we have per-query storage, we will want to apply the per-query events here. + } + return this.#applyTableUpdates( + this.#mergeTableUpdates(allUpdates), + eventContext + ); + } + async #processMessage(data: Uint8Array): Promise { const serverMessage = ServerMessage.deserialize(new BinaryReader(data)); switch (serverMessage.tag) { @@ -600,7 +628,10 @@ export class DbConnectionImpl ); return; } - const event: Event = { tag: 'SubscribeApplied' }; + const event: Event = { + id: this.#nextEventId(), + tag: 'SubscribeApplied', + }; const eventContext = this.#makeEventContext(event); const tableUpdates = this.#queryRowsToTableUpdates( serverMessage.value.rows, @@ -625,7 +656,10 @@ export class DbConnectionImpl ); return; } - const event: Event = { tag: 'UnsubscribeApplied' }; + const event: Event = { + id: this.#nextEventId(), + tag: 'UnsubscribeApplied', + }; const eventContext = this.#makeEventContext(event); const tableUpdates = serverMessage.value.rows ? this.#queryRowsToTableUpdates(serverMessage.value.rows, 'delete') @@ -642,7 +676,11 @@ export class DbConnectionImpl case 'SubscriptionError': { const querySetId = serverMessage.value.querySetId.id; const error = Error(serverMessage.value.error); - const event: Event = { tag: 'Error', value: error }; + const event: Event = { + id: this.#nextEventId(), + tag: 'Error', + value: error, + }; const eventContext = this.#makeEventContext(event); const errorContext = { ...eventContext, @@ -662,15 +700,17 @@ export class DbConnectionImpl break; } case 'TransactionUpdate': { - const event: Event = { tag: 'UnknownTransaction' }; + const event: Event = { + id: this.#nextEventId(), + 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 +718,36 @@ 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 eventId: string = this.#nextEventId(); + const event: Event = reducerInfo + ? { + id: eventId, + tag: 'Reducer', + value: { + timestamp: serverMessage.value.timestamp, + outcome: result, + reducer: { + name: reducerInfo.name, + args: reducerInfo.args, + }, + }, + } + : { + id: eventId, + 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 +793,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 +807,28 @@ 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(new SenderError(errorString)); + } else if (result.tag === 'InternalError') { + reject(new InternalError(result.value)); + } else { + const unreachable: never = result; + reject(new Error('Unexpected reducer result')); + void unreachable; + } } }); return promise; @@ -768,7 +849,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/event.ts b/crates/bindings-typescript/src/sdk/event.ts index b3d07405d..1a15e690c 100644 --- a/crates/bindings-typescript/src/sdk/event.ts +++ b/crates/bindings-typescript/src/sdk/event.ts @@ -1,9 +1,18 @@ import type { ReducerEvent } from './reducer_event'; import type { ReducerEventInfo } from './reducers'; -export type Event = - | { tag: 'Reducer'; value: ReducerEvent } - | { tag: 'SubscribeApplied' } - | { tag: 'UnsubscribeApplied' } - | { tag: 'Error'; value: Error } - | { tag: 'UnknownTransaction' }; +type WithId = { + /** + * A client-generated id to distinguish between different events. + */ + id: string; +}; + +export type Event = WithId & + ( + | { tag: 'Reducer'; value: ReducerEvent } + | { tag: 'SubscribeApplied' } + | { tag: 'UnsubscribeApplied' } + | { tag: 'Error'; value: Error } + | { tag: 'UnknownTransaction' } + ); diff --git a/crates/bindings-typescript/src/sdk/index.ts b/crates/bindings-typescript/src/sdk/index.ts index dea4fee8d..081d75bab 100644 --- a/crates/bindings-typescript/src/sdk/index.ts +++ b/crates/bindings-typescript/src/sdk/index.ts @@ -2,6 +2,7 @@ export * from './db_connection_impl.ts'; export * from './client_cache.ts'; export * from './message_types.ts'; +export * from '../lib/errors.ts'; export { type ClientTable } from './client_table.ts'; export { type RemoteModule } from './spacetime_module.ts'; export * from '../lib/type_builders.ts'; diff --git a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts index a63daccbe..35eb41565 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/src/server/errors.ts b/crates/bindings-typescript/src/server/errors.ts index c4b757ce2..397512e50 100644 --- a/crates/bindings-typescript/src/server/errors.ts +++ b/crates/bindings-typescript/src/server/errors.ts @@ -1,3 +1,5 @@ +import { SenderError } from '../lib/errors'; + /** * Base class for all Spacetime host errors (i.e. errors that may be thrown * by database functions). @@ -11,20 +13,7 @@ export class SpacetimeHostError extends Error { } } -/** - * An error thrown by a reducer that indicates a problem to the sender. - * - * When this error is thrown by a reducer, the sender will be notified - * that the reducer failed gracefully with the given message. - */ -export class SenderError extends Error { - constructor(message: string) { - super(message); - } - get name() { - return 'SenderError'; - } -} +export { SenderError }; const errorData = { /** 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 430ed677d..a799af685 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 742b1e4fe..a7e9bac1b 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -1,13 +1,21 @@ 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, + InternalError, + SenderError, + 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 +64,63 @@ 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, + }, + }); +} + +function makeReducerInternalErrorResult(requestId: number, error: string) { + return ServerMessage.ReducerResult({ + requestId, + timestamp: new Timestamp(0n), + result: { + tag: 'InternalError', + value: error, + }, + }); +} + describe('DbConnection', () => { test('call onConnectError callback after websocket connection failed to be established', async () => { const onConnectErrorPromise = new Deferred(); @@ -113,6 +178,158 @@ 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.toBeInstanceOf(SenderError); + await expect(reducerPromise).rejects.toHaveProperty( + 'message', + 'test error' + ); + expect(insertCalled).toBeFalsy(); + }); + + test('reducer internal error rejects with InternalError', 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; + + const reducerPromise = client.reducers.createPlayer({ + name: 'A Player', + location: { x: 1, y: 2 }, + }); + + await Promise.resolve(); + const requestId = getLastCallReducerRequestId(wsAdapter); + wsAdapter.sendToClient( + makeReducerInternalErrorResult(requestId, 'internal test error') + ); + + await expect(reducerPromise).rejects.toBeInstanceOf(InternalError); + await expect(reducerPromise).rejects.toHaveProperty( + 'message', + 'internal test error' + ); + }); + /* 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/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts index 24bd687d0..f4e242178 100644 --- a/crates/bindings-typescript/tests/query.test.ts +++ b/crates/bindings-typescript/tests/query.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from 'vitest'; import { Identity } from '../src/lib/identity'; -import { makeQueryBuilder, and, or, not, toSql } from '../src/server/query'; +import { + makeQueryBuilder, + and, + or, + not, + toSql, + toComparableValue, +} from '../src/server/query'; import { ModuleContext, tablesToSchema } from '../src/lib/schema'; import { table } from '../src/lib/table'; import { t } from '../src/lib/type_builders'; +import { Timestamp } from '../src'; const personTable = table( { @@ -48,6 +56,18 @@ const schemaDef = tablesToSchema(new ModuleContext(), { orders: ordersTable, }); +describe('Timestamp thing', () => { + it('Compares them', () => { + const d1 = new Date('2024-01-01T00:00:00Z'); + const d2 = new Date('2024-01-02T00:00:00Z'); + const t1 = Timestamp.fromDate(d1); + const t2 = Timestamp.fromDate(d2); + + expect(toComparableValue(t1) <= toComparableValue(t2)).toBe(true); + expect(toComparableValue(t1) >= toComparableValue(t2)).toBe(false); + }); +}); + describe('TableScan.toSql', () => { it('renders a full-table scan when no filters are applied', () => { const qb = makeQueryBuilder(schemaDef); diff --git a/templates/angular-ts/package.json b/templates/angular-ts/package.json index 4f8e35ba3..2f47cf0e5 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 7b098247c..cbc5d6b3e 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 7b4349476..d8ceaeb27 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 0ca8d4f88..0710daad4 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 7b4349476..d8ceaeb27 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 460ced697..678713c80 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 a49c2f232..bd51ae129 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 8751d04a7..cbc5d6b3e 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 7b4349476..d8ceaeb27 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;