Skip to content

Commit 20f810e

Browse files
KyleAMathewsclaude
andauthored
Add explicit collection readiness detection with isReady() and markReady() (#270)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1758eda commit 20f810e

File tree

11 files changed

+174
-73
lines changed

11 files changed

+174
-73
lines changed

.changeset/wide-dancers-battle.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@tanstack/electric-db-collection": patch
3+
"@tanstack/query-db-collection": patch
4+
"@tanstack/db": patch
5+
---
6+
7+
Add explicit collection readiness detection with `isReady()` and `markReady()`
8+
9+
- Add `isReady()` method to check if a collection is ready for use
10+
- Add `onFirstReady()` method to register callbacks for when collection becomes ready
11+
- Add `markReady()` to SyncConfig interface for sync implementations to explicitly signal readiness
12+
- Replace `onFirstCommit()` with `onFirstReady()` for better semantics
13+
- Update status state machine to allow `loading``ready` transition for cases with no data to commit
14+
- Update all sync implementations (Electric, Query, Local-only, Local-storage) to use `markReady()`
15+
- Improve error handling by allowing collections to be marked ready even when sync errors occur
16+
17+
This provides a more intuitive and ergonomic API for determining collection readiness, replacing the previous approach of using commits as a readiness signal.

packages/db/src/collection.ts

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,9 @@ export class CollectionImpl<
229229
private hasReceivedFirstCommit = false
230230
private isCommittingSyncTransactions = false
231231

232-
// Array to store one-time commit listeners
233-
private onFirstCommitCallbacks: Array<() => void> = []
232+
// Array to store one-time ready listeners
233+
private onFirstReadyCallbacks: Array<() => void> = []
234+
private hasBeenReady = false
234235

235236
// Event batching for preventing duplicate emissions during transaction flows
236237
private batchedEvents: Array<ChangeMessage<T, TKey>> = []
@@ -244,17 +245,66 @@ export class CollectionImpl<
244245
private syncCleanupFn: (() => void) | null = null
245246

246247
/**
247-
* Register a callback to be executed on the next commit
248+
* Register a callback to be executed when the collection first becomes ready
248249
* Useful for preloading collections
249-
* @param callback Function to call after the next commit
250+
* @param callback Function to call when the collection first becomes ready
250251
* @example
251-
* collection.onFirstCommit(() => {
252-
* console.log('Collection has received first data')
252+
* collection.onFirstReady(() => {
253+
* console.log('Collection is ready for the first time')
253254
* // Safe to access collection.state now
254255
* })
255256
*/
256-
public onFirstCommit(callback: () => void): void {
257-
this.onFirstCommitCallbacks.push(callback)
257+
public onFirstReady(callback: () => void): void {
258+
// If already ready, call immediately
259+
if (this.hasBeenReady) {
260+
callback()
261+
return
262+
}
263+
264+
this.onFirstReadyCallbacks.push(callback)
265+
}
266+
267+
/**
268+
* Check if the collection is ready for use
269+
* Returns true if the collection has been marked as ready by its sync implementation
270+
* @returns true if the collection is ready, false otherwise
271+
* @example
272+
* if (collection.isReady()) {
273+
* console.log('Collection is ready, data is available')
274+
* // Safe to access collection.state
275+
* } else {
276+
* console.log('Collection is still loading')
277+
* }
278+
*/
279+
public isReady(): boolean {
280+
return this._status === `ready`
281+
}
282+
283+
/**
284+
* Mark the collection as ready for use
285+
* This is called by sync implementations to explicitly signal that the collection is ready,
286+
* providing a more intuitive alternative to using commits for readiness signaling
287+
* @private - Should only be called by sync implementations
288+
*/
289+
private markReady(): void {
290+
// Can transition to ready from loading or initialCommit states
291+
if (this._status === `loading` || this._status === `initialCommit`) {
292+
this.setStatus(`ready`)
293+
294+
// Call any registered first ready callbacks (only on first time becoming ready)
295+
if (!this.hasBeenReady) {
296+
this.hasBeenReady = true
297+
298+
// Also mark as having received first commit for backwards compatibility
299+
if (!this.hasReceivedFirstCommit) {
300+
this.hasReceivedFirstCommit = true
301+
}
302+
303+
const callbacks = [...this.onFirstReadyCallbacks]
304+
this.onFirstReadyCallbacks = []
305+
callbacks.forEach((callback) => callback())
306+
}
307+
}
258308
}
259309

260310
public id = ``
@@ -302,7 +352,7 @@ export class CollectionImpl<
302352
Array<CollectionStatus>
303353
> = {
304354
idle: [`loading`, `error`, `cleaned-up`],
305-
loading: [`initialCommit`, `error`, `cleaned-up`],
355+
loading: [`initialCommit`, `ready`, `error`, `cleaned-up`],
306356
initialCommit: [`ready`, `error`, `cleaned-up`],
307357
ready: [`cleaned-up`, `error`],
308358
error: [`cleaned-up`, `idle`],
@@ -455,11 +505,9 @@ export class CollectionImpl<
455505
}
456506

457507
this.commitPendingTransactions()
458-
459-
// Transition from initialCommit to ready after the first commit is complete
460-
if (this._status === `initialCommit`) {
461-
this.setStatus(`ready`)
462-
}
508+
},
509+
markReady: () => {
510+
this.markReady()
463511
},
464512
})
465513

@@ -492,7 +540,7 @@ export class CollectionImpl<
492540
}
493541

494542
// Register callback BEFORE starting sync to avoid race condition
495-
this.onFirstCommit(() => {
543+
this.onFirstReady(() => {
496544
resolve()
497545
})
498546

@@ -555,7 +603,8 @@ export class CollectionImpl<
555603
this.pendingSyncedTransactions = []
556604
this.syncedKeys.clear()
557605
this.hasReceivedFirstCommit = false
558-
this.onFirstCommitCallbacks = []
606+
this.hasBeenReady = false
607+
this.onFirstReadyCallbacks = []
559608
this.preloadPromise = null
560609
this.batchedEvents = []
561610
this.shouldBatchEvents = false
@@ -1184,8 +1233,8 @@ export class CollectionImpl<
11841233
// Call any registered one-time commit listeners
11851234
if (!this.hasReceivedFirstCommit) {
11861235
this.hasReceivedFirstCommit = true
1187-
const callbacks = [...this.onFirstCommitCallbacks]
1188-
this.onFirstCommitCallbacks = []
1236+
const callbacks = [...this.onFirstReadyCallbacks]
1237+
this.onFirstReadyCallbacks = []
11891238
callbacks.forEach((callback) => callback())
11901239
}
11911240
}
@@ -1812,14 +1861,14 @@ export class CollectionImpl<
18121861
* @returns Promise that resolves to a Map containing all items in the collection
18131862
*/
18141863
stateWhenReady(): Promise<Map<TKey, T>> {
1815-
// If we already have data or there are no loading collections, resolve immediately
1816-
if (this.size > 0 || this.hasReceivedFirstCommit === true) {
1864+
// If we already have data or collection is ready, resolve immediately
1865+
if (this.size > 0 || this.isReady()) {
18171866
return Promise.resolve(this.state)
18181867
}
18191868

1820-
// Otherwise, wait for the first commit
1869+
// Otherwise, wait for the collection to be ready
18211870
return new Promise<Map<TKey, T>>((resolve) => {
1822-
this.onFirstCommit(() => {
1871+
this.onFirstReady(() => {
18231872
resolve(this.state)
18241873
})
18251874
})
@@ -1841,14 +1890,14 @@ export class CollectionImpl<
18411890
* @returns Promise that resolves to an Array containing all items in the collection
18421891
*/
18431892
toArrayWhenReady(): Promise<Array<T>> {
1844-
// If we already have data or there are no loading collections, resolve immediately
1845-
if (this.size > 0 || this.hasReceivedFirstCommit === true) {
1893+
// If we already have data or collection is ready, resolve immediately
1894+
if (this.size > 0 || this.isReady()) {
18461895
return Promise.resolve(this.toArray)
18471896
}
18481897

1849-
// Otherwise, wait for the first commit
1898+
// Otherwise, wait for the collection to be ready
18501899
return new Promise<Array<T>>((resolve) => {
1851-
this.onFirstCommit(() => {
1900+
this.onFirstReady(() => {
18521901
resolve(this.toArray)
18531902
})
18541903
})

packages/db/src/local-only.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ function createLocalOnlySync<T extends object, TKey extends string | number>(
240240
* @returns Unsubscribe function (empty since no ongoing sync is needed)
241241
*/
242242
sync: (params) => {
243-
const { begin, write, commit } = params
243+
const { begin, write, commit, markReady } = params
244244

245245
// Capture sync functions for later use by confirmOperationsSync
246246
syncBegin = begin
@@ -259,6 +259,9 @@ function createLocalOnlySync<T extends object, TKey extends string | number>(
259259
commit()
260260
}
261261

262+
// Mark collection as ready since local-only collections are immediately ready
263+
markReady()
264+
262265
// Return empty unsubscribe function - no ongoing sync needed
263266
return () => {}
264267
},

packages/db/src/local-storage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ function createLocalStorageSync<T extends object>(
586586

587587
const syncConfig: SyncConfig<T> & { manualTrigger?: () => void } = {
588588
sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
589-
const { begin, write, commit } = params
589+
const { begin, write, commit, markReady } = params
590590

591591
// Store sync params for later use
592592
syncParams = params
@@ -608,6 +608,9 @@ function createLocalStorageSync<T extends object>(
608608
lastKnownData.set(key, storedItem)
609609
})
610610

611+
// Mark collection as ready after initial load
612+
markReady()
613+
611614
// Listen for storage events from other tabs
612615
const handleStorageEvent = (event: StorageEvent) => {
613616
// Only respond to changes to our specific key and from our storage

packages/db/src/query/live-query-collection.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export function liveQueryCollectionOptions<
203203
// Create the sync configuration
204204
const sync: SyncConfig<TResult> = {
205205
rowUpdateMode: `full`,
206-
sync: ({ begin, write, commit, collection: theCollection }) => {
206+
sync: ({ begin, write, commit, markReady, collection: theCollection }) => {
207207
const { graph, inputs, pipeline } = maybeCompileBasePipeline()
208208
let messagesCount = 0
209209
pipeline.pipe(
@@ -295,6 +295,8 @@ export function liveQueryCollectionOptions<
295295
begin()
296296
commit()
297297
}
298+
// Mark the collection as ready after the first successful run
299+
markReady()
298300
}
299301
}
300302

packages/db/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export interface SyncConfig<
203203
begin: () => void
204204
write: (message: Omit<ChangeMessage<T>, `key`>) => void
205205
commit: () => void
206+
markReady: () => void
206207
}) => void
207208

208209
/**

packages/db/tests/collection-lifecycle.test.ts

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,12 @@ describe(`Collection Lifecycle Management`, () => {
5252
id: `status-test`,
5353
getKey: (item) => item.id,
5454
sync: {
55-
sync: ({ begin, commit }) => {
55+
sync: ({ begin, commit, markReady }) => {
5656
beginCallback = begin as () => void
57-
commitCallback = commit as () => void
57+
commitCallback = () => {
58+
commit()
59+
markReady()
60+
}
5861
},
5962
},
6063
})
@@ -80,9 +83,12 @@ describe(`Collection Lifecycle Management`, () => {
8083
getKey: (item) => item.id,
8184
startSync: true,
8285
sync: {
83-
sync: ({ begin, commit }) => {
86+
sync: ({ begin, commit, markReady }) => {
8487
beginCallback = begin as () => void
85-
commitCallback = commit as () => void
88+
commitCallback = () => {
89+
commit()
90+
markReady()
91+
}
8692
},
8793
},
8894
})
@@ -121,9 +127,12 @@ describe(`Collection Lifecycle Management`, () => {
121127
getKey: (item) => item.id,
122128
gcTime: 0,
123129
sync: {
124-
sync: ({ begin, commit }) => {
130+
sync: ({ begin, commit, markReady }) => {
125131
beginCallback = begin as () => void
126-
commitCallback = commit as () => void
132+
commitCallback = () => {
133+
commit()
134+
markReady()
135+
}
127136
},
128137
},
129138
})
@@ -154,9 +163,10 @@ describe(`Collection Lifecycle Management`, () => {
154163
getKey: (item) => item.id,
155164
startSync: false, // Test lazy loading behavior
156165
sync: {
157-
sync: ({ begin, commit }) => {
166+
sync: ({ begin, commit, markReady }) => {
158167
begin()
159168
commit()
169+
markReady()
160170
syncCallCount++
161171
},
162172
},
@@ -327,9 +337,12 @@ describe(`Collection Lifecycle Management`, () => {
327337
getKey: (item) => item.id,
328338
startSync: true,
329339
sync: {
330-
sync: ({ begin, commit }) => {
340+
sync: ({ begin, commit, markReady }) => {
331341
beginCallback = begin as () => void
332-
commitCallback = commit as () => void
342+
commitCallback = () => {
343+
commit()
344+
markReady()
345+
}
333346
},
334347
},
335348
})
@@ -389,42 +402,38 @@ describe(`Collection Lifecycle Management`, () => {
389402
})
390403

391404
describe(`Lifecycle Events`, () => {
392-
it(`should call onFirstCommit callbacks`, () => {
393-
let beginCallback: (() => void) | undefined
394-
let commitCallback: (() => void) | undefined
405+
it(`should call onFirstReady callbacks`, () => {
406+
let markReadyCallback: (() => void) | undefined
395407
const callbacks: Array<() => void> = []
396408

397409
const collection = createCollection<{ id: string; name: string }>({
398-
id: `first-commit-test`,
410+
id: `first-ready-test`,
399411
getKey: (item) => item.id,
400412
sync: {
401-
sync: ({ begin, commit }) => {
402-
beginCallback = begin as () => void
403-
commitCallback = commit as () => void
413+
sync: ({ markReady }) => {
414+
markReadyCallback = markReady as () => void
404415
},
405416
},
406417
})
407418

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

410421
// Register callbacks
411-
collection.onFirstCommit(() => callbacks.push(() => `callback1`))
412-
collection.onFirstCommit(() => callbacks.push(() => `callback2`))
422+
collection.onFirstReady(() => callbacks.push(() => `callback1`))
423+
collection.onFirstReady(() => callbacks.push(() => `callback2`))
413424

414425
expect(callbacks).toHaveLength(0)
415426

416-
// Trigger first commit
417-
if (beginCallback && commitCallback) {
418-
beginCallback()
419-
commitCallback()
427+
// Trigger first ready
428+
if (markReadyCallback) {
429+
markReadyCallback()
420430
}
421431

422432
expect(callbacks).toHaveLength(2)
423433

424-
// Subsequent commits should not trigger callbacks
425-
if (beginCallback && commitCallback) {
426-
beginCallback()
427-
commitCallback()
434+
// Subsequent markReady calls should not trigger callbacks
435+
if (markReadyCallback) {
436+
markReadyCallback()
428437
}
429438
expect(callbacks).toHaveLength(2)
430439

0 commit comments

Comments
 (0)