From ef0ca8123eeaadb0ad420b6dbb7906090d34057e Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 5 Mar 2026 10:26:49 -0800 Subject: [PATCH 01/10] Add rust sdk tests exercising on_update for views --- modules/sdk-test-view/src/lib.rs | 37 +++++++++++++++++++++++++- sdks/rust/tests/view-pk-client/LICENSE | 1 + 2 files changed, 37 insertions(+), 1 deletion(-) create mode 120000 sdks/rust/tests/view-pk-client/LICENSE diff --git a/modules/sdk-test-view/src/lib.rs b/modules/sdk-test-view/src/lib.rs index c6e01161210..d33addc0c5d 100644 --- a/modules/sdk-test-view/src/lib.rs +++ b/modules/sdk-test-view/src/lib.rs @@ -1,5 +1,5 @@ use spacetimedb::{ - reducer, table, view, AnonymousViewContext, Identity, ReducerContext, SpacetimeType, Table, ViewContext, + reducer, table, view, AnonymousViewContext, Identity, Query, ReducerContext, SpacetimeType, Table, ViewContext, }; #[table(accessor = player, public)] @@ -36,6 +36,41 @@ struct PlayerAndLevel { level: u64, } +#[table(accessor = view_pk_player, public)] +pub struct ViewPkPlayer { + #[primary_key] + pub id: u64, + pub name: String, +} + +#[table(accessor = view_pk_membership, public)] +pub struct ViewPkMembership { + #[primary_key] + pub id: u64, + #[index(btree)] + pub player_id: u64, +} + +#[reducer] +pub fn insert_view_pk_player(ctx: &ReducerContext, id: u64, name: String) { + ctx.db.view_pk_player().insert(ViewPkPlayer { id, name }); +} + +#[reducer] +pub fn update_view_pk_player(ctx: &ReducerContext, id: u64, name: String) { + ctx.db.view_pk_player().id().update(ViewPkPlayer { id, name }); +} + +#[reducer] +pub fn insert_view_pk_membership(ctx: &ReducerContext, id: u64, player_id: u64) { + ctx.db.view_pk_membership().insert(ViewPkMembership { id, player_id }); +} + +#[view(accessor = all_view_pk_players, public)] +pub fn all_view_pk_players(ctx: &ViewContext) -> impl Query { + ctx.from.view_pk_player() +} + #[reducer] fn insert_player(ctx: &ReducerContext, identity: Identity, level: u64) { let Player { entity_id, .. } = ctx.db.player().insert(Player { entity_id: 0, identity }); diff --git a/sdks/rust/tests/view-pk-client/LICENSE b/sdks/rust/tests/view-pk-client/LICENSE new file mode 120000 index 00000000000..424c4c33df2 --- /dev/null +++ b/sdks/rust/tests/view-pk-client/LICENSE @@ -0,0 +1 @@ +../../../../licenses/BSL.txt \ No newline at end of file From c1101bed81e37f023b8a922041e2bca8801289c8 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 5 Mar 2026 19:54:25 -0800 Subject: [PATCH 02/10] [TS]: Primary keys for query builder views --- crates/bindings-typescript/src/lib/query.ts | 2 +- crates/bindings-typescript/src/lib/schema.ts | 5 + .../src/lib/type_builders.ts | 46 ++++ .../src/server/view.test-d.ts | 16 +- .../bindings-typescript/src/server/views.ts | 73 +++--- .../all_view_pk_players_table.ts | 16 ++ .../test-app/src/module_bindings/index.ts | 94 +++++++ .../sender_view_pk_players_a_table.ts | 16 ++ .../sender_view_pk_players_b_table.ts | 16 ++ .../view_pk_membership_table.ts | 16 ++ .../tests/db_connection.test.ts | 238 ++++++++++++++++++ crates/bindings-typescript/tests/utils.ts | 27 ++ 12 files changed, 524 insertions(+), 41 deletions(-) create mode 100644 crates/bindings-typescript/test-app/src/module_bindings/all_view_pk_players_table.ts create mode 100644 crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_a_table.ts create mode 100644 crates/bindings-typescript/test-app/src/module_bindings/sender_view_pk_players_b_table.ts create mode 100644 crates/bindings-typescript/test-app/src/module_bindings/view_pk_membership_table.ts diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts index e9c6109ccb6..a0d78e43c13 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..68ded4fdbcd 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,40 @@ export class ArrayBuilder> } } +type QueryReturnType> = { + tag: 'Product'; + value: { + elements: [ + { + name: typeof QUERY_VIEW_RETURN_TAG; + algebraicType: InferSpacetimeTypeOfTypeBuilder; + }, + ]; + }; +}; + +export class QueryTypeBuilder> + extends TypeBuilder[], 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 +3907,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 1ce372f3190..c7331655704 100644 --- a/crates/bindings-typescript/src/server/view.test-d.ts +++ b/crates/bindings-typescript/src/server/view.test-d.ts @@ -71,10 +71,10 @@ const spacetime = schema({ personWithMissing, }); -const arrayRetValue = t.array(person.rowType); +const queryRetValue = t.query(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(); }); @@ -106,7 +106,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 +116,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 +126,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 +137,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..92baea1f12c 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, @@ -88,7 +89,41 @@ export type ViewOpts = { public: true; }; -type FlattenedArray = T extends readonly (infer E)[] ? E : never; +type ProceduralViewReturnTypeBuilder = + | TypeBuilder< + readonly object[], + { tag: 'Array'; value: AlgebraicTypeVariants.Product } + > + | TypeBuilder< + object | undefined, + OptionAlgebraicType + >; + +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 ViewReturn = + Ret extends QueryViewReturnTypeBuilder + ? RowTypedQuery, QueryReturnProduct> + : Infer; // // If we allowed functions to return either. // type ViewReturn = @@ -99,33 +134,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 +220,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..9f4d9f88aa4 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,229 @@ 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 From bba5e21d396c6a3b0135dad169ffff43f887c1cc Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 5 Mar 2026 21:38:46 -0800 Subject: [PATCH 03/10] fix lints --- .../src/lib/type_builders.ts | 9 ++++-- .../tests/db_connection.test.ts | 32 ++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/crates/bindings-typescript/src/lib/type_builders.ts b/crates/bindings-typescript/src/lib/type_builders.ts index 68ded4fdbcd..df94587583c 100644 --- a/crates/bindings-typescript/src/lib/type_builders.ts +++ b/crates/bindings-typescript/src/lib/type_builders.ts @@ -1309,9 +1309,12 @@ type QueryReturnType> = { }; }; -export class QueryTypeBuilder> - extends TypeBuilder[], QueryReturnType> -{ +export class QueryTypeBuilder< + Row extends TypeBuilder, +> extends TypeBuilder< + readonly InferTypeOfTypeBuilder[], + QueryReturnType +> { readonly row: Row; constructor(row: Row) { diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index 9f4d9f88aa4..a6f4e628f01 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -775,9 +775,7 @@ describe('DbConnection', () => { }) ); - client - .subscriptionBuilder() - .subscribe('SELECT * FROM all_view_pk_players'); + client.subscriptionBuilder().subscribe('SELECT * FROM all_view_pk_players'); await Promise.resolve(); expect(getLastSubscribeQueryStrings(wsAdapter)).toEqual([ @@ -844,12 +842,14 @@ describe('DbConnection', () => { }) ); - client.subscriptionBuilder().subscribe(t => - t.view_pk_membership.rightSemijoin( - t.all_view_pk_players, - (membership, player) => membership.playerId.eq(player.id) - ) - ); + 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([ @@ -921,12 +921,14 @@ describe('DbConnection', () => { }) ); - client.subscriptionBuilder().subscribe(t => - t.sender_view_pk_players_a.rightSemijoin( - t.sender_view_pk_players_b, - (lhsView, rhsView) => lhsView.id.eq(rhsView.id) - ) - ); + 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([ From d88a0f42010c84199f74a1b83bcfe021d7bf8104 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 5 Mar 2026 21:54:17 -0800 Subject: [PATCH 04/10] No primary key with t.array() --- .../src/server/view.test-d.ts | 10 ++++++ .../bindings-typescript/src/server/views.ts | 36 ++++++++++++++----- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/crates/bindings-typescript/src/server/view.test-d.ts b/crates/bindings-typescript/src/server/view.test-d.ts index c7331655704..473aecadc34 100644 --- a/crates/bindings-typescript/src/server/view.test-d.ts +++ b/crates/bindings-typescript/src/server/view.test-d.ts @@ -72,12 +72,22 @@ const spacetime = schema({ }); const queryRetValue = t.query(person.rowType); +const arrayRetValue = t.array(person.rowType); const optionalPerson = t.option(person.rowType); 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, diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index 92baea1f12c..2650db68475 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -89,15 +89,21 @@ export type ViewOpts = { public: true; }; +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 = - | TypeBuilder< - readonly object[], - { tag: 'Array'; value: AlgebraicTypeVariants.Product } - > - | TypeBuilder< - object | undefined, - OptionAlgebraicType - >; + | ArrayViewReturnTypeBuilder + | OptionViewReturnTypeBuilder; export type QueryViewReturnTypeBuilder = QueryTypeBuilder< TypeBuilder @@ -120,10 +126,22 @@ type QueryReturnProduct = ? 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> - : Infer; + : Ret extends ArrayViewReturnTypeBuilder + ? Infer | LegacyArrayQueryReturn + : Infer; // // If we allowed functions to return either. // type ViewReturn = From 038eb0581d249f3278b4d37e7ae313e32e122473 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Mar 2026 10:20:21 -0700 Subject: [PATCH 05/10] Add typescript variant of view_pk_tests --- modules/sdk-test-view-pk-ts/package.json | 17 +++++ modules/sdk-test-view-pk-ts/src/index.ts | 92 +++++++++++++++++++++++ modules/sdk-test-view-pk-ts/tsconfig.json | 15 ++++ pnpm-workspace.yaml | 1 + sdks/rust/tests/test.rs | 1 + 5 files changed, 126 insertions(+) create mode 100644 modules/sdk-test-view-pk-ts/package.json create mode 100644 modules/sdk-test-view-pk-ts/src/index.ts create mode 100644 modules/sdk-test-view-pk-ts/tsconfig.json diff --git a/modules/sdk-test-view-pk-ts/package.json b/modules/sdk-test-view-pk-ts/package.json new file mode 100644 index 00000000000..ae27725a5fa --- /dev/null +++ b/modules/sdk-test-view-pk-ts/package.json @@ -0,0 +1,17 @@ +{ + "name": "sdk-test-view-pk-ts", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "cargo build -p spacetimedb-standalone && cargo run -p spacetimedb-cli -- build", + "generate-ts": "cargo build -p spacetimedb-standalone && cargo run -p spacetimedb-cli -- generate --lang typescript --out-dir ts-codegen", + "publish": "cargo run -p spacetimedb-cli -- publish" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "spacetimedb": "workspace:^" + } +} diff --git a/modules/sdk-test-view-pk-ts/src/index.ts b/modules/sdk-test-view-pk-ts/src/index.ts new file mode 100644 index 00000000000..e24097080ff --- /dev/null +++ b/modules/sdk-test-view-pk-ts/src/index.ts @@ -0,0 +1,92 @@ +import { schema, t, table } from 'spacetimedb/server'; + +const view_pk_player = table( + { name: 'view_pk_player', public: true }, + { + id: t.u64().primaryKey(), + name: t.string(), + } +); + +const view_pk_membership = table( + { name: 'view_pk_membership', public: true }, + { + id: t.u64().primaryKey(), + player_id: t.u64().index('btree'), + } +); + +const view_pk_membership_secondary = table( + { name: 'view_pk_membership_secondary', public: true }, + { + id: t.u64().primaryKey(), + player_id: t.u64().index('btree'), + } +); + +const spacetimedb = schema({ + view_pk_player, + view_pk_membership, + view_pk_membership_secondary, +}); +export default spacetimedb; + +export const insert_view_pk_player = spacetimedb.reducer( + { id: t.u64(), name: t.string() }, + (ctx, { id, name }) => { + ctx.db.view_pk_player.insert({ id, name }); + } +); + +export const update_view_pk_player = spacetimedb.reducer( + { id: t.u64(), name: t.string() }, + (ctx, { id, name }) => { + ctx.db.view_pk_player.id.update({ id, name }); + } +); + +export const insert_view_pk_membership = spacetimedb.reducer( + { id: t.u64(), player_id: t.u64() }, + (ctx, { id, player_id }) => { + ctx.db.view_pk_membership.insert({ id, player_id }); + } +); + +export const insert_view_pk_membership_secondary = spacetimedb.reducer( + { id: t.u64(), player_id: t.u64() }, + (ctx, { id, player_id }) => { + ctx.db.view_pk_membership_secondary.insert({ id, player_id }); + } +); + +export const all_view_pk_players = spacetimedb.view( + { name: 'all_view_pk_players', public: true }, + t.query(view_pk_player.rowType), + ctx => { + return ctx.from.view_pk_player.build(); + } +); + +export const sender_view_pk_players_a = spacetimedb.view( + { name: 'sender_view_pk_players_a', public: true }, + t.query(view_pk_player.rowType), + ctx => { + return ctx.from.view_pk_membership + .rightSemijoin(ctx.from.view_pk_player, (membership, player) => + membership.player_id.eq(player.id) + ) + .build(); + } +); + +export const sender_view_pk_players_b = spacetimedb.view( + { name: 'sender_view_pk_players_b', public: true }, + t.query(view_pk_player.rowType), + ctx => { + return ctx.from.view_pk_membership_secondary + .rightSemijoin(ctx.from.view_pk_player, (membership, player) => + membership.player_id.eq(player.id) + ) + .build(); + } +); diff --git a/modules/sdk-test-view-pk-ts/tsconfig.json b/modules/sdk-test-view-pk-ts/tsconfig.json new file mode 100644 index 00000000000..40371338766 --- /dev/null +++ b/modules/sdk-test-view-pk-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68ba113176a..cff25d7cbcb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,4 +19,5 @@ packages: - 'modules/sdk-test-connect-disconnect-ts' - 'modules/sdk-test-procedure-ts' - 'modules/sdk-test-ts' + - 'modules/sdk-test-view-pk-ts' - 'docs' diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index ed623c7d36f..d1b417ac360 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -528,3 +528,4 @@ macro_rules! view_pk_tests { } view_pk_tests!(rust_view_pk, ""); +view_pk_tests!(typescript_view_pk, "-ts"); From 714dc69ce0e94ee2458628265da6cd68a774b8e5 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Mar 2026 12:15:29 -0700 Subject: [PATCH 06/10] update pnpm lock file --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 195c53848e4..b30b9d4bdca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,12 @@ importers: specifier: workspace:^ version: link:../../crates/bindings-typescript + modules/sdk-test-view-pk-ts: + dependencies: + spacetimedb: + specifier: workspace:^ + version: link:../../crates/bindings-typescript + templates/angular-ts: dependencies: '@angular/common': From 09f76fe37033cc43f378b39deaecfd6d035067bc Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 10 Mar 2026 21:44:14 -0700 Subject: [PATCH 07/10] regen bindings --- sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs b/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs index f3d316df012..ed211e1c1c3 100644 --- a/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs @@ -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.4 (commit fe987f3e3103528cd95cc86e13fcf206196672c7). +// This was generated using spacetimedb cli version 2.0.4 (commit 3e0bb7e88e80cee747d20ed7dfae64beb61a7558). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; From e641fda964230a87810704f6720253d152ae52f8 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 09:16:13 -0700 Subject: [PATCH 08/10] remove dead module code --- modules/sdk-test-view/src/lib.rs | 37 +------------------------------- 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/modules/sdk-test-view/src/lib.rs b/modules/sdk-test-view/src/lib.rs index d33addc0c5d..c6e01161210 100644 --- a/modules/sdk-test-view/src/lib.rs +++ b/modules/sdk-test-view/src/lib.rs @@ -1,5 +1,5 @@ use spacetimedb::{ - reducer, table, view, AnonymousViewContext, Identity, Query, ReducerContext, SpacetimeType, Table, ViewContext, + reducer, table, view, AnonymousViewContext, Identity, ReducerContext, SpacetimeType, Table, ViewContext, }; #[table(accessor = player, public)] @@ -36,41 +36,6 @@ struct PlayerAndLevel { level: u64, } -#[table(accessor = view_pk_player, public)] -pub struct ViewPkPlayer { - #[primary_key] - pub id: u64, - pub name: String, -} - -#[table(accessor = view_pk_membership, public)] -pub struct ViewPkMembership { - #[primary_key] - pub id: u64, - #[index(btree)] - pub player_id: u64, -} - -#[reducer] -pub fn insert_view_pk_player(ctx: &ReducerContext, id: u64, name: String) { - ctx.db.view_pk_player().insert(ViewPkPlayer { id, name }); -} - -#[reducer] -pub fn update_view_pk_player(ctx: &ReducerContext, id: u64, name: String) { - ctx.db.view_pk_player().id().update(ViewPkPlayer { id, name }); -} - -#[reducer] -pub fn insert_view_pk_membership(ctx: &ReducerContext, id: u64, player_id: u64) { - ctx.db.view_pk_membership().insert(ViewPkMembership { id, player_id }); -} - -#[view(accessor = all_view_pk_players, public)] -pub fn all_view_pk_players(ctx: &ViewContext) -> impl Query { - ctx.from.view_pk_player() -} - #[reducer] fn insert_player(ctx: &ReducerContext, identity: Identity, level: u64) { let Player { entity_id, .. } = ctx.db.player().insert(Player { entity_id: 0, identity }); From f3890936669be3a0bda374afc3eae4ba0478cae6 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 09:22:59 -0700 Subject: [PATCH 09/10] comments --- .../tests/db_connection.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index a6f4e628f01..5aaa2ef0810 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -757,6 +757,21 @@ describe('DbConnection', () => { expect(client.db.user.count()).toEqual(1n); }); + /** + * Subscribe to a query builder view whose underlying table has a primary key. + * Ensures the TypeScript SDK emits an `onUpdate` callback and that the client + * receives the correct old and new rows. + * + * Test: + * 1. Subscribe to: SELECT * FROM all_view_pk_players + * 2. Insert row: (id=1, name="before") + * 3. Update row: (id=1, name="after") + * + * Expect: + * - `onUpdate` is called for PK=1 + * - `oldRow` should be the "before" value + * - `newRow` should be the "after" value + */ test('it calls onUpdate for a primary-key view subscription', async () => { const wsAdapter = new WebsocketTestAdapter(); const client = DbConnection.builder() @@ -824,6 +839,29 @@ describe('DbConnection', () => { expect(updates[0]!.newRow).toEqual(after); }); + /** + * Subscribe to a right semijoin whose rhs is a view with primary key. + * + * Ensures: + * 1. A semijoin subscription involving a view is valid + * 2. The TypeScript SDK emits an `onUpdate` callback and that the client + * receives the correct old and new rows + * + * Query: + * SELECT player.* + * FROM view_pk_membership membership + * JOIN all_view_pk_players player ON membership.player_id = player.id + * + * Test: + * 1. Insert player row (id=1, "before"). + * 2. Insert membership row referencing player_id=1, allowing the semijoin match. + * 3. Update player row to (id=1, "after"). + * + * Expect: + * - `onUpdate` is called for player PK=1 + * - `oldRow` should be the "before" value + * - `newRow` should be the "after" value + */ 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() @@ -903,6 +941,30 @@ describe('DbConnection', () => { expect(updates[0]!.newRow).toEqual(after); }); + /** + * Subscribe to a semijoin between two views with primary keys. + * + * Ensures: + * 1. A semijoin subscription involving a view is valid + * 2. The TypeScript SDK emits an `onUpdate` callback and that the client + * receives the correct old and new rows + * + * Query: + * SELECT b.* + * FROM sender_view_pk_players_a a + * JOIN sender_view_pk_players_b b ON a.id = b.id + * + * Test: + * 1. Insert player row (id=1, "before"). + * 2. Insert membership for sender view A. + * 3. Insert membership for sender view B. + * 4. Update player row to (id=1, "after"). + * + * Expect: + * - `onUpdate` is called for player PK=1 + * - `oldRow` should be the "before" value + * - `newRow` should be the "after" value + */ 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() From 10ceba208d3fae33f91d35cffeb8711f9392f5bb Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 16:46:27 -0700 Subject: [PATCH 10/10] regen bindings --- sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs b/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs index ed211e1c1c3..e9a1d024d45 100644 --- a/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/view-pk-client/src/module_bindings/mod.rs @@ -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.4 (commit 3e0bb7e88e80cee747d20ed7dfae64beb61a7558). +// This was generated using spacetimedb cli version 2.0.4 (commit dfc726be29516b8cdecc651f5c9705026a624a04). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};