diff --git a/.changeset/ready-bats-call.md b/.changeset/ready-bats-call.md new file mode 100644 index 000000000..72098a0f6 --- /dev/null +++ b/.changeset/ready-bats-call.md @@ -0,0 +1,6 @@ +--- +"@tanstack/react-db": patch +"@tanstack/db": patch +--- + +Refactored live queries to execute eagerly during sync. Live queries now materialize their results immediately as data arrives from source collections, even while those collections are still in a "loading" state, rather than waiting for all sources to be "ready" before executing. diff --git a/packages/angular-db/README.md b/packages/angular-db/README.md index 1d8074391..a2e3b2964 100644 --- a/packages/angular-db/README.md +++ b/packages/angular-db/README.md @@ -258,7 +258,7 @@ An object with Angular signals: - state: Signal> - Map of results by key, automatically updates - collection: Signal - The underlying collection instance - status: Signal - Current status ('idle' | 'loading' | 'ready' | 'error' | 'cleaned-up') -- isLoading: Signal - true when status is 'loading' or 'initialCommit' +- isLoading: Signal - true when status is 'loading' - isReady: Signal - true when status is 'ready' - isIdle: Signal - true when status is 'idle' - isError: Signal - true when status is 'error' diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 2a38ec086..b2080b3e4 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -184,9 +184,7 @@ export function injectLiveQuery(opts: any) { data, collection, status, - isLoading: computed( - () => status() === `loading` || status() === `initialCommit` - ), + isLoading: computed(() => status() === `loading`), isReady: computed(() => status() === `ready`), isIdle: computed(() => status() === `idle`), isError: computed(() => status() === `error`), diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index 0e21cb735..6579bd0a3 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -751,4 +751,284 @@ describe(`injectLiveQuery`, () => { expect(returnedCollection()).toBe(liveQueryCollection) }) }) + + describe(`eager execution during sync`, () => { + it(`should show state while isLoading is true during sync`, async () => { + await TestBed.runInInjectionContext(async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-execution-test-angular`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { + isLoading, + state, + data, + collection: liveQueryCollection, + } = injectLiveQuery({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: false, + }) + + // Start the live query sync manually + liveQueryCollection().preload() + + await waitForAngularUpdate() + + // Now isLoading should be true + expect(isLoading()).toBe(true) + expect(state().size).toBe(0) + expect(data()).toEqual([]) + + // Add first batch of data (but don't mark ready yet) + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + await waitForAngularUpdate() + + // Data should be visible even though still loading + expect(state().size).toBe(1) + expect(isLoading()).toBe(true) // Still loading + expect(data()).toHaveLength(1) + expect(data()[0]).toMatchObject({ + id: `1`, + name: `John Smith`, + }) + + // Add second batch of data + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Jane Doe`, + age: 32, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncCommit!() + + await waitForAngularUpdate() + + // More data should be visible + expect(state().size).toBe(2) + expect(isLoading()).toBe(true) // Still loading + expect(data()).toHaveLength(2) + + // Now mark as ready + syncMarkReady!() + + await waitForAngularUpdate() + + // Should now be ready + expect(isLoading()).toBe(false) + expect(state().size).toBe(2) + expect(data()).toHaveLength(2) + }) + }) + + it(`should show filtered results during sync with isLoading true`, async () => { + await TestBed.runInInjectionContext(async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-filter-test-angular`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { + isLoading, + state, + data, + collection: liveQueryCollection, + } = injectLiveQuery({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => eq(persons.team, `team1`)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })), + startSync: false, + }) + + // Start the live query sync manually + liveQueryCollection().preload() + + await waitForAngularUpdate() + + expect(isLoading()).toBe(true) + + // Add items from different teams + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `Alice`, + age: 30, + email: `alice@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Bob`, + age: 25, + email: `bob@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `3`, + name: `Charlie`, + age: 35, + email: `charlie@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + await waitForAngularUpdate() + + // Should only show team1 members, even while loading + expect(state().size).toBe(2) + expect(isLoading()).toBe(true) + expect(data()).toHaveLength(2) + expect(data().every((p) => p.team === `team1`)).toBe(true) + + // Mark ready + syncMarkReady!() + + await waitForAngularUpdate() + + expect(isLoading()).toBe(false) + expect(state().size).toBe(2) + }) + }) + + it(`should update isReady when source collection is marked ready with no data`, async () => { + await TestBed.runInInjectionContext(async () => { + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `ready-no-data-test-angular`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ markReady }) => { + syncMarkReady = markReady + // Don't call begin/commit - just provide markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { + isLoading, + isReady, + state, + data, + status, + collection: liveQueryCollection, + } = injectLiveQuery({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: false, + }) + + // Start the live query sync manually + liveQueryCollection().preload() + + await waitForAngularUpdate() + + // Now isLoading should be true + expect(isLoading()).toBe(true) + expect(isReady()).toBe(false) + expect(state().size).toBe(0) + expect(data()).toEqual([]) + + // Mark ready without any data commits + syncMarkReady!() + + await waitForAngularUpdate() + + // Should now be ready, even with no data + expect(isReady()).toBe(true) + expect(isLoading()).toBe(false) + expect(state().size).toBe(0) // Still no data + expect(data()).toEqual([]) // Empty array + expect(status()).toBe(`ready`) + }) + }) + }) }) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 6a2fd12ee..965d02592 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -217,7 +217,7 @@ export class CollectionImpl< // Managers private _events: CollectionEventsManager private _changes: CollectionChangesManager - private _lifecycle: CollectionLifecycleManager + public _lifecycle: CollectionLifecycleManager private _sync: CollectionSyncManager private _indexes: CollectionIndexesManager private _mutations: CollectionMutationsManager< diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 71c09d022..be607a3c9 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -75,8 +75,7 @@ export class CollectionLifecycleManager< Array > = { idle: [`loading`, `error`, `cleaned-up`], - loading: [`initialCommit`, `ready`, `error`, `cleaned-up`], - initialCommit: [`ready`, `error`, `cleaned-up`], + loading: [`ready`, `error`, `cleaned-up`], ready: [`cleaned-up`, `error`], error: [`cleaned-up`, `idle`], "cleaned-up": [`loading`, `error`], @@ -145,8 +144,8 @@ export class CollectionLifecycleManager< */ public markReady(): void { this.validateStatusTransition(this.status, `ready`) - // Can transition to ready from loading or initialCommit states - if (this.status === `loading` || this.status === `initialCommit`) { + // Can transition to ready from loading state + if (this.status === `loading`) { this.setStatus(`ready`, true) // Call any registered first ready callbacks (only on first time becoming ready) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index fa621f83b..fe29417f5 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -785,12 +785,9 @@ export class CollectionStateManager< this.recentlySyncedKeys.clear() }) - // Call any registered one-time commit listeners + // Mark that we've received the first commit (for tracking purposes) if (!this.hasReceivedFirstCommit) { this.hasReceivedFirstCommit = true - const callbacks = [...this.lifecycle.onFirstReadyCallbacks] - this.lifecycle.onFirstReadyCallbacks = [] - callbacks.forEach((callback) => callback()) } } } diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 1a26038dc..f0f1f9fb8 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -148,12 +148,6 @@ export class CollectionSyncManager< pendingTransaction.committed = true - // Update status to initialCommit when transitioning from loading - // This indicates we're in the process of committing the first transaction - if (this.lifecycle.status === `loading`) { - this.lifecycle.setStatus(`initialCommit`) - } - this.state.commitPendingTransactions() }, markReady: () => { diff --git a/packages/db/src/indexes/auto-index.ts b/packages/db/src/indexes/auto-index.ts index e06f95f0c..f9387c968 100644 --- a/packages/db/src/indexes/auto-index.ts +++ b/packages/db/src/indexes/auto-index.ts @@ -14,14 +14,6 @@ function shouldAutoIndex(collection: CollectionImpl) { return false } - // Don't auto-index during sync operations - if ( - collection.status === `loading` || - collection.status === `initialCommit` - ) { - return false - } - return true } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 4572b345a..6e68a5faa 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -21,10 +21,15 @@ import type { LiveQueryCollectionConfig, SyncState, } from "./types.js" +import type { AllCollectionEvents } from "../../collection/events.js" // Global counter for auto-generated collection IDs let liveQueryCollectionCounter = 0 +type SyncMethods = Parameters< + SyncConfig[`sync`] +>[0] + export class CollectionConfigBuilder< TContext extends Context, TResult extends object = GetResult, @@ -44,6 +49,12 @@ export class CollectionConfigBuilder< private isGraphRunning = false + // Error state tracking + private isInErrorState = false + + // Reference to the live query collection for error state transitions + private liveQueryCollection?: Collection + private graphCache: D2 | undefined private inputsCache: Record> | undefined private pipelineCache: ResultStream | undefined @@ -101,12 +112,12 @@ export class CollectionConfigBuilder< // This gives the callback a chance to load more data if needed, // that's used to optimize orderBy operators that set a limit, // in order to load some more data if we still don't have enough rows after the pipeline has run. - // That can happend because even though we load N rows, the pipeline might filter some of these rows out + // That can happen because even though we load N rows, the pipeline might filter some of these rows out // causing the orderBy operator to receive less than N rows or even no rows at all. // So this callback would notice that it doesn't have enough rows and load some more. - // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready. + // The callback returns a boolean, when it's true it's done loading data. maybeRunGraph( - config: Parameters[`sync`]>[0], + config: SyncMethods, syncState: FullSyncState, callback?: () => boolean ) { @@ -120,13 +131,15 @@ export class CollectionConfigBuilder< this.isGraphRunning = true try { - const { begin, commit, markReady } = config + const { begin, commit } = config + + // Don't run if the live query is in an error state + if (this.isInErrorState) { + return + } - // We only run the graph if all the collections are ready - if ( - this.allCollectionsReadyOrInitialCommit() && - syncState.subscribedToAllCollections - ) { + // Always run the graph if subscribed (eager execution) + if (syncState.subscribedToAllCollections) { while (syncState.graph.pendingWork()) { syncState.graph.run() callback?.() @@ -137,10 +150,9 @@ export class CollectionConfigBuilder< if (syncState.messagesCount === 0) { begin() commit() - } - // Mark the collection as ready after the first successful run - if (this.allCollectionsReady()) { - markReady() + // After initial commit, check if we should mark ready + // (in case all sources were already ready before we subscribed) + this.updateLiveQueryStatus(config) } } } finally { @@ -155,7 +167,10 @@ export class CollectionConfigBuilder< } } - private syncFn(config: Parameters[`sync`]>[0]) { + private syncFn(config: SyncMethods) { + // Store reference to the live query collection for error state transitions + this.liveQueryCollection = config.collection + const syncState: SyncState = { messagesCount: 0, subscribedToAllCollections: false, @@ -233,7 +248,7 @@ export class CollectionConfigBuilder< } private extendPipelineWithChangeProcessing( - config: Parameters[`sync`]>[0], + config: SyncMethods, syncState: SyncState ): FullSyncState { const { begin, commit } = config @@ -266,7 +281,7 @@ export class CollectionConfigBuilder< } private applyChanges( - config: Parameters[`sync`]>[0], + config: SyncMethods, changes: { deletes: number inserts: number @@ -317,21 +332,76 @@ export class CollectionConfigBuilder< } } + /** + * Handle status changes from source collections + */ + private handleSourceStatusChange( + config: SyncMethods, + collectionId: string, + event: AllCollectionEvents[`status:change`] + ) { + const { status } = event + + // Handle error state - any source collection in error puts live query in error + if (status === `error`) { + this.transitionToError( + `Source collection '${collectionId}' entered error state` + ) + return + } + + // Handle manual cleanup - this should not happen due to GC prevention, + // but could happen if user manually calls cleanup() + if (status === `cleaned-up`) { + this.transitionToError( + `Source collection '${collectionId}' was manually cleaned up while live query '${this.id}' depends on it. ` + + `Live queries prevent automatic GC, so this was likely a manual cleanup() call.` + ) + return + } + + // Update ready status based on all source collections + this.updateLiveQueryStatus(config) + } + + /** + * Update the live query status based on source collection statuses + */ + private updateLiveQueryStatus(config: SyncMethods) { + const { markReady } = config + + // Don't update status if already in error + if (this.isInErrorState) { + return + } + + // Mark ready when all source collections are ready + if (this.allCollectionsReady()) { + markReady() + } + } + + /** + * Transition the live query to error state + */ + private transitionToError(message: string) { + this.isInErrorState = true + + // Log error to console for debugging + console.error(`[Live Query Error] ${message}`) + + // Transition live query collection to error state + this.liveQueryCollection?._lifecycle.setStatus(`error`) + } + private allCollectionsReady() { return Object.values(this.collections).every((collection) => collection.isReady() ) } - private allCollectionsReadyOrInitialCommit() { - return Object.values(this.collections).every( - (collection) => - collection.status === `ready` || collection.status === `initialCommit` - ) - } - private subscribeToAllCollections( - config: Parameters[`sync`]>[0], + config: SyncMethods, syncState: FullSyncState ) { const loaders = Object.entries(this.collections).map( @@ -347,6 +417,12 @@ export class CollectionConfigBuilder< const subscription = collectionSubscriber.subscribe() this.subscriptions[collectionId] = subscription + // Subscribe to status changes for status flow + const statusUnsubscribe = collection.on(`status:change`, (event) => { + this.handleSourceStatusChange(config, collectionId, event) + }) + syncState.unsubscribeCallbacks.add(statusUnsubscribe) + const loadMore = collectionSubscriber.loadMoreIfNeeded.bind( collectionSubscriber, subscription @@ -364,6 +440,9 @@ export class CollectionConfigBuilder< // Mark the collections as subscribed in the sync state syncState.subscribedToAllCollections = true + // Initial status check after all subscriptions are set up + this.updateLiveQueryStatus(config) + return loadMoreDataCallback } } diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index fa52a341c..c2930faeb 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -103,9 +103,9 @@ export class CollectionSubscriber< // otherwise we end up in an infinite loop trying to load more data const dataLoader = sentChanges > 0 ? callback : undefined - // We need to call `maybeRunGraph` even if there's no data to load - // because we need to mark the collection as ready if it's not already - // and that's only done in `maybeRunGraph` + // Always call maybeRunGraph to process changes eagerly. + // The graph will run unless the live query is in an error state. + // Status management is handled separately via status:change event listeners. this.collectionConfigBuilder.maybeRunGraph( this.config, this.syncState, diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 4111cf134..b48ab2f5f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -298,17 +298,15 @@ export type DeleteMutationFn< * * @example * // Status transitions - * // idle → loading → initialCommit → ready + * // idle → loading → ready (when markReady() is called) * // Any status can transition to → error or cleaned-up */ 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 */ + /** Sync has started and is loading data */ | `loading` - /** Collection is in the process of committing its first transaction */ - | `initialCommit` - /** Collection has received at least one commit and is ready for use */ + /** Collection has been explicitly marked ready via markReady() */ | `ready` /** An error occurred during sync initialization */ | `error` diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index 69237e84c..53ede7f0d 100644 --- a/packages/db/tests/collection-errors.test.ts +++ b/packages/db/tests/collection-errors.test.ts @@ -290,9 +290,10 @@ describe(`Collection Error Handling`, () => { onUpdate: async () => {}, // Add handler to prevent "no handler" error onDelete: async () => {}, // Add handler to prevent "no handler" error sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { begin() commit() + markReady() }, }, }) @@ -317,10 +318,11 @@ describe(`Collection Error Handling`, () => { onUpdate: async () => {}, onDelete: async () => {}, sync: { - sync: ({ begin, write, commit }) => { + sync: ({ begin, write, commit, markReady }) => { begin() write({ type: `insert`, value: { id: `2`, name: `test2` } }) commit() + markReady() }, }, } @@ -408,10 +410,7 @@ describe(`Collection Error Handling`, () => { // Valid transitions from loading expect(() => - collectionImpl._lifecycle.validateStatusTransition( - `loading`, - `initialCommit` - ) + collectionImpl._lifecycle.validateStatusTransition(`loading`, `ready`) ).not.toThrow() expect(() => collectionImpl._lifecycle.validateStatusTransition(`loading`, `error`) @@ -423,26 +422,6 @@ describe(`Collection Error Handling`, () => { ) ).not.toThrow() - // Valid transitions from initialCommit - expect(() => - collectionImpl._lifecycle.validateStatusTransition( - `initialCommit`, - `ready` - ) - ).not.toThrow() - expect(() => - collectionImpl._lifecycle.validateStatusTransition( - `initialCommit`, - `error` - ) - ).not.toThrow() - expect(() => - collectionImpl._lifecycle.validateStatusTransition( - `initialCommit`, - `cleaned-up` - ) - ).not.toThrow() - // Valid transitions from ready expect(() => collectionImpl._lifecycle.validateStatusTransition( @@ -483,12 +462,6 @@ describe(`Collection Error Handling`, () => { expect(() => collectionImpl._lifecycle.validateStatusTransition(`idle`, `idle`) ).not.toThrow() - expect(() => - collectionImpl._lifecycle.validateStatusTransition( - `initialCommit`, - `initialCommit` - ) - ).not.toThrow() expect(() => collectionImpl._lifecycle.validateStatusTransition(`ready`, `ready`) ).not.toThrow() diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index c7dc58770..da9f73004 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -418,17 +418,19 @@ describe(`Collection getters`, () => { it(`waits for data if not yet available`, async () => { // Create a createCollection with a sync that doesn't immediately commit let commitFn: () => void + let markReadyFn: () => void const delayedSyncMock = { - sync: vi.fn(({ begin, write, commit }) => { + sync: vi.fn(({ begin, write, commit, markReady }) => { // Start sync but don't commit yet begin() write({ type: `insert`, value: { id: `delayed-item`, name: `Delayed Item` }, }) - // Save the commit function for later + // Save the commit and markReady functions for later commitFn = commit + markReadyFn = markReady }), } @@ -442,9 +444,10 @@ describe(`Collection getters`, () => { // Start the stateWhenReady promise const statePromise = delayedCollection.stateWhenReady() - // Manually trigger the commit after a short delay + // Manually trigger the commit and markReady after a short delay setTimeout(() => { commitFn() + markReadyFn() }, 10) // Now the promise should resolve @@ -478,9 +481,10 @@ describe(`Collection getters`, () => { it(`waits for data if not yet available`, async () => { // Create a createCollection with a sync that doesn't immediately commit let commitFn: () => void + let markReadyFn: () => void const delayedSyncMock = { - sync: vi.fn(({ begin, write, commit }) => { + sync: vi.fn(({ begin, write, commit, markReady }) => { // Start sync but don't commit yet begin() write({ @@ -488,8 +492,9 @@ describe(`Collection getters`, () => { id: `delayed-item`, value: { id: `delayed-item`, name: `Delayed Item` }, }) - // Save the commit function for later + // Save the commit and markReady functions for later commitFn = commit + markReadyFn = markReady }), } @@ -503,9 +508,10 @@ describe(`Collection getters`, () => { // Start the toArrayWhenReady promise const arrayPromise = delayedCollection.toArrayWhenReady() - // Manually trigger the commit after a short delay + // Manually trigger the commit and markReady after a short delay setTimeout(() => { commitFn() + markReadyFn() }, 10) // Now the promise should resolve diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index d3537263a..533ecfce5 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -88,7 +88,7 @@ describe(`Collection Indexes`, () => { getKey: (item) => item.id, startSync: true, sync: { - sync: ({ begin, write, commit }) => { + sync: ({ begin, write, commit, markReady }) => { // Provide initial data through sync begin() for (const item of testData) { @@ -98,6 +98,7 @@ describe(`Collection Indexes`, () => { }) } commit() + markReady() // Listen for mutations and sync them back (only register once) if (!emitter.all.has(`sync`)) { diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index 31bf2737a..02c2f78ae 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -1641,4 +1641,208 @@ describe(`Collection.subscribeChanges`, () => { vi.restoreAllMocks() } }) + + it(`should emit change events for multiple sync transactions before marking ready`, async () => { + const changeEvents: Array = [] + let testSyncFunctions: any = null + + const collection = createCollection<{ id: number; value: string }>({ + id: `sync-changes-before-ready`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + // Store the sync functions for testing + testSyncFunctions = { begin, write, commit, markReady } + }, + }, + }) + + // Subscribe to changes + collection.subscribeChanges((changes) => { + changeEvents.push(...changes) + }) + + const { begin, write, commit, markReady } = testSyncFunctions + + // First sync transaction - should emit insert events + begin() + write({ type: `insert`, value: { id: 1, value: `first item` } }) + write({ type: `insert`, value: { id: 2, value: `second item` } }) + commit() + + expect(changeEvents).toHaveLength(2) + expect(changeEvents[0]).toEqual({ + type: `insert`, + key: 1, + value: { id: 1, value: `first item` }, + }) + expect(changeEvents[1]).toEqual({ + type: `insert`, + key: 2, + value: { id: 2, value: `second item` }, + }) + + // Collection should still be loading + expect(collection.status).toBe(`loading`) + + // Clear events + changeEvents.length = 0 + + // Second sync transaction - should emit update and insert events + begin() + write({ type: `update`, value: { id: 1, value: `first item updated` } }) + write({ type: `insert`, value: { id: 3, value: `third item` } }) + commit() + + expect(changeEvents).toHaveLength(2) + expect(changeEvents[0]).toEqual({ + type: `update`, + key: 1, + value: { id: 1, value: `first item updated` }, + previousValue: { id: 1, value: `first item` }, + }) + expect(changeEvents[1]).toEqual({ + type: `insert`, + key: 3, + value: { id: 3, value: `third item` }, + }) + + expect(collection.status).toBe(`loading`) + + // Clear events + changeEvents.length = 0 + + // Third sync transaction - should emit delete event + begin() + write({ type: `delete`, value: { id: 2, value: `second item` } }) + commit() + + expect(changeEvents).toHaveLength(1) + expect(changeEvents[0]).toEqual({ + type: `delete`, + key: 2, + value: { id: 2, value: `second item` }, + }) + + expect(collection.status).toBe(`loading`) + + // Clear events + changeEvents.length = 0 + + // Mark as ready - should not emit any change events + markReady() + + expect(changeEvents).toHaveLength(0) + expect(collection.status).toBe(`ready`) + + // Verify final state + expect(collection.size).toBe(2) + expect(collection.state.get(1)).toEqual({ + id: 1, + value: `first item updated`, + }) + expect(collection.state.get(3)).toEqual({ id: 3, value: `third item` }) + }) + + it(`should emit change events while collection is loading for filtered subscriptions`, async () => { + const changeEvents: Array = [] + let testSyncFunctions: any = null + + const collection = createCollection<{ + id: number + value: string + active: boolean + }>({ + id: `filtered-sync-changes-before-ready`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + testSyncFunctions = { begin, write, commit, markReady } + }, + }, + }) + + // Subscribe to changes with a filter for active items only + collection.subscribeChanges( + (changes) => { + changeEvents.push(...changes) + }, + { + whereExpression: eq(new PropRef([`active`]), true), + } + ) + + const { begin, write, commit, markReady } = testSyncFunctions + + // First sync transaction - insert active and inactive items + begin() + write({ + type: `insert`, + value: { id: 1, value: `active item`, active: true }, + }) + write({ + type: `insert`, + value: { id: 2, value: `inactive item`, active: false }, + }) + commit() + + // Should only receive the active item + expect(changeEvents).toHaveLength(1) + expect(changeEvents[0]).toEqual({ + type: `insert`, + key: 1, + value: { id: 1, value: `active item`, active: true }, + }) + + expect(collection.status).toBe(`loading`) + + // Clear events + changeEvents.length = 0 + + // Second sync transaction - update inactive to active + begin() + write({ + type: `update`, + value: { id: 2, value: `inactive item`, active: true }, + }) + commit() + + // Should receive insert for the newly active item + expect(changeEvents).toHaveLength(1) + expect(changeEvents[0]).toMatchObject({ + type: `insert`, + key: 2, + value: { id: 2, value: `inactive item`, active: true }, + // Note: previousValue is included because the item existed in the collection before + }) + + expect(collection.status).toBe(`loading`) + + // Clear events + changeEvents.length = 0 + + // Third sync transaction - update active to inactive + begin() + write({ + type: `update`, + value: { id: 1, value: `active item`, active: false }, + }) + commit() + + // Should receive delete for the newly inactive item + expect(changeEvents).toHaveLength(1) + expect(changeEvents[0]).toMatchObject({ + type: `delete`, + key: 1, + value: { id: 1, value: `active item`, active: true }, + }) + + // Mark as ready + markReady() + + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(2) + }) }) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 9abfbb2ac..77b74ec75 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -1267,4 +1267,92 @@ describe(`Collection`, () => { // we truncated everything, so we should only have one item left that synced expect(collection.state.size).toBe(1) }) + + it(`should allow multiple sync transactions before marking collection ready and data should be visible`, async () => { + let testSyncFunctions: any = null + + const collection = createCollection<{ id: number; value: string }>({ + id: `multiple-sync-before-ready`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + // Store the sync functions for testing + testSyncFunctions = { begin, write, commit, markReady } + }, + }, + }) + + // Collection should start in loading state + expect(collection.status).toBe(`loading`) + expect(collection.size).toBe(0) + + const { begin, write, commit, markReady } = testSyncFunctions + + // First sync transaction + begin() + write({ type: `insert`, value: { id: 1, value: `first batch item 1` } }) + write({ type: `insert`, value: { id: 2, value: `first batch item 2` } }) + commit() + + // Data should be visible even though not ready + expect(collection.status).toBe(`loading`) + expect(collection.size).toBe(2) + expect(collection.state.get(1)).toEqual({ + id: 1, + value: `first batch item 1`, + }) + expect(collection.state.get(2)).toEqual({ + id: 2, + value: `first batch item 2`, + }) + + // Second sync transaction + begin() + write({ type: `insert`, value: { id: 3, value: `second batch item 1` } }) + write({ + type: `update`, + value: { id: 1, value: `first batch item 1 updated` }, + }) + commit() + + // More data should be visible + expect(collection.status).toBe(`loading`) + expect(collection.size).toBe(3) + expect(collection.state.get(1)).toEqual({ + id: 1, + value: `first batch item 1 updated`, + }) + expect(collection.state.get(3)).toEqual({ + id: 3, + value: `second batch item 1`, + }) + + // Third sync transaction + begin() + write({ type: `delete`, value: { id: 2, value: `first batch item 2` } }) + write({ type: `insert`, value: { id: 4, value: `third batch item 1` } }) + commit() + + // Updates should be reflected + expect(collection.status).toBe(`loading`) + expect(collection.size).toBe(3) // Deleted 2, added 4 + expect(collection.state.get(2)).toBeUndefined() + expect(collection.state.get(4)).toEqual({ + id: 4, + value: `third batch item 1`, + }) + + // Now mark as ready + markReady() + + // Should transition to ready with all data intact + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(3) + expect(Array.from(collection.state.keys()).sort()).toEqual([1, 3, 4]) + + // Verify we can use stateWhenReady + const state = await collection.stateWhenReady() + expect(state.size).toBe(3) + }) }) diff --git a/packages/db/tests/query/indexes.test.ts b/packages/db/tests/query/indexes.test.ts index b10c6fd17..4fdf16e0c 100644 --- a/packages/db/tests/query/indexes.test.ts +++ b/packages/db/tests/query/indexes.test.ts @@ -599,6 +599,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { @@ -696,6 +697,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id2, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { @@ -802,6 +804,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id2, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { @@ -890,6 +893,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id2, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { @@ -1012,6 +1016,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id2, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { @@ -1104,6 +1109,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id2, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { @@ -1203,6 +1209,7 @@ describe(`Query Index Optimization`, () => { // Create a second collection for the join with its own index const secondCollection = createCollection({ getKey: (item) => item.id2, + autoIndex: `off`, startSync: true, sync: { sync: ({ begin, write, commit }) => { diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 23b6e3d62..14c84d7e7 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -284,8 +284,7 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.get(1)).toEqual({ id: 1, name: `Alice`, active: true }) expect(liveQuery.get(3)).toEqual({ id: 3, name: `Charlie`, active: true }) expect(liveQuery.get(2)).toBeUndefined() // Bob is not active - // This test should fail because the live query is stuck in 'initialCommit' status - expect(liveQuery.status).toBe(`ready`) // This should be 'ready' but is currently 'initialCommit' + expect(liveQuery.status).toBe(`ready`) // Now add some new data to the source collection (this should work as per the original report) sourceCollection.insert({ id: 4, name: `David`, active: true }) diff --git a/packages/db/tests/query/query-while-syncing.test.ts b/packages/db/tests/query/query-while-syncing.test.ts new file mode 100644 index 000000000..81c57da3e --- /dev/null +++ b/packages/db/tests/query/query-while-syncing.test.ts @@ -0,0 +1,1137 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { createLiveQueryCollection, eq, gt } from "../../src/query/index.js" +import { createCollection } from "../../src/collection/index.js" +import { createTransaction } from "../../src/transactions.js" + +// Sample user type for tests +type User = { + id: number + name: string + age: number + active: boolean +} + +type Department = { + id: number + name: string + budget: number +} + +// Sample data for tests +const sampleUsers: Array = [ + { id: 1, name: `Alice`, age: 25, active: true }, + { id: 2, name: `Bob`, age: 19, active: true }, + { id: 3, name: `Charlie`, age: 30, active: false }, + { id: 4, name: `Dave`, age: 22, active: true }, +] + +const sampleDepartments: Array = [ + { id: 1, name: `Engineering`, budget: 100000 }, + { id: 2, name: `Sales`, budget: 80000 }, + { id: 3, name: `Marketing`, budget: 60000 }, +] + +describe(`Query while syncing`, () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe.each([`off`, `eager`] as const)(`with autoIndex %s`, (autoIndex) => { + describe(`Basic queries with startSync: true`, () => { + test(`should update live query results while source collection is syncing`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + // Create a collection that doesn't auto-start syncing + const usersCollection = createCollection({ + id: `test-users-delayed-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + // Create a live query before the source collection has any data + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // The live query starts with startSync: true, which immediately subscribes to the source + // This triggers the source collection to start syncing too (even though it has startSync: false) + // Wait a moment for the subscription to set up + await vi.advanceTimersByTimeAsync(10) + + // Both should now be in loading state + expect(usersCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) + + // Write first batch of data (but don't mark ready yet) + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) + syncWrite!({ type: `insert`, value: sampleUsers[1] }) + syncCommit!() + + // Data should be visible in both collections + expect(usersCollection.size).toBe(2) + expect(liveQuery.size).toBe(2) // Both Alice and Bob are active + + // Both should still be in loading state + expect(usersCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) + + // Write second batch of data + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[2] }) + syncWrite!({ type: `insert`, value: sampleUsers[3] }) + syncCommit!() + + // More data should be visible + expect(usersCollection.size).toBe(4) + expect(liveQuery.size).toBe(3) // Alice, Bob, and Dave are active + + // Still loading + expect(usersCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) + + // Mark the source collection as ready + syncMarkReady!() + + // Now both should be ready + expect(usersCollection.status).toBe(`ready`) + expect(liveQuery.status).toBe(`ready`) + + // Final data check + expect(liveQuery.toArray).toEqual([ + { id: 1, name: `Alice` }, + { id: 2, name: `Bob` }, + { id: 4, name: `Dave` }, + ]) + }) + + test(`should handle WHERE filters correctly during sync`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const usersCollection = createCollection({ + id: `test-users-where-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + // Query for users over 20 + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => gt(user.age, 20)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + age: user.age, + })), + }) + + // The live query will trigger the source collection to start syncing + await vi.advanceTimersByTimeAsync(10) + + // Add users one by one + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) // Alice, 25 + syncCommit!() + + expect(liveQuery.size).toBe(1) + expect(liveQuery.get(1)?.name).toBe(`Alice`) + expect(liveQuery.status).toBe(`loading`) + + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[1] }) // Bob, 19 (should be filtered out) + syncCommit!() + + expect(liveQuery.size).toBe(1) // Bob should not appear + expect(liveQuery.status).toBe(`loading`) + + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[2] }) // Charlie, 30 + syncWrite!({ type: `insert`, value: sampleUsers[3] }) // Dave, 22 + syncCommit!() + + expect(liveQuery.size).toBe(3) // Alice, Charlie, Dave + expect(liveQuery.status).toBe(`loading`) + + syncMarkReady!() + + expect(liveQuery.status).toBe(`ready`) + expect(liveQuery.toArray.map((u) => u.name).sort()).toEqual([ + `Alice`, + `Charlie`, + `Dave`, + ]) + }) + + test(`should handle SELECT projection correctly during sync`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const usersCollection = createCollection({ + id: `test-users-select-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + // Only select id and name, not age or active + })), + }) + + // The live query will trigger the source collection to start syncing + await vi.advanceTimersByTimeAsync(10) + + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) + syncCommit!() + + const result = liveQuery.get(1) + expect(result).toEqual({ id: 1, name: `Alice` }) + expect(result).not.toHaveProperty(`age`) + expect(result).not.toHaveProperty(`active`) + expect(liveQuery.status).toBe(`loading`) + + syncMarkReady!() + expect(liveQuery.status).toBe(`ready`) + }) + }) + + describe(`Join queries`, () => { + test(`should update live query with join while both sources are syncing`, async () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let deptSyncBegin: (() => void) | undefined + let deptSyncWrite: ((op: any) => void) | undefined + let deptSyncCommit: (() => void) | undefined + let deptSyncMarkReady: (() => void) | undefined + + const usersCollection = createCollection< + User & { department_id?: number }, + number + >({ + id: `test-users-join-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + }) + + const departmentsCollection = createCollection({ + id: `test-departments-join-sync`, + getKey: (dept) => dept.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + deptSyncBegin = begin + deptSyncWrite = write + deptSyncCommit = commit + deptSyncMarkReady = markReady + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .join( + { dept: departmentsCollection }, + ({ user, dept }) => eq(user.department_id, dept.id), + `inner` + ) + .select(({ user, dept }) => ({ + user_name: user.name, + department_name: dept.name, + })), + }) + + // The live query will trigger both source collections to start syncing + await vi.advanceTimersByTimeAsync(10) + + expect(liveQuery.status).toBe(`loading`) + + // Add a department first + deptSyncBegin!() + deptSyncWrite!({ type: `insert`, value: sampleDepartments[0] }) + deptSyncCommit!() + + expect(departmentsCollection.size).toBe(1) + expect(liveQuery.size).toBe(0) // No users yet + expect(liveQuery.status).toBe(`loading`) + + // Add a user with matching department + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { ...sampleUsers[0], department_id: 1 }, + }) + userSyncCommit!() + + expect(usersCollection.size).toBe(1) + expect(liveQuery.size).toBe(1) // Should have a join result now + expect(liveQuery.toArray[0]).toEqual({ + user_name: `Alice`, + department_name: `Engineering`, + }) + expect(liveQuery.status).toBe(`loading`) // Still loading because neither source is ready + + // Add more data + deptSyncBegin!() + deptSyncWrite!({ type: `insert`, value: sampleDepartments[1] }) + deptSyncCommit!() + + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { ...sampleUsers[1], department_id: 2 }, + }) + userSyncCommit!() + + expect(liveQuery.size).toBe(2) + expect(liveQuery.status).toBe(`loading`) + + // Mark first source ready + userSyncMarkReady!() + expect(usersCollection.status).toBe(`ready`) + expect(departmentsCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) // Still loading because departments not ready + + // Mark second source ready + deptSyncMarkReady!() + expect(departmentsCollection.status).toBe(`ready`) + expect(liveQuery.status).toBe(`ready`) // Now ready because all sources are ready + + expect(liveQuery.toArray).toEqual([ + { user_name: `Alice`, department_name: `Engineering` }, + { user_name: `Bob`, department_name: `Sales` }, + ]) + }) + + test(`should handle left join correctly while syncing`, async () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let deptSyncBegin: (() => void) | undefined + let deptSyncWrite: ((op: any) => void) | undefined + let deptSyncCommit: (() => void) | undefined + let deptSyncMarkReady: (() => void) | undefined + + const usersCollection = createCollection< + User & { department_id?: number }, + number + >({ + id: `test-users-left-join-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + }) + + const departmentsCollection = createCollection({ + id: `test-departments-left-join-sync`, + getKey: (dept) => dept.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + deptSyncBegin = begin + deptSyncWrite = write + deptSyncCommit = commit + deptSyncMarkReady = markReady + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .leftJoin({ dept: departmentsCollection }, ({ user, dept }) => + eq(user.department_id, dept.id) + ) + .select(({ user, dept }) => ({ + user_name: user.name, + department_name: dept?.name, + })), + }) + + // The live query will trigger both source collections to start syncing + await vi.advanceTimersByTimeAsync(10) + + // Add a user without a department + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { ...sampleUsers[0], department_id: undefined }, + }) + userSyncCommit!() + + expect(liveQuery.size).toBe(1) + expect(liveQuery.toArray[0]).toEqual({ + user_name: `Alice`, + department_name: undefined, + }) + expect(liveQuery.status).toBe(`loading`) + + // Add a department + deptSyncBegin!() + deptSyncWrite!({ type: `insert`, value: sampleDepartments[0] }) + deptSyncCommit!() + + // Add a user with matching department + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { ...sampleUsers[1], department_id: 1 }, + }) + userSyncCommit!() + + expect(liveQuery.size).toBe(2) + const results = liveQuery.toArray + expect(results.find((r) => r.user_name === `Alice`)).toEqual({ + user_name: `Alice`, + department_name: undefined, + }) + expect(results.find((r) => r.user_name === `Bob`)).toEqual({ + user_name: `Bob`, + department_name: `Engineering`, + }) + + userSyncMarkReady!() + deptSyncMarkReady!() + + expect(liveQuery.status).toBe(`ready`) + }) + }) + + describe(`Multiple source collections`, () => { + test(`should wait for all sources to be ready before marking live query ready`, async () => { + let sync1Begin: (() => void) | undefined + let sync1Write: ((op: any) => void) | undefined + let sync1Commit: (() => void) | undefined + let sync1MarkReady: (() => void) | undefined + + let sync2Begin: (() => void) | undefined + let sync2Write: ((op: any) => void) | undefined + let sync2Commit: (() => void) | undefined + let sync2MarkReady: (() => void) | undefined + + let sync3Begin: (() => void) | undefined + let sync3Write: ((op: any) => void) | undefined + let sync3Commit: (() => void) | undefined + let sync3MarkReady: (() => void) | undefined + + const collection1 = createCollection({ + id: `multi-source-1`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + sync1Begin = begin + sync1Write = write + sync1Commit = commit + sync1MarkReady = markReady + }, + }, + }) + + const collection2 = createCollection({ + id: `multi-source-2`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + sync2Begin = begin + sync2Write = write + sync2Commit = commit + sync2MarkReady = markReady + }, + }, + }) + + const collection3 = createCollection({ + id: `multi-source-3`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + sync3Begin = begin + sync3Write = write + sync3Commit = commit + sync3MarkReady = markReady + }, + }, + }) + + // Create a query that uses all three collections + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ c1: collection1 }) + .join( + { c2: collection2 }, + ({ c1, c2 }) => eq(c1.id, c2.id), + `inner` + ) + .join( + { c3: collection3 }, + ({ c1, c3 }) => eq(c1.id, c3.id), + `inner` + ) + .select(({ c1 }) => ({ + id: c1.id, + name: c1.name, + })), + }) + + // The live query will trigger all source collections to start syncing + await vi.advanceTimersByTimeAsync(10) + + expect(liveQuery.status).toBe(`loading`) + + // Add data to all collections + sync1Begin!() + sync1Write!({ type: `insert`, value: sampleUsers[0] }) + sync1Commit!() + + sync2Begin!() + sync2Write!({ type: `insert`, value: sampleUsers[0] }) + sync2Commit!() + + sync3Begin!() + sync3Write!({ type: `insert`, value: sampleUsers[0] }) + sync3Commit!() + + // Should have one matching result + expect(liveQuery.size).toBe(1) + expect(liveQuery.status).toBe(`loading`) + + // Mark first collection ready + sync1MarkReady!() + expect(collection1.status).toBe(`ready`) + expect(liveQuery.status).toBe(`loading`) // Still loading + + // Mark second collection ready + sync2MarkReady!() + expect(collection2.status).toBe(`ready`) + expect(liveQuery.status).toBe(`loading`) // Still loading + + // Mark third collection ready + sync3MarkReady!() + expect(collection3.status).toBe(`ready`) + expect(liveQuery.status).toBe(`ready`) // Now ready! + }) + }) + + describe(`Error handling during sync`, () => { + test(`should transition to error if source collection errors during sync`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + + const usersCollection = createCollection({ + id: `test-users-error-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // The live query will trigger the source collection to start syncing + await vi.advanceTimersByTimeAsync(10) + + // Add some data + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) + syncCommit!() + + expect(liveQuery.size).toBe(1) + expect(liveQuery.status).toBe(`loading`) + + // Trigger an error in the source collection by directly setting its status + // In a real scenario, this would happen if the sync function throws an error + usersCollection._lifecycle.setStatus(`error`) + + // Wait for the status change event to propagate + await vi.advanceTimersByTimeAsync(10) + + // Live query should also transition to error + expect(usersCollection.status).toBe(`error`) + expect(liveQuery.status).toBe(`error`) + + // Data should still be visible + expect(liveQuery.size).toBe(1) + }) + }) + + describe(`Basic queries with .preload()`, () => { + test(`should update live query results while source collection is syncing using preload()`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + // Create a collection that doesn't auto-start syncing + const usersCollection = createCollection({ + id: `test-users-preload-sync`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + // Create a live query WITHOUT startSync - it will be idle + const liveQuery = createLiveQueryCollection({ + startSync: false, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // Both should be idle initially + expect(usersCollection.status).toBe(`idle`) + expect(liveQuery.status).toBe(`idle`) + + // Trigger loading with preload() + const preloadPromise = liveQuery.preload() + + // Wait for subscription to set up + await vi.advanceTimersByTimeAsync(10) + + // Both should now be in loading state + expect(usersCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) + + // Write first batch of data (but don't mark ready yet) + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) + syncWrite!({ type: `insert`, value: sampleUsers[1] }) + syncCommit!() + + // Data should be visible in both collections + expect(usersCollection.size).toBe(2) + expect(liveQuery.size).toBe(2) // Both Alice and Bob are active + + // Both should still be in loading state + expect(usersCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) + + // Write second batch of data + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[2] }) + syncWrite!({ type: `insert`, value: sampleUsers[3] }) + syncCommit!() + + // More data should be visible + expect(usersCollection.size).toBe(4) + expect(liveQuery.size).toBe(3) // Alice, Bob, and Dave are active + + // Still loading + expect(usersCollection.status).toBe(`loading`) + expect(liveQuery.status).toBe(`loading`) + + // Mark the source collection as ready + syncMarkReady!() + + // Now both should be ready + expect(usersCollection.status).toBe(`ready`) + expect(liveQuery.status).toBe(`ready`) + + // preload() promise should resolve + await preloadPromise + + // Final data check + expect(liveQuery.toArray).toEqual([ + { id: 1, name: `Alice` }, + { id: 2, name: `Bob` }, + { id: 4, name: `Dave` }, + ]) + }) + + test(`should handle WHERE filters during sync with preload()`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const usersCollection = createCollection({ + id: `test-users-where-preload`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + // Query for users over 20 + const liveQuery = createLiveQueryCollection({ + startSync: false, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => gt(user.age, 20)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + age: user.age, + })), + }) + + expect(liveQuery.status).toBe(`idle`) + + // Trigger loading + liveQuery.preload() + await vi.advanceTimersByTimeAsync(10) + + // Add users one by one + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) // Alice, 25 + syncCommit!() + + expect(liveQuery.size).toBe(1) + expect(liveQuery.get(1)?.name).toBe(`Alice`) + expect(liveQuery.status).toBe(`loading`) + + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[1] }) // Bob, 19 (filtered out) + syncCommit!() + + expect(liveQuery.size).toBe(1) // Bob should not appear + expect(liveQuery.status).toBe(`loading`) + + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[2] }) // Charlie, 30 + syncWrite!({ type: `insert`, value: sampleUsers[3] }) // Dave, 22 + syncCommit!() + + expect(liveQuery.size).toBe(3) // Alice, Charlie, Dave + expect(liveQuery.status).toBe(`loading`) + + syncMarkReady!() + + expect(liveQuery.status).toBe(`ready`) + expect(liveQuery.toArray.map((u) => u.name).sort()).toEqual([ + `Alice`, + `Charlie`, + `Dave`, + ]) + }) + + test(`should handle join queries during sync with preload()`, async () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let deptSyncBegin: (() => void) | undefined + let deptSyncWrite: ((op: any) => void) | undefined + let deptSyncCommit: (() => void) | undefined + let deptSyncMarkReady: (() => void) | undefined + + const usersCollection = createCollection< + User & { department_id?: number }, + number + >({ + id: `test-users-join-preload`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + }) + + const departmentsCollection = createCollection({ + id: `test-departments-join-preload`, + getKey: (dept) => dept.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + deptSyncBegin = begin + deptSyncWrite = write + deptSyncCommit = commit + deptSyncMarkReady = markReady + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: false, + query: (q) => + q + .from({ user: usersCollection }) + .join( + { dept: departmentsCollection }, + ({ user, dept }) => eq(user.department_id, dept.id), + `inner` + ) + .select(({ user, dept }) => ({ + user_name: user.name, + department_name: dept.name, + })), + }) + + expect(liveQuery.status).toBe(`idle`) + + // Trigger loading with preload + const preloadPromise = liveQuery.preload() + await vi.advanceTimersByTimeAsync(10) + + expect(liveQuery.status).toBe(`loading`) + + // Add a department first + deptSyncBegin!() + deptSyncWrite!({ type: `insert`, value: sampleDepartments[0] }) + deptSyncCommit!() + + expect(departmentsCollection.size).toBe(1) + expect(liveQuery.size).toBe(0) // No users yet + + // Add a user with matching department + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { ...sampleUsers[0], department_id: 1 }, + }) + userSyncCommit!() + + expect(liveQuery.size).toBe(1) // Should have a join result now + expect(liveQuery.toArray[0]).toEqual({ + user_name: `Alice`, + department_name: `Engineering`, + }) + expect(liveQuery.status).toBe(`loading`) + + // Mark both sources ready + userSyncMarkReady!() + deptSyncMarkReady!() + + expect(liveQuery.status).toBe(`ready`) + await preloadPromise + }) + + test(`should handle stateWhenReady() while syncing with preload()`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const usersCollection = createCollection({ + id: `test-users-state-when-ready`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: false, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + expect(liveQuery.status).toBe(`idle`) + + // Trigger loading and wait for ready state + const statePromise = liveQuery.stateWhenReady() + + await vi.advanceTimersByTimeAsync(10) + expect(liveQuery.status).toBe(`loading`) + + // Add data while loading + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) + syncWrite!({ type: `insert`, value: sampleUsers[1] }) + syncCommit!() + + expect(liveQuery.size).toBe(2) + expect(liveQuery.status).toBe(`loading`) + + // stateWhenReady() should still be pending + let stateResolved = false + statePromise.then(() => { + stateResolved = true + }) + + await vi.advanceTimersByTimeAsync(10) + expect(stateResolved).toBe(false) + + // Mark ready + syncMarkReady!() + + // Now stateWhenReady() should resolve + const state = await statePromise + expect(stateResolved).toBe(true) + expect(state.size).toBe(2) + expect(liveQuery.status).toBe(`ready`) + }) + + test(`should reflect local optimistic mutations in live query before source is ready`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const usersCollection = createCollection({ + id: `test-users-optimistic-mutations`, + getKey: (user) => user.id, + autoIndex, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + startSync: false, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // Trigger loading + liveQuery.preload() + await vi.advanceTimersByTimeAsync(10) + + expect(liveQuery.status).toBe(`loading`) + + // Add initial data via sync + syncBegin!() + syncWrite!({ type: `insert`, value: sampleUsers[0] }) // Alice, active + syncWrite!({ type: `insert`, value: sampleUsers[2] }) // Charlie, inactive + syncCommit!() + + // Live query should show only Alice (active user) + expect(usersCollection.size).toBe(2) + expect(liveQuery.size).toBe(1) + expect(liveQuery.get(1)?.name).toBe(`Alice`) + + // Create a controlled promise for the mutation function + let resolveInsertMutation: (() => void) | undefined + const insertMutationPromise = new Promise((resolve) => { + resolveInsertMutation = resolve + }) + + // Perform a local optimistic mutation while still loading + const insertTx = createTransaction({ + mutationFn: async () => { + await insertMutationPromise + }, + }) + insertTx.mutate(() => { + usersCollection.insert({ id: 5, name: `Eve`, age: 28, active: true }) + }) + + // The optimistic mutation should be visible immediately (before mutationFn resolves) + expect(usersCollection.size).toBe(3) + expect(liveQuery.size).toBe(2) + expect(liveQuery.get(5)?.name).toBe(`Eve`) + + // Resolve the mutation WITHOUT syncing the data back + resolveInsertMutation!() + await vi.advanceTimersByTimeAsync(10) // Wait for rollback microtask + + // The optimistic mutation should be rolled back since we didn't sync it + expect(usersCollection.size).toBe(2) + expect(liveQuery.size).toBe(1) + expect(liveQuery.get(5)).toBeUndefined() + + // Now sync the data to persist it + syncBegin!() + syncWrite!({ + type: `insert`, + value: { id: 5, name: `Eve`, age: 28, active: true }, + }) + syncCommit!() + + // Now it should be persisted + expect(usersCollection.size).toBe(3) + expect(liveQuery.size).toBe(2) + expect(liveQuery.get(5)?.name).toBe(`Eve`) + + // Test update with controlled resolution + let resolveUpdateMutation: (() => void) | undefined + const updateMutationPromise = new Promise((resolve) => { + resolveUpdateMutation = resolve + }) + + const updateTx = createTransaction({ + mutationFn: async () => { + await updateMutationPromise + }, + }) + updateTx.mutate(() => { + usersCollection.update(1, (draft) => { + draft.name = `Alice Updated` + }) + }) + + // Update should be visible optimistically + expect(liveQuery.get(1)?.name).toBe(`Alice Updated`) + + // Resolve mutation and sync the update back to persist it + resolveUpdateMutation!() + await vi.advanceTimersByTimeAsync(10) + + // Without sync, it would roll back to original, but let's sync it + syncBegin!() + syncWrite!({ + type: `update`, + value: { id: 1, name: `Alice Updated`, age: 25, active: true }, + }) + syncCommit!() + + // Now it should be persisted + expect(liveQuery.get(1)?.name).toBe(`Alice Updated`) + + // Mark ready + syncMarkReady!() + + expect(usersCollection.status).toBe(`ready`) + expect(liveQuery.status).toBe(`ready`) + }) + }) + }) +}) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index e1ae4d04b..90145d25d 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -522,9 +522,7 @@ export function useLiveQuery( }, collection: snapshot.collection, status: snapshot.collection.status, - isLoading: - snapshot.collection.status === `loading` || - snapshot.collection.status === `initialCommit`, + isLoading: snapshot.collection.status === `loading`, isReady: snapshot.collection.status === `ready`, isIdle: snapshot.collection.status === `idle`, isError: snapshot.collection.status === `error`, diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index cd57cbd37..214e9238d 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1552,6 +1552,423 @@ describe(`Query Collections`, () => { }) }) + describe(`eager execution during sync`, () => { + it(`should show state while isLoading is true during sync`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + // Create a collection that doesn't auto-start syncing + const collection = createCollection({ + id: `eager-execution-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Initially isLoading should be true + expect(result.current.isLoading).toBe(true) + expect(result.current.state.size).toBe(0) + expect(result.current.data).toEqual([]) + + // Start sync manually + act(() => { + collection.preload() + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Still loading + expect(result.current.isLoading).toBe(true) + + // Add first batch of data (but don't mark ready yet) + act(() => { + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + }) + + // Data should be visible even though still loading + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + expect(result.current.isLoading).toBe(true) // Still loading + expect(result.current.data).toHaveLength(1) + expect(result.current.data[0]).toMatchObject({ + id: `1`, + name: `John Smith`, + }) + + // Add second batch of data + act(() => { + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Jane Doe`, + age: 32, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncCommit!() + }) + + // More data should be visible + await waitFor(() => { + expect(result.current.state.size).toBe(2) + }) + expect(result.current.isLoading).toBe(true) // Still loading + expect(result.current.data).toHaveLength(2) + + // Now mark as ready + act(() => { + syncMarkReady!() + }) + + // Should now be ready + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + expect(result.current.isReady).toBe(true) + expect(result.current.state.size).toBe(2) + expect(result.current.data).toHaveLength(2) + }) + + it(`should show filtered results during sync with isLoading true`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-filter-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => eq(persons.team, `team1`)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })) + ) + }) + + // Start sync + act(() => { + collection.preload() + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.current.isLoading).toBe(true) + + // Add items from different teams + act(() => { + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `Alice`, + age: 30, + email: `alice@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Bob`, + age: 25, + email: `bob@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `3`, + name: `Charlie`, + age: 35, + email: `charlie@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + }) + + // Should only show team1 members, even while loading + await waitFor(() => { + expect(result.current.state.size).toBe(2) + }) + expect(result.current.isLoading).toBe(true) + expect(result.current.data).toHaveLength(2) + expect(result.current.data.every((p) => p.team === `team1`)).toBe(true) + + // Mark ready + act(() => { + syncMarkReady!() + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + }) + expect(result.current.isLoading).toBe(false) + expect(result.current.state.size).toBe(2) + }) + + it(`should show join results during sync with isLoading true`, async () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let issueSyncBegin: (() => void) | undefined + let issueSyncWrite: ((op: any) => void) | undefined + let issueSyncCommit: (() => void) | undefined + let issueSyncMarkReady: (() => void) | undefined + + const personCollection = createCollection({ + id: `eager-join-persons`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const issueCollection = createCollection({ + id: `eager-join-issues`, + getKey: (issue: Issue) => issue.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + issueSyncBegin = begin + issueSyncWrite = write + issueSyncCommit = commit + issueSyncMarkReady = markReady + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons?.name, + })) + ) + }) + + // Start sync for both + act(() => { + personCollection.preload() + issueCollection.preload() + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.current.isLoading).toBe(true) + + // Add a person first + act(() => { + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Doe`, + age: 30, + email: `john@example.com`, + isActive: true, + team: `team1`, + }, + }) + userSyncCommit!() + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.current.isLoading).toBe(true) + expect(result.current.state.size).toBe(0) // No joins yet + + // Add an issue for that person + act(() => { + issueSyncBegin!() + issueSyncWrite!({ + type: `insert`, + value: { + id: `1`, + title: `First Issue`, + description: `Description`, + userId: `1`, + }, + }) + issueSyncCommit!() + }) + + // Should see join result even while loading + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + expect(result.current.isLoading).toBe(true) + expect(result.current.data).toHaveLength(1) + expect(result.current.data[0]).toMatchObject({ + id: `1`, + title: `First Issue`, + userName: `John Doe`, + }) + + // Mark both as ready + act(() => { + userSyncMarkReady!() + issueSyncMarkReady!() + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + }) + expect(result.current.isLoading).toBe(false) + expect(result.current.state.size).toBe(1) + }) + + it(`should update isReady when source collection is marked ready with no data`, async () => { + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `ready-no-data-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ markReady }) => { + syncMarkReady = markReady + // Don't call begin/commit - just provide markReady + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Initially isLoading should be true + expect(result.current.isLoading).toBe(true) + expect(result.current.isReady).toBe(false) + expect(result.current.state.size).toBe(0) + expect(result.current.data).toEqual([]) + + // Start sync manually + act(() => { + collection.preload() + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Still loading + expect(result.current.isLoading).toBe(true) + expect(result.current.isReady).toBe(false) + + // Mark ready without any data commits + act(() => { + syncMarkReady!() + }) + + // Should now be ready, even with no data + await waitFor(() => { + expect(result.current.isReady).toBe(true) + }) + expect(result.current.isLoading).toBe(false) + expect(result.current.state.size).toBe(0) // Still no data + expect(result.current.data).toEqual([]) // Empty array + expect(result.current.status).toBe(`ready`) + }) + }) + describe(`callback variants with conditional returns`, () => { it(`should handle callback returning undefined with proper state`, async () => { const collection = createCollection( diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index a4c4919f5..d3e645871 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -319,7 +319,7 @@ export function useLiveQuery( data, collection, status, - isLoading: () => status() === `loading` || status() === `initialCommit`, + isLoading: () => status() === `loading`, isReady: () => status() === `ready`, isIdle: () => status() === `idle`, isError: () => status() === `error`, diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 182600fef..459a10bd1 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -1361,4 +1361,391 @@ describe(`Query Collections`, () => { }) }) }) + + describe(`eager execution during sync`, () => { + it(`should show state while isLoading is true during sync`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-execution-test-solid`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Initially isLoading should be true + expect(result.isLoading()).toBe(true) + expect(result.state.size).toBe(0) + expect(result.data).toEqual([]) + + // Start sync manually + collection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Still loading + expect(result.isLoading()).toBe(true) + + // Add first batch of data (but don't mark ready yet) + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + // Data should be visible even though still loading + await waitFor(() => { + expect(result.state.size).toBe(1) + }) + expect(result.isLoading()).toBe(true) // Still loading + expect(result.data).toHaveLength(1) + expect(result.data[0]).toMatchObject({ + id: `1`, + name: `John Smith`, + }) + + // Add second batch of data + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Jane Doe`, + age: 32, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncCommit!() + + // More data should be visible + await waitFor(() => { + expect(result.state.size).toBe(2) + }) + expect(result.isLoading()).toBe(true) // Still loading + expect(result.data).toHaveLength(2) + + // Now mark as ready + syncMarkReady!() + + // Should now be ready + await waitFor(() => { + expect(result.isLoading()).toBe(false) + }) + expect(result.state.size).toBe(2) + expect(result.data).toHaveLength(2) + }) + + it(`should show filtered results during sync with isLoading true`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-filter-test-solid`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => eq(persons.team, `team1`)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })) + ) + }) + + // Start sync + collection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.isLoading()).toBe(true) + + // Add items from different teams + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `Alice`, + age: 30, + email: `alice@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Bob`, + age: 25, + email: `bob@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `3`, + name: `Charlie`, + age: 35, + email: `charlie@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + // Should only show team1 members, even while loading + await waitFor(() => { + expect(result.state.size).toBe(2) + }) + expect(result.isLoading()).toBe(true) + expect(result.data).toHaveLength(2) + expect(result.data.every((p) => p.team === `team1`)).toBe(true) + + // Mark ready + syncMarkReady!() + + await waitFor(() => { + expect(result.isLoading()).toBe(false) + }) + expect(result.state.size).toBe(2) + }) + + it(`should show join results during sync with isLoading true`, async () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let issueSyncBegin: (() => void) | undefined + let issueSyncWrite: ((op: any) => void) | undefined + let issueSyncCommit: (() => void) | undefined + let issueSyncMarkReady: (() => void) | undefined + + const personCollection = createCollection({ + id: `eager-join-persons-solid`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const issueCollection = createCollection({ + id: `eager-join-issues-solid`, + getKey: (issue: Issue) => issue.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + issueSyncBegin = begin + issueSyncWrite = write + issueSyncCommit = commit + issueSyncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name, + })) + ) + }) + + // Start sync for both + personCollection.preload() + issueCollection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.isLoading()).toBe(true) + + // Add a person first + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Doe`, + age: 30, + email: `john@example.com`, + isActive: true, + team: `team1`, + }, + }) + userSyncCommit!() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.isLoading()).toBe(true) + expect(result.state.size).toBe(0) // No joins yet + + // Add an issue for that person + issueSyncBegin!() + issueSyncWrite!({ + type: `insert`, + value: { + id: `1`, + title: `First Issue`, + description: `Description`, + userId: `1`, + }, + }) + issueSyncCommit!() + + // Should see join result even while loading + await waitFor(() => { + expect(result.state.size).toBe(1) + }) + expect(result.isLoading()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.data[0]).toMatchObject({ + id: `1`, + title: `First Issue`, + userName: `John Doe`, + }) + + // Mark both as ready + userSyncMarkReady!() + issueSyncMarkReady!() + + await waitFor(() => { + expect(result.isLoading()).toBe(false) + }) + expect(result.state.size).toBe(1) + }) + + it(`should update isReady when source collection is marked ready with no data`, async () => { + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `ready-no-data-test-solid`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ markReady }) => { + syncMarkReady = markReady + // Don't call begin/commit - just provide markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Initially isLoading should be true + expect(result.isLoading()).toBe(true) + expect(result.isReady()).toBe(false) + expect(result.state.size).toBe(0) + expect(result.data).toEqual([]) + + // Start sync manually + collection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Still loading + expect(result.isLoading()).toBe(true) + expect(result.isReady()).toBe(false) + + // Mark ready without any data commits + syncMarkReady!() + + // Should now be ready, even with no data + await waitFor(() => { + expect(result.isReady()).toBe(true) + }) + expect(result.isLoading()).toBe(false) + expect(result.state.size).toBe(0) // Still no data + expect(result.data).toEqual([]) // Empty array + expect(result.status()).toBe(`ready`) + }) + }) }) diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 97bd8d35b..4c3625fdc 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -384,7 +384,7 @@ export function useLiveQuery( return status }, get isLoading() { - return status === `loading` || status === `initialCommit` + return status === `loading` }, get isReady() { return status === `ready` diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index 08b51535d..5916c9da0 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -1298,4 +1298,383 @@ describe(`Query Collections`, () => { }) }) }) + + describe(`eager execution during sync`, () => { + it(`should show state while isLoading is true during sync`, () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-execution-test-svelte`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially isLoading should be true + expect(query.isLoading).toBe(true) + expect(query.state.size).toBe(0) + expect(query.data).toEqual([]) + + // Start sync manually + collection.preload() + + // Still loading + expect(query.isLoading).toBe(true) + + // Add first batch of data (but don't mark ready yet) + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + flushSync() + + // Data should be visible even though still loading + expect(query.state.size).toBe(1) + expect(query.isLoading).toBe(true) // Still loading + expect(query.data).toHaveLength(1) + expect(query.data[0]).toMatchObject({ + id: `1`, + name: `John Smith`, + }) + + // Add second batch of data + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Jane Doe`, + age: 32, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncCommit!() + + flushSync() + + // More data should be visible + expect(query.state.size).toBe(2) + expect(query.isLoading).toBe(true) // Still loading + expect(query.data).toHaveLength(2) + + // Now mark as ready + syncMarkReady!() + + flushSync() + + // Should now be ready + expect(query.isLoading).toBe(false) + expect(query.state.size).toBe(2) + expect(query.data).toHaveLength(2) + }) + }) + + it(`should show filtered results during sync with isLoading true`, () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-filter-test-svelte`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => eq(persons.team, `team1`)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })) + ) + + // Start sync + collection.preload() + + expect(query.isLoading).toBe(true) + + // Add items from different teams + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `Alice`, + age: 30, + email: `alice@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Bob`, + age: 25, + email: `bob@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `3`, + name: `Charlie`, + age: 35, + email: `charlie@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + flushSync() + + // Should only show team1 members, even while loading + expect(query.state.size).toBe(2) + expect(query.isLoading).toBe(true) + expect(query.data).toHaveLength(2) + expect(query.data.every((p) => p.team === `team1`)).toBe(true) + + // Mark ready + syncMarkReady!() + + flushSync() + + expect(query.isLoading).toBe(false) + expect(query.state.size).toBe(2) + }) + }) + + it(`should show join results during sync with isLoading true`, () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let issueSyncBegin: (() => void) | undefined + let issueSyncWrite: ((op: any) => void) | undefined + let issueSyncCommit: (() => void) | undefined + let issueSyncMarkReady: (() => void) | undefined + + const personCollection = createCollection({ + id: `eager-join-persons-svelte`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const issueCollection = createCollection({ + id: `eager-join-issues-svelte`, + getKey: (issue: Issue) => issue.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + issueSyncBegin = begin + issueSyncWrite = write + issueSyncCommit = commit + issueSyncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name, + })) + ) + + // Start sync for both + personCollection.preload() + issueCollection.preload() + + expect(query.isLoading).toBe(true) + + // Add a person first + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Doe`, + age: 30, + email: `john@example.com`, + isActive: true, + team: `team1`, + }, + }) + userSyncCommit!() + + flushSync() + + expect(query.isLoading).toBe(true) + expect(query.state.size).toBe(0) // No joins yet + + // Add an issue for that person + issueSyncBegin!() + issueSyncWrite!({ + type: `insert`, + value: { + id: `1`, + title: `First Issue`, + description: `Description`, + userId: `1`, + }, + }) + issueSyncCommit!() + + flushSync() + + // Should see join result even while loading + expect(query.state.size).toBe(1) + expect(query.isLoading).toBe(true) + expect(query.data).toHaveLength(1) + expect(query.data[0]).toMatchObject({ + id: `1`, + title: `First Issue`, + userName: `John Doe`, + }) + + // Mark both as ready + userSyncMarkReady!() + issueSyncMarkReady!() + + flushSync() + + expect(query.isLoading).toBe(false) + expect(query.state.size).toBe(1) + }) + }) + + it(`should update isReady when source collection is marked ready with no data`, () => { + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `ready-no-data-test-svelte`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ markReady }) => { + syncMarkReady = markReady + // Don't call begin/commit - just provide markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially isLoading should be true + expect(query.isLoading).toBe(true) + expect(query.isReady).toBe(false) + expect(query.state.size).toBe(0) + expect(query.data).toEqual([]) + + // Start sync manually + collection.preload() + + // Still loading + expect(query.isLoading).toBe(true) + expect(query.isReady).toBe(false) + + // Mark ready without any data commits + syncMarkReady!() + + flushSync() + + // Should now be ready, even with no data + expect(query.isReady).toBe(true) + expect(query.isLoading).toBe(false) + expect(query.state.size).toBe(0) // Still no data + expect(query.data).toEqual([]) // Empty array + expect(query.status).toBe(`ready`) + }) + }) + }) }) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 4de3881ad..298eaed91 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -365,9 +365,7 @@ export function useLiveQuery( data, collection: computed(() => collection.value), status: computed(() => status.value), - isLoading: computed( - () => status.value === `loading` || status.value === `initialCommit` - ), + isLoading: computed(() => status.value === `loading`), isReady: computed(() => status.value === `ready`), isIdle: computed(() => status.value === `idle`), isError: computed(() => status.value === `error`), diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 5fbbf321a..372bba52d 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -1327,7 +1327,7 @@ describe(`Query Collections`, () => { }) it(`should handle status transitions without change events`, async () => { - // This test reproduces the bug where status gets stuck in 'initialCommit' + // This test reproduces the bug where status gets stuck in 'loading' // when the collection status changes without triggering change events let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined @@ -1433,4 +1433,383 @@ describe(`Query Collections`, () => { age: 35, }) }) + + describe(`eager execution during sync`, () => { + it(`should show state while isLoading is true during sync`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-execution-test-vue`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, state, data } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially isLoading should be true + expect(isLoading.value).toBe(true) + expect(state.value.size).toBe(0) + expect(data.value).toEqual([]) + + // Start sync manually + collection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Still loading + expect(isLoading.value).toBe(true) + + // Add first batch of data (but don't mark ready yet) + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + // Data should be visible even though still loading + await waitFor(() => { + expect(state.value.size).toBe(1) + }) + expect(isLoading.value).toBe(true) // Still loading + expect(data.value).toHaveLength(1) + expect(data.value[0]).toMatchObject({ + id: `1`, + name: `John Smith`, + }) + + // Add second batch of data + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Jane Doe`, + age: 32, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncCommit!() + + // More data should be visible + await waitFor(() => { + expect(state.value.size).toBe(2) + }) + expect(isLoading.value).toBe(true) // Still loading + expect(data.value).toHaveLength(2) + + // Now mark as ready + syncMarkReady!() + + // Should now be ready + await waitFor(() => { + expect(isLoading.value).toBe(false) + }) + expect(state.value.size).toBe(2) + expect(data.value).toHaveLength(2) + }) + + it(`should show filtered results during sync with isLoading true`, async () => { + let syncBegin: (() => void) | undefined + let syncWrite: ((op: any) => void) | undefined + let syncCommit: (() => void) | undefined + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `eager-filter-test-vue`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncBegin = begin + syncWrite = write + syncCommit = commit + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, state, data } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => eq(persons.team, `team1`)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + })) + ) + + // Start sync + collection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(isLoading.value).toBe(true) + + // Add items from different teams + syncBegin!() + syncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `Alice`, + age: 30, + email: `alice@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `2`, + name: `Bob`, + age: 25, + email: `bob@example.com`, + isActive: true, + team: `team2`, + }, + }) + syncWrite!({ + type: `insert`, + value: { + id: `3`, + name: `Charlie`, + age: 35, + email: `charlie@example.com`, + isActive: true, + team: `team1`, + }, + }) + syncCommit!() + + // Should only show team1 members, even while loading + await waitFor(() => { + expect(state.value.size).toBe(2) + }) + expect(isLoading.value).toBe(true) + expect(data.value).toHaveLength(2) + expect(data.value.every((p) => p.team === `team1`)).toBe(true) + + // Mark ready + syncMarkReady!() + + await waitFor(() => { + expect(isLoading.value).toBe(false) + }) + expect(state.value.size).toBe(2) + }) + + it(`should show join results during sync with isLoading true`, async () => { + let userSyncBegin: (() => void) | undefined + let userSyncWrite: ((op: any) => void) | undefined + let userSyncCommit: (() => void) | undefined + let userSyncMarkReady: (() => void) | undefined + + let issueSyncBegin: (() => void) | undefined + let issueSyncWrite: ((op: any) => void) | undefined + let issueSyncCommit: (() => void) | undefined + let issueSyncMarkReady: (() => void) | undefined + + const personCollection = createCollection({ + id: `eager-join-persons-vue`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + userSyncBegin = begin + userSyncWrite = write + userSyncCommit = commit + userSyncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const issueCollection = createCollection({ + id: `eager-join-issues-vue`, + getKey: (issue: Issue) => issue.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + issueSyncBegin = begin + issueSyncWrite = write + issueSyncCommit = commit + issueSyncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, state, data } = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name, + })) + ) + + // Start sync for both + personCollection.preload() + issueCollection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(isLoading.value).toBe(true) + + // Add a person first + userSyncBegin!() + userSyncWrite!({ + type: `insert`, + value: { + id: `1`, + name: `John Doe`, + age: 30, + email: `john@example.com`, + isActive: true, + team: `team1`, + }, + }) + userSyncCommit!() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(isLoading.value).toBe(true) + expect(state.value.size).toBe(0) // No joins yet + + // Add an issue for that person + issueSyncBegin!() + issueSyncWrite!({ + type: `insert`, + value: { + id: `1`, + title: `First Issue`, + description: `Description`, + userId: `1`, + }, + }) + issueSyncCommit!() + + // Should see join result even while loading + await waitFor(() => { + expect(state.value.size).toBe(1) + }) + expect(isLoading.value).toBe(true) + expect(data.value).toHaveLength(1) + expect(data.value[0]).toMatchObject({ + id: `1`, + title: `First Issue`, + userName: `John Doe`, + }) + + // Mark both as ready + userSyncMarkReady!() + issueSyncMarkReady!() + + await waitFor(() => { + expect(isLoading.value).toBe(false) + }) + expect(state.value.size).toBe(1) + }) + + it(`should update isReady when source collection is marked ready with no data`, async () => { + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `ready-no-data-test-vue`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ markReady }) => { + syncMarkReady = markReady + // Don't call begin/commit - just provide markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, isReady, state, data, status } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially isLoading should be true + expect(isLoading.value).toBe(true) + expect(isReady.value).toBe(false) + expect(state.value.size).toBe(0) + expect(data.value).toEqual([]) + + // Start sync manually + collection.preload() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Still loading + expect(isLoading.value).toBe(true) + expect(isReady.value).toBe(false) + + // Mark ready without any data commits + syncMarkReady!() + + // Should now be ready, even with no data + await waitFor(() => { + expect(isReady.value).toBe(true) + }) + expect(isLoading.value).toBe(false) + expect(state.value.size).toBe(0) // Still no data + expect(data.value).toEqual([]) // Empty array + expect(status.value).toBe(`ready`) + }) + }) })