Skip to content
Merged
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/neat-queens-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tanstack/electric-db-collection": patch
"@tanstack/db": patch
---

fix disabling of gc by setting `gcTime: 0` on the collection options
7 changes: 6 additions & 1 deletion packages/db/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,12 @@ export class CollectionImpl<
}

const gcTime = this.config.gcTime ?? 300000 // 5 minutes default

// If gcTime is 0, GC is disabled
if (gcTime === 0) {
return
}

this.gcTimeoutId = setTimeout(() => {
if (this.activeSubscribersCount === 0) {
this.cleanup()
Expand Down Expand Up @@ -784,7 +790,6 @@ export class CollectionImpl<
this.activeSubscribersCount--

if (this.activeSubscribersCount === 0) {
this.activeSubscribersCount = 0
this.startGCTimer()
} else if (this.activeSubscribersCount < 0) {
throw new NegativeActiveSubscribersError()
Expand Down
42 changes: 42 additions & 0 deletions packages/db/tests/collection-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,30 @@ describe(`Collection Lifecycle Management`, () => {
unsubscribe3()
expect((collection as any).activeSubscribersCount).toBe(0)
})

it(`should handle rapid subscribe/unsubscribe correctly`, () => {
const collection = createCollection<{ id: string; name: string }>({
id: `rapid-sub-test`,
getKey: (item) => item.id,
gcTime: 1000, // Short GC time for testing
sync: {
sync: () => {},
},
})

// Subscribe and immediately unsubscribe multiple times
for (let i = 0; i < 5; i++) {
const unsubscribe = collection.subscribeChanges(() => {})
expect((collection as any).activeSubscribersCount).toBe(1)
unsubscribe()
expect((collection as any).activeSubscribersCount).toBe(0)

// Should start GC timer each time
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 1000)
}

expect(mockSetTimeout).toHaveBeenCalledTimes(5)
})
})

describe(`Garbage Collection`, () => {
Expand Down Expand Up @@ -325,6 +349,24 @@ describe(`Collection Lifecycle Management`, () => {
// Should use default 5 minutes (300000ms)
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 300000)
})

it(`should disable GC when gcTime is 0`, () => {
const collection = createCollection<{ id: string; name: string }>({
id: `disabled-gc-test`,
getKey: (item) => item.id,
gcTime: 0, // Disabled GC
sync: {
sync: () => {},
},
})

const unsubscribe = collection.subscribeChanges(() => {})
unsubscribe()

// Should not start any timer when GC is disabled
expect(mockSetTimeout).not.toHaveBeenCalled()
expect(collection.status).not.toBe(`cleaned-up`)
})
})

describe(`Manual Preload and Cleanup`, () => {
Expand Down
100 changes: 100 additions & 0 deletions packages/electric-db-collection/tests/electric.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,5 +1028,105 @@ describe(`Electric Integration`, () => {
await expect(testCollection.utils.awaitTxId(300)).resolves.toBe(true)
await expect(testCollection.utils.awaitTxId(400)).resolves.toBe(true)
})

it(`should resync after garbage collection and new subscription`, () => {
// Use fake timers for this test
vi.useFakeTimers()

const config = {
id: `gc-resync-test`,
shapeOptions: {
url: `http://test-url`,
params: {
table: `test_table`,
},
},
getKey: (item: Row) => item.id as number,
startSync: true,
gcTime: 100, // Short GC time for testing
}

const testCollection = createCollection(electricCollectionOptions(config))

// Populate collection with initial data
subscriber([
{
key: `1`,
value: { id: 1, name: `Initial User` },
headers: { operation: `insert` },
},
{
key: `2`,
value: { id: 2, name: `Another User` },
headers: { operation: `insert` },
},
{
headers: { control: `up-to-date` },
},
])

// Verify initial data is present
expect(testCollection.has(1)).toBe(true)
expect(testCollection.has(2)).toBe(true)
expect(testCollection.size).toBe(2)

// Subscribe and then unsubscribe to trigger GC timer
const unsubscribe = testCollection.subscribeChanges(() => {})
unsubscribe()

// Collection should still be ready before GC timer fires
expect(testCollection.status).toBe(`ready`)
expect(testCollection.size).toBe(2)

// Fast-forward time to trigger GC (past the 100ms gcTime)
vi.advanceTimersByTime(150)

// Collection should be cleaned up
expect(testCollection.status).toBe(`cleaned-up`)
expect(testCollection.size).toBe(0)

// Reset mock call count for new subscription
const initialMockCallCount = mockSubscribe.mock.calls.length

// Subscribe again - this should restart the sync
const newUnsubscribe = testCollection.subscribeChanges(() => {})

// Should have created a new stream
expect(mockSubscribe.mock.calls.length).toBe(initialMockCallCount + 1)
expect(testCollection.status).toBe(`loading`)

// Send new data to simulate resync
subscriber([
{
key: `3`,
value: { id: 3, name: `Resynced User` },
headers: { operation: `insert` },
},
{
key: `1`,
value: { id: 1, name: `Updated User` },
headers: { operation: `insert` },
},
{
headers: { control: `up-to-date` },
},
])

// Verify the collection has resynced with new data
expect(testCollection.status).toBe(`ready`)
expect(testCollection.has(1)).toBe(true)
expect(testCollection.has(3)).toBe(true)
expect(testCollection.get(1)).toEqual({ id: 1, name: `Updated User` })
expect(testCollection.get(3)).toEqual({ id: 3, name: `Resynced User` })
expect(testCollection.size).toBe(2)

// Old data should not be present (collection was cleaned)
expect(testCollection.has(2)).toBe(false)

newUnsubscribe()

// Restore real timers
vi.useRealTimers()
})
})
})
Loading