From fb95dadde74db625b6e66e2e2c8152c76a9d29ea Mon Sep 17 00:00:00 2001 From: Christian Lentfort <1284808+clentfort@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:49:04 +0100 Subject: [PATCH 1/2] test(partykit): add persistence integration tests Add a PartyKit client/server integration test harness that verifies add and update persistence, including custom path and prefix settings. Keep a delete assertion that currently fails to capture issue #281 behavior in server storage. --- test/unit/persisters/partykit.test.ts | 275 ++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 test/unit/persisters/partykit.test.ts diff --git a/test/unit/persisters/partykit.test.ts b/test/unit/persisters/partykit.test.ts new file mode 100644 index 0000000000..adc7935c00 --- /dev/null +++ b/test/unit/persisters/partykit.test.ts @@ -0,0 +1,275 @@ +import type {PartySocket} from 'partysocket'; +import type {Store} from 'tinybase'; +import {createStore} from 'tinybase'; +import type { + PartyKitPersister, + PartyKitPersisterConfig, +} from 'tinybase/persisters/persister-partykit-client'; +import {createPartyKitPersister} from 'tinybase/persisters/persister-partykit-client'; +import type {TinyBasePartyKitServerConfig} from 'tinybase/persisters/persister-partykit-server'; +import { + TinyBasePartyKitServer, + hasStoreInStorage, + loadStoreFromStorage, +} from 'tinybase/persisters/persister-partykit-server'; +import {afterEach, describe, expect, test} from 'vitest'; +import {pause} from '../common/other.ts'; + +type StorageValue = string | number | boolean; +type MessageListener = (event: MessageEvent) => void; + +type MockStorage = { + get: (key: string) => Promise; + list: () => Promise>; + put: (entries: {[key: string]: StorageValue}) => Promise; + delete: (keys: string[]) => Promise; +}; + +type MockSocket = PartySocket & { + receive: (message: string) => void; +}; + +type MockEnvironment = { + storage: MockStorage; + server: TinyBasePartyKitServer; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + createSocket: () => MockSocket; +}; + +const createMockStorage = (): MockStorage => { + const map = new Map(); + return { + get: async (key: string): Promise => + map.get(key) as Value | undefined, + list: async (): Promise> => + map as unknown as Map, + put: async (entries: {[key: string]: StorageValue}): Promise => { + Object.entries(entries).forEach(([key, value]) => map.set(key, value)); + }, + delete: async (keys: string[]): Promise => { + keys.forEach((key) => map.delete(key)); + }, + }; +}; + +const createMockEnvironment = ( + config: TinyBasePartyKitServerConfig = {}, +): MockEnvironment => { + const storage = createMockStorage(); + const sockets = new Map(); + const party = { + storage, + broadcast: async (message: string, without: string[] = []): Promise => + sockets.forEach((socket, socketId) => + without.includes(socketId) ? 0 : socket.receive(message), + ), + }; + const server = new TinyBasePartyKitServer(party as any); + Object.assign(server.config, config); + + const createSocket = (): MockSocket => { + const id = 'c' + sockets.size; + const listeners = new Set(); + const socket = { + name: 'tinybase', + partySocketOptions: {host: 'localhost:1999', room: 'room1'}, + send: (message: string): void => { + void server.onMessage(message, {id} as any); + }, + addEventListener: (_type: string, listener: MessageListener): void => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: MessageListener): void => { + listeners.delete(listener); + }, + receive: (message: string): void => { + listeners.forEach((listener) => + listener({data: message} as MessageEvent), + ); + }, + } as unknown as MockSocket; + sockets.set(id, socket); + return socket; + }; + + return { + storage, + server, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request ? input : new Request(String(input), init); + return await server.onRequest(request as any); + }, + createSocket, + }; +}; + +const createClient = ( + environment: MockEnvironment, + config: PartyKitPersisterConfig = {}, +): [Store, PartyKitPersister] => { + const store = createStore(); + return [ + store, + createPartyKitPersister(store, environment.createSocket(), { + storeProtocol: 'http', + ...config, + }), + ]; +}; + +describe('PartyKit persister integration', () => { + let fetchWas: typeof fetch; + const persisters: PartyKitPersister[] = []; + + afterEach(async () => { + await Promise.all( + persisters.splice(0).map((persister) => persister.destroy()), + ); + globalThis.fetch = fetchWas; + }); + + test('syncs two clients and updates durable storage', async () => { + const environment = createMockEnvironment(); + fetchWas = globalThis.fetch; + globalThis.fetch = environment.fetch as typeof fetch; + + const [store1, persister1] = createClient(environment); + const [store2, persister2] = createClient(environment); + persisters.push(persister1, persister2); + + await persister1.startAutoPersisting(); + await persister2.startAutoPersisting(); + + store1.setCell('pets', 'fido', 'species', 'dog'); + store1.setValue('open', true); + await pause(); + + store2.setCell('pets', 'fido', 'species', 'cat'); + store2.setCell('pets', 'fido', 'age', 5); + store2.setValue('open', false); + await pause(); + + const expectedContent = [ + {pets: {fido: {species: 'cat', age: 5}}}, + {open: false}, + ]; + expect(store1.getContent()).toEqual(expectedContent); + expect(store2.getContent()).toEqual(expectedContent); + expect(await loadStoreFromStorage(environment.storage as any)).toEqual( + expectedContent, + ); + expect(await hasStoreInStorage(environment.storage as any)).toEqual(true); + }); + + test('supports non-default server and client configuration', async () => { + const environment = createMockEnvironment({ + storePath: '/my_store', + messagePrefix: 'tb:', + storagePrefix: 'tb_', + }); + fetchWas = globalThis.fetch; + globalThis.fetch = environment.fetch as typeof fetch; + + const [store1, persister1] = createClient(environment, { + storePath: '/my_store', + messagePrefix: 'tb:', + }); + const [store2, persister2] = createClient(environment, { + storePath: '/my_store', + messagePrefix: 'tb:', + }); + persisters.push(persister1, persister2); + + await persister1.startAutoPersisting(); + await persister2.startAutoPersisting(); + + store1.setCell('pets', 'fido', 'species', 'dog'); + await pause(); + store2.setCell('pets', 'fido', 'species', 'cat'); + await pause(); + + expect(store1.getTables()).toEqual({pets: {fido: {species: 'cat'}}}); + expect(store2.getTables()).toEqual({pets: {fido: {species: 'cat'}}}); + expect(await hasStoreInStorage(environment.storage as any, 'tb_')).toEqual( + true, + ); + expect( + await loadStoreFromStorage(environment.storage as any, 'tb_'), + ).toEqual([{pets: {fido: {species: 'cat'}}}, {}]); + }); + + test('persists additions to server storage', async () => { + const environment = createMockEnvironment(); + fetchWas = globalThis.fetch; + globalThis.fetch = environment.fetch as typeof fetch; + + const [store1, persister1] = createClient(environment); + persisters.push(persister1); + await persister1.startAutoPersisting(); + + store1.setCell('pets', 'fido', 'species', 'dog'); + store1.setCell('pets', 'fido', 'age', 4); + store1.setValue('open', true); + await pause(); + + expect(store1.getContent()).toEqual([ + {pets: {fido: {species: 'dog', age: 4}}}, + {open: true}, + ]); + expect(await loadStoreFromStorage(environment.storage as any)).toEqual([ + {pets: {fido: {species: 'dog', age: 4}}}, + {open: true}, + ]); + }); + + test('persists updates to server storage', async () => { + const environment = createMockEnvironment(); + fetchWas = globalThis.fetch; + globalThis.fetch = environment.fetch as typeof fetch; + + const [store1, persister1] = createClient(environment); + persisters.push(persister1); + await persister1.startAutoPersisting(); + + store1.setCell('pets', 'fido', 'species', 'dog'); + store1.setCell('pets', 'fido', 'age', 4); + store1.setValue('open', true); + await pause(); + + store1.setCell('pets', 'fido', 'species', 'cat'); + store1.setCell('pets', 'fido', 'age', 5); + store1.setValue('open', false); + await pause(); + + expect(store1.getContent()).toEqual([ + {pets: {fido: {species: 'cat', age: 5}}}, + {open: false}, + ]); + expect(await loadStoreFromStorage(environment.storage as any)).toEqual([ + {pets: {fido: {species: 'cat', age: 5}}}, + {open: false}, + ]); + }); + + test('persists deletions to server storage', async () => { + const environment = createMockEnvironment(); + fetchWas = globalThis.fetch; + globalThis.fetch = environment.fetch as typeof fetch; + + const [store1, persister1] = createClient(environment); + persisters.push(persister1); + await persister1.startAutoPersisting(); + + store1.setCell('feeding-sessions', '1767445910545', 'durationInSeconds', 2); + await pause(); + store1.delRow('feeding-sessions', '1767445910545'); + await pause(); + + expect(store1.getContent()).toEqual([{}, {}]); + expect(await loadStoreFromStorage(environment.storage as any)).toEqual([ + {}, + {}, + ]); + }); +}); From 774c6077877c261d25672ec16cbbabf14d525d26 Mon Sep 17 00:00:00 2001 From: Christian Lentfort <1284808+clentfort@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:18:41 +0100 Subject: [PATCH 2/2] fix(partykit): preserve delete tombstones Encode PartyKit messages with undefined-aware JSON and decode them without using a reviver so tombstone keys are preserved. This keeps delete changes intact across transport instead of dropping row and cell deletions during parse. Fixes: https://github.com/tinyplex/tinybase/issues/281 --- src/common/json.ts | 37 ++++++++++++++++++++++++++++--- src/persisters/common/partykit.ts | 13 ++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/common/json.ts b/src/common/json.ts index 8139283ff6..63b0214071 100644 --- a/src/common/json.ts +++ b/src/common/json.ts @@ -1,5 +1,5 @@ import {object} from './obj.ts'; -import {isInstanceOf, isUndefined} from './other.ts'; +import {isArray, isInstanceOf, isUndefined} from './other.ts'; import {UNDEFINED} from './strings.ts'; export const jsonString = JSON.stringify; @@ -13,5 +13,36 @@ export const jsonStringWithMap = (obj: unknown): string => export const jsonStringWithUndefined = (obj: unknown): string => jsonString(obj, (_key, value) => (isUndefined(value) ? UNDEFINED : value)); -export const jsonParseWithUndefined = (str: string): any => - jsonParse(str, (_key, value) => (value === UNDEFINED ? undefined : value)); +const replaceUndefinedString = (obj: any): any => { + if (obj === UNDEFINED) { + return undefined; + } + if (isArray(obj)) { + return obj.map(replaceUndefinedString); + } + if (isInstanceOf(obj, Object)) { + object + .entries(obj) + .forEach( + ([key, value]) => ((obj as any)[key] = replaceUndefinedString(value)), + ); + } + return obj; +}; + +export const jsonParseWithUndefined = (str: string): any => { + // Do not use a JSON.parse reviver for this mapping. It removes properties + // with undefined values, which is not what we want. + // + // That would remove tombstone keys such as {rowId: undefined} and break + // delete propagation. + // + // > If the reviver function returns undefined (or returns no value - for + // > example, if execution falls off the end of the function), the property is + // > deleted from the object." + // See + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#the_reviver_parameter + // Related bug report: + // https://github.com/tinyplex/tinybase/issues/281 + return replaceUndefinedString(jsonParse(str)); +}; diff --git a/src/persisters/common/partykit.ts b/src/persisters/common/partykit.ts index e3c534c03b..c151760103 100644 --- a/src/persisters/common/partykit.ts +++ b/src/persisters/common/partykit.ts @@ -1,4 +1,7 @@ -import {jsonParse, jsonStringWithMap} from '../../common/json.ts'; +import { + jsonParseWithUndefined, + jsonStringWithUndefined, +} from '../../common/json.ts'; import {isString, size, slice} from '../../common/other.ts'; import {T, V, strStartsWith} from '../../common/strings.ts'; @@ -15,7 +18,9 @@ export const construct = ( type: MessageType | StorageKeyType, payload: any, ): string => - prefix + type + (isString(payload) ? payload : jsonStringWithMap(payload)); + prefix + + type + + (isString(payload) ? payload : jsonStringWithUndefined(payload)); export const deconstruct = ( prefix: string, @@ -26,7 +31,9 @@ export const deconstruct = ( return strStartsWith(message, prefix) ? [ message[prefixSize] as MessageType | StorageKeyType, - (stringified ? jsonParse : String)(slice(message, prefixSize + 1)), + (stringified ? jsonParseWithUndefined : String)( + slice(message, prefixSize + 1), + ), ] : undefined; };