mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-06 07:26:43 -04:00
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.
This commit is contained in:
committed by
GitHub
parent
c0442ea146
commit
45bc0451ac
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<any, any> | LiteralExpr<any>;
|
||||
@@ -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<string, any>
|
||||
): 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<string, unknown>)[
|
||||
'__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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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__ -
|
||||
|
||||
@@ -84,7 +84,7 @@ export function useTable<TableDef extends UntypedTableDef>(
|
||||
|
||||
const querySql = toSql(query);
|
||||
|
||||
const latestTransactionEvent = useRef<any>(null);
|
||||
const latestTransactionEventId = useRef<string | null>(null);
|
||||
const lastSnapshotRef = useRef<
|
||||
[readonly Prettify<UseTableRowType>[], boolean] | null
|
||||
>(null);
|
||||
@@ -132,11 +132,8 @@ export function useTable<TableDef extends UntypedTableDef>(
|
||||
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<TableDef extends UntypedTableDef>(
|
||||
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<TableDef extends UntypedTableDef>(
|
||||
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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 */
|
||||
|
||||
@@ -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<RemoteModule extends UntypedRemoteModule>
|
||||
// These fields are meant to be strictly private.
|
||||
#queryId = 0;
|
||||
#requestId = 0;
|
||||
#eventId = 0;
|
||||
#emitter: EventEmitter<ConnectionEvent>;
|
||||
#messageQueue = Promise.resolve();
|
||||
#subscriptionManager = new SubscriptionManager<RemoteModule>();
|
||||
@@ -154,6 +157,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
number,
|
||||
(result: ReducerResultMessage['result']) => void
|
||||
>();
|
||||
#reducerCallInfo = new Map<number, { name: string; args: object }>();
|
||||
#procedureCallbacks = new Map<number, ProcedureCallback>();
|
||||
#rowDeserializers: Record<string, Deserializer<any>>;
|
||||
#reducerArgsSerializers: Record<
|
||||
@@ -313,7 +317,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
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<RemoteModule extends UntypedRemoteModule>
|
||||
ClientMessage.Unsubscribe({
|
||||
querySetId: { id: querySetId },
|
||||
requestId,
|
||||
flags: UnsubscribeFlags.Default,
|
||||
flags: UnsubscribeFlags.SendDroppedRows,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -460,6 +464,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Take a bunch of table updates and ensure that there is at most one update per table.
|
||||
#mergeTableUpdates(
|
||||
updates: CacheTableUpdate<UntypedTableDef>[]
|
||||
): CacheTableUpdate<UntypedTableDef>[] {
|
||||
@@ -467,9 +472,9 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
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<RemoteModule extends UntypedRemoteModule>
|
||||
});
|
||||
}
|
||||
|
||||
#nextEventId(): string {
|
||||
this.#eventId += 1;
|
||||
return `${this.connectionId.toHexString()}:${this.#eventId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles WebSocket onOpen event.
|
||||
*/
|
||||
@@ -577,6 +587,24 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
return pendingCallbacks;
|
||||
}
|
||||
|
||||
#applyTransactionUpdates(
|
||||
eventContext: EventContextInterface<RemoteModule>,
|
||||
tu: TransactionUpdate
|
||||
): PendingCallback[] {
|
||||
const allUpdates: CacheTableUpdate<UntypedTableDef>[] = [];
|
||||
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<void> {
|
||||
const serverMessage = ServerMessage.deserialize(new BinaryReader(data));
|
||||
switch (serverMessage.tag) {
|
||||
@@ -600,7 +628,10 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
);
|
||||
return;
|
||||
}
|
||||
const event: Event<never> = { tag: 'SubscribeApplied' };
|
||||
const event: Event<never> = {
|
||||
id: this.#nextEventId(),
|
||||
tag: 'SubscribeApplied',
|
||||
};
|
||||
const eventContext = this.#makeEventContext(event);
|
||||
const tableUpdates = this.#queryRowsToTableUpdates(
|
||||
serverMessage.value.rows,
|
||||
@@ -625,7 +656,10 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
);
|
||||
return;
|
||||
}
|
||||
const event: Event<never> = { tag: 'UnsubscribeApplied' };
|
||||
const event: Event<never> = {
|
||||
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<RemoteModule extends UntypedRemoteModule>
|
||||
case 'SubscriptionError': {
|
||||
const querySetId = serverMessage.value.querySetId.id;
|
||||
const error = Error(serverMessage.value.error);
|
||||
const event: Event<never> = { tag: 'Error', value: error };
|
||||
const event: Event<never> = {
|
||||
id: this.#nextEventId(),
|
||||
tag: 'Error',
|
||||
value: error,
|
||||
};
|
||||
const eventContext = this.#makeEventContext(event);
|
||||
const errorContext = {
|
||||
...eventContext,
|
||||
@@ -662,15 +700,17 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
break;
|
||||
}
|
||||
case 'TransactionUpdate': {
|
||||
const event: Event<never> = { tag: 'UnknownTransaction' };
|
||||
const event: Event<never> = {
|
||||
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<RemoteModule extends UntypedRemoteModule>
|
||||
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<any> = 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<never> = { 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<RemoteModule extends UntypedRemoteModule>
|
||||
* @param reducerName The name of the reducer to call
|
||||
* @param argsSerializer The arguments to pass to the reducer
|
||||
*/
|
||||
callReducer(reducerName: string, argsBuffer: Uint8Array): Promise<void> {
|
||||
callReducer(
|
||||
reducerName: string,
|
||||
argsBuffer: Uint8Array,
|
||||
reducerArgs?: object
|
||||
): Promise<void> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
const requestId = this.#getNextRequestId();
|
||||
const message = ClientMessage.CallReducer({
|
||||
@@ -743,11 +807,28 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
|
||||
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<RemoteModule extends UntypedRemoteModule>
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { ReducerEvent } from './reducer_event';
|
||||
import type { ReducerEventInfo } from './reducers';
|
||||
|
||||
export type Event<Reducer extends ReducerEventInfo> =
|
||||
| { tag: 'Reducer'; value: ReducerEvent<Reducer> }
|
||||
| { 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<Reducer extends ReducerEventInfo> = WithId &
|
||||
(
|
||||
| { tag: 'Reducer'; value: ReducerEvent<Reducer> }
|
||||
| { tag: 'SubscribeApplied' }
|
||||
| { tag: 'UnsubscribeApplied' }
|
||||
| { tag: 'Error'; value: Error }
|
||||
| { tag: 'UnknownTransaction' }
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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<T> {
|
||||
|
||||
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<typeof makeQuerySetUpdate>
|
||||
) {
|
||||
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<void>();
|
||||
@@ -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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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 */
|
||||
|
||||
@@ -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<typeof AddReducer>;
|
||||
export type OnConnectParams = __Infer<typeof OnConnectReducer>;
|
||||
export type OnDisconnectParams = __Infer<typeof OnDisconnectReducer>;
|
||||
export type SayHelloParams = __Infer<typeof SayHelloReducer>;
|
||||
|
||||
+1
-1
@@ -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 */
|
||||
|
||||
@@ -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<typeof AddReducer>;
|
||||
export type OnConnectParams = __Infer<typeof OnConnectReducer>;
|
||||
export type OnDisconnectParams = __Infer<typeof OnDisconnectReducer>;
|
||||
export type SayHelloParams = __Infer<typeof SayHelloReducer>;
|
||||
|
||||
+1
-1
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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 */
|
||||
|
||||
@@ -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<typeof AddReducer>;
|
||||
export type OnConnectParams = __Infer<typeof OnConnectReducer>;
|
||||
export type OnDisconnectParams = __Infer<typeof OnDisconnectReducer>;
|
||||
export type SayHelloParams = __Infer<typeof SayHelloReducer>;
|
||||
|
||||
Reference in New Issue
Block a user