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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-query-collection-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/db': patch
'@tanstack/query-db-collection': patch
---

Start idle collections only after locally decidable mutation validation succeeds, and publish authoritative Query Collection refetch results without stale intermediate snapshots.
2 changes: 1 addition & 1 deletion docs/contributing/oracle-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ comment and the current API/architecture contract before extending its model.
| --- | --- | --- |
| Ordered relations and BTree | [top-K relation oracle](../../packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [BTree/Map](../../packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](../../packages/db-ivm/tests/incrementalization-law.property.test.ts) | Independent ordered relations and cumulative signed output. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. Other hash-based operators retain hashing's declared domain. Algebra does not specify client readiness. |
| Includes and publication | [cross-formulation](../../packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](../../packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](../../packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](../../packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Observe raw promised order; fresh queries do not establish continuous publication safety. |
| Collection lifecycle | [history](../../packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](../../packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](../../packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](../../packages/db/tests/effect-disposal-oracle.test.ts) | Ownership and phase histories, exact caller/error/publication evidence, late completion and restart. Effect self-dependent disposal remains a separate contract question. |
| Collection lifecycle | [mutation startup](../../packages/db/tests/collection-mutation-startup-oracle.test.ts), [history](../../packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](../../packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](../../packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](../../packages/db/tests/effect-disposal-oracle.test.ts) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; ownership and phase histories; exact caller/error/publication evidence; late completion and restart. Query write utilities and effect self-dependent disposal remain separate contracts. |
| Optimistic state | [history model](../../packages/db/tests/optimistic-history-oracle.ts), [generated histories](../../packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](../../packages/db/tests/optimistic-history-outcomes.test.ts), [publication](../../packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. |
| Drafts and native values | [proxy](../../packages/db/tests/proxy.test.ts), [detachment](../../packages/db/tests/proxy-detachment-contract.test.ts), [iteration](../../packages/db/tests/proxy-iteration-contract.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. General native-mutator and symbol-write support is not established by a plain-object oracle. |
| Query DB and observer | [ownership](../../packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](../../packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](../../packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary and a per-listener eligibility ledger, not a duplicate dispatch queue. Check reentry, peer survival, FIFO and disposal independently of final rows. |
Expand Down
2 changes: 1 addition & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"lint": "eslint . --fix",
"test": "vitest --run",
"test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts",
"test:oracles": "vitest --run tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts",
"test:oracles": "vitest --run tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts",
"bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run"
},
"type": "module",
Expand Down
30 changes: 21 additions & 9 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,23 @@ const collectionSyncConfigCleanup: unique symbol = Symbol.for(

type CollectionSyncConfigWithFactory<TSync extends object> = TSync & {
readonly [collectionSyncConfigFactory]: (
this: TSync,
source: TSync,
utilities: object,
startSyncIfIdle: () => void,
) => TSync
}

/** @internal Lets adapters bind a sync config to each collection instance. */
/** @internal The factory must defer `startSyncIfIdle` until construction ends. */
export function withCollectionSyncConfigFactory<TSync extends object>(
sync: TSync,
factory: (source: TSync, utilities: object) => TSync,
factory: (
source: TSync,
utilities: object,
startSyncIfIdle: () => void,
) => TSync,
): CollectionSyncConfigWithFactory<TSync> {
Object.defineProperty(sync, collectionSyncConfigFactory, {
value(this: TSync, utilities: object) {
return factory(this, utilities)
},
value: factory,
// Preserve the hook when callers wrap a sync config with object spread.
enumerable: true,
})
Expand All @@ -92,7 +95,11 @@ export function withCollectionSyncConfigCleanup<TSync extends object>(
function materializeCollectionSyncConfig<
TSync extends object,
TUtils extends object,
>(sync: TSync, utilities: TUtils): { sync: TSync; utilities: TUtils } {
>(
sync: TSync,
utilities: TUtils,
startSyncIfIdle: () => void,
): { sync: TSync; utilities: TUtils } {
const factory = (
sync as unknown as Partial<CollectionSyncConfigWithFactory<TSync>>
)[collectionSyncConfigFactory]
Expand All @@ -104,7 +111,10 @@ function materializeCollectionSyncConfig<
Object.getPrototypeOf(utilities),
Object.getOwnPropertyDescriptors(utilities),
) as TUtils
return { sync: factory.call(sync, ownedUtilities), utilities: ownedUtilities }
return {
sync: factory(sync, ownedUtilities, startSyncIfIdle),
utilities: ownedUtilities,
}
}

function cleanupCollectionSyncConfig(sync: object): void {
Expand Down Expand Up @@ -396,7 +406,9 @@ export class CollectionImpl<

// Set default values for optional config properties
const { sync: collectionSync, utilities: collectionUtils } =
materializeCollectionSyncConfig(config.sync, config.utils ?? {})
materializeCollectionSyncConfig(config.sync, config.utils ?? {}, () => {
if (this._lifecycle.status === `idle`) this._sync.startSync()
})
this.config = {
...config,
sync: collectionSync,
Expand Down
18 changes: 15 additions & 3 deletions packages/db/src/collection/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ export class CollectionMutationsManager<
// Validate the data against the schema if one exists
const validatedData = this.validateData(item, `insert`)

// Check if an item with this ID already exists in the collection or in the current batch
// Reject duplicate keys within this batch before starting sync.
const key = this.config.getKey(validatedData)
if (this.state.has(key) || keysInCurrentBatch.has(key)) {
if (keysInCurrentBatch.has(key)) {
throw new DuplicateKeyError(key)
}
keysInCurrentBatch.add(key)
Expand Down Expand Up @@ -241,6 +241,14 @@ export class CollectionMutationsManager<
mutations.push(mutation)
})

// Reject duplicates already visible before explicitly starting sync; startup may
// synchronously reveal additional keys, so check again afterward.
let duplicate = mutations.find(({ key }) => state.has(key))
if (duplicate) throw new DuplicateKeyError(duplicate.key)
this.collection._sync.startSync()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
duplicate = mutations.find(({ key }) => state.has(key))
if (duplicate) throw new DuplicateKeyError(duplicate.key)

// If an ambient transaction exists, use it
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations)
Expand Down Expand Up @@ -317,10 +325,13 @@ export class CollectionMutationsManager<
}

const callback =
typeof configOrCallback === `function` ? configOrCallback : maybeCallback!
typeof configOrCallback === `function` ? configOrCallback : maybeCallback
if (typeof callback !== `function`) throw new TypeError()
const config =
typeof configOrCallback === `function` ? {} : configOrCallback

this.collection._sync.startSync()

// Get the current objects or empty objects if they don't exist
const currentObjects = keysArray.map((key) => {
const item = this.state.get(key)
Expand Down Expand Up @@ -497,6 +508,7 @@ export class CollectionMutationsManager<
}

const keysArray = Array.isArray(keys) ? keys : [keys]
this.collection._sync.startSync()
const mutations: Array<
PendingMutation<
TOutput,
Expand Down
Loading
Loading