From 39c232d8837d330e929c9a82df4c76a5897af21a Mon Sep 17 00:00:00 2001 From: bitmage Date: Mon, 5 Jan 2026 13:48:40 -0700 Subject: [PATCH] add tests, fix prefix parsing, fix empty storage handling --- .../persister-durable-object-storage/index.ts | 7 +- .../__snapshots__/mergeable.test.ts.snap | 90 ++++++++++++ test/unit/persisters/common/mocks.ts | 131 ++++++++++++++++++ test/unit/persisters/mergeable.test.ts | 2 + 4 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/persisters/persister-durable-object-storage/index.ts b/src/persisters/persister-durable-object-storage/index.ts index fdc79264b5b..38b073e7449 100644 --- a/src/persisters/persister-durable-object-storage/index.ts +++ b/src/persisters/persister-durable-object-storage/index.ts @@ -43,7 +43,7 @@ export const createDurableObjectStoragePersister = (( key: string, ): [type: string, ...ids: Ids] | undefined => { if (strStartsWith(key, storagePrefix)) { - const type = slice(key, storagePrefix.length, 1); + const type = slice(key, storagePrefix.length, storagePrefix.length + 1); return type == T || type == V ? [ type, @@ -54,7 +54,7 @@ export const createDurableObjectStoragePersister = (( }; const getPersisted = async (): Promise< - PersistedContent + PersistedContent | undefined > => { const tables: TablesStamp = stampNewObjectWithHash(); const values: ValuesStamp = stampNewObjectWithHash(); @@ -100,6 +100,9 @@ export const createDurableObjectStoragePersister = (( : 0, ), ); + if (Object.keys(tables[0]).length === 0 && Object.keys(values[0]).length === 0) { + return undefined; + } return [tables, values]; }; diff --git a/test/unit/persisters/__snapshots__/mergeable.test.ts.snap b/test/unit/persisters/__snapshots__/mergeable.test.ts.snap index 61cda6b3f94..9bb22a4fc82 100644 --- a/test/unit/persisters/__snapshots__/mergeable.test.ts.snap +++ b/test/unit/persisters/__snapshots__/mergeable.test.ts.snap @@ -662,6 +662,96 @@ exports[`Persists to/from customSynchronizer > saves 2`] = ` ] `; +exports[`Persists to/from durableObjectStorage > autoSaves > delCell 1`] = `undefined`; + +exports[`Persists to/from durableObjectStorage > autoSaves > delValue 1`] = `undefined`; + +exports[`Persists to/from durableObjectStorage > autoSaves > initial 1`] = `undefined`; + +exports[`Persists to/from durableObjectStorage > autoSaves > setTables 1`] = `undefined`; + +exports[`Persists to/from durableObjectStorage > autoSaves > setValues 1`] = `undefined`; + +exports[`Persists to/from durableObjectStorage > loads 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "_", + 4065945599, + ], + }, + "", + 1279994494, + ], + }, + "", + 1293085726, + ], + }, + "", + 4033596827, + ], + [ + { + "v1": [ + 1, + "_", + 4065945599, + ], + }, + "", + 2304392760, + ], +] +`; + +exports[`Persists to/from durableObjectStorage > saves 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + }, + "", + 550994372, + ], + }, + "", + 1072852846, + ], + }, + "", + 1771939739, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + }, + "", + 3877632732, + ], +] +`; + +exports[`Persists to/from durableObjectStorage > saves 2`] = `undefined`; + exports[`Persists to/from file > autoLoads 1`] = ` [ [ diff --git a/test/unit/persisters/common/mocks.ts b/test/unit/persisters/common/mocks.ts index 9f2fcc12fc1..c18e92cf404 100644 --- a/test/unit/persisters/common/mocks.ts +++ b/test/unit/persisters/common/mocks.ts @@ -28,6 +28,7 @@ import { createOpfsPersister, createSessionPersister, } from 'tinybase/persisters/persister-browser'; +import {createDurableObjectStoragePersister} from 'tinybase/persisters/persister-durable-object-storage'; import {createFilePersister} from 'tinybase/persisters/persister-file'; import {createIndexedDbPersister} from 'tinybase/persisters/persister-indexed-db'; import {createRemotePersister} from 'tinybase/persisters/persister-remote'; @@ -778,3 +779,133 @@ export const mockAutomerge: Persistable> = { testMissing: false, testAutoLoad: true, }; + +// Mock DurableObjectStorage - simple Map-based implementation +class MockDurableObjectStorage { + private data = new Map(); + + async get(key: string): Promise; + async get(keys: string[]): Promise>; + async get( + keyOrKeys: string | string[], + ): Promise> { + if (Array.isArray(keyOrKeys)) { + const result = new Map(); + for (const key of keyOrKeys) { + const value = this.data.get(key); + if (value !== undefined) result.set(key, value); + } + return result; + } + return this.data.get(keyOrKeys); + } + + async put(entries: Record): Promise { + for (const [key, value] of Object.entries(entries)) { + this.data.set(key, value); + } + } + + async list(options?: {prefix?: string}): Promise> { + const result = new Map(); + const prefix = options?.prefix ?? ''; + for (const [key, value] of this.data.entries()) { + if (key.startsWith(prefix)) { + result.set(key, value); + } + } + return result; + } + + async delete(key: string): Promise { + return this.data.delete(key); + } + + clear(): void { + this.data.clear(); + } +} + +const STORAGE_PREFIX = 'tinybase_'; +const T = 't'; +const V = 'v'; + +// Key construction matching the persister's format +const constructStorageKey = (type: string, ...ids: string[]) => + STORAGE_PREFIX + type + JSON.stringify(ids).slice(1, -1); + +export const mockDurableObjectStorage: Persistable = { + autoLoadPause: 10, + getLocation: async () => new MockDurableObjectStorage(), + getLocationMethod: ['getStorage', (storage) => storage], + getPersister: ( + store: Store | MergeableStore, + storage: MockDurableObjectStorage, + ) => + createDurableObjectStoragePersister( + store as MergeableStore, + storage as unknown as DurableObjectStorage, + STORAGE_PREFIX, + ), + get: async ( + storage: MockDurableObjectStorage, + ): Promise => { + const entries = await storage.list({prefix: STORAGE_PREFIX}); + if (entries.size > 0) { + return undefined; + } + }, + set: async ( + storage: MockDurableObjectStorage, + content: Content | MergeableContent, + ): Promise => { + // Convert MergeableContent to the key-value format the persister uses + const [[tablesObj, tablesHlc, tablesHash], [valuesObj, valuesHlc, valuesHash]] = + content as MergeableContent; + const entries: Record = {}; + + // Store tables root + entries[constructStorageKey(T)] = [0, tablesHlc, tablesHash]; + + // Process tables + Object.entries(tablesObj).forEach( + ([tableId, [tableObj, tableHlc, tableHash]]: any) => { + entries[constructStorageKey(T, tableId)] = [0, tableHlc, tableHash]; + Object.entries(tableObj).forEach( + ([rowId, [rowObj, rowHlc, rowHash]]: any) => { + entries[constructStorageKey(T, tableId, rowId)] = [ + 0, + rowHlc, + rowHash, + ]; + Object.entries(rowObj).forEach(([cellId, cellStamp]) => { + entries[constructStorageKey(T, tableId, rowId, cellId)] = + cellStamp; + }); + }, + ); + }, + ); + + // Store values root + entries[constructStorageKey(V)] = [0, valuesHlc, valuesHash]; + + // Process values + Object.entries(valuesObj).forEach(([valueId, valueStamp]) => { + entries[constructStorageKey(V, valueId)] = valueStamp; + }); + + await storage.put(entries); + }, + write: async ( + _storage: MockDurableObjectStorage, + _rawContent: any, + ): Promise => { + // Not used for DO storage + }, + del: async (storage: MockDurableObjectStorage): Promise => { + storage.clear(); + }, + testMissing: false, + testAutoLoad: false, +}; diff --git a/test/unit/persisters/mergeable.test.ts b/test/unit/persisters/mergeable.test.ts index e523a0cf04e..8216cd4002c 100644 --- a/test/unit/persisters/mergeable.test.ts +++ b/test/unit/persisters/mergeable.test.ts @@ -15,6 +15,7 @@ import {MERGEABLE_VARIANTS} from './common/databases.ts'; import { getMockDatabases, mockCustomSynchronizer, + mockDurableObjectStorage, mockFile, mockLocalStorage, mockLocalSynchronizer, @@ -40,6 +41,7 @@ describe.each([ ['opfs', mockOpfs], ['localStorage', mockLocalStorage], ['sessionStorage', mockSessionStorage], + ['durableObjectStorage', mockDurableObjectStorage], ['localSynchronizer', mockLocalSynchronizer], ['customSynchronizer', mockCustomSynchronizer], ...getMockDatabases(MERGEABLE_VARIANTS),