Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-on-demand-write-operations.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 11 additions & 3 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,17 @@ export class CollectionImpl<
public id: string
public config: CollectionConfig<TOutput, TKey, TSchema>

// Utilities namespace
// This is populated by createCollection
public utils: Record<string, Fn> = {}
// Utilities namespace - stored privately, accessed via getter that validates collection state
private _utils: Record<string, Fn> = {}

public get utils(): Record<string, Fn> {
this._lifecycle.validateCollectionUsable(`utils`)
return this._utils
}

public set utils(value: Record<string, Fn>) {
this._utils = value
}
Comment on lines -213 to +223
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very strange way to implement this. It seems to be a hack that means that when you access the collection utils at all it will call _lifecycle.validateCollectionUsable and trigger sync to start?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah — it seems like a reasonable approach to me — the other way was to have query collection manually call startSync but the idea here is that if a collection is active in any way to start sync. Which covers a lot of stuff.

Copy link
Collaborator

@samwillis samwillis Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this doesn't fix collection.insert or using a custom mutation (are they also broken?), and will trigger sync to start for any collection implementation that places anything it want on the utils, some of which could very much be intended not to start sync.

I really don't think this is the right approach.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

say a sync engine added a utils.status, accessing that would trigger sync to start.


// Managers
private _events: CollectionEventsManager
Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/collection/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
80 changes: 77 additions & 3 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItem> = {
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 })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the issue was with any mutation, not just the direct writes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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<TestItem> = [{ id: `1`, name: `Item 1` }]
Expand Down Expand Up @@ -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` }])

Expand All @@ -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()
})
Expand Down
Loading