diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts index 2d7bcf052c7..2ba17797f68 100644 --- a/crates/bindings-typescript/src/lib/query.ts +++ b/crates/bindings-typescript/src/lib/query.ts @@ -54,7 +54,7 @@ export const isRowTypedQuery = (val: unknown): val is RowTypedQuery => export const isTypedQuery = (val: unknown): val is TableTypedQuery => !!val && typeof val === 'object' && QueryBrand in (val as object); -export function toSql(q: Query): string { +export function toSql(q: RowTypedQuery): string { return (q as unknown as { toSql(): string }).toSql(); } diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index be9edc9e113..f245cefa9dc 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -19,6 +19,7 @@ import { ArrayBuilder, OptionBuilder, ProductBuilder, + QueryTypeBuilder, RefBuilder, ResultBuilder, RowBuilder, @@ -307,6 +308,10 @@ export class ModuleContext { return new ArrayBuilder( this.registerTypesRecursively(typeBuilder.element) ) as any; + } else if (typeBuilder instanceof QueryTypeBuilder) { + return new QueryTypeBuilder( + this.registerTypesRecursively(typeBuilder.row) + ) as any; } else { return typeBuilder as any; } diff --git a/crates/bindings-typescript/src/lib/type_builders.ts b/crates/bindings-typescript/src/lib/type_builders.ts index 6bf8ae1ec99..df94587583c 100644 --- a/crates/bindings-typescript/src/lib/type_builders.ts +++ b/crates/bindings-typescript/src/lib/type_builders.ts @@ -15,6 +15,8 @@ import { Uuid, type UuidAlgebraicType } from './uuid'; // Used in codegen files export { type AlgebraicTypeType } from './algebraic_type'; +export const QUERY_VIEW_RETURN_TAG = '__query__' as const; + /** * Helper type to extract the TypeScript type from a TypeBuilder */ @@ -1295,6 +1297,43 @@ export class ArrayBuilder> } } +type QueryReturnType> = { + tag: 'Product'; + value: { + elements: [ + { + name: typeof QUERY_VIEW_RETURN_TAG; + algebraicType: InferSpacetimeTypeOfTypeBuilder; + }, + ]; + }; +}; + +export class QueryTypeBuilder< + Row extends TypeBuilder, +> extends TypeBuilder< + readonly InferTypeOfTypeBuilder[], + QueryReturnType +> { + readonly row: Row; + + constructor(row: Row) { + super( + AlgebraicType.Product({ + elements: [ + { + name: QUERY_VIEW_RETURN_TAG, + get algebraicType() { + return row.algebraicType; + }, + }, + ], + }) as QueryReturnType + ); + this.row = row; + } +} + export class ByteArrayBuilder extends TypeBuilder< Uint8Array, @@ -3871,6 +3910,16 @@ export const t = { return new ArrayBuilder(e); }, + /** + * Creates a return type marker for query-builder views. + * + * This encodes as the special SATS product shape `{ __query__: T }`, + * where `T` is the row product type returned by the query. + */ + query>(row: Row): QueryTypeBuilder { + return new QueryTypeBuilder(row); + }, + enum: enumImpl, /** diff --git a/crates/bindings-typescript/src/server/view.test-d.ts b/crates/bindings-typescript/src/server/view.test-d.ts index f2c220fd6d0..5c33c5ced53 100644 --- a/crates/bindings-typescript/src/server/view.test-d.ts +++ b/crates/bindings-typescript/src/server/view.test-d.ts @@ -71,13 +71,23 @@ const spacetime = schema({ personWithMissing, }); +const queryRetValue = t.query(person.rowType); const arrayRetValue = t.array(person.rowType); const optionalPerson = t.option(person.rowType); -spacetime.anonymousView({ name: 'v1', public: true }, arrayRetValue, ctx => { +spacetime.anonymousView({ name: 'v1', public: true }, queryRetValue, ctx => { return ctx.from.person.build(); }); +// Legacy compatibility: query-builder views can still be declared with array return types. +spacetime.anonymousView( + { name: 'v1_legacy_array_query', public: true }, + arrayRetValue, + ctx => { + return ctx.from.person.build(); + } +); + spacetime.anonymousView( { name: 'optionalPerson', public: true }, optionalPerson, @@ -106,7 +116,7 @@ spacetime.anonymousView( spacetime.anonymousView( { name: 'v2', public: true }, - arrayRetValue, + queryRetValue, // @ts-expect-error returns a query of the wrong type. ctx => { return ctx.from.order.build(); @@ -116,7 +126,7 @@ spacetime.anonymousView( // For queries, we can't return rows with extra fields. spacetime.anonymousView( { name: 'v3', public: true }, - arrayRetValue, + queryRetValue, // @ts-expect-error returns a query of the wrong type. ctx => { return ctx.from.personWithExtra.build(); @@ -126,7 +136,7 @@ spacetime.anonymousView( // Ideally this would fail, since we depend on the field ordering for serialization. spacetime.anonymousView( { name: 'reorderedPerson', public: true }, - arrayRetValue, + queryRetValue, // Comment this out if we can fix the types. // // @ts-expect-error returns a query of the wrong type. ctx => { @@ -137,21 +147,21 @@ spacetime.anonymousView( // Fails because it is missing a field. spacetime.anonymousView( { name: 'missingField', public: true }, - arrayRetValue, + queryRetValue, // @ts-expect-error returns a query of the wrong type. ctx => { return ctx.from.personWithMissing.build(); } ); -spacetime.anonymousView({ name: 'v4', public: true }, arrayRetValue, ctx => { +spacetime.anonymousView({ name: 'v4', public: true }, queryRetValue, ctx => { // @ts-expect-error returns a query of the wrong type. const _invalid = ctx.from.person.where(row => row.id.eq('string')).build(); const _columnEqs = ctx.from.person.where(row => row.id.eq(row.id)).build(); return ctx.from.person.where(row => row.id.eq(5)).build(); }); -spacetime.anonymousView({ name: 'v5', public: true }, arrayRetValue, ctx => { +spacetime.anonymousView({ name: 'v5', public: true }, queryRetValue, ctx => { const _nonIndexedSemijoin = ctx.from.person .where(row => row.id.eq(5)) // @ts-expect-error person_id is not indexed. diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index accd0c92563..2650db68475 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -10,6 +10,7 @@ import type { OptionAlgebraicType } from '../lib/option'; import type { ParamsObj } from '../lib/reducers'; import { type UntypedSchemaDef } from '../lib/schema'; import { + QueryTypeBuilder, RowBuilder, type Infer, type InferSpacetimeTypeOfTypeBuilder, @@ -90,6 +91,58 @@ export type ViewOpts = { type FlattenedArray = T extends readonly (infer E)[] ? E : never; +type ArrayViewReturnTypeBuilder = TypeBuilder< + readonly object[], + { tag: 'Array'; value: AlgebraicTypeVariants.Product } +>; + +type OptionViewReturnTypeBuilder = TypeBuilder< + object | undefined, + OptionAlgebraicType +>; + +type ProceduralViewReturnTypeBuilder = + | ArrayViewReturnTypeBuilder + | OptionViewReturnTypeBuilder; + +export type QueryViewReturnTypeBuilder = QueryTypeBuilder< + TypeBuilder +>; + +export type ViewReturnTypeBuilder = + | ProceduralViewReturnTypeBuilder + | QueryViewReturnTypeBuilder; + +type ExtractProductFromTypeBuilder> = + InferSpacetimeTypeOfTypeBuilder extends { tag: 'Product'; value: infer P } + ? P + : never; + +type QueryReturnRow = + Ret extends QueryTypeBuilder ? Infer : never; + +type QueryReturnProduct = + Ret extends QueryTypeBuilder + ? ExtractProductFromTypeBuilder + : never; + +type ExtractArrayProduct> = + InferSpacetimeTypeOfTypeBuilder extends { tag: 'Array'; value: infer V } + ? V extends { tag: 'Product'; value: infer P } + ? P + : never + : never; + +type LegacyArrayQueryReturn = + RowTypedQuery>, ExtractArrayProduct>; + +type ViewReturn = + Ret extends QueryViewReturnTypeBuilder + ? RowTypedQuery, QueryReturnProduct> + : Ret extends ArrayViewReturnTypeBuilder + ? Infer | LegacyArrayQueryReturn + : Infer; + // // If we allowed functions to return either. // type ViewReturn = // | Infer @@ -99,33 +152,16 @@ export type ViewFn< S extends UntypedSchemaDef, Params extends ParamsObj, Ret extends ViewReturnTypeBuilder, -> = - | ((ctx: ViewCtx, params: InferTypeOfRow) => Infer) - | (( - ctx: ViewCtx, - params: InferTypeOfRow - ) => RowTypedQuery>, ExtractArrayProduct>); +> = (ctx: ViewCtx, params: InferTypeOfRow) => ViewReturn; export type AnonymousViewFn< S extends UntypedSchemaDef, Params extends ParamsObj, Ret extends ViewReturnTypeBuilder, -> = - | ((ctx: AnonymousViewCtx, params: InferTypeOfRow) => Infer) - | (( - ctx: AnonymousViewCtx, - params: InferTypeOfRow - ) => RowTypedQuery>, ExtractArrayProduct>); - -export type ViewReturnTypeBuilder = - | TypeBuilder< - readonly object[], - { tag: 'Array'; value: AlgebraicTypeVariants.Product } - > - | TypeBuilder< - object | undefined, - OptionAlgebraicType - >; +> = ( + ctx: AnonymousViewCtx, + params: InferTypeOfRow +) => ViewReturn; export function registerView< S extends UntypedSchemaDef, @@ -202,12 +238,3 @@ type ViewInfo = { export type Views = ViewInfo>[]; export type AnonViews = ViewInfo>[]; - -// A helper to get the product type out of a type builder. -// This is only non-never if the type builder is an array. -type ExtractArrayProduct> = - InferSpacetimeTypeOfTypeBuilder extends { tag: 'Array'; value: infer V } - ? V extends { tag: 'Product'; value: infer P } - ? P - : never - : never; diff --git a/crates/bindings-typescript/test-app/src/module_bindings/all_view_pk_players_table.ts b/crates/bindings-typescript/test-app/src/module_bindings/all_view_pk_players_table.ts new file mode 100644 index 00000000000..64085ef60e7 --- /dev/null +++ b/crates/bindings-typescript/test-app/src/module_bindings/all_view_pk_players_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../../src/index'; + +export default __t.row({ + id: __t.u64().primaryKey(), + name: __t.string(), +}); 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 852b010b300..062ba1d4d12 100644 --- a/crates/bindings-typescript/test-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-app/src/module_bindings/index.ts @@ -39,14 +39,39 @@ import CreatePlayerReducer from './create_player_reducer'; // Import all procedure arg schemas // Import all table schema definitions +import AllViewPkPlayersRow from './all_view_pk_players_table'; import PlayerRow from './player_table'; +import SenderViewPkPlayersARow from './sender_view_pk_players_a_table'; +import SenderViewPkPlayersBRow from './sender_view_pk_players_b_table'; import UnindexedPlayerRow from './unindexed_player_table'; import UserRow from './user_table'; +import ViewPkMembershipRow from './view_pk_membership_table'; /** Type-only namespace exports for generated type groups. */ /** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ const tablesSchema = __schema({ + all_view_pk_players: __table( + { + name: 'all_view_pk_players', + indexes: [ + { + accessor: 'id', + name: 'all_view_pk_players_id_idx_btree', + algorithm: 'btree', + columns: ['id'], + }, + ], + constraints: [ + { + name: 'all_view_pk_players_id_key', + constraint: 'unique', + columns: ['id'], + }, + ], + }, + AllViewPkPlayersRow + ), player: __table( { name: 'player', @@ -64,6 +89,48 @@ const tablesSchema = __schema({ }, PlayerRow ), + sender_view_pk_players_a: __table( + { + name: 'sender_view_pk_players_a', + indexes: [ + { + accessor: 'id', + name: 'sender_view_pk_players_a_id_idx_btree', + algorithm: 'btree', + columns: ['id'], + }, + ], + constraints: [ + { + name: 'sender_view_pk_players_a_id_key', + constraint: 'unique', + columns: ['id'], + }, + ], + }, + SenderViewPkPlayersARow + ), + sender_view_pk_players_b: __table( + { + name: 'sender_view_pk_players_b', + indexes: [ + { + accessor: 'id', + name: 'sender_view_pk_players_b_id_idx_btree', + algorithm: 'btree', + columns: ['id'], + }, + ], + constraints: [ + { + name: 'sender_view_pk_players_b_id_key', + constraint: 'unique', + columns: ['id'], + }, + ], + }, + SenderViewPkPlayersBRow + ), unindexed_player: __table( { name: 'unindexed_player', @@ -106,6 +173,33 @@ const tablesSchema = __schema({ }, UserRow ), + view_pk_membership: __table( + { + name: 'view_pk_membership', + indexes: [ + { + accessor: 'id', + name: 'view_pk_membership_id_idx_btree', + algorithm: 'btree', + columns: ['id'], + }, + { + accessor: 'playerId', + name: 'view_pk_membership_player_id_idx_btree', + algorithm: 'btree', + columns: ['playerId'], + }, + ], + constraints: [ + { + name: 'view_pk_membership_id_key', + constraint: 'unique', + columns: ['id'], + }, + ], + }, + ViewPkMembershipRow + ), }); /** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ diff --git a/crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_a_table.ts b/crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_a_table.ts new file mode 100644 index 00000000000..64085ef60e7 --- /dev/null +++ b/crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_a_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../../src/index'; + +export default __t.row({ + id: __t.u64().primaryKey(), + name: __t.string(), +}); diff --git a/crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_b_table.ts b/crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_b_table.ts new file mode 100644 index 00000000000..64085ef60e7 --- /dev/null +++ b/crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_b_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../../src/index'; + +export default __t.row({ + id: __t.u64().primaryKey(), + name: __t.string(), +}); diff --git a/crates/bindings-typescript/test-app/src/module_bindings/view_pk_membership_table.ts b/crates/bindings-typescript/test-app/src/module_bindings/view_pk_membership_table.ts new file mode 100644 index 00000000000..c7cff1bd14f --- /dev/null +++ b/crates/bindings-typescript/test-app/src/module_bindings/view_pk_membership_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../../src/index'; + +export default __t.row({ + id: __t.u64().primaryKey(), + playerId: __t.u64().index().name('player_id'), +}); diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index ec17430e41a..a6f4e628f01 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -15,8 +15,11 @@ import User from '../test-app/src/module_bindings/user_table'; import { anIdentity, bobIdentity, + encodeAllViewPkPlayer, encodePlayer, + encodeSenderViewPkPlayerB, encodeUser, + encodeViewPkMembership, makeQuerySetUpdate, sallyIdentity, } from './utils'; @@ -93,6 +96,18 @@ function getLastSubscribeMessageInfo(wsAdapter: WebsocketTestAdapter): { throw new Error('No Subscribe message found in messageQueue.'); } +function getLastSubscribeQueryStrings( + wsAdapter: WebsocketTestAdapter +): string[] { + for (let i = wsAdapter.outgoingMessages.length - 1; i >= 0; i--) { + const message = wsAdapter.outgoingMessages[i]; + if (message.tag === 'Subscribe') { + return message.value.queryStrings; + } + } + throw new Error('No Subscribe message found in messageQueue.'); +} + function makeReducerResult( requestId: number, reducerQuerySetUpdate: ReturnType @@ -742,6 +757,231 @@ describe('DbConnection', () => { expect(client.db.user.count()).toEqual(1n); }); + test('it calls onUpdate for a primary-key view subscription', async () => { + const wsAdapter = new WebsocketTestAdapter(); + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.createWebSocketFn.bind(wsAdapter) as any) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ); + + client.subscriptionBuilder().subscribe('SELECT * FROM all_view_pk_players'); + + await Promise.resolve(); + expect(getLastSubscribeQueryStrings(wsAdapter)).toEqual([ + 'SELECT * FROM all_view_pk_players', + ]); + + const before = { id: 1n, name: 'before' }; + const after = { id: 1n, name: 'after' }; + + const updates: { oldRow: typeof before; newRow: typeof after }[] = []; + const onUpdatePromise = new Deferred(); + client.db.all_view_pk_players.onUpdate((_ctx, oldRow, newRow) => { + updates.push({ oldRow, newRow }); + onUpdatePromise.resolve(); + }); + + wsAdapter.sendToClient( + ServerMessage.TransactionUpdate({ + querySets: [ + makeQuerySetUpdate( + 0, + 'all_view_pk_players', + encodeAllViewPkPlayer(before) + ), + ], + }) + ); + + wsAdapter.sendToClient( + ServerMessage.TransactionUpdate({ + querySets: [ + makeQuerySetUpdate( + 0, + 'all_view_pk_players', + encodeAllViewPkPlayer(after), + encodeAllViewPkPlayer(before) + ), + ], + }) + ); + + await onUpdatePromise.promise; + + expect(updates).toHaveLength(1); + expect(updates[0]!.oldRow).toEqual(before); + expect(updates[0]!.newRow).toEqual(after); + }); + + test('it calls onUpdate for a query-builder join where the rhs is a primary-key view', async () => { + const wsAdapter = new WebsocketTestAdapter(); + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.createWebSocketFn.bind(wsAdapter) as any) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ); + + client + .subscriptionBuilder() + .subscribe(t => + t.view_pk_membership.rightSemijoin( + t.all_view_pk_players, + (membership, player) => membership.playerId.eq(player.id) + ) + ); + + await Promise.resolve(); + expect(getLastSubscribeQueryStrings(wsAdapter)).toEqual([ + 'SELECT "all_view_pk_players".* FROM "view_pk_membership" JOIN "all_view_pk_players" ON "view_pk_membership"."playerId" = "all_view_pk_players"."id"', + ]); + + const before = { id: 1n, name: 'before' }; + const after = { id: 1n, name: 'after' }; + + const updates: { oldRow: typeof before; newRow: typeof after }[] = []; + const onUpdatePromise = new Deferred(); + client.db.all_view_pk_players.onUpdate((_ctx, oldRow, newRow) => { + updates.push({ oldRow, newRow }); + onUpdatePromise.resolve(); + }); + + wsAdapter.sendToClient( + ServerMessage.TransactionUpdate({ + querySets: [ + makeQuerySetUpdate( + 0, + 'view_pk_membership', + encodeViewPkMembership({ id: 1n, playerId: 1n }) + ), + makeQuerySetUpdate( + 0, + 'all_view_pk_players', + encodeAllViewPkPlayer(before) + ), + ], + }) + ); + + wsAdapter.sendToClient( + ServerMessage.TransactionUpdate({ + querySets: [ + makeQuerySetUpdate( + 0, + 'all_view_pk_players', + encodeAllViewPkPlayer(after), + encodeAllViewPkPlayer(before) + ), + ], + }) + ); + + await onUpdatePromise.promise; + + expect(updates).toHaveLength(1); + expect(updates[0]!.oldRow).toEqual(before); + expect(updates[0]!.newRow).toEqual(after); + }); + + test('it calls onUpdate for a query-builder semijoin between two sender views with primary keys', async () => { + const wsAdapter = new WebsocketTestAdapter(); + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.createWebSocketFn.bind(wsAdapter) as any) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ); + + client + .subscriptionBuilder() + .subscribe(t => + t.sender_view_pk_players_a.rightSemijoin( + t.sender_view_pk_players_b, + (lhsView, rhsView) => lhsView.id.eq(rhsView.id) + ) + ); + + await Promise.resolve(); + expect(getLastSubscribeQueryStrings(wsAdapter)).toEqual([ + 'SELECT "sender_view_pk_players_b".* FROM "sender_view_pk_players_a" JOIN "sender_view_pk_players_b" ON "sender_view_pk_players_a"."id" = "sender_view_pk_players_b"."id"', + ]); + + const before = { id: 1n, name: 'before' }; + const after = { id: 1n, name: 'after' }; + + const updates: { oldRow: typeof before; newRow: typeof after }[] = []; + const onUpdatePromise = new Deferred(); + client.db.sender_view_pk_players_b.onUpdate((_ctx, oldRow, newRow) => { + updates.push({ oldRow, newRow }); + onUpdatePromise.resolve(); + }); + + wsAdapter.sendToClient( + ServerMessage.TransactionUpdate({ + querySets: [ + makeQuerySetUpdate( + 0, + 'sender_view_pk_players_a', + encodeAllViewPkPlayer(before) + ), + makeQuerySetUpdate( + 0, + 'sender_view_pk_players_b', + encodeSenderViewPkPlayerB(before) + ), + ], + }) + ); + + wsAdapter.sendToClient( + ServerMessage.TransactionUpdate({ + querySets: [ + makeQuerySetUpdate( + 0, + 'sender_view_pk_players_b', + encodeSenderViewPkPlayerB(after), + encodeSenderViewPkPlayerB(before) + ), + ], + }) + ); + + await onUpdatePromise.promise; + + expect(updates).toHaveLength(1); + expect(updates[0]!.oldRow).toEqual(before); + expect(updates[0]!.newRow).toEqual(after); + }); + test('Filtering works', async () => { const wsAdapter = new WebsocketTestAdapter(); const client = DbConnection.builder() diff --git a/crates/bindings-typescript/tests/utils.ts b/crates/bindings-typescript/tests/utils.ts index 4219aeb6013..859cccb89bd 100644 --- a/crates/bindings-typescript/tests/utils.ts +++ b/crates/bindings-typescript/tests/utils.ts @@ -2,9 +2,12 @@ import BinaryWriter from '../src/lib/binary_writer'; import { Identity } from '../src/lib/identity'; import type { Infer } from '../src/lib/type_builders'; import { RowSizeHint, TableUpdateRows } from '../src/sdk/client_api/types'; +import AllViewPkPlayersRow from '../test-app/src/module_bindings/all_view_pk_players_table'; import PlayerRow from '../test-app/src/module_bindings/player_table'; +import SenderViewPkPlayersBRow from '../test-app/src/module_bindings/sender_view_pk_players_b_table'; import { Point } from '../test-app/src/module_bindings/types'; import UserRow from '../test-app/src/module_bindings/user_table'; +import ViewPkMembershipRow from '../test-app/src/module_bindings/view_pk_membership_table'; export const anIdentity = Identity.fromString( '0000000000000000000000000000000000000000000000000000000000000069' @@ -28,6 +31,30 @@ export function encodeUser(value: Infer): Uint8Array { return writer.getBuffer(); } +export function encodeAllViewPkPlayer( + value: Infer +): Uint8Array { + const writer = new BinaryWriter(1024); + AllViewPkPlayersRow.serialize(writer, value); + return writer.getBuffer(); +} + +export function encodeSenderViewPkPlayerB( + value: Infer +): Uint8Array { + const writer = new BinaryWriter(1024); + SenderViewPkPlayersBRow.serialize(writer, value); + return writer.getBuffer(); +} + +export function encodeViewPkMembership( + value: Infer +): Uint8Array { + const writer = new BinaryWriter(1024); + ViewPkMembershipRow.serialize(writer, value); + return writer.getBuffer(); +} + export function encodeCreatePlayerArgs( name: string, location: Infer