diff --git a/.changeset/collection-lifecycle-management.md b/.changeset/collection-lifecycle-management.md new file mode 100644 index 0000000000..e4318c701f --- /dev/null +++ b/.changeset/collection-lifecycle-management.md @@ -0,0 +1,30 @@ +--- +"@tanstack/db": patch +"@tanstack/vue-db": patch +"@tanstack/react-db": patch +"@tanstack/db-collections": patch +--- + +feat: implement Collection Lifecycle Management + +Adds automatic lifecycle management for collections to optimize resource usage. + +**New Features:** + +- Added `startSync` option (defaults to `false`, set to `true` to start syncing immediately) +- Automatic garbage collection after `gcTime` (default 5 minutes) of inactivity +- Collection status tracking: "idle" | "loading" | "ready" | "error" | "cleaned-up" +- Manual `preload()` and `cleanup()` methods for lifecycle control + +**Usage:** + +```typescript +const collection = createCollection({ + startSync: false, // Enable lazy loading + gcTime: 300000, // Cleanup timeout (default: 5 minutes) +}) + +console.log(collection.status) // Current state +await collection.preload() // Ensure ready +await collection.cleanup() // Manual cleanup +``` diff --git a/packages/db-collections/src/electric.ts b/packages/db-collections/src/electric.ts index 18ba169c3c..61af14eae9 100644 --- a/packages/db-collections/src/electric.ts +++ b/packages/db-collections/src/electric.ts @@ -290,14 +290,30 @@ function createElectricSync( } } + // Abort controller for the stream - wraps the signal if provided + const abortController = new AbortController() + if (shapeOptions.signal) { + shapeOptions.signal.addEventListener(`abort`, () => { + abortController.abort() + }) + if (shapeOptions.signal.aborted) { + abortController.abort() + } + } + + let unsubscribeStream: () => void + return { sync: (params: Parameters[`sync`]>[0]) => { const { begin, write, commit } = params - const stream = new ShapeStream(shapeOptions) + const stream = new ShapeStream({ + ...shapeOptions, + signal: abortController.signal, + }) let transactionStarted = false let newTxids = new Set() - stream.subscribe((messages: Array>) => { + unsubscribeStream = stream.subscribe((messages: Array>) => { let hasUpToDate = false for (const message of messages) { @@ -338,8 +354,14 @@ function createElectricSync( } } - if (hasUpToDate && transactionStarted) { - commit() + if (hasUpToDate) { + // Commit transaction if one was started + if (transactionStarted) { + commit() + transactionStarted = false + } + + // Always commit txids when we receive up-to-date, regardless of transaction state seenTxids.setState((currentTxids) => { const clonedSeen = new Set(currentTxids) newTxids.forEach((txid) => clonedSeen.add(String(txid))) @@ -347,9 +369,16 @@ function createElectricSync( newTxids = new Set() return clonedSeen }) - transactionStarted = false } }) + + // Return the unsubscribe function + return () => { + // Unsubscribe from the stream + unsubscribeStream() + // Abort the abort controller to stop the stream + abortController.abort() + } }, // Expose the getSyncMetadata function getSyncMetadata, diff --git a/packages/db-collections/src/query.ts b/packages/db-collections/src/query.ts index 6b628afe77..3fdfa4cd50 100644 --- a/packages/db-collections/src/query.ts +++ b/packages/db-collections/src/query.ts @@ -57,6 +57,7 @@ export interface QueryCollectionConfig< getKey: CollectionConfig[`getKey`] schema?: CollectionConfig[`schema`] sync?: CollectionConfig[`sync`] + startSync?: CollectionConfig[`startSync`] // Direct persistence handlers /** @@ -250,7 +251,11 @@ export function queryCollectionOptions< } }) - return actualUnsubscribeFn + return async () => { + actualUnsubscribeFn() + await queryClient.cancelQueries({ queryKey }) + queryClient.removeQueries({ queryKey }) + } } /** diff --git a/packages/db-collections/tests/electric.test.ts b/packages/db-collections/tests/electric.test.ts index ddce663ac5..c83135dd5b 100644 --- a/packages/db-collections/tests/electric.test.ts +++ b/packages/db-collections/tests/electric.test.ts @@ -47,6 +47,7 @@ describe(`Electric Integration`, () => { table: `test_table`, }, }, + startSync: true, getKey: (item: Row) => item.id as number, } @@ -532,6 +533,7 @@ describe(`Electric Integration`, () => { table: `test_table`, }, }, + startSync: true, getKey: (item: Row) => item.id as number, onInsert, } @@ -561,4 +563,391 @@ describe(`Electric Integration`, () => { expect(testCollection.syncedData.size).toEqual(1) }) }) + + // Tests for Electric stream lifecycle management + describe(`Electric stream lifecycle management`, () => { + let mockUnsubscribe: ReturnType + let mockAbortController: { + abort: ReturnType + signal: AbortSignal + } + + beforeEach(() => { + // Clear all mocks before each lifecycle test + vi.clearAllMocks() + + // Reset mocks before each test + mockUnsubscribe = vi.fn() + mockAbortController = { + abort: vi.fn(), + signal: new AbortController().signal, + } + + // Update the mock to return our mock unsubscribe function + mockSubscribe.mockImplementation((callback) => { + subscriber = callback + return mockUnsubscribe + }) + + // Mock AbortController + global.AbortController = vi + .fn() + .mockImplementation(() => mockAbortController) + }) + + it(`should call unsubscribe and abort when collection is cleaned up`, async () => { + const config = { + id: `cleanup-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Verify stream is set up + expect(mockSubscribe).toHaveBeenCalled() + + // Cleanup the collection + await testCollection.cleanup() + + // Verify that both unsubscribe and abort were called + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + expect(mockAbortController.abort).toHaveBeenCalledTimes(1) + }) + + it(`should properly cleanup Electric-specific resources`, async () => { + const config = { + id: `resource-cleanup-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Add some txids to track + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test` }, + headers: { + operation: `insert`, + txids: [100, 200], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Verify txids are tracked + await expect(testCollection.utils.awaitTxId(`100`)).resolves.toBe(true) + + // Cleanup collection + await testCollection.cleanup() + + // Verify cleanup was called + expect(mockUnsubscribe).toHaveBeenCalled() + expect(mockAbortController.abort).toHaveBeenCalled() + }) + + it(`should handle multiple cleanup calls gracefully`, async () => { + const config = { + id: `multiple-cleanup-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Call cleanup multiple times + await testCollection.cleanup() + await testCollection.cleanup() + await testCollection.cleanup() + + // Should only call unsubscribe once (from the first cleanup) + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + expect(mockAbortController.abort).toHaveBeenCalledTimes(1) + }) + + it(`should restart stream when collection is accessed after cleanup`, async () => { + const config = { + id: `restart-stream-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Initial stream setup + expect(mockSubscribe).toHaveBeenCalledTimes(1) + + // Cleanup + await testCollection.cleanup() + expect(testCollection.status).toBe(`cleaned-up`) + + // Access collection data to restart sync + const unsubscribe = testCollection.subscribeChanges(() => {}) + + // Should have started a new stream + expect(mockSubscribe).toHaveBeenCalledTimes(2) + expect(testCollection.status).toBe(`loading`) + + unsubscribe() + }) + + it(`should handle stream errors gracefully`, () => { + const config = { + id: `error-handling-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + // Mock stream to throw an error + mockSubscribe.mockImplementation(() => { + throw new Error(`Stream connection failed`) + }) + + expect(() => { + createCollection(electricCollectionOptions(config)) + }).toThrow(`Stream connection failed`) + }) + + it(`should handle subscriber function errors without breaking`, () => { + const config = { + id: `subscriber-error-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Mock console.error to capture error logs + const consoleSpy = vi.spyOn(console, `error`).mockImplementation(() => {}) + + // Send messages with invalid data that might cause internal errors + // but shouldn't break the entire system + expect(() => { + subscriber([ + { + key: `1`, + value: { id: 1, name: `Valid User` }, // Use valid data + headers: { operation: `insert` }, + }, + ]) + }).not.toThrow() + + // Should have processed the valid message without issues + expect(testCollection.syncedData.size).toBe(0) // Still pending until up-to-date + + // Send up-to-date to commit + expect(() => { + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + }).not.toThrow() + + // Now the data should be committed + expect(testCollection.has(1)).toBe(true) + + consoleSpy.mockRestore() + }) + + it(`should properly handle concurrent stream operations`, async () => { + const config = { + id: `concurrent-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Simulate concurrent messages + const promises = [ + new Promise((resolve) => { + setTimeout(() => { + subscriber([ + { + key: `1`, + value: { id: 1, name: `User 1` }, + headers: { operation: `insert` }, + }, + ]) + resolve() + }, 10) + }), + new Promise((resolve) => { + setTimeout(() => { + subscriber([ + { + key: `2`, + value: { id: 2, name: `User 2` }, + headers: { operation: `insert` }, + }, + ]) + resolve() + }, 20) + }), + new Promise((resolve) => { + setTimeout(() => { + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + resolve() + }, 30) + }), + ] + + await Promise.all(promises) + + // Both items should be in the collection + expect(testCollection.has(1)).toBe(true) + expect(testCollection.has(2)).toBe(true) + }) + + it(`should handle schema information extraction from messages`, () => { + const config = { + id: `schema-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send message with schema information + subscriber([ + { + key: `1`, + value: { id: 1, name: `User 1` }, + headers: { + operation: `insert`, + schema: `custom_schema`, + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Schema should be stored and used in sync metadata + // This is internal behavior, but we can verify it doesn't cause errors + expect(testCollection.has(1)).toBe(true) + }) + + it(`should handle invalid schema information gracefully`, () => { + const config = { + id: `invalid-schema-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send message with invalid schema information + expect(() => { + subscriber([ + { + key: `1`, + value: { id: 1, name: `User 1` }, + headers: { + operation: `insert`, + schema: 123 as any, // Invalid schema type + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + }).not.toThrow() + + expect(testCollection.has(1)).toBe(true) + }) + + it(`should handle txids from control messages`, async () => { + const config = { + id: `control-txid-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send control message with txids (as numbers, per Electric API) + subscriber([ + { + headers: { + control: `up-to-date`, + txids: [300, 400], + }, + }, + ]) + + // Txids should be tracked (converted to strings internally) + await expect(testCollection.utils.awaitTxId(`300`)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(`400`)).resolves.toBe(true) + }) + }) }) diff --git a/packages/db-collections/tests/query.test.ts b/packages/db-collections/tests/query.test.ts index 1716140fad..9d1624b881 100644 --- a/packages/db-collections/tests/query.test.ts +++ b/packages/db-collections/tests/query.test.ts @@ -56,6 +56,7 @@ describe(`QueryCollection`, () => { queryKey, queryFn, getKey, + startSync: true, } const options = queryCollectionOptions(config) @@ -107,6 +108,7 @@ describe(`QueryCollection`, () => { queryKey, queryFn, getKey, + startSync: true, } const options = queryCollectionOptions(config) @@ -184,6 +186,7 @@ describe(`QueryCollection`, () => { queryKey, queryFn, getKey, + startSync: true, retry: 0, // Disable retries for this test case }) const collection = createCollection(options) @@ -232,6 +235,7 @@ describe(`QueryCollection`, () => { queryKey, queryFn, getKey, + startSync: true, }) const collection = createCollection(options) @@ -280,6 +284,7 @@ describe(`QueryCollection`, () => { queryKey, queryFn, getKey, + startSync: true, }) const collection = createCollection(options) @@ -338,6 +343,7 @@ describe(`QueryCollection`, () => { queryKey, queryFn, getKey: getKeySpy, + startSync: true, }) const collection = createCollection(options) @@ -524,4 +530,473 @@ describe(`QueryCollection`, () => { vi.restoreAllMocks() }) }) + + // Tests for lifecycle management + describe(`lifecycle management`, () => { + it(`should properly cleanup query and collection when collection is cleaned up`, async () => { + const queryKey = [`cleanup-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `cleanup-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data to load + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + + // Cleanup the collection + await collection.cleanup() + + // Verify collection status + expect(collection.status).toBe(`cleaned-up`) + + // Note: Query cleanup happens during sync cleanup, not collection cleanup + // We're mainly verifying the collection cleanup works without errors + }) + + it(`should call cancelQueries and removeQueries on sync cleanup`, async () => { + const queryKey = [`sync-cleanup-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `sync-cleanup-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + // Spy on the queryClient methods that should be called during sync cleanup + const cancelQueriesSpy = vi + .spyOn(queryClient, `cancelQueries`) + .mockResolvedValue() + const removeQueriesSpy = vi.spyOn(queryClient, `removeQueries`) + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data to load + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + + // Cleanup the collection which should trigger sync cleanup + await collection.cleanup() + + // Wait a bit to ensure all async operations complete + await flushPromises() + + // Verify collection status + expect(collection.status).toBe(`cleaned-up`) + + // Verify that the TanStack Query cleanup methods were called + expect(cancelQueriesSpy).toHaveBeenCalledWith({ queryKey }) + expect(removeQueriesSpy).toHaveBeenCalledWith({ queryKey }) + + // Restore spies + cancelQueriesSpy.mockRestore() + removeQueriesSpy.mockRestore() + }) + + it(`should handle multiple cleanup calls gracefully`, async () => { + const queryKey = [`multiple-cleanup-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `multiple-cleanup-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Call cleanup multiple times + await collection.cleanup() + expect(collection.status).toBe(`cleaned-up`) + + await collection.cleanup() + await collection.cleanup() + + // Should handle multiple cleanups gracefully + expect(collection.status).toBe(`cleaned-up`) + }) + + it(`should restart sync when collection is accessed after cleanup`, async () => { + const queryKey = [`restart-sync-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `restart-sync-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + + // Cleanup + await collection.cleanup() + expect(collection.status).toBe(`cleaned-up`) + + // Access collection data to restart sync + const unsubscribe = collection.subscribeChanges(() => {}) + + // Should restart sync (might be ready immediately if query is cached) + expect([`loading`, `ready`]).toContain(collection.status) + + unsubscribe() + }) + + it(`should handle query lifecycle during restart cycle`, async () => { + const queryKey = [`restart-lifecycle-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `restart-lifecycle-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + // Spy on queryClient methods + const cancelQueriesSpy = vi + .spyOn(queryClient, `cancelQueries`) + .mockResolvedValue() + const removeQueriesSpy = vi.spyOn(queryClient, `removeQueries`) + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Cleanup which should call query cleanup methods + await collection.cleanup() + await flushPromises() + expect(collection.status).toBe(`cleaned-up`) + + // Verify cleanup methods were called + expect(cancelQueriesSpy).toHaveBeenCalledWith({ queryKey }) + expect(removeQueriesSpy).toHaveBeenCalledWith({ queryKey }) + + // Clear the spies to track new calls + cancelQueriesSpy.mockClear() + removeQueriesSpy.mockClear() + + // Restart by accessing collection + const unsubscribe = collection.subscribeChanges(() => {}) + + // Should restart sync + expect([`loading`, `ready`]).toContain(collection.status) + + // Cleanup again to verify the new sync cleanup works + unsubscribe() + await collection.cleanup() + await flushPromises() + + // Verify cleanup methods were called again for the restarted sync + expect(cancelQueriesSpy).toHaveBeenCalledWith({ queryKey }) + expect(removeQueriesSpy).toHaveBeenCalledWith({ queryKey }) + + // Restore spies + cancelQueriesSpy.mockRestore() + removeQueriesSpy.mockRestore() + }) + + it(`should handle query invalidation and refetch properly`, async () => { + const queryKey = [`invalidation-test`] + let items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const config: QueryCollectionConfig = { + id: `invalidation-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + + // Update data for next fetch + items = [ + { id: `1`, name: `Updated Item 1` }, + { id: `2`, name: `Item 2` }, + ] + + // Invalidate and refetch + await queryClient.invalidateQueries({ queryKey }) + + // Wait for refetch to complete + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(collection.size).toBe(2) + }) + + expect(collection.get(`1`)).toEqual({ id: `1`, name: `Updated Item 1` }) + expect(collection.get(`2`)).toEqual({ id: `2`, name: `Item 2` }) + }) + + it(`should handle concurrent query operations`, async () => { + const queryKey = [`concurrent-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `concurrent-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Perform concurrent operations + const promises = [ + collection.utils.refetch(), + collection.utils.refetch(), + collection.utils.refetch(), + ] + + // All should complete without errors + await Promise.all(promises) + + // Collection should remain in a consistent state + expect(collection.size).toBe(1) + expect(collection.get(`1`)).toEqual({ id: `1`, name: `Item 1` }) + }) + + it(`should handle query state transitions properly`, async () => { + const queryKey = [`state-transition-test`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `state-transition-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Initially loading + expect(collection.status).toBe(`loading`) + + // Wait for data to load + await vi.waitFor(() => { + expect(collection.size).toBe(1) + expect(collection.status).toBe(`ready`) + }) + + // Trigger a refetch which should transition to loading and back to ready + const refetchPromise = collection.utils.refetch() + + // Should transition back to ready after refetch + await refetchPromise + expect(collection.status).toBe(`ready`) + }) + + it(`should properly handle subscription lifecycle`, async () => { + const queryKey = [`subscription-lifecycle-test`] + let items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const config: QueryCollectionConfig = { + id: `subscription-lifecycle-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Create multiple subscriptions + const changeHandler1 = vi.fn() + const changeHandler2 = vi.fn() + + const unsubscribe1 = collection.subscribeChanges(changeHandler1) + const unsubscribe2 = collection.subscribeChanges(changeHandler2) + + // Change the data and trigger a refetch + items = [{ id: `1`, name: `Item 1 Updated` }] + await collection.utils.refetch() + + // Wait for changes to propagate + await vi.waitFor(() => { + expect(collection.get(`1`)?.name).toBe(`Item 1 Updated`) + }) + + // Both handlers should have been called + expect(changeHandler1).toHaveBeenCalled() + expect(changeHandler2).toHaveBeenCalled() + + // Unsubscribe one + unsubscribe1() + changeHandler1.mockClear() + changeHandler2.mockClear() + + // Change data again and trigger another refetch + items = [{ id: `1`, name: `Item 1 Updated Again` }] + await collection.utils.refetch() + + // Wait for changes to propagate + await vi.waitFor(() => { + expect(collection.get(`1`)?.name).toBe(`Item 1 Updated Again`) + }) + + // Only the second handler should be called + expect(changeHandler1).not.toHaveBeenCalled() + expect(changeHandler2).toHaveBeenCalled() + + // Cleanup + unsubscribe2() + }) + + it(`should handle query cancellation gracefully`, async () => { + const queryKey = [`cancellation-test`] + let resolvePromise: (value: Array) => void + const queryPromise = new Promise>((resolve) => { + resolvePromise = resolve + }) + const queryFn = vi.fn().mockReturnValue(queryPromise) + + const config: QueryCollectionConfig = { + id: `cancellation-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should be in loading state + expect(collection.status).toBe(`loading`) + + // Cancel by cleaning up before query resolves + await collection.cleanup() + + // Now resolve the promise + resolvePromise!([{ id: `1`, name: `Item 1` }]) + + // Wait a bit to ensure any async operations complete + await flushPromises() + + // Collection should be cleaned up and not have processed the data + expect(collection.status).toBe(`cleaned-up`) + expect(collection.size).toBe(0) + }) + + it(`should maintain data consistency during rapid updates`, async () => { + const queryKey = [`rapid-updates-test`] + let updateCount = 0 + const queryFn = vi.fn().mockImplementation(() => { + updateCount++ + return Promise.resolve([{ id: `1`, name: `Item ${updateCount}` }]) + }) + + const config: QueryCollectionConfig = { + id: `rapid-updates-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Perform rapid updates + const updatePromises = [] + for (let i = 0; i < 5; i++) { + updatePromises.push(collection.utils.refetch()) + } + + await Promise.all(updatePromises) + + // Collection should be in a consistent state + expect(collection.size).toBe(1) + expect(collection.status).toBe(`ready`) + + // The final data should reflect one of the updates + const finalItem = collection.get(`1`) + expect(finalItem?.name).toMatch(/^Item \d+$/) + }) + }) }) diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index a22f129807..c2b543717d 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -6,6 +6,7 @@ import type { ChangeListener, ChangeMessage, CollectionConfig, + CollectionStatus, Fn, InsertConfig, OperationConfig, @@ -171,6 +172,13 @@ export class CollectionImpl< // Array to store one-time commit listeners private onFirstCommitCallbacks: Array<() => void> = [] + // Lifecycle management + private _status: CollectionStatus = `idle` + private activeSubscribersCount = 0 + private gcTimeoutId: ReturnType | null = null + private preloadPromise: Promise | null = null + private syncCleanupFn: (() => void) | null = null + /** * Register a callback to be executed on the next commit * Useful for preloading collections @@ -182,6 +190,71 @@ export class CollectionImpl< public id = `` + /** + * Gets the current status of the collection + */ + public get status(): CollectionStatus { + return this._status + } + + /** + * Validates that the collection is in a usable state for data operations + * @private + */ + private validateCollectionUsable(operation: string): void { + switch (this._status) { + case `error`: + throw new Error( + `Cannot perform ${operation} on collection "${this.id}" - collection is in error state. ` + + `Try calling cleanup() and restarting the collection.` + ) + case `cleaned-up`: + throw new Error( + `Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. ` + + `The collection will automatically restart on next access.` + ) + } + } + + /** + * Validates state transitions to prevent invalid status changes + * @private + */ + private validateStatusTransition( + from: CollectionStatus, + to: CollectionStatus + ): void { + if (from === to) { + // Allow same state transitions + return + } + const validTransitions: Record< + CollectionStatus, + Array + > = { + idle: [`loading`, `error`, `cleaned-up`], + loading: [`ready`, `error`, `cleaned-up`], + ready: [`cleaned-up`, `error`], + error: [`cleaned-up`, `idle`], + "cleaned-up": [`loading`, `error`], + } + + if (!validTransitions[from].includes(to)) { + throw new Error( + `Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"` + ) + } + } + + /** + * Safely update the collection status with validation + * @private + */ + private setStatus(newStatus: CollectionStatus): void { + this.validateStatusTransition(this._status, newStatus) + this._status = newStatus + } + /** * Creates a new Collection instance * @@ -210,74 +283,265 @@ export class CollectionImpl< this.config = config + // Store in global collections store + collectionsStore.set(this.id, this) + + // Set up data storage with optional comparison function if (this.config.compare) { this.syncedData = new SortedMap(this.config.compare) } else { this.syncedData = new Map() } - // Start the sync process - config.sync.sync({ - collection: this, - begin: () => { - this.pendingSyncedTransactions.push({ - committed: false, - operations: [], - }) - }, - write: (messageWithoutKey: Omit, `key`>) => { - const pendingTransaction = - this.pendingSyncedTransactions[ - this.pendingSyncedTransactions.length - 1 - ] - if (!pendingTransaction) { - throw new Error(`No pending sync transaction to write to`) - } - if (pendingTransaction.committed) { - throw new Error( - `The pending sync transaction is already committed, you can't still write to it.` - ) - } - const key = this.getKeyFromItem(messageWithoutKey.value) + // Only start sync immediately if explicitly enabled + if (config.startSync === true) { + this.startSync() + } + } - // Check if an item with this key already exists when inserting - if (messageWithoutKey.type === `insert`) { - if ( - this.syncedData.has(key) && - !pendingTransaction.operations.some( - (op) => op.key === key && op.type === `delete` + /** + * Start sync immediately - internal method for compiled queries + * This bypasses lazy loading for special cases like live query results + */ + public startSyncImmediate(): void { + this.startSync() + } + + /** + * Start the sync process for this collection + * This is called when the collection is first accessed or preloaded + */ + private startSync(): void { + if (this._status !== `idle` && this._status !== `cleaned-up`) { + return // Already started or in progress + } + + this.setStatus(`loading`) + + try { + const cleanupFn = this.config.sync.sync({ + collection: this, + begin: () => { + this.pendingSyncedTransactions.push({ + committed: false, + operations: [], + }) + }, + write: (messageWithoutKey: Omit, `key`>) => { + const pendingTransaction = + this.pendingSyncedTransactions[ + this.pendingSyncedTransactions.length - 1 + ] + if (!pendingTransaction) { + throw new Error(`No pending sync transaction to write to`) + } + if (pendingTransaction.committed) { + throw new Error( + `The pending sync transaction is already committed, you can't still write to it.` ) - ) { + } + const key = this.getKeyFromItem(messageWithoutKey.value) + + // Check if an item with this key already exists when inserting + if (messageWithoutKey.type === `insert`) { + if ( + this.syncedData.has(key) && + !pendingTransaction.operations.some( + (op) => op.key === key && op.type === `delete` + ) + ) { + throw new Error( + `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"` + ) + } + } + + const message: ChangeMessage = { + ...messageWithoutKey, + key, + } + pendingTransaction.operations.push(message) + }, + commit: () => { + const pendingTransaction = + this.pendingSyncedTransactions[ + this.pendingSyncedTransactions.length - 1 + ] + if (!pendingTransaction) { + throw new Error(`No pending sync transaction to commit`) + } + if (pendingTransaction.committed) { throw new Error( - `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"` + `The pending sync transaction is already committed, you can't commit it again.` ) } - } - const message: ChangeMessage = { - ...messageWithoutKey, - key, - } - pendingTransaction.operations.push(message) - }, - commit: () => { - const pendingTransaction = - this.pendingSyncedTransactions[ - this.pendingSyncedTransactions.length - 1 - ] - if (!pendingTransaction) { - throw new Error(`No pending sync transaction to commit`) + pendingTransaction.committed = true + this.commitPendingTransactions() + + // Update status to ready after first commit + if (this._status === `loading`) { + this.setStatus(`ready`) + } + }, + }) + + // Store cleanup function if provided + this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null + } catch (error) { + this.setStatus(`error`) + throw error + } + } + + /** + * Preload the collection data by starting sync if not already started + * Multiple concurrent calls will share the same promise + */ + public preload(): Promise { + if (this.preloadPromise) { + return this.preloadPromise + } + + this.preloadPromise = new Promise((resolve, reject) => { + if (this._status === `ready`) { + resolve() + return + } + + if (this._status === `error`) { + reject(new Error(`Collection is in error state`)) + return + } + + // Register callback BEFORE starting sync to avoid race condition + this.onFirstCommit(() => { + resolve() + }) + + // Start sync if collection hasn't started yet or was cleaned up + if (this._status === `idle` || this._status === `cleaned-up`) { + try { + this.startSync() + } catch (error) { + reject(error) + return } - if (pendingTransaction.committed) { + } + }) + + return this.preloadPromise + } + + /** + * Clean up the collection by stopping sync and clearing data + * This can be called manually or automatically by garbage collection + */ + public async cleanup(): Promise { + // Clear GC timeout + if (this.gcTimeoutId) { + clearTimeout(this.gcTimeoutId) + this.gcTimeoutId = null + } + + // Stop sync - wrap in try/catch since it's user-provided code + try { + if (this.syncCleanupFn) { + this.syncCleanupFn() + this.syncCleanupFn = null + } + } catch (error) { + // Re-throw in a microtask to surface the error after cleanup completes + queueMicrotask(() => { + if (error instanceof Error) { + // Preserve the original error and stack trace + const wrappedError = new Error( + `Collection "${this.id}" sync cleanup function threw an error: ${error.message}` + ) + wrappedError.cause = error + wrappedError.stack = error.stack + throw wrappedError + } else { throw new Error( - `The pending sync transaction is already committed, you can't commit it again.` + `Collection "${this.id}" sync cleanup function threw an error: ${String(error)}` ) } + }) + } - pendingTransaction.committed = true - this.commitPendingTransactions() - }, - }) + // Clear data + this.syncedData.clear() + this.syncedMetadata.clear() + this.derivedUpserts.clear() + this.derivedDeletes.clear() + this._size = 0 + this.pendingSyncedTransactions = [] + this.syncedKeys.clear() + this.hasReceivedFirstCommit = false + this.onFirstCommitCallbacks = [] + this.preloadPromise = null + + // Update status + this.setStatus(`cleaned-up`) + + return Promise.resolve() + } + + /** + * Start the garbage collection timer + * Called when the collection becomes inactive (no subscribers) + */ + private startGCTimer(): void { + if (this.gcTimeoutId) { + clearTimeout(this.gcTimeoutId) + } + + const gcTime = this.config.gcTime ?? 300000 // 5 minutes default + this.gcTimeoutId = setTimeout(() => { + if (this.activeSubscribersCount === 0) { + this.cleanup() + } + }, gcTime) + } + + /** + * Cancel the garbage collection timer + * Called when the collection becomes active again + */ + private cancelGCTimer(): void { + if (this.gcTimeoutId) { + clearTimeout(this.gcTimeoutId) + this.gcTimeoutId = null + } + } + + /** + * Increment the active subscribers count and start sync if needed + */ + private addSubscriber(): void { + this.activeSubscribersCount++ + this.cancelGCTimer() + + // Start sync if collection was cleaned up + if (this._status === `cleaned-up` || this._status === `idle`) { + this.startSync() + } + } + + /** + * Decrement the active subscribers count and start GC timer if needed + */ + private removeSubscriber(): void { + this.activeSubscribersCount-- + + if (this.activeSubscribersCount === 0) { + this.activeSubscribersCount = 0 + this.startGCTimer() + } else if (this.activeSubscribersCount < 0) { + throw new Error( + `Active subscribers count is negative - this should never happen` + ) + } } /** @@ -924,6 +1188,8 @@ export class CollectionImpl< * insert({ text: "Buy groceries" }, { key: "grocery-task" }) */ insert = (data: T | Array, config?: InsertConfig) => { + this.validateCollectionUsable(`insert`) + const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onInsert handler early @@ -1069,6 +1335,8 @@ export class CollectionImpl< throw new Error(`The first argument to update is missing`) } + this.validateCollectionUsable(`update`) + const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onUpdate handler early @@ -1234,6 +1502,8 @@ export class CollectionImpl< keys: Array | TKey, config?: OperationConfig ): TransactionType => { + this.validateCollectionUsable(`delete`) + const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onDelete handler early @@ -1346,7 +1616,19 @@ export class CollectionImpl< * @returns An Array containing all items in the collection */ get toArray() { - return Array.from(this.values()) + const array = Array.from(this.values()) + + // Currently a query with an orderBy will add a _orderByIndex to the items + // so for now we need to sort the array by _orderByIndex if it exists + // TODO: in the future it would be much better is the keys are sorted - this + // should be done by the query engine. + if (array[0] && (array[0] as { _orderByIndex?: number })._orderByIndex) { + return (array as Array<{ _orderByIndex: number }>).sort( + (a, b) => a._orderByIndex - b._orderByIndex + ) as Array + } + + return array } /** @@ -1390,6 +1672,9 @@ export class CollectionImpl< callback: (changes: Array>) => void, { includeInitialState = false }: { includeInitialState?: boolean } = {} ): () => void { + // Start sync and track subscriber + this.addSubscriber() + if (includeInitialState) { // First send the current state as changes callback(this.currentStateAsChanges()) @@ -1400,6 +1685,7 @@ export class CollectionImpl< return () => { this.changeListeners.delete(callback) + this.removeSubscriber() } } @@ -1411,6 +1697,9 @@ export class CollectionImpl< listener: ChangeListener, { includeInitialState = false }: { includeInitialState?: boolean } = {} ): () => void { + // Start sync and track subscriber + this.addSubscriber() + if (!this.changeKeyListeners.has(key)) { this.changeKeyListeners.set(key, new Set()) } @@ -1436,6 +1725,7 @@ export class CollectionImpl< this.changeKeyListeners.delete(key) } } + this.removeSubscriber() } } @@ -1494,7 +1784,7 @@ export class CollectionImpl< public asStoreMap(): Store> { if (!this._storeMap) { this._storeMap = new Store(new Map(this.entries())) - this.subscribeChanges(() => { + this.changeListeners.add(() => { this._storeMap!.setState(() => new Map(this.entries())) }) } @@ -1512,7 +1802,7 @@ export class CollectionImpl< public asStoreArray(): Store> { if (!this._storeArray) { this._storeArray = new Store(this.toArray) - this.subscribeChanges(() => { + this.changeListeners.add(() => { this._storeArray!.setState(() => this.toArray) }) } diff --git a/packages/db/src/query/compiled-query.ts b/packages/db/src/query/compiled-query.ts index 0948a6e087..718a47e483 100644 --- a/packages/db/src/query/compiled-query.ts +++ b/packages/db/src/query/compiled-query.ts @@ -142,6 +142,8 @@ export class CompiledQuery> { getKey: (val: unknown) => { return (val as any)._key }, + gcTime: 0, + startSync: true, compare, sync: { sync: sync as unknown as (params: { @@ -203,26 +205,21 @@ export class CompiledQuery> { throw new Error(`Query is stopped`) } - // Send initial state - Object.entries(this.inputCollections).forEach(([key, collection]) => { - this.sendChangesToInput( - key, - collection.currentStateAsChanges(), - collection.config.getKey - ) - }) - this.runGraph() - // Subscribe to changes Object.entries(this.inputCollections).forEach(([key, collection]) => { - const unsubscribe = collection.subscribeChanges((changes) => { - this.sendChangesToInput(key, changes, collection.config.getKey) - this.runGraph() - }) + const unsubscribe = collection.subscribeChanges( + (changes) => { + this.sendChangesToInput(key, changes, collection.config.getKey) + this.runGraph() + }, + { includeInitialState: true } + ) this.unsubscribeCallbacks.push(unsubscribe) }) + this.runGraph() + this.state = `running` return () => { this.stop() diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index bf0b40f4b9..95ae5e5a17 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -216,6 +216,21 @@ export type DeleteMutationFn> = ( params: DeleteMutationFnParams ) => Promise +/** + * Collection status values for lifecycle management + */ +export type CollectionStatus = + /** Collection is created but sync hasn't started yet (when startSync config is false) */ + | `idle` + /** Sync has started but hasn't received the first commit yet */ + | `loading` + /** Collection has received at least one commit and is ready for use */ + | `ready` + /** An error occurred during sync initialization */ + | `error` + /** Collection has been cleaned up and resources freed */ + | `cleaned-up` + export interface CollectionConfig< T extends object = Record, TKey extends string | number = string | number, @@ -236,6 +251,16 @@ export interface CollectionConfig< * getKey: (item) => item.uuid */ getKey: (item: T) => TKey + /** + * Time in milliseconds after which the collection will be garbage collected + * when it has no active subscribers. Defaults to 5 minutes (300000ms). + */ + gcTime?: number + /** + * Whether to start syncing immediately when the collection is created. + * Defaults to false for lazy loading. Set to true to immediately sync. + */ + startSync?: boolean /** * Optional function to compare two items. * This is used to order the items in the collection. diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts new file mode 100644 index 0000000000..888a5f3239 --- /dev/null +++ b/packages/db/tests/collection-errors.test.ts @@ -0,0 +1,408 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createCollection } from "../src/collection" + +describe(`Collection Error Handling`, () => { + let originalQueueMicrotask: typeof queueMicrotask + let mockQueueMicrotask: ReturnType + + beforeEach(() => { + // Store original queueMicrotask + originalQueueMicrotask = globalThis.queueMicrotask + + // Create mock that doesn't actually queue microtasks + mockQueueMicrotask = vi.fn() + globalThis.queueMicrotask = mockQueueMicrotask + }) + + afterEach(() => { + // Restore original queueMicrotask + globalThis.queueMicrotask = originalQueueMicrotask + vi.clearAllMocks() + }) + + describe(`Cleanup Error Handling`, () => { + it(`should complete cleanup successfully even when sync cleanup function throws an Error`, async () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `error-test-collection`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + + // Return a cleanup function that throws an error + return () => { + throw new Error(`Sync cleanup failed`) + } + }, + }, + }) + + // Start sync to get the cleanup function + collection.preload() + + // Cleanup should complete successfully despite the error + await expect(collection.cleanup()).resolves.toBeUndefined() + + // Collection should be in cleaned-up state + expect(collection.status).toBe(`cleaned-up`) + + // Verify that a microtask was queued to re-throw the error + expect(mockQueueMicrotask).toHaveBeenCalledTimes(1) + + // Get the microtask callback and verify it throws the expected error + const microtaskCallback = mockQueueMicrotask.mock.calls[0]?.[0] + expect(microtaskCallback).toBeDefined() + expect(() => microtaskCallback()).toThrow( + `Collection "error-test-collection" sync cleanup function threw an error: Sync cleanup failed` + ) + }) + + it(`should preserve original error stack trace when re-throwing in microtask`, async () => { + const originalError = new Error(`Original sync error`) + const originalStack = `original stack trace` + originalError.stack = originalStack + + const collection = createCollection<{ id: string; name: string }>({ + id: `stack-trace-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + + return () => { + throw originalError + } + }, + }, + }) + + // Start sync and cleanup + collection.preload() + await collection.cleanup() + + // Verify microtask was queued + expect(mockQueueMicrotask).toHaveBeenCalledTimes(1) + + // Execute the microtask callback and catch the re-thrown error + const microtaskCallback = mockQueueMicrotask.mock.calls[0]?.[0] + expect(microtaskCallback).toBeDefined() + + let caughtError: Error | undefined + try { + microtaskCallback() + } catch (error) { + caughtError = error as Error + } + + // Verify the re-thrown error has proper context and preserved stack + expect(caughtError).toBeDefined() + expect(caughtError!.message).toBe( + `Collection "stack-trace-test" sync cleanup function threw an error: Original sync error` + ) + expect(caughtError!.stack).toBe(originalStack) // Original stack preserved + expect(caughtError!.cause).toBe(originalError) // Original error chained + }) + + it(`should handle non-Error thrown values in sync cleanup`, async () => { + const nonErrorValue = `String error message` + + const collection = createCollection<{ id: string; name: string }>({ + id: `non-error-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + + return () => { + throw nonErrorValue + } + }, + }, + }) + + // Start sync and cleanup + collection.preload() + await collection.cleanup() + + // Verify microtask was queued + expect(mockQueueMicrotask).toHaveBeenCalledTimes(1) + + // Execute the microtask callback and catch the re-thrown error + const microtaskCallback = mockQueueMicrotask.mock.calls[0]?.[0] + expect(microtaskCallback).toBeDefined() + + let caughtError: Error | undefined + try { + microtaskCallback() + } catch (error) { + caughtError = error as Error + } + + // Verify non-Error values are handled properly + expect(caughtError).toBeDefined() + expect(caughtError!.message).toBe( + `Collection "non-error-test" sync cleanup function threw an error: String error message` + ) + + // No cause or stack preservation for non-Error values + expect(caughtError!.cause).toBeUndefined() + }) + + it(`should not interfere with cleanup when sync cleanup function is undefined`, async () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `no-cleanup-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + // No cleanup function returned + }, + }, + }) + + // Start sync + collection.preload() + + // Cleanup should work normally without any cleanup function + await expect(collection.cleanup()).resolves.toBeUndefined() + expect(collection.status).toBe(`cleaned-up`) + + // No microtask should be queued when there's no cleanup function + expect(mockQueueMicrotask).not.toHaveBeenCalled() + }) + + it(`should handle multiple cleanup calls gracefully`, async () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `multiple-cleanup-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + + return () => { + throw new Error(`Cleanup error`) + } + }, + }, + }) + + // Start sync + collection.preload() + + // First cleanup should complete successfully despite error + await expect(collection.cleanup()).resolves.toBeUndefined() + expect(collection.status).toBe(`cleaned-up`) + + // Second cleanup should also complete successfully (idempotent) + await expect(collection.cleanup()).resolves.toBeUndefined() + expect(collection.status).toBe(`cleaned-up`) + + // Third cleanup should also work (proving idempotency) + await expect(collection.cleanup()).resolves.toBeUndefined() + expect(collection.status).toBe(`cleaned-up`) + + // Verify that microtasks were queued for cleanup attempts + // (Each cleanup call that encounters a cleanup function will queue a microtask) + expect(mockQueueMicrotask).toHaveBeenCalled() + + // All queued microtasks should throw the expected error when executed + for (const call of mockQueueMicrotask.mock.calls) { + const microtaskCallback = call[0] + expect(microtaskCallback).toBeDefined() + expect(() => microtaskCallback()).toThrow( + `Collection "multiple-cleanup-test" sync cleanup function threw an error: Cleanup error` + ) + } + }) + }) + + describe(`Operation Validation Errors`, () => { + it(`should throw helpful errors when trying to use operations on error status collection`, async () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `error-status-test`, + getKey: (item) => item.id, + sync: { + sync: () => { + throw new Error(`Sync initialization failed`) + }, + }, + }) + + // Try to start sync, which should put collection in error state + try { + await collection.preload() + } catch { + // Expected to throw + } + expect(collection.status).toBe(`error`) + + // Now operations should be blocked with helpful messages + expect(() => { + collection.insert({ id: `1`, name: `test` }) + }).toThrow( + `Cannot perform insert on collection "error-status-test" - collection is in error state. Try calling cleanup() and restarting the collection.` + ) + + expect(() => { + collection.update(`1`, (draft) => { + draft.name = `updated` + }) + }).toThrow( + `Cannot perform update on collection "error-status-test" - collection is in error state. Try calling cleanup() and restarting the collection.` + ) + + expect(() => { + collection.delete(`1`) + }).toThrow( + `Cannot perform delete on collection "error-status-test" - collection is in error state. Try calling cleanup() and restarting the collection.` + ) + }) + + it(`should throw helpful errors when trying to use operations on cleaned-up collection`, async () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `cleaned-up-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + }) + + // Clean up the collection + await collection.cleanup() + expect(collection.status).toBe(`cleaned-up`) + + // Operations should be blocked with helpful messages + expect(() => { + collection.insert({ id: `1`, name: `test` }) + }).toThrow( + `Cannot perform insert on collection "cleaned-up-test" - collection has been cleaned up. The collection will automatically restart on next access.` + ) + + expect(() => { + collection.update(`1`, (draft) => { + draft.name = `updated` + }) + }).toThrow( + `Cannot perform update on collection "cleaned-up-test" - collection has been cleaned up. The collection will automatically restart on next access.` + ) + + expect(() => { + collection.delete(`1`) + }).toThrow( + `Cannot perform delete on collection "cleaned-up-test" - collection has been cleaned up. The collection will automatically restart on next access.` + ) + }) + }) + + describe(`State Transition Validation`, () => { + it(`should prevent invalid state transitions`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `transition-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + }) + + // Access private method for testing (using any cast) + const collectionImpl = collection as any + + expect(collection.status).toBe(`idle`) + + // Test invalid transition + expect(() => { + collectionImpl.validateStatusTransition(`ready`, `loading`) + }).toThrow( + `Invalid collection status transition from "ready" to "loading" for collection "transition-test"` + ) + + // Test valid transition + expect(() => { + collectionImpl.validateStatusTransition(`idle`, `loading`) + }).not.toThrow() + }) + + it(`should allow all valid state transitions`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `valid-transitions-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + }, + }, + }) + + const collectionImpl = collection as any + + // Valid transitions from idle + expect(() => + collectionImpl.validateStatusTransition(`idle`, `loading`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`idle`, `error`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`idle`, `cleaned-up`) + ).not.toThrow() + + // Valid transitions from loading + expect(() => + collectionImpl.validateStatusTransition(`loading`, `ready`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`loading`, `error`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`loading`, `cleaned-up`) + ).not.toThrow() + + // Valid transitions from ready + expect(() => + collectionImpl.validateStatusTransition(`ready`, `cleaned-up`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`ready`, `error`) + ).not.toThrow() + + // Valid transitions from error (allow recovery) + expect(() => + collectionImpl.validateStatusTransition(`error`, `cleaned-up`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`error`, `idle`) + ).not.toThrow() + + // Valid transitions from cleaned-up (allow restart) + expect(() => + collectionImpl.validateStatusTransition(`cleaned-up`, `loading`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`cleaned-up`, `error`) + ).not.toThrow() + + // Allow same-state transitions (idempotent operations) + expect(() => + collectionImpl.validateStatusTransition(`idle`, `idle`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`ready`, `ready`) + ).not.toThrow() + expect(() => + collectionImpl.validateStatusTransition(`cleaned-up`, `cleaned-up`) + ).not.toThrow() + }) + }) +}) diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index 6d94604363..51a088ebbf 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -37,6 +37,7 @@ describe(`Collection getters`, () => { id: `test-collection`, getKey: (val) => val.id as string, sync: mockSync, + startSync: true, } collection = createCollection(config) @@ -85,6 +86,7 @@ describe(`Collection getters`, () => { const syncCollection = createCollection<{ id: string; name: string }>({ id: `sync-size-test`, getKey: (val) => val.id, + startSync: true, sync: { sync: (callbacks) => { syncCallbacks = callbacks @@ -439,6 +441,7 @@ describe(`Collection getters`, () => { const delayedCollection = createCollection({ id: `delayed-collection`, getKey: (val) => val.id as string, + startSync: true, sync: delayedSyncMock, }) @@ -499,6 +502,7 @@ describe(`Collection getters`, () => { const delayedCollection = createCollection({ id: `delayed-collection`, getKey: (val) => val.id as string, + startSync: true, sync: delayedSyncMock, }) diff --git a/packages/db/tests/collection-lifecycle.test.ts b/packages/db/tests/collection-lifecycle.test.ts new file mode 100644 index 0000000000..34ef36a4ee --- /dev/null +++ b/packages/db/tests/collection-lifecycle.test.ts @@ -0,0 +1,434 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createCollection } from "../src/collection.js" + +// Mock setTimeout and clearTimeout for testing GC behavior +const originalSetTimeout = global.setTimeout +const originalClearTimeout = global.clearTimeout + +describe(`Collection Lifecycle Management`, () => { + let mockSetTimeout: ReturnType + let mockClearTimeout: ReturnType + let timeoutCallbacks: Map void> + let timeoutId = 1 + + beforeEach(() => { + timeoutCallbacks = new Map() + timeoutId = 1 + + mockSetTimeout = vi.fn((callback: () => void, _delay: number) => { + const id = timeoutId++ + timeoutCallbacks.set(id, callback) + return id + }) + + mockClearTimeout = vi.fn((id: number) => { + timeoutCallbacks.delete(id) + }) + + global.setTimeout = mockSetTimeout as any + global.clearTimeout = mockClearTimeout as any + }) + + afterEach(() => { + global.setTimeout = originalSetTimeout + global.clearTimeout = originalClearTimeout + vi.clearAllMocks() + }) + + const triggerTimeout = (id: number) => { + const callback = timeoutCallbacks.get(id) + if (callback) { + callback() + timeoutCallbacks.delete(id) + } + } + + describe(`Collection Status Tracking`, () => { + it(`should start with idle status and transition to ready after first commit when startSync is false`, () => { + let beginCallback: (() => void) | undefined + let commitCallback: (() => void) | undefined + + const collection = createCollection<{ id: string; name: string }>({ + id: `status-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + beginCallback = begin as () => void + commitCallback = commit as () => void + }, + }, + }) + + expect(collection.status).toBe(`idle`) + + collection.preload() + + if (beginCallback && commitCallback) { + beginCallback() + commitCallback() + } + + expect(collection.status).toBe(`ready`) + }) + + it(`should start with loading status and transition to ready after first commit when startSync is true`, () => { + let beginCallback: (() => void) | undefined + let commitCallback: (() => void) | undefined + + const collection = createCollection<{ id: string; name: string }>({ + id: `status-test`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, commit }) => { + beginCallback = begin as () => void + commitCallback = commit as () => void + }, + }, + }) + + // Should start in loading state since sync starts immediately + expect(collection.status).toBe(`loading`) + + // Trigger first commit (begin then commit) + if (beginCallback && commitCallback) { + beginCallback() + commitCallback() + } + + expect(collection.status).toBe(`ready`) + }) + + it(`should transition to cleaned-up status after cleanup`, async () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `cleanup-status-test`, + getKey: (item) => item.id, + sync: { + sync: () => {}, + }, + }) + + await collection.cleanup() + expect(collection.status).toBe(`cleaned-up`) + }) + + it(`should transition when subscribing to changes`, async () => { + let beginCallback: (() => void) | undefined + let commitCallback: (() => void) | undefined + + const collection = createCollection<{ id: string; name: string }>({ + id: `subscribe-test`, + getKey: (item) => item.id, + gcTime: 0, + sync: { + sync: ({ begin, commit }) => { + beginCallback = begin as () => void + commitCallback = commit as () => void + }, + }, + }) + + expect(collection.status).toBe(`idle`) + + const unsubscribe = collection.subscribeChanges(() => {}) + + expect(collection.status).toBe(`loading`) + + if (beginCallback && commitCallback) { + beginCallback() + commitCallback() + } + + expect(collection.status).toBe(`ready`) + + unsubscribe() + + expect(collection.status).toBe(`ready`) + }) + + it(`should restart sync when accessing cleaned-up collection`, async () => { + let syncCallCount = 0 + + const collection = createCollection<{ id: string; name: string }>({ + id: `restart-test`, + getKey: (item) => item.id, + startSync: false, // Test lazy loading behavior + sync: { + sync: ({ begin, commit }) => { + begin() + commit() + syncCallCount++ + }, + }, + }) + + expect(syncCallCount).toBe(0) // no sync yet + + await collection.preload() + + expect(syncCallCount).toBe(1) // sync called when subscribing + + await collection.cleanup() + + expect(collection.status).toBe(`cleaned-up`) + + await collection.preload() + + expect(syncCallCount).toBe(2) + expect(collection.status).toBe(`ready`) // Sync completes immediately in this test + }) + }) + + describe(`Subscriber Management`, () => { + it(`should track active subscribers correctly`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `subscriber-test`, + getKey: (item) => item.id, + sync: { + sync: () => {}, + }, + }) + + // No subscribers initially + expect((collection as any).activeSubscribersCount).toBe(0) + + // Subscribe to changes + const unsubscribe1 = collection.subscribeChanges(() => {}) + expect((collection as any).activeSubscribersCount).toBe(1) + + const unsubscribe2 = collection.subscribeChanges(() => {}) + expect((collection as any).activeSubscribersCount).toBe(2) + + // Unsubscribe + unsubscribe1() + expect((collection as any).activeSubscribersCount).toBe(1) + + unsubscribe2() + expect((collection as any).activeSubscribersCount).toBe(0) + }) + + it(`should track key-specific subscribers`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `key-subscriber-test`, + getKey: (item) => item.id, + sync: { + sync: () => {}, + }, + }) + + const unsubscribe1 = collection.subscribeChangesKey(`key1`, () => {}) + const unsubscribe2 = collection.subscribeChangesKey(`key2`, () => {}) + const unsubscribe3 = collection.subscribeChangesKey(`key1`, () => {}) + + expect((collection as any).activeSubscribersCount).toBe(3) + + unsubscribe1() + expect((collection as any).activeSubscribersCount).toBe(2) + + unsubscribe2() + unsubscribe3() + expect((collection as any).activeSubscribersCount).toBe(0) + }) + }) + + describe(`Garbage Collection`, () => { + it(`should start GC timer when last subscriber is removed`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `gc-timer-test`, + getKey: (item) => item.id, + gcTime: 5000, // 5 seconds + sync: { + sync: () => {}, + }, + }) + + const unsubscribe = collection.subscribeChanges(() => {}) + + // Should not have GC timer while there are subscribers + expect(mockSetTimeout).not.toHaveBeenCalled() + + unsubscribe() + + // Should start GC timer when last subscriber is removed + expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000) + }) + + it(`should cancel GC timer when new subscriber is added`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `gc-cancel-test`, + getKey: (item) => item.id, + gcTime: 5000, + sync: { + sync: () => {}, + }, + }) + + const unsubscribe1 = collection.subscribeChanges(() => {}) + unsubscribe1() + + expect(mockSetTimeout).toHaveBeenCalledTimes(1) + const timerId = mockSetTimeout.mock.results[0]?.value + + // Add new subscriber should cancel GC timer + const unsubscribe2 = collection.subscribeChanges(() => {}) + expect(mockClearTimeout).toHaveBeenCalledWith(timerId) + + unsubscribe2() + }) + + it(`should cleanup collection when GC timer fires`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `gc-cleanup-test`, + getKey: (item) => item.id, + gcTime: 1000, + sync: { + sync: () => {}, + }, + }) + + const unsubscribe = collection.subscribeChanges(() => {}) + unsubscribe() + + expect(collection.status).toBe(`loading`) // or "ready" + + // Trigger GC timeout + const timerId = mockSetTimeout.mock.results[0]?.value + if (timerId) { + triggerTimeout(timerId) + } + + expect(collection.status).toBe(`cleaned-up`) + }) + + it(`should use default GC time when not specified`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `default-gc-test`, + getKey: (item) => item.id, + sync: { + sync: () => {}, + }, + }) + + const unsubscribe = collection.subscribeChanges(() => {}) + unsubscribe() + + // Should use default 5 minutes (300000ms) + expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 300000) + }) + }) + + describe(`Manual Preload and Cleanup`, () => { + it(`should resolve preload immediately if already ready`, async () => { + let beginCallback: (() => void) | undefined + let commitCallback: (() => void) | undefined + + const collection = createCollection<{ id: string; name: string }>({ + id: `preload-ready-test`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, commit }) => { + beginCallback = begin as () => void + commitCallback = commit as () => void + }, + }, + }) + + // Make collection ready + if (beginCallback && commitCallback) { + beginCallback() + commitCallback() + } + + // Preload should resolve immediately + const startTime = Date.now() + await collection.preload() + const endTime = Date.now() + + expect(endTime - startTime).toBeLessThan(50) // Should be nearly instant + }) + + it(`should share preload promise for concurrent calls`, () => { + const collection = createCollection<{ id: string; name: string }>({ + id: `concurrent-preload-test`, + getKey: (item) => item.id, + sync: { + sync: () => {}, + }, + }) + + const promise1 = collection.preload() + const promise2 = collection.preload() + + expect(promise1).toBe(promise2) // Should be the same promise + }) + + it(`should cleanup collection manually`, async () => { + let cleanupCalled = false + + const collection = createCollection<{ id: string; name: string }>({ + id: `manual-cleanup-test`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: () => { + return () => { + cleanupCalled = true + } + }, + }, + }) + + expect(collection.status).toBe(`loading`) + + await collection.cleanup() + + expect(collection.status).toBe(`cleaned-up`) + expect(cleanupCalled).toBe(true) + }) + }) + + describe(`Lifecycle Events`, () => { + it(`should call onFirstCommit callbacks`, () => { + let beginCallback: (() => void) | undefined + let commitCallback: (() => void) | undefined + const callbacks: Array<() => void> = [] + + const collection = createCollection<{ id: string; name: string }>({ + id: `first-commit-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, commit }) => { + beginCallback = begin as () => void + commitCallback = commit as () => void + }, + }, + }) + + const unsubscribe = collection.subscribeChanges(() => {}) + + // Register callbacks + collection.onFirstCommit(() => callbacks.push(() => `callback1`)) + collection.onFirstCommit(() => callbacks.push(() => `callback2`)) + + expect(callbacks).toHaveLength(0) + + // Trigger first commit + if (beginCallback && commitCallback) { + beginCallback() + commitCallback() + } + + expect(callbacks).toHaveLength(2) + + // Subsequent commits should not trigger callbacks + if (beginCallback && commitCallback) { + beginCallback() + commitCallback() + } + expect(callbacks).toHaveLength(2) + + unsubscribe() + }) + }) +}) diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index c32a9c61cc..2edd7ac965 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -226,6 +226,7 @@ describe(`Collection.subscribeChanges`, () => { getKey: (item) => { return item.id }, + startSync: true, sync: { sync: ({ begin, write, commit }) => { // Listen for sync events diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 48021a3de8..39c25925cb 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -16,6 +16,7 @@ describe(`Collection`, () => { const collection = createCollection<{ value: string }>({ id: `foo`, getKey: (item) => item.value, + startSync: true, sync: { sync: ({ begin, write, commit }) => { // Immediately execute the sync cycle @@ -65,6 +66,7 @@ describe(`Collection`, () => { const collection = createCollection<{ id: string; value: string }>({ id: `id-update-test`, getKey: (item) => item.id, + startSync: true, sync: { sync: ({ begin, write, commit }) => { begin() @@ -102,6 +104,7 @@ describe(`Collection`, () => { createCollection<{ name: string }>({ id: `foo`, getKey: (item) => item.name, + startSync: true, sync: { sync: ({ collection, begin, write, commit }) => { // Initial state should be empty @@ -150,6 +153,7 @@ describe(`Collection`, () => { }>({ id: `mock`, getKey: (item) => item.id, + startSync: true, sync: { sync: ({ begin, write, commit }) => { // @ts-expect-error don't trust mitt's typing @@ -428,6 +432,7 @@ describe(`Collection`, () => { getKey: (item) => { return item.id }, + startSync: true, sync: { sync: ({ begin, write, commit }) => { // @ts-expect-error don't trust Mitt's typing and this works. @@ -500,6 +505,7 @@ describe(`Collection`, () => { const collection = createCollection<{ name: string }>({ id: `delete-errors`, getKey: (val) => val.name, + startSync: true, sync: { sync: ({ begin, commit }) => { begin() @@ -532,6 +538,7 @@ describe(`Collection`, () => { const collection = createCollection<{ id: number; value: string }>({ id: `duplicate-id-test`, getKey: (item) => item.id, + startSync: true, sync: { sync: ({ begin, write, commit }) => { begin() @@ -573,6 +580,7 @@ describe(`Collection`, () => { const collection = createCollection<{ id: number; value: string }>({ id: `handlers-test`, getKey: (item) => item.id, + startSync: true, sync: { sync: ({ begin, write, commit }) => { begin() @@ -637,6 +645,7 @@ describe(`Collection`, () => { const collection = createCollection<{ id: number; value: string }>({ id: `direct-operations-test`, getKey: (item) => item.id, + startSync: true, sync: { sync: ({ begin, write, commit }) => { begin() @@ -690,6 +699,7 @@ describe(`Collection`, () => { const collection = createCollection<{ id: number; value: string }>({ id: `no-handlers-test`, getKey: (item) => item.id, + startSync: true, sync: { sync: ({ begin, write, commit }) => { begin() @@ -743,6 +753,7 @@ describe(`Collection with schema validation`, () => { const collection = createCollection>({ id: `test`, getKey: (item) => item.name, + startSync: true, sync: { sync: ({ begin, commit }) => { begin() diff --git a/packages/db/tests/query/query-collection.test.ts b/packages/db/tests/query/query-collection.test.ts index 4aa01f6e23..56e5135be0 100644 --- a/packages/db/tests/query/query-collection.test.ts +++ b/packages/db/tests/query/query-collection.test.ts @@ -95,15 +95,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - const query = queryBuilder() .from({ collection }) .where(`@age`, `>`, 30) @@ -111,8 +102,18 @@ describe(`Query Collections`, () => { const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing compiledQuery.start() + // Now sync the initial state after the query has started + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + const result = compiledQuery.results expect(result.state.size).toBe(1) @@ -211,7 +212,14 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state + const query = queryBuilder().from({ person: collection }) + + const compiledQuery = compileQuery(query) + + // Starting the query should trigger collection syncing + compiledQuery.start() + + // Now sync the initial state after the query has started emitter.emit( `sync`, initialPersons.map((person) => ({ @@ -220,12 +228,6 @@ describe(`Query Collections`, () => { })) ) - const query = queryBuilder().from({ person: collection }) - - const compiledQuery = compileQuery(query) - - compiledQuery.start() - const result = compiledQuery.results expect(result.state.size).toBe(3) @@ -309,23 +311,24 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - const query = queryBuilder() .from({ person: collection }) .where(({ person }) => (person.age ?? 0) > 30) const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing compiledQuery.start() + // Now sync the initial state after the query has started + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + const result = compiledQuery.results expect(result.state.size).toBe(1) @@ -458,7 +461,21 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data + // Create a query with a join between persons and issues + const query = queryBuilder() + .from({ issues: issueCollection }) + .join({ + type: `inner`, + from: { persons: personCollection }, + on: [`@persons.id`, `=`, `@issues.userId`], + }) + .select(`@issues.id`, `@issues.title`, `@persons.name`) + + const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing for both collections + compiledQuery.start() + + // Now sync the initial data after the query has started emitter.emit( `sync-person`, initialPersons.map((person) => ({ @@ -467,7 +484,6 @@ describe(`Query Collections`, () => { })) ) - // Sync initial issue data emitter.emit( `sync-issue`, initialIssues.map((issue) => ({ @@ -476,19 +492,6 @@ describe(`Query Collections`, () => { })) ) - // Create a query with a join between persons and issues - const query = queryBuilder() - .from({ issues: issueCollection }) - .join({ - type: `inner`, - from: { persons: personCollection }, - on: [`@persons.id`, `=`, `@issues.userId`], - }) - .select(`@issues.id`, `@issues.title`, `@persons.name`) - - const compiledQuery = compileQuery(query) - compiledQuery.start() - const result = compiledQuery.results await waitForChanges() @@ -619,7 +622,20 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data + // Create a query with a join between persons and issues + const query = queryBuilder() + .from({ issues: issueCollection }) + .join({ + type: `inner`, + from: { persons: personCollection }, + on: [`@persons.id`, `=`, `@issues.userId`], + }) + + const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing for both collections + compiledQuery.start() + + // Now sync the initial data after the query has started emitter.emit( `sync-person`, initialPersons.map((person) => ({ @@ -628,7 +644,6 @@ describe(`Query Collections`, () => { })) ) - // Sync initial issue data emitter.emit( `sync-issue`, initialIssues.map((issue) => ({ @@ -637,18 +652,6 @@ describe(`Query Collections`, () => { })) ) - // Create a query with a join between persons and issues - const query = queryBuilder() - .from({ issues: issueCollection }) - .join({ - type: `inner`, - from: { persons: personCollection }, - on: [`@persons.id`, `=`, `@issues.userId`], - }) - - const compiledQuery = compileQuery(query) - compiledQuery.start() - const result = compiledQuery.results await waitForChanges() @@ -814,15 +817,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - // Test ascending order by age const ascendingQuery = queryBuilder() .from({ collection }) @@ -830,8 +824,18 @@ describe(`Query Collections`, () => { .select(`@id`, `@name`, `@age`) const compiledAscendingQuery = compileQuery(ascendingQuery) + // Starting the query should trigger collection syncing compiledAscendingQuery.start() + // Now sync the initial state after the query has started + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + const ascendingResult = compiledAscendingQuery.results await waitForChanges() @@ -991,15 +995,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - // Create a query that orders by age in ascending order const query = queryBuilder() .from({ collection }) @@ -1007,8 +1002,18 @@ describe(`Query Collections`, () => { .select(`@id`, `@name`, `@age`) const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing compiledQuery.start() + // Now sync the initial state after the query has started + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + await waitForChanges() // Verify initial ordering @@ -1157,7 +1162,21 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data + // Create a query with a join between persons and issues + const query = queryBuilder() + .from({ issues: issueCollection }) + .join({ + type: `inner`, + from: { persons: personCollection }, + on: [`@persons.id`, `=`, `@issues.userId`], + }) + .select(`@issues.id`, `@issues.title`, `@persons.name`) + + const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing for both collections + compiledQuery.start() + + // Now sync the initial data after the query has started emitter.emit( `sync-person`, initialPersons.map((person) => ({ @@ -1166,7 +1185,6 @@ describe(`Query Collections`, () => { })) ) - // Sync initial issue data emitter.emit( `sync-issue`, initialIssues.map((issue) => ({ @@ -1175,19 +1193,6 @@ describe(`Query Collections`, () => { })) ) - // Create a query with a join between persons and issues - const query = queryBuilder() - .from({ issues: issueCollection }) - .join({ - type: `inner`, - from: { persons: personCollection }, - on: [`@persons.id`, `=`, `@issues.userId`], - }) - .select(`@issues.id`, `@issues.title`, `@persons.name`) - - const compiledQuery = compileQuery(query) - compiledQuery.start() - const result = compiledQuery.results await waitForChanges() @@ -1271,15 +1276,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - const query = queryBuilder() .from({ collection }) .select(({ collection: result }) => { @@ -1299,8 +1295,18 @@ describe(`Query Collections`, () => { const compiledQuery = compileQuery(query) + // Starting the query should trigger collection syncing compiledQuery.start() + // Now sync the initial state after the query has started + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + const result = compiledQuery.results await waitForChanges() diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 2af27a01e4..c482c7759c 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -100,17 +100,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - act(() => { - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - }) - const { result } = renderHook(() => { return useLiveQuery((q) => q @@ -121,6 +110,17 @@ describe(`Query Collections`, () => { ) }) + // Now sync the initial state after the query hook has started - this should trigger collection syncing + act(() => { + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + }) + expect(result.current.state.size).toBe(1) expect(result.current.state.get(`3`)).toEqual({ _key: `3`, @@ -287,7 +287,20 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ + type: `inner`, + from: { persons: personCollection }, + on: [`@persons.id`, `=`, `@issues.userId`], + }) + .select(`@issues.id`, `@issues.title`, `@persons.name`) + ) + }) + + // Now sync the initial data after the query hook has started - this should trigger collection syncing for both collections act(() => { emitter.emit( `sync-person`, @@ -299,7 +312,6 @@ describe(`Query Collections`, () => { ) }) - // Sync initial issue data act(() => { emitter.emit( `sync-issue`, @@ -311,19 +323,6 @@ describe(`Query Collections`, () => { ) }) - const { result } = renderHook(() => { - return useLiveQuery((q) => - q - .from({ issues: issueCollection }) - .join({ - type: `inner`, - from: { persons: personCollection }, - on: [`@persons.id`, `=`, `@issues.userId`], - }) - .select(`@issues.id`, `@issues.title`, `@persons.name`) - ) - }) - await waitForChanges() // Verify that we have the expected joined results @@ -439,18 +438,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - act(() => { - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - key: person.id, - type: `insert`, - changes: person, - })) - ) - }) - const { result, rerender } = renderHook( ({ minAge }: { minAge: number }) => { return useLiveQuery( @@ -465,6 +452,18 @@ describe(`Query Collections`, () => { { initialProps: { minAge: 30 } } ) + // Now sync the initial state after the query hook has started - this should trigger collection syncing + act(() => { + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + key: person.id, + type: `insert`, + changes: person, + })) + ) + }) + // Initially should return only people older than 30 expect(result.current.state.size).toBe(1) expect(result.current.state.get(`3`)).toEqual({ @@ -562,18 +561,6 @@ describe(`Query Collections`, () => { return result as T } - // Sync initial state - act(() => { - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - key: person.id, - type: `insert`, - changes: person, - })) - ) - }) - const { rerender } = renderHook( ({ minAge }: { minAge: number }) => { return useTrackedLiveQuery( @@ -588,6 +575,18 @@ describe(`Query Collections`, () => { { initialProps: { minAge: 30 } } ) + // Now sync the initial state after the query hook has started - this should trigger collection syncing + act(() => { + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + key: person.id, + type: `insert`, + changes: person, + })) + ) + }) + // Initial query should be created expect( logCalls.some((call) => call.includes(`Creating new query with deps 30`)) @@ -639,17 +638,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - act(() => { - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - }) - // Initial query const { result } = renderHook(() => { return useLiveQuery((q) => @@ -661,6 +649,17 @@ describe(`Query Collections`, () => { ) }) + // Now sync the initial state after the query hook has started - this should trigger collection syncing + act(() => { + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + }) + // Grouped query derived from initial query const { result: groupedResult } = renderHook(() => { return useLiveQuery((q) => @@ -777,28 +776,6 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data - act(() => { - emitter.emit( - `sync-person`, - initialPersons.map((person) => ({ - type: `insert`, - changes: person, - })) - ) - }) - - // Sync initial issue data - act(() => { - emitter.emit( - `sync-issue`, - initialIssues.map((issue) => ({ - type: `insert`, - changes: issue, - })) - ) - }) - // Render the hook with a query that joins persons and issues const { result } = renderHook(() => { const queryResult = useLiveQuery((q) => @@ -825,6 +802,27 @@ describe(`Query Collections`, () => { return queryResult }) + // Now sync the initial data after the query hook has started - this should trigger collection syncing for both collections + act(() => { + emitter.emit( + `sync-person`, + initialPersons.map((person) => ({ + type: `insert`, + changes: person, + })) + ) + }) + + act(() => { + emitter.emit( + `sync-issue`, + initialIssues.map((issue) => ({ + type: `insert`, + changes: issue, + })) + ) + }) + await waitForChanges() // Verify initial state diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index a9a9282943..1abd1ce11e 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -100,7 +100,15 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state + const { state, data } = useLiveQuery((q) => + q + .from({ collection }) + .where(`@age`, `>`, 30) + .select(`@id`, `@name`) + .orderBy({ "@id": `asc` }) + ) + + // Now sync the initial state after the hook has started - this should trigger collection syncing emitter.emit( `sync`, initialPersons.map((person) => ({ @@ -110,14 +118,6 @@ describe(`Query Collections`, () => { })) ) - const { state, data } = useLiveQuery((q) => - q - .from({ collection }) - .where(`@age`, `>`, 30) - .select(`@id`, `@name`) - .orderBy({ "@id": `asc` }) - ) - expect(state.value.size).toBe(1) expect(state.value.get(`3`)).toEqual({ id: `3`, @@ -278,7 +278,18 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data + const { state } = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ + type: `inner`, + from: { persons: personCollection }, + on: [`@persons.id`, `=`, `@issues.userId`], + }) + .select(`@issues.id`, `@issues.title`, `@persons.name`) + ) + + // Now sync the initial data after the hook has started - this should trigger collection syncing for both collections emitter.emit( `sync-person`, initialPersons.map((person) => ({ @@ -288,7 +299,6 @@ describe(`Query Collections`, () => { })) ) - // Sync initial issue data emitter.emit( `sync-issue`, initialIssues.map((issue) => ({ @@ -298,17 +308,6 @@ describe(`Query Collections`, () => { })) ) - const { state } = useLiveQuery((q) => - q - .from({ issues: issueCollection }) - .join({ - type: `inner`, - from: { persons: personCollection }, - on: [`@persons.id`, `=`, `@issues.userId`], - }) - .select(`@issues.id`, `@issues.title`, `@persons.name`) - ) - await waitForChanges() // Verify that we have the expected joined results @@ -417,16 +416,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - key: person.id, - type: `insert`, - changes: person, - })) - ) - const minAge = ref(30) const { state } = useLiveQuery((q) => { @@ -436,6 +425,16 @@ describe(`Query Collections`, () => { .select(`@id`, `@name`, `@age`) }) + // Now sync the initial state after the hook has started - this should trigger collection syncing + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + key: person.id, + type: `insert`, + changes: person, + })) + ) + // Initially should return only people older than 30 expect(state.value.size).toBe(1) expect(state.value.get(`3`)).toEqual({ @@ -530,16 +529,6 @@ describe(`Query Collections`, () => { return result as T } - // Sync initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - key: person.id, - type: `insert`, - changes: person, - })) - ) - const minAge = ref(30) useTrackedLiveQuery( (q) => @@ -550,6 +539,16 @@ describe(`Query Collections`, () => { [minAge] ) + // Now sync the initial state after the hook has started - this should trigger collection syncing + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + key: person.id, + type: `insert`, + changes: person, + })) + ) + // Initial query should be created expect( logCalls.some((call) => call.includes(`Creating new query with deps 30`)) @@ -599,16 +598,6 @@ describe(`Query Collections`, () => { }, }) - // Sync from initial state - emitter.emit( - `sync`, - initialPersons.map((person) => ({ - key: person.id, - type: `insert`, - changes: person, - })) - ) - // Initial query const result = useLiveQuery((q) => q @@ -618,6 +607,16 @@ describe(`Query Collections`, () => { .orderBy({ "@id": `asc` }) ) + // Now sync the initial state after the hook has started - this should trigger collection syncing + emitter.emit( + `sync`, + initialPersons.map((person) => ({ + key: person.id, + type: `insert`, + changes: person, + })) + ) + // Grouped query derived from initial query const groupedResult = useLiveQuery((q) => q @@ -730,7 +729,19 @@ describe(`Query Collections`, () => { }, }) - // Sync initial person data + // Render the hook with a query that joins persons and issues + const { state } = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ + type: `inner`, + from: { persons: personCollection }, + on: [`@persons.id`, `=`, `@issues.userId`], + }) + .select(`@issues.id`, `@issues.title`, `@persons.name`) + ) + + // Now sync the initial data after the hook has started - this should trigger collection syncing for both collections emitter.emit( `sync-person`, initialPersons.map((person) => ({ @@ -740,7 +751,6 @@ describe(`Query Collections`, () => { })) ) - // Sync initial issue data emitter.emit( `sync-issue`, initialIssues.map((issue) => ({ @@ -750,18 +760,6 @@ describe(`Query Collections`, () => { })) ) - // Render the hook with a query that joins persons and issues - const { state } = useLiveQuery((q) => - q - .from({ issues: issueCollection }) - .join({ - type: `inner`, - from: { persons: personCollection }, - on: [`@persons.id`, `=`, `@issues.userId`], - }) - .select(`@issues.id`, `@issues.title`, `@persons.name`) - ) - // Track each render state watchEffect(() => { renderStates.push({