From 7e49cda3e9e42307f41614b6b57790a8a2dfbbdb Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Mon, 2 Oct 2023 18:39:44 +0200 Subject: [PATCH] Fix onUpdate when PK is of type Identity When the primary key is of type Identity we were still doing === comparison by using the Map data structure. This commit introduces a different data structure called OperationsMap which can also use isEqual to compare keys if isEqual function is available --- src/operations_map.ts | 45 ++++++++++ src/spacetimedb.ts | 3 +- tests/spacetimedb_client.test.ts | 125 +++++++++++++++++++++++++- tests/types/user.ts | 145 +++++++++++++++++++++++++++++++ 4 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 src/operations_map.ts create mode 100644 tests/types/user.ts diff --git a/src/operations_map.ts b/src/operations_map.ts new file mode 100644 index 0000000..5511e7f --- /dev/null +++ b/src/operations_map.ts @@ -0,0 +1,45 @@ +export default class OperationsMap { + private items: { key: K; value: V }[] = []; + + private isEqual(a: K, b: K): boolean { + if (a && typeof a === "object" && "isEqual" in a) { + return (a as any).isEqual(b); + } + return a === b; + } + + set(key: K, value: V): void { + const existingIndex = this.items.findIndex(({ key: k }) => + this.isEqual(k, key) + ); + if (existingIndex > -1) { + this.items[existingIndex].value = value; + } else { + this.items.push({ key, value }); + } + } + + get(key: K): V | undefined { + const item = this.items.find(({ key: k }) => this.isEqual(k, key)); + return item ? item.value : undefined; + } + + delete(key: K): boolean { + const existingIndex = this.items.findIndex(({ key: k }) => + this.isEqual(k, key) + ); + if (existingIndex > -1) { + this.items.splice(existingIndex, 1); + return true; + } + return false; + } + + has(key: K): boolean { + return this.items.some(({ key: k }) => this.isEqual(k, key)); + } + + values(): Array { + return this.items.map((i) => i.value); + } +} diff --git a/src/spacetimedb.ts b/src/spacetimedb.ts index 5c5af44..c06feae 100644 --- a/src/spacetimedb.ts +++ b/src/spacetimedb.ts @@ -31,6 +31,7 @@ import { TableRowOperation_OperationType, } from "./client_api"; import BinaryReader from "./binary_reader"; +import OperationsMap from "./operations_map"; export { ProductValue, @@ -169,7 +170,7 @@ class Table { if (this.entityClass.primaryKey !== undefined) { const pkName = this.entityClass.primaryKey; const inserts: any[] = []; - const deleteMap = new Map(); + const deleteMap = new OperationsMap(); for (const dbOp of dbOps) { if (dbOp.type === "insert") { inserts.push(dbOp); diff --git a/tests/spacetimedb_client.test.ts b/tests/spacetimedb_client.test.ts index 32abb05..615f70d 100644 --- a/tests/spacetimedb_client.test.ts +++ b/tests/spacetimedb_client.test.ts @@ -2,6 +2,7 @@ import { SpacetimeDBClient, ReducerEvent } from "../src/spacetimedb"; import { Identity } from "../src/identity"; import WebsocketTestAdapter from "../src/websocket_test_adapter"; import Player from "./types/player"; +import User from "./types/user"; import Point from "./types/point"; import CreatePlayerReducer from "./types/create_player_reducer"; @@ -283,7 +284,7 @@ describe("SpacetimeDBClient", () => { { op: "delete", row_pk: "abcdef", - row: ["player-2", "Jamie", [0, 0]], + row: ["player-2", "Jaime", [0, 0]], }, { op: "insert", @@ -299,7 +300,7 @@ describe("SpacetimeDBClient", () => { wsAdapter.sendToClient({ data: transactionUpdate }); expect(updates).toHaveLength(2); - expect(updates[1]["oldPlayer"].name).toBe("Jamie"); + expect(updates[1]["oldPlayer"].name).toBe("Jaime"); expect(updates[1]["newPlayer"].name).toBe("Kingslayer"); }); @@ -364,4 +365,124 @@ describe("SpacetimeDBClient", () => { expect(callbackLog).toEqual(["Player", "CreatePlayerReducer"]); }); + + test("it calls onUpdate callback when a record is added with a subscription update and then with a transaction update when the PK is of type Identity", async () => { + const client = new SpacetimeDBClient( + "ws://127.0.0.1:1234", + "db", + undefined, + "json" + ); + const wsAdapter = new WebsocketTestAdapter(); + client._setCreateWSFn((_url: string, _protocol: string) => { + return wsAdapter; + }); + + let called = false; + client.onConnect(() => { + called = true; + }); + + await client.connect(); + wsAdapter.acceptConnection(); + + const tokenMessage = { + data: { + IdentityToken: { + identity: "an-identity", + token: "a-token", + }, + }, + }; + wsAdapter.sendToClient(tokenMessage); + + const updates: { oldUser: User; newUser: User }[] = []; + User.onUpdate((oldUser: User, newUser: User) => { + updates.push({ + oldUser, + newUser, + }); + }); + + const subscriptionMessage = { + SubscriptionUpdate: { + table_updates: [ + { + table_id: 35, + table_name: "User", + table_row_operations: [ + { + op: "delete", + row_pk: "abcd123", + row: [ + "41db74c20cdda916dd2637e5a11b9f31eb1672249aa7172f7e22b4043a6a9008", + "drogus", + ], + }, + { + op: "insert", + row_pk: "def456", + row: [ + "41db74c20cdda916dd2637e5a11b9f31eb1672249aa7172f7e22b4043a6a9008", + "mr.drogus", + ], + }, + ], + }, + ], + }, + }; + wsAdapter.sendToClient({ data: subscriptionMessage }); + + expect(updates).toHaveLength(1); + expect(updates[0]["oldUser"].username).toBe("drogus"); + expect(updates[0]["newUser"].username).toBe("mr.drogus"); + + const transactionUpdate = { + TransactionUpdate: { + event: { + timestamp: 1681391805281203, + status: "committed", + caller_identity: "identity-0", + function_call: { + reducer: "create_user", + args: '["A User",[0.2, 0.3]]', + }, + energy_quanta_used: 33841000, + message: "", + }, + subscription_update: { + table_updates: [ + { + table_id: 35, + table_name: "User", + table_row_operations: [ + { + op: "delete", + row_pk: "abcdef", + row: [ + "11db74c20cdda916dd2637e5a11b9f31eb1672249aa7172f7e22b4043a6a9008", + "jaime", + ], + }, + { + op: "insert", + row_pk: "123456", + row: [ + "11db74c20cdda916dd2637e5a11b9f31eb1672249aa7172f7e22b4043a6a9008", + "kingslayer", + ], + }, + ], + }, + ], + }, + }, + }; + wsAdapter.sendToClient({ data: transactionUpdate }); + + expect(updates).toHaveLength(2); + expect(updates[1]["oldUser"].username).toBe("jaime"); + expect(updates[1]["newUser"].username).toBe("kingslayer"); + }); }); diff --git a/tests/types/user.ts b/tests/types/user.ts new file mode 100644 index 0000000..f88b13f --- /dev/null +++ b/tests/types/user.ts @@ -0,0 +1,145 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +// @ts-ignore +import { + __SPACETIMEDB__, + AlgebraicType, + ProductType, + BuiltinType, + ProductTypeElement, + SumType, + SumTypeVariant, + IDatabaseTable, + AlgebraicValue, + ReducerEvent, + Identity, +} from "../../src/index"; + +export class User extends IDatabaseTable { + public static tableName = "User"; + public identity: Identity; + public username: string; + + public static primaryKey: string | undefined = "identity"; + + constructor(identity: Identity, username: string) { + super(); + this.identity = identity; + this.username = username; + } + + public static serialize(value: User): object { + return [Array.from(value.identity.toUint8Array()), value.username]; + } + + public static getAlgebraicType(): AlgebraicType { + return AlgebraicType.createProductType([ + new ProductTypeElement( + "identity", + AlgebraicType.createProductType([ + new ProductTypeElement( + "__identity_bytes", + AlgebraicType.createArrayType( + AlgebraicType.createPrimitiveType(BuiltinType.Type.U8) + ) + ), + ]) + ), + new ProductTypeElement( + "username", + AlgebraicType.createPrimitiveType(BuiltinType.Type.String) + ), + ]); + } + + public static fromValue(value: AlgebraicValue): User { + let productValue = value.asProductValue(); + let __identity = new Identity( + productValue.elements[0].asProductValue().elements[0].asBytes() + ); + let __username = productValue.elements[1].asString(); + return new this(__identity, __username); + } + + public static count(): number { + return __SPACETIMEDB__.clientDB.getTable("User").count(); + } + + public static all(): User[] { + return __SPACETIMEDB__.clientDB + .getTable("User") + .getInstances() as unknown as User[]; + } + + public static filterByIdentity(value: Identity): User | null { + for (let instance of __SPACETIMEDB__.clientDB + .getTable("User") + .getInstances()) { + if (instance.identity.isEqual(value)) { + return instance; + } + } + return null; + } + + public static filterByUsername(value: string): User[] { + let result: User[] = []; + for (let instance of __SPACETIMEDB__.clientDB + .getTable("User") + .getInstances()) { + if (instance.username === value) { + result.push(instance); + } + } + return result; + } + + public static onInsert( + callback: (value: User, reducerEvent: ReducerEvent | undefined) => void + ) { + __SPACETIMEDB__.clientDB.getTable("User").onInsert(callback); + } + + public static onUpdate( + callback: ( + oldValue: User, + newValue: User, + reducerEvent: ReducerEvent | undefined + ) => void + ) { + __SPACETIMEDB__.clientDB.getTable("User").onUpdate(callback); + } + + public static onDelete( + callback: (value: User, reducerEvent: ReducerEvent | undefined) => void + ) { + __SPACETIMEDB__.clientDB.getTable("User").onDelete(callback); + } + + public static removeOnInsert( + callback: (value: User, reducerEvent: ReducerEvent | undefined) => void + ) { + __SPACETIMEDB__.clientDB.getTable("User").removeOnInsert(callback); + } + + public static removeOnUpdate( + callback: ( + oldValue: User, + newValue: User, + reducerEvent: ReducerEvent | undefined + ) => void + ) { + __SPACETIMEDB__.clientDB.getTable("User").removeOnUpdate(callback); + } + + public static removeOnDelete( + callback: (value: User, reducerEvent: ReducerEvent | undefined) => void + ) { + __SPACETIMEDB__.clientDB.getTable("User").removeOnDelete(callback); + } +} + +export default User; + +__SPACETIMEDB__.registerComponent("User", User);