diff --git a/.changeset/fix-on-demand-write-operations.md b/.changeset/fix-on-demand-write-operations.md new file mode 100644 index 000000000..28d741e92 --- /dev/null +++ b/.changeset/fix-on-demand-write-operations.md @@ -0,0 +1,6 @@ +--- +"@tanstack/db": patch +"@tanstack/query-db-collection": patch +--- + +Fixed `SyncNotInitializedError` being thrown when calling write operations (`writeUpsert`, `writeInsert`, etc.) or mutations (`insert`, `update`, `delete`) on collections before sync is started. Previously, these operations required `startSync: true` to be explicitly set or `preload()` to be called first. Now, sync is automatically started when any write operation or mutation is called on an idle collection, enabling these operations to work immediately without explicit initialization. diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 5616a8ceb..9fa67717e 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -210,9 +210,17 @@ export class CollectionImpl< public id: string public config: CollectionConfig - // Utilities namespace - // This is populated by createCollection - public utils: Record = {} + // Utilities namespace - stored privately, accessed via getter that validates collection state + private _utils: Record = {} + + public get utils(): Record { + this._lifecycle.validateCollectionUsable(`utils`) + return this._utils + } + + public set utils(value: Record) { + this._utils = value + } // Managers private _events: CollectionEventsManager diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 5181ab216..a4a04390e 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -129,8 +129,9 @@ export class CollectionLifecycleManager< switch (this.status) { case `error`: throw new CollectionInErrorStateError(operation, this.id) + case `idle`: case `cleaned-up`: - // Automatically restart the collection when operations are called on cleaned-up collections + // Automatically start sync when operations are called on idle/cleaned-up collections this.sync.startSync() break } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 0c4408646..db0c7f434 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1622,6 +1622,74 @@ describe(`QueryCollection`, () => { expect(collection.has(`1`)).toBe(false) }) + it(`should auto-start sync when write operations are called on idle collections`, async () => { + // This test verifies that write operations automatically start sync + // even when startSync is not explicitly set to true. + // This fixes SyncNotInitializedError when trying to writeUpsert on + // collections that haven't been preloaded yet. + + const queryKey = [`on-demand-write-test`] + const queryFn = vi.fn().mockResolvedValue([]) + + const config: QueryCollectionConfig = { + id: `on-demand-write-collection`, + queryClient, + queryKey, + queryFn, + getKey, + syncMode: `on-demand`, + // Note: startSync is NOT set to true - sync should auto-start on write + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection starts in idle state (sync not started yet) + expect(collection.status).toBe(`idle`) + expect(queryFn).not.toHaveBeenCalled() + + // Write operation should auto-start sync and work without error + const newItem: TestItem = { id: `1`, name: `Item 1`, value: 10 } + collection.utils.writeInsert(newItem) + + // After write, collection should be ready (sync was auto-started) + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(1) + expect(collection.get(`1`)).toEqual(newItem) + + // Test writeUpsert (the specific operation from the bug report) + collection.utils.writeUpsert({ id: `2`, name: `Item 2`, value: 20 }) + + expect(collection.size).toBe(2) + expect(collection.get(`2`)).toEqual({ + id: `2`, + name: `Item 2`, + value: 20, + }) + + // Test writeUpdate + collection.utils.writeUpdate({ id: `1`, name: `Updated Item 1` }) + expect(collection.get(`1`)?.name).toBe(`Updated Item 1`) + + // Test writeDelete + collection.utils.writeDelete(`1`) + expect(collection.size).toBe(1) + expect(collection.has(`1`)).toBe(false) + + // Test writeBatch + collection.utils.writeBatch(() => { + collection.utils.writeInsert({ id: `3`, name: `Item 3`, value: 30 }) + collection.utils.writeUpsert({ id: `4`, name: `Item 4`, value: 40 }) + }) + + expect(collection.size).toBe(3) + expect(collection.get(`3`)?.name).toBe(`Item 3`) + expect(collection.get(`4`)?.name).toBe(`Item 4`) + + // queryFn should still not be called since no data was loaded via loadSubset + expect(queryFn).not.toHaveBeenCalled() + }) + it(`should handle sync method errors appropriately`, async () => { const queryKey = [`sync-error-test`] const initialItems: Array = [{ id: `1`, name: `Item 1` }] @@ -2473,7 +2541,7 @@ describe(`QueryCollection`, () => { await collection.cleanup() }) - it(`should be no-op when sync has not started (no observer created)`, async () => { + it(`should auto-start sync when utils are accessed`, async () => { const queryKey = [`refetch-test-no-sync`] const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) @@ -2488,9 +2556,15 @@ describe(`QueryCollection`, () => { }) ) - // Refetch should be no-op because observer doesn't exist yet + // Collection starts idle + expect(collection.status).toBe(`idle`) + + // Accessing utils auto-starts sync, so refetch will work await collection.utils.refetch() - expect(queryFn).not.toHaveBeenCalled() + + // Sync was started and queryFn was called + expect(collection.status).toBe(`ready`) + expect(queryFn).toHaveBeenCalled() await collection.cleanup() })