From f66ce6a6b2361419d5184c699b49731c4d6fac6e Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 5 Nov 2025 11:15:47 +0000 Subject: [PATCH 1/5] Predicate Comparison and Merging Utilities for Predicate Push-Down (#668) * Add unit tests that reproduce the problems with moveWindow on infinite limits * Handle Infinity limit in move * Changeset * Add support for orderBy and limit options in currentStateAsChanges * Unit tests for currentStateAsChanges * changeset * fix for eslint * Move helper functions to the end of the file * add predicate utils * optimise in when all primatives * optimisations * changeset * minusWherePredicates * Add unit test for OR in both subset and superset that shows bug with OR handling * Reorder OR handling to fix bug and handle AND similarly * change chageset * dedupe code * add createDeduplicatedLoadSubset function * convert deduper to a class, with reset, and dedupe inflight * Simplify matching of inflight requests * Improve minusWherePredicates such that it can handle common conditions in a conjunction * Modify DeduplicatedLoadSubset.loadSubset such that it only loads the missing data * Remove unused predicate helper functions * Fix eslint issue * Prettier * Unify handling of IN and OR * Handle subset that is false * Fix isLimitSubset to correctly handle unlimited subsets --------- Co-authored-by: Kevin De Porre --- .changeset/legal-cooks-sink.md | 5 + .changeset/light-phones-flash.md | 5 + .changeset/open-cups-lose.md | 5 + packages/db/src/query/index.ts | 12 + packages/db/src/query/predicate-utils.ts | 1415 +++++++++++++++++++++ packages/db/src/query/subset-dedupe.ts | 228 ++++ packages/db/tests/predicate-utils.test.ts | 1115 ++++++++++++++++ packages/db/tests/subset-dedupe.test.ts | 562 ++++++++ 8 files changed, 3347 insertions(+) create mode 100644 .changeset/legal-cooks-sink.md create mode 100644 .changeset/light-phones-flash.md create mode 100644 .changeset/open-cups-lose.md create mode 100644 packages/db/src/query/predicate-utils.ts create mode 100644 packages/db/src/query/subset-dedupe.ts create mode 100644 packages/db/tests/predicate-utils.test.ts create mode 100644 packages/db/tests/subset-dedupe.test.ts diff --git a/.changeset/legal-cooks-sink.md b/.changeset/legal-cooks-sink.md new file mode 100644 index 000000000..ddddba41d --- /dev/null +++ b/.changeset/legal-cooks-sink.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db-ivm": patch +--- + +Fix bug with setWindow on ordered queries that have no limit. diff --git a/.changeset/light-phones-flash.md b/.changeset/light-phones-flash.md new file mode 100644 index 000000000..95a030b32 --- /dev/null +++ b/.changeset/light-phones-flash.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Add predicate comparison and merging utilities (isWhereSubset, intersectWherePredicates, unionWherePredicates, and related functions) to support predicate push-down in collection sync operations, enabling efficient tracking of loaded data ranges and preventing redundant server requests. Includes performance optimizations for large primitive IN predicates and full support for Date objects in equality, range, and IN clause comparisons. diff --git a/.changeset/open-cups-lose.md b/.changeset/open-cups-lose.md new file mode 100644 index 000000000..44eee1a35 --- /dev/null +++ b/.changeset/open-cups-lose.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Add support for orderBy and limit in currentStateAsChanges function diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 17f4dd8e7..cb0e3d9e0 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -57,3 +57,15 @@ export { export { type LiveQueryCollectionConfig } from "./live/types.js" export { type LiveQueryCollectionUtils } from "./live/collection-config-builder.js" + +// Predicate utilities for predicate push-down +export { + isWhereSubset, + unionWherePredicates, + minusWherePredicates, + isOrderBySubset, + isLimitSubset, + isPredicateSubset, +} from "./predicate-utils.js" + +export { DeduplicatedLoadSubset } from "./subset-dedupe.js" diff --git a/packages/db/src/query/predicate-utils.ts b/packages/db/src/query/predicate-utils.ts new file mode 100644 index 000000000..35d95b98e --- /dev/null +++ b/packages/db/src/query/predicate-utils.ts @@ -0,0 +1,1415 @@ +import { Func, Value } from "./ir.js" +import type { BasicExpression, OrderBy, PropRef } from "./ir.js" +import type { LoadSubsetOptions } from "../types.js" + +/** + * Check if one where clause is a logical subset of another. + * Returns true if the subset predicate is more restrictive than (or equal to) the superset predicate. + * + * @example + * // age > 20 is subset of age > 10 (more restrictive) + * isWhereSubset(gt(ref('age'), val(20)), gt(ref('age'), val(10))) // true + * + * @example + * // age > 10 AND name = 'X' is subset of age > 10 (more conditions) + * isWhereSubset(and(gt(ref('age'), val(10)), eq(ref('name'), val('X'))), gt(ref('age'), val(10))) // true + * + * @param subset - The potentially more restrictive predicate + * @param superset - The potentially less restrictive predicate + * @returns true if subset logically implies superset + */ +export function isWhereSubset( + subset: BasicExpression | undefined, + superset: BasicExpression | undefined +): boolean { + // undefined/missing where clause means "no filter" (all data) + // Both undefined means subset relationship holds (all data ⊆ all data) + if (subset === undefined && superset === undefined) { + return true + } + + // If subset is undefined but superset is not, we're requesting ALL data + // but have only loaded SOME data - subset relationship does NOT hold + if (subset === undefined && superset !== undefined) { + return false + } + + // If superset is undefined (no filter = all data loaded), + // then any constrained subset is contained + if (superset === undefined && subset !== undefined) { + return true + } + + return isWhereSubsetInternal(subset!, superset!) +} + +function makeDisjunction( + preds: Array> +): BasicExpression { + if (preds.length === 0) { + return new Value(false) + } + if (preds.length === 1) { + return preds[0]! + } + return new Func(`or`, preds) +} + +function convertInToOr(inField: InField) { + const equalities = inField.values.map( + (value) => new Func(`eq`, [inField.ref, new Value(value)]) + ) + return makeDisjunction(equalities) +} + +function isWhereSubsetInternal( + subset: BasicExpression, + superset: BasicExpression +): boolean { + // If subset is false it is requesting no data, + // thus the result set is empty + // and the empty set is a subset of any set + if (subset.type === `val` && subset.value === false) { + return true + } + + // If expressions are structurally equal, subset relationship holds + if (areExpressionsEqual(subset, superset)) { + return true + } + + // Handle superset being an AND: subset must imply ALL conjuncts + // If superset is (A AND B), then subset ⊆ (A AND B) only if subset ⊆ A AND subset ⊆ B + // Example: (age > 20) ⊆ (age > 10 AND status = 'active') is false (doesn't imply status condition) + if (superset.type === `func` && superset.name === `and`) { + return superset.args.every((arg) => + isWhereSubsetInternal(subset, arg as BasicExpression) + ) + } + + // Handle subset being an AND: (A AND B) implies both A and B + if (subset.type === `func` && subset.name === `and`) { + // For (A AND B) ⊆ C, since (A AND B) implies A, we check if any conjunct implies C + return subset.args.some((arg) => + isWhereSubsetInternal(arg as BasicExpression, superset) + ) + } + + // Turn x IN [A, B, C] into x = A OR x = B OR x = C + // for unified handling of IN and OR + if (subset.type === `func` && subset.name === `in`) { + const inField = extractInField(subset) + if (inField) { + return isWhereSubsetInternal(convertInToOr(inField), superset) + } + } + + if (superset.type === `func` && superset.name === `in`) { + const inField = extractInField(superset) + if (inField) { + return isWhereSubsetInternal(subset, convertInToOr(inField)) + } + } + + // Handle OR in subset: (A OR B) is subset of C only if both A and B are subsets of C + if (subset.type === `func` && subset.name === `or`) { + return subset.args.every((arg) => + isWhereSubsetInternal(arg as BasicExpression, superset) + ) + } + + // Handle OR in superset: subset ⊆ (A OR B) if subset ⊆ A or subset ⊆ B + // (A OR B) as superset means data can satisfy A or B + // If subset is contained in any disjunct, it's contained in the union + if (superset.type === `func` && superset.name === `or`) { + return superset.args.some((arg) => + isWhereSubsetInternal(subset, arg as BasicExpression) + ) + } + + // Handle comparison operators on the same field + if (subset.type === `func` && superset.type === `func`) { + const subsetFunc = subset as Func + const supersetFunc = superset as Func + + // Check if both are comparisons on the same field + const subsetField = extractComparisonField(subsetFunc) + const supersetField = extractComparisonField(supersetFunc) + + if ( + subsetField && + supersetField && + areRefsEqual(subsetField.ref, supersetField.ref) + ) { + return isComparisonSubset( + subsetFunc, + subsetField.value, + supersetFunc, + supersetField.value + ) + } + + /* + // Handle eq vs in + if (subsetFunc.name === `eq` && supersetFunc.name === `in`) { + const subsetFieldEq = extractEqualityField(subsetFunc) + const supersetFieldIn = extractInField(supersetFunc) + if ( + subsetFieldEq && + supersetFieldIn && + areRefsEqual(subsetFieldEq.ref, supersetFieldIn.ref) + ) { + // field = X is subset of field IN [X, Y, Z] if X is in the array + // Use cached primitive set and metadata from extraction + return arrayIncludesWithSet( + supersetFieldIn.values, + subsetFieldEq.value, + supersetFieldIn.primitiveSet ?? null, + supersetFieldIn.areAllPrimitives + ) + } + } + + // Handle in vs in + if (subsetFunc.name === `in` && supersetFunc.name === `in`) { + const subsetFieldIn = extractInField(subsetFunc) + const supersetFieldIn = extractInField(supersetFunc) + if ( + subsetFieldIn && + supersetFieldIn && + areRefsEqual(subsetFieldIn.ref, supersetFieldIn.ref) + ) { + // field IN [A, B] is subset of field IN [A, B, C] if all values in subset are in superset + // Use cached primitive set and metadata from extraction + return subsetFieldIn.values.every((subVal) => + arrayIncludesWithSet( + supersetFieldIn.values, + subVal, + supersetFieldIn.primitiveSet ?? null, + supersetFieldIn.areAllPrimitives + ) + ) + } + } + */ + } + + // Conservative: if we can't determine, return false + return false +} + +/** + * Helper to combine where predicates with common logic for AND/OR operations + */ +function combineWherePredicates( + predicates: Array>, + operation: `and` | `or`, + simplifyFn: ( + preds: Array> + ) => BasicExpression | null +): BasicExpression { + const emptyValue = operation === `and` ? true : false + const identityValue = operation === `and` ? true : false + + if (predicates.length === 0) { + return { type: `val`, value: emptyValue } as BasicExpression + } + + if (predicates.length === 1) { + return predicates[0]! + } + + // Flatten nested expressions of the same operation + const flatPredicates: Array> = [] + for (const pred of predicates) { + if (pred.type === `func` && pred.name === operation) { + flatPredicates.push(...pred.args) + } else { + flatPredicates.push(pred) + } + } + + // Group predicates by field for simplification + const grouped = groupPredicatesByField(flatPredicates) + + // Simplify each group + const simplified: Array> = [] + for (const [field, preds] of grouped.entries()) { + if (field === null) { + // Complex predicates that we can't group by field + simplified.push(...preds) + } else { + // Try to simplify same-field predicates + const result = simplifyFn(preds) + + // For intersection: check for empty set (contradiction) + if ( + operation === `and` && + result && + result.type === `val` && + result.value === false + ) { + // Intersection is empty (conflicting constraints) - entire AND is false + return { type: `val`, value: false } as BasicExpression + } + + // For union: result may be null if simplification failed + if (result) { + simplified.push(result) + } + } + } + + if (simplified.length === 0) { + return { type: `val`, value: identityValue } as BasicExpression + } + + if (simplified.length === 1) { + return simplified[0]! + } + + // Return combined predicate + return { + type: `func`, + name: operation, + args: simplified, + } as BasicExpression +} + +/** + * Combine multiple where predicates with OR logic (union). + * Returns a predicate that is satisfied when any input predicate is satisfied. + * Simplifies when possible (e.g., age > 10 OR age > 20 → age > 10). + * + * @example + * // Take least restrictive + * unionWherePredicates([gt(ref('age'), val(10)), gt(ref('age'), val(20))]) // age > 10 + * + * @example + * // Combine equals into IN + * unionWherePredicates([eq(ref('age'), val(5)), eq(ref('age'), val(10))]) // age IN [5, 10] + * + * @param predicates - Array of where predicates to union + * @returns Combined predicate representing the union + */ +export function unionWherePredicates( + predicates: Array> +): BasicExpression { + return combineWherePredicates(predicates, `or`, unionSameFieldPredicates) +} + +/** + * Compute the difference between two where predicates: `fromPredicate AND NOT(subtractPredicate)`. + * Returns the simplified predicate, or null if the difference cannot be simplified + * (in which case the caller should fetch the full fromPredicate). + * + * @example + * // Range difference + * minusWherePredicates( + * gt(ref('age'), val(10)), // age > 10 + * gt(ref('age'), val(20)) // age > 20 + * ) // → age > 10 AND age <= 20 + * + * @example + * // Set difference + * minusWherePredicates( + * inOp(ref('status'), ['A', 'B', 'C', 'D']), // status IN ['A','B','C','D'] + * inOp(ref('status'), ['B', 'C']) // status IN ['B','C'] + * ) // → status IN ['A', 'D'] + * + * @example + * // Common conditions + * minusWherePredicates( + * and(gt(ref('age'), val(10)), eq(ref('status'), val('active'))), // age > 10 AND status = 'active' + * and(gt(ref('age'), val(20)), eq(ref('status'), val('active'))) // age > 20 AND status = 'active' + * ) // → age > 10 AND age <= 20 AND status = 'active' + * + * @example + * // Complete overlap - empty result + * minusWherePredicates( + * gt(ref('age'), val(20)), // age > 20 + * gt(ref('age'), val(10)) // age > 10 + * ) // → {type: 'val', value: false} (empty set) + * + * @param fromPredicate - The predicate to subtract from + * @param subtractPredicate - The predicate to subtract + * @returns The simplified difference, or null if cannot be simplified + */ +export function minusWherePredicates( + fromPredicate: BasicExpression | undefined, + subtractPredicate: BasicExpression | undefined +): BasicExpression | null { + // If nothing to subtract, return the original + if (subtractPredicate === undefined) { + return ( + fromPredicate ?? + ({ type: `val`, value: true } as BasicExpression) + ) + } + + // If from is undefined then we are asking for all data + // so we need to load all data minus what we already loaded + // i.e. we need to load NOT(subtractPredicate) + if (fromPredicate === undefined) { + return { + type: `func`, + name: `not`, + args: [subtractPredicate], + } as BasicExpression + } + + // Check if fromPredicate is entirely contained in subtractPredicate + // In that case, fromPredicate AND NOT(subtractPredicate) = empty set + if (isWhereSubset(fromPredicate, subtractPredicate)) { + return { type: `val`, value: false } as BasicExpression + } + + // Try to detect and handle common conditions + const commonConditions = findCommonConditions( + fromPredicate, + subtractPredicate + ) + if (commonConditions.length > 0) { + // Extract predicates without common conditions + const fromWithoutCommon = removeConditions(fromPredicate, commonConditions) + const subtractWithoutCommon = removeConditions( + subtractPredicate, + commonConditions + ) + + // Recursively compute difference on simplified predicates + const simplifiedDifference = minusWherePredicates( + fromWithoutCommon, + subtractWithoutCommon + ) + + if (simplifiedDifference !== null) { + // Combine the simplified difference with common conditions + return combineConditions([...commonConditions, simplifiedDifference]) + } + } + + // Check if they are on the same field - if so, we can try to simplify + if (fromPredicate.type === `func` && subtractPredicate.type === `func`) { + const result = minusSameFieldPredicates(fromPredicate, subtractPredicate) + if (result !== null) { + return result + } + } + + // Can't simplify - return null to indicate caller should fetch full fromPredicate + return null +} + +/** + * Helper function to compute difference for same-field predicates + */ +function minusSameFieldPredicates( + fromPred: Func, + subtractPred: Func +): BasicExpression | null { + // Extract field information + const fromField = + extractComparisonField(fromPred) || + extractEqualityField(fromPred) || + extractInField(fromPred) + const subtractField = + extractComparisonField(subtractPred) || + extractEqualityField(subtractPred) || + extractInField(subtractPred) + + // Must be on the same field + if ( + !fromField || + !subtractField || + !areRefsEqual(fromField.ref, subtractField.ref) + ) { + return null + } + + // Handle IN minus IN: status IN [A,B,C,D] - status IN [B,C] = status IN [A,D] + if (fromPred.name === `in` && subtractPred.name === `in`) { + const fromInField = fromField as InField + const subtractInField = subtractField as InField + + // Filter out values that are in the subtract set + const remainingValues = fromInField.values.filter( + (v) => + !arrayIncludesWithSet( + subtractInField.values, + v, + subtractInField.primitiveSet ?? null, + subtractInField.areAllPrimitives + ) + ) + + if (remainingValues.length === 0) { + return { type: `val`, value: false } as BasicExpression + } + + if (remainingValues.length === 1) { + return { + type: `func`, + name: `eq`, + args: [fromField.ref, { type: `val`, value: remainingValues[0] }], + } as BasicExpression + } + + return { + type: `func`, + name: `in`, + args: [fromField.ref, { type: `val`, value: remainingValues }], + } as BasicExpression + } + + // Handle IN minus equality: status IN [A,B,C] - status = B = status IN [A,C] + if (fromPred.name === `in` && subtractPred.name === `eq`) { + const fromInField = fromField as InField + const subtractValue = (subtractField as { ref: PropRef; value: any }).value + + const remainingValues = fromInField.values.filter( + (v) => !areValuesEqual(v, subtractValue) + ) + + if (remainingValues.length === 0) { + return { type: `val`, value: false } as BasicExpression + } + + if (remainingValues.length === 1) { + return { + type: `func`, + name: `eq`, + args: [fromField.ref, { type: `val`, value: remainingValues[0] }], + } as BasicExpression + } + + return { + type: `func`, + name: `in`, + args: [fromField.ref, { type: `val`, value: remainingValues }], + } as BasicExpression + } + + // Handle equality minus equality: age = 15 - age = 15 = empty, age = 15 - age = 20 = age = 15 + if (fromPred.name === `eq` && subtractPred.name === `eq`) { + const fromValue = (fromField as { ref: PropRef; value: any }).value + const subtractValue = (subtractField as { ref: PropRef; value: any }).value + + if (areValuesEqual(fromValue, subtractValue)) { + return { type: `val`, value: false } as BasicExpression + } + + // No overlap - return original + return fromPred as BasicExpression + } + + // Handle range minus range: age > 10 - age > 20 = age > 10 AND age <= 20 + const fromComp = extractComparisonField(fromPred) + const subtractComp = extractComparisonField(subtractPred) + + if ( + fromComp && + subtractComp && + areRefsEqual(fromComp.ref, subtractComp.ref) + ) { + // Try to compute the difference using range logic + const result = minusRangePredicates( + fromPred, + fromComp.value, + subtractPred, + subtractComp.value + ) + return result + } + + // Can't simplify + return null +} + +/** + * Helper to compute difference between range predicates + */ +function minusRangePredicates( + fromFunc: Func, + fromValue: any, + subtractFunc: Func, + subtractValue: any +): BasicExpression | null { + const fromOp = fromFunc.name as `gt` | `gte` | `lt` | `lte` | `eq` + const subtractOp = subtractFunc.name as `gt` | `gte` | `lt` | `lte` | `eq` + const ref = (extractComparisonField(fromFunc) || + extractEqualityField(fromFunc))!.ref + + // age > 10 - age > 20 = (age > 10 AND age <= 20) + if (fromOp === `gt` && subtractOp === `gt`) { + if (fromValue < subtractValue) { + // Result is: fromValue < field <= subtractValue + return { + type: `func`, + name: `and`, + args: [ + fromFunc as BasicExpression, + { + type: `func`, + name: `lte`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + ], + } as BasicExpression + } + // fromValue >= subtractValue means no overlap + return fromFunc as BasicExpression + } + + // age >= 10 - age >= 20 = (age >= 10 AND age < 20) + if (fromOp === `gte` && subtractOp === `gte`) { + if (fromValue < subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + fromFunc as BasicExpression, + { + type: `func`, + name: `lt`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // age > 10 - age >= 20 = (age > 10 AND age < 20) + if (fromOp === `gt` && subtractOp === `gte`) { + if (fromValue < subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + fromFunc as BasicExpression, + { + type: `func`, + name: `lt`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // age >= 10 - age > 20 = (age >= 10 AND age <= 20) + if (fromOp === `gte` && subtractOp === `gt`) { + if (fromValue <= subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + fromFunc as BasicExpression, + { + type: `func`, + name: `lte`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // age < 30 - age < 20 = (age >= 20 AND age < 30) + if (fromOp === `lt` && subtractOp === `lt`) { + if (fromValue > subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + { + type: `func`, + name: `gte`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + fromFunc as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // age <= 30 - age <= 20 = (age > 20 AND age <= 30) + if (fromOp === `lte` && subtractOp === `lte`) { + if (fromValue > subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + { + type: `func`, + name: `gt`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + fromFunc as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // age < 30 - age <= 20 = (age > 20 AND age < 30) + if (fromOp === `lt` && subtractOp === `lte`) { + if (fromValue > subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + { + type: `func`, + name: `gt`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + fromFunc as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // age <= 30 - age < 20 = (age >= 20 AND age <= 30) + if (fromOp === `lte` && subtractOp === `lt`) { + if (fromValue >= subtractValue) { + return { + type: `func`, + name: `and`, + args: [ + { + type: `func`, + name: `gte`, + args: [ref, { type: `val`, value: subtractValue }], + } as BasicExpression, + fromFunc as BasicExpression, + ], + } as BasicExpression + } + return fromFunc as BasicExpression + } + + // Can't simplify other combinations + return null +} + +/** + * Check if one orderBy clause is a subset of another. + * Returns true if the subset ordering requirements are satisfied by the superset ordering. + * + * @example + * // Subset is prefix of superset + * isOrderBySubset([{expr: age, asc}], [{expr: age, asc}, {expr: name, desc}]) // true + * + * @param subset - The ordering requirements to check + * @param superset - The ordering that might satisfy the requirements + * @returns true if subset is satisfied by superset + */ +export function isOrderBySubset( + subset: OrderBy | undefined, + superset: OrderBy | undefined +): boolean { + // No ordering requirement is always satisfied + if (!subset || subset.length === 0) { + return true + } + + // If there's no superset ordering but subset requires ordering, not satisfied + if (!superset || superset.length === 0) { + return false + } + + // Check if subset is a prefix of superset with matching expressions and compare options + if (subset.length > superset.length) { + return false + } + + for (let i = 0; i < subset.length; i++) { + const subClause = subset[i]! + const superClause = superset[i]! + + // Check if expressions match + if (!areExpressionsEqual(subClause.expression, superClause.expression)) { + return false + } + + // Check if compare options match + if ( + !areCompareOptionsEqual( + subClause.compareOptions, + superClause.compareOptions + ) + ) { + return false + } + } + + return true +} + +/** + * Check if one limit is a subset of another. + * Returns true if the subset limit requirements are satisfied by the superset limit. + * + * @example + * isLimitSubset(10, 20) // true (requesting 10 items when 20 are available) + * isLimitSubset(20, 10) // false (requesting 20 items when only 10 are available) + * isLimitSubset(10, undefined) // true (requesting 10 items when unlimited are available) + * + * @param subset - The limit requirement to check + * @param superset - The limit that might satisfy the requirement + * @returns true if subset is satisfied by superset + */ +export function isLimitSubset( + subset: number | undefined, + superset: number | undefined +): boolean { + // Unlimited superset satisfies any limit requirement + if (superset === undefined) { + return true + } + + // If requesting all data (no limit), we need unlimited data to satisfy it + // But we know superset is not unlimited so we return false + if (subset === undefined) { + return false + } + + // Otherwise, subset must be less than or equal to superset + return subset <= superset +} + +/** + * Check if one predicate (where + orderBy + limit) is a subset of another. + * Returns true if all aspects of the subset predicate are satisfied by the superset. + * + * @example + * isPredicateSubset( + * { where: gt(ref('age'), val(20)), limit: 10 }, + * { where: gt(ref('age'), val(10)), limit: 20 } + * ) // true + * + * @param subset - The predicate requirements to check + * @param superset - The predicate that might satisfy the requirements + * @returns true if subset is satisfied by superset + */ +export function isPredicateSubset( + subset: LoadSubsetOptions, + superset: LoadSubsetOptions +): boolean { + return ( + isWhereSubset(subset.where, superset.where) && + isOrderBySubset(subset.orderBy, superset.orderBy) && + isLimitSubset(subset.limit, superset.limit) + ) +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +/** + * Find common conditions between two predicates. + * Returns an array of conditions that appear in both predicates. + */ +function findCommonConditions( + predicate1: BasicExpression, + predicate2: BasicExpression +): Array> { + const conditions1 = extractAllConditions(predicate1) + const conditions2 = extractAllConditions(predicate2) + + const common: Array> = [] + + for (const cond1 of conditions1) { + for (const cond2 of conditions2) { + if (areExpressionsEqual(cond1, cond2)) { + // Avoid duplicates + if (!common.some((c) => areExpressionsEqual(c, cond1))) { + common.push(cond1) + } + break + } + } + } + + return common +} + +/** + * Extract all individual conditions from a predicate, flattening AND operations. + */ +function extractAllConditions( + predicate: BasicExpression +): Array> { + if (predicate.type === `func` && predicate.name === `and`) { + const conditions: Array> = [] + for (const arg of predicate.args) { + conditions.push(...extractAllConditions(arg as BasicExpression)) + } + return conditions + } + + return [predicate] +} + +/** + * Remove specified conditions from a predicate. + * Returns the predicate with the specified conditions removed, or undefined if all conditions are removed. + */ +function removeConditions( + predicate: BasicExpression, + conditionsToRemove: Array> +): BasicExpression | undefined { + if (predicate.type === `func` && predicate.name === `and`) { + const remainingArgs = predicate.args.filter( + (arg) => + !conditionsToRemove.some((cond) => + areExpressionsEqual(arg as BasicExpression, cond) + ) + ) + + if (remainingArgs.length === 0) { + return undefined + } else if (remainingArgs.length === 1) { + return remainingArgs[0]! + } else { + return { + type: `func`, + name: `and`, + args: remainingArgs, + } as BasicExpression + } + } + + // For non-AND predicates, don't remove anything + return predicate +} + +/** + * Combine multiple conditions into a single predicate using AND logic. + * Flattens nested AND operations to avoid unnecessary nesting. + */ +function combineConditions( + conditions: Array> +): BasicExpression { + if (conditions.length === 0) { + return { type: `val`, value: true } as BasicExpression + } else if (conditions.length === 1) { + return conditions[0]! + } else { + // Flatten all conditions, including those that are already AND operations + const flattenedConditions: Array> = [] + + for (const condition of conditions) { + if (condition.type === `func` && condition.name === `and`) { + // Flatten nested AND operations + flattenedConditions.push(...condition.args) + } else { + flattenedConditions.push(condition) + } + } + + if (flattenedConditions.length === 1) { + return flattenedConditions[0]! + } else { + return { + type: `func`, + name: `and`, + args: flattenedConditions, + } as BasicExpression + } + } +} + +/** + * Find a predicate with a specific operator and value + */ +function findPredicateWithOperator( + predicates: Array>, + operator: string, + value: any +): BasicExpression | undefined { + return predicates.find((p) => { + if (p.type === `func`) { + const f = p as Func + const field = extractComparisonField(f) + return f.name === operator && field && areValuesEqual(field.value, value) + } + return false + }) +} + +function areExpressionsEqual(a: BasicExpression, b: BasicExpression): boolean { + if (a.type !== b.type) { + return false + } + + if (a.type === `val` && b.type === `val`) { + return areValuesEqual(a.value, b.value) + } + + if (a.type === `ref` && b.type === `ref`) { + return areRefsEqual(a, b) + } + + if (a.type === `func` && b.type === `func`) { + const aFunc = a + const bFunc = b + if (aFunc.name !== bFunc.name) { + return false + } + if (aFunc.args.length !== bFunc.args.length) { + return false + } + return aFunc.args.every((arg, i) => + areExpressionsEqual(arg, bFunc.args[i]!) + ) + } + + return false +} + +function areValuesEqual(a: any, b: any): boolean { + // Simple equality check - could be enhanced for deep object comparison + if (a === b) { + return true + } + + // Handle NaN + if (typeof a === `number` && typeof b === `number` && isNaN(a) && isNaN(b)) { + return true + } + + // Handle Date objects + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime() + } + + // For arrays and objects, use reference equality + // (In practice, we don't need deep equality for these cases - + // same object reference means same value for our use case) + if ( + typeof a === `object` && + typeof b === `object` && + a !== null && + b !== null + ) { + return a === b + } + + return false +} + +function areRefsEqual(a: PropRef, b: PropRef): boolean { + if (a.path.length !== b.path.length) { + return false + } + return a.path.every((segment, i) => segment === b.path[i]) +} + +/** + * Check if a value is a primitive (string, number, boolean, null, undefined) + * Primitives can use Set for fast lookups + */ +function isPrimitive(value: any): boolean { + return ( + value === null || + value === undefined || + typeof value === `string` || + typeof value === `number` || + typeof value === `boolean` + ) +} + +/** + * Check if all values in an array are primitives + */ +function areAllPrimitives(values: Array): boolean { + return values.every(isPrimitive) +} + +/** + * Check if a value is in an array, with optional pre-built Set for optimization. + * The primitiveSet is cached in InField during extraction and reused for all lookups. + */ +function arrayIncludesWithSet( + array: Array, + value: any, + primitiveSet: Set | null, + arrayIsAllPrimitives?: boolean +): boolean { + // Fast path: use pre-built Set for O(1) lookup + if (primitiveSet) { + // Skip isPrimitive check if we know the value must be primitive for a match + // (if array is all primitives, only primitives can match) + if (arrayIsAllPrimitives || isPrimitive(value)) { + return primitiveSet.has(value) + } + return false // Non-primitive can't be in primitive-only set + } + + // Fallback: use areValuesEqual for Dates and objects + return array.some((v) => areValuesEqual(v, value)) +} + +/** + * Get the maximum of two values, handling both numbers and Dates + */ +function maxValue(a: any, b: any): any { + if (a instanceof Date && b instanceof Date) { + return a.getTime() > b.getTime() ? a : b + } + return Math.max(a, b) +} + +/** + * Get the minimum of two values, handling both numbers and Dates + */ +function minValue(a: any, b: any): any { + if (a instanceof Date && b instanceof Date) { + return a.getTime() < b.getTime() ? a : b + } + return Math.min(a, b) +} + +function areCompareOptionsEqual( + a: { direction?: `asc` | `desc`; [key: string]: any }, + b: { direction?: `asc` | `desc`; [key: string]: any } +): boolean { + // For now, just compare direction - could be enhanced for other options + return a.direction === b.direction +} + +interface ComparisonField { + ref: PropRef + value: any +} + +function extractComparisonField(func: Func): ComparisonField | null { + // Handle comparison operators: eq, gt, gte, lt, lte + if ([`eq`, `gt`, `gte`, `lt`, `lte`].includes(func.name)) { + // Assume first arg is ref, second is value + const firstArg = func.args[0] + const secondArg = func.args[1] + + if (firstArg?.type === `ref` && secondArg?.type === `val`) { + return { + ref: firstArg, + value: secondArg.value, + } + } + } + + return null +} + +function extractEqualityField(func: Func): ComparisonField | null { + if (func.name === `eq`) { + const firstArg = func.args[0] + const secondArg = func.args[1] + + if (firstArg?.type === `ref` && secondArg?.type === `val`) { + return { + ref: firstArg, + value: secondArg.value, + } + } + } + return null +} + +interface InField { + ref: PropRef + values: Array + // Cached optimization data (computed once, reused many times) + areAllPrimitives?: boolean + primitiveSet?: Set | null +} + +function extractInField(func: Func): InField | null { + if (func.name === `in`) { + const firstArg = func.args[0] + const secondArg = func.args[1] + + if ( + firstArg?.type === `ref` && + secondArg?.type === `val` && + Array.isArray(secondArg.value) + ) { + let values = secondArg.value + // Precompute optimization metadata once + const allPrimitives = areAllPrimitives(values) + let primitiveSet: Set | null = null + + if (allPrimitives && values.length > 10) { + // Build Set and dedupe values at the same time + primitiveSet = new Set(values) + // If we found duplicates, use the deduped array going forward + if (primitiveSet.size < values.length) { + values = Array.from(primitiveSet) + } + } + + return { + ref: firstArg, + values, + areAllPrimitives: allPrimitives, + primitiveSet, + } + } + } + return null +} + +function isComparisonSubset( + subsetFunc: Func, + subsetValue: any, + supersetFunc: Func, + supersetValue: any +): boolean { + const subOp = subsetFunc.name + const superOp = supersetFunc.name + + // Handle same operator + if (subOp === superOp) { + if (subOp === `eq`) { + // field = X is subset of field = X only + // Fast path: primitives can use strict equality + if (isPrimitive(subsetValue) && isPrimitive(supersetValue)) { + return subsetValue === supersetValue + } + return areValuesEqual(subsetValue, supersetValue) + } else if (subOp === `gt`) { + // field > 20 is subset of field > 10 if 20 > 10 + return subsetValue >= supersetValue + } else if (subOp === `gte`) { + // field >= 20 is subset of field >= 10 if 20 >= 10 + return subsetValue >= supersetValue + } else if (subOp === `lt`) { + // field < 10 is subset of field < 20 if 10 <= 20 + return subsetValue <= supersetValue + } else if (subOp === `lte`) { + // field <= 10 is subset of field <= 20 if 10 <= 20 + return subsetValue <= supersetValue + } + } + + // Handle different operators on same field + // eq vs gt/gte: field = 15 is subset of field > 10 if 15 > 10 + if (subOp === `eq` && superOp === `gt`) { + return subsetValue > supersetValue + } + if (subOp === `eq` && superOp === `gte`) { + return subsetValue >= supersetValue + } + if (subOp === `eq` && superOp === `lt`) { + return subsetValue < supersetValue + } + if (subOp === `eq` && superOp === `lte`) { + return subsetValue <= supersetValue + } + + // gt/gte vs gte/gt + if (subOp === `gt` && superOp === `gte`) { + // field > 10 is subset of field >= 10 if 10 >= 10 (always true for same value) + return subsetValue >= supersetValue + } + if (subOp === `gte` && superOp === `gt`) { + // field >= 11 is subset of field > 10 if 11 > 10 + return subsetValue > supersetValue + } + + // lt/lte vs lte/lt + if (subOp === `lt` && superOp === `lte`) { + // field < 10 is subset of field <= 10 if 10 <= 10 + return subsetValue <= supersetValue + } + if (subOp === `lte` && superOp === `lt`) { + // field <= 9 is subset of field < 10 if 9 < 10 + return subsetValue < supersetValue + } + + return false +} + +function groupPredicatesByField( + predicates: Array> +): Map>> { + const groups = new Map>>() + + for (const pred of predicates) { + let fieldKey: string | null = null + + if (pred.type === `func`) { + const func = pred as Func + const field = + extractComparisonField(func) || + extractEqualityField(func) || + extractInField(func) + if (field) { + fieldKey = field.ref.path.join(`.`) + } + } + + const group = groups.get(fieldKey) || [] + group.push(pred) + groups.set(fieldKey, group) + } + + return groups +} + +function unionSameFieldPredicates( + predicates: Array> +): BasicExpression | null { + if (predicates.length === 1) { + return predicates[0]! + } + + // Try to extract range constraints + let maxGt: number | null = null + let maxGte: number | null = null + let minLt: number | null = null + let minLte: number | null = null + const eqValues: Set = new Set() + const inValues: Set = new Set() + const otherPredicates: Array> = [] + + for (const pred of predicates) { + if (pred.type === `func`) { + const func = pred as Func + const field = extractComparisonField(func) + + if (field) { + const value = field.value + if (func.name === `gt`) { + maxGt = maxGt === null ? value : minValue(maxGt, value) + } else if (func.name === `gte`) { + maxGte = maxGte === null ? value : minValue(maxGte, value) + } else if (func.name === `lt`) { + minLt = minLt === null ? value : maxValue(minLt, value) + } else if (func.name === `lte`) { + minLte = minLte === null ? value : maxValue(minLte, value) + } else if (func.name === `eq`) { + eqValues.add(value) + } else { + otherPredicates.push(pred) + } + } else { + const inField = extractInField(func) + if (inField) { + for (const val of inField.values) { + inValues.add(val) + } + } else { + otherPredicates.push(pred) + } + } + } else { + otherPredicates.push(pred) + } + } + + // If we have multiple equality values, combine into IN + if (eqValues.size > 1 || (eqValues.size > 0 && inValues.size > 0)) { + const allValues = [...eqValues, ...inValues] + const ref = predicates.find((p) => { + if (p.type === `func`) { + const field = + extractComparisonField(p as Func) || extractInField(p as Func) + return field !== null + } + return false + }) + + if (ref && ref.type === `func`) { + const field = + extractComparisonField(ref as Func) || extractInField(ref as Func) + if (field) { + return { + type: `func`, + name: `in`, + args: [ + field.ref, + { type: `val`, value: allValues } as BasicExpression, + ], + } as BasicExpression + } + } + } + + // Build the least restrictive range + const result: Array> = [] + + // Choose the least restrictive lower bound + if (maxGt !== null && maxGte !== null) { + // Take the smaller one (less restrictive) + const pred = + maxGte <= maxGt + ? findPredicateWithOperator(predicates, `gte`, maxGte) + : findPredicateWithOperator(predicates, `gt`, maxGt) + if (pred) result.push(pred) + } else if (maxGt !== null) { + const pred = findPredicateWithOperator(predicates, `gt`, maxGt) + if (pred) result.push(pred) + } else if (maxGte !== null) { + const pred = findPredicateWithOperator(predicates, `gte`, maxGte) + if (pred) result.push(pred) + } + + // Choose the least restrictive upper bound + if (minLt !== null && minLte !== null) { + const pred = + minLte >= minLt + ? findPredicateWithOperator(predicates, `lte`, minLte) + : findPredicateWithOperator(predicates, `lt`, minLt) + if (pred) result.push(pred) + } else if (minLt !== null) { + const pred = findPredicateWithOperator(predicates, `lt`, minLt) + if (pred) result.push(pred) + } else if (minLte !== null) { + const pred = findPredicateWithOperator(predicates, `lte`, minLte) + if (pred) result.push(pred) + } + + // Add single eq value + if (eqValues.size === 1 && inValues.size === 0) { + const pred = findPredicateWithOperator(predicates, `eq`, [...eqValues][0]) + if (pred) result.push(pred) + } + + // Add IN if only IN values + if (eqValues.size === 0 && inValues.size > 0) { + result.push( + predicates.find((p) => { + if (p.type === `func`) { + return (p as Func).name === `in` + } + return false + })! + ) + } + + // Add other predicates + result.push(...otherPredicates) + + if (result.length === 0) { + return { type: `val`, value: true } as BasicExpression + } + + if (result.length === 1) { + return result[0]! + } + + return { + type: `func`, + name: `or`, + args: result, + } as BasicExpression +} diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts new file mode 100644 index 000000000..fa8172559 --- /dev/null +++ b/packages/db/src/query/subset-dedupe.ts @@ -0,0 +1,228 @@ +import { + isPredicateSubset, + isWhereSubset, + minusWherePredicates, + unionWherePredicates, +} from "./predicate-utils.js" +import type { BasicExpression } from "./ir.js" +import type { LoadSubsetOptions } from "../types.js" + +/** + * Deduplicated wrapper for a loadSubset function. + * Tracks what data has been loaded and avoids redundant calls by applying + * subset logic to predicates. + * + * @example + * const dedupe = new DeduplicatedLoadSubset(myLoadSubset) + * + * // First call - fetches data + * await dedupe.loadSubset({ where: gt(ref('age'), val(10)) }) + * + * // Second call - subset of first, returns true immediately + * await dedupe.loadSubset({ where: gt(ref('age'), val(20)) }) + * + * // Clear state to start fresh + * dedupe.reset() + */ +export class DeduplicatedLoadSubset { + // The underlying loadSubset function to wrap + private readonly _loadSubset: ( + options: LoadSubsetOptions + ) => true | Promise + + // Combined where predicate for all unlimited calls (no limit) + private unlimitedWhere: BasicExpression | undefined = undefined + + // Flag to track if we've loaded all data (unlimited call with no where clause) + private hasLoadedAllData = false + + // List of all limited calls (with limit, possibly with orderBy) + // We clone options before storing to prevent mutation of stored predicates + private limitedCalls: Array = [] + + // Track in-flight calls to prevent concurrent duplicate requests + // We store both the options and the promise so we can apply subset logic + private inflightCalls: Array<{ + options: LoadSubsetOptions + promise: Promise + }> = [] + + // Generation counter to invalidate in-flight requests after reset() + // When reset() is called, this increments, and any in-flight completion handlers + // check if their captured generation matches before updating tracking state + private generation = 0 + + constructor( + loadSubset: (options: LoadSubsetOptions) => true | Promise + ) { + this._loadSubset = loadSubset + } + + /** + * Load a subset of data, with automatic deduplication based on previously + * loaded predicates and in-flight requests. + * + * This method is auto-bound, so it can be safely passed as a callback without + * losing its `this` context (e.g., `loadSubset: dedupe.loadSubset` in a sync config). + * + * @param options - The predicate options (where, orderBy, limit) + * @returns true if data is already loaded, or a Promise that resolves when data is loaded + */ + loadSubset = (options: LoadSubsetOptions): true | Promise => { + // If we've loaded all data, everything is covered + if (this.hasLoadedAllData) { + return true + } + + // Check against unlimited combined predicate + // If we've loaded all data matching a where clause, we don't need to refetch subsets + if (this.unlimitedWhere !== undefined && options.where !== undefined) { + if (isWhereSubset(options.where, this.unlimitedWhere)) { + return true // Data already loaded via unlimited call + } + } + + // Check against limited calls + if (options.limit !== undefined) { + const alreadyLoaded = this.limitedCalls.some((loaded) => + isPredicateSubset(options, loaded) + ) + + if (alreadyLoaded) { + return true // Already loaded + } + } + + // Check against in-flight calls using the same subset logic as resolved calls + // This prevents duplicate requests when concurrent calls have subset relationships + const matchingInflight = this.inflightCalls.find((inflight) => + isPredicateSubset(options, inflight.options) + ) + + if (matchingInflight !== undefined) { + // An in-flight call will load data that covers this request + // Return the same promise so this caller waits for the data to load + // The in-flight promise already handles tracking updates when it completes + return matchingInflight.promise + } + + // Not fully covered by existing data + // Compute the subset of data that is not covered by the existing data + // such that we only have to load that subset of missing data + const clonedOptions = cloneOptions(options) + if (this.unlimitedWhere !== undefined && options.limit === undefined) { + // Compute difference to get only the missing data + // We can only do this for unlimited queries + // and we can only remove data that was loaded from unlimited queries + // because with limited queries we have no way to express that we already loaded part of the matching data + clonedOptions.where = + minusWherePredicates(clonedOptions.where, this.unlimitedWhere) ?? + clonedOptions.where + } + + // Call underlying loadSubset to load the missing data + const resultPromise = this._loadSubset(clonedOptions) + + // Handle both sync (true) and async (Promise) return values + if (resultPromise === true) { + // Sync return - update tracking synchronously + // Clone options before storing to protect against caller mutation + this.updateTracking(clonedOptions) + return true + } else { + // Async return - track the promise and update tracking after it resolves + + // Capture the current generation - this lets us detect if reset() was called + // while this request was in-flight, so we can skip updating tracking state + const capturedGeneration = this.generation + + // We need to create a reference to the in-flight entry so we can remove it later + const inflightEntry = { + options: clonedOptions, // Store cloned options for subset matching + promise: resultPromise + .then((result) => { + // Only update tracking if this request is still from the current generation + // If reset() was called, the generation will have incremented and we should + // not repopulate the state that was just cleared + if (capturedGeneration === this.generation) { + // Use the cloned options that we captured before any caller mutations + // This ensures we track exactly what was loaded, not what the caller changed + this.updateTracking(clonedOptions) + } + return result + }) + .finally(() => { + // Always remove from in-flight array on completion OR rejection + // This ensures failed requests can be retried instead of being cached forever + const index = this.inflightCalls.indexOf(inflightEntry) + if (index !== -1) { + this.inflightCalls.splice(index, 1) + } + }), + } + + // Store the in-flight entry so concurrent subset calls can wait for it + this.inflightCalls.push(inflightEntry) + return inflightEntry.promise + } + } + + /** + * Reset all tracking state. + * Clears the history of loaded predicates and in-flight calls. + * Use this when you want to start fresh, for example after clearing the underlying data store. + * + * Note: Any in-flight requests will still complete, but they will not update the tracking + * state after the reset. This prevents old requests from repopulating cleared state. + */ + reset(): void { + this.unlimitedWhere = undefined + this.hasLoadedAllData = false + this.limitedCalls = [] + this.inflightCalls = [] + // Increment generation to invalidate any in-flight completion handlers + // This ensures requests that were started before reset() don't repopulate the state + this.generation++ + } + + private updateTracking(options: LoadSubsetOptions): void { + // Update tracking based on whether this was a limited or unlimited call + if (options.limit === undefined) { + // Unlimited call - update combined where predicate + // We ignore orderBy for unlimited calls as mentioned in requirements + if (options.where === undefined) { + // No where clause = all data loaded + this.hasLoadedAllData = true + this.unlimitedWhere = undefined + this.limitedCalls = [] + this.inflightCalls = [] + } else if (this.unlimitedWhere === undefined) { + this.unlimitedWhere = options.where + } else { + this.unlimitedWhere = unionWherePredicates([ + this.unlimitedWhere, + options.where, + ]) + } + } else { + // Limited call - add to list for future subset checks + // Options are already cloned by caller to prevent mutation issues + this.limitedCalls.push(options) + } + } +} + +/** + * Clones a LoadSubsetOptions object to prevent mutation of stored predicates. + * This is crucial because callers often reuse the same options object and mutate + * properties like limit or where between calls. Without cloning, our stored history + * would reflect the mutated values rather than what was actually loaded. + */ +export function cloneOptions(options: LoadSubsetOptions): LoadSubsetOptions { + return { + where: options.where, + orderBy: options.orderBy, + limit: options.limit, + // Note: We don't clone subscription as it's not part of predicate matching + } +} diff --git a/packages/db/tests/predicate-utils.test.ts b/packages/db/tests/predicate-utils.test.ts new file mode 100644 index 000000000..bf973f15c --- /dev/null +++ b/packages/db/tests/predicate-utils.test.ts @@ -0,0 +1,1115 @@ +import { describe, expect, it } from "vitest" +import { + isLimitSubset, + isOrderBySubset, + isPredicateSubset, + isWhereSubset, + minusWherePredicates, + unionWherePredicates, +} from "../src/query/predicate-utils" +import { Func, PropRef, Value } from "../src/query/ir" +import type { BasicExpression, OrderBy, OrderByClause } from "../src/query/ir" +import type { LoadSubsetOptions } from "../src/types" + +// Helper functions to build expressions more easily +function ref(path: string | Array): PropRef { + return new PropRef(typeof path === `string` ? [path] : path) +} + +function val(value: any): Value { + return new Value(value) +} + +function func(name: string, ...args: Array): Func { + return new Func(name, args) +} + +function eq(left: BasicExpression, right: BasicExpression): Func { + return func(`eq`, left, right) +} + +function gt(left: BasicExpression, right: BasicExpression): Func { + return func(`gt`, left, right) +} + +function gte(left: BasicExpression, right: BasicExpression): Func { + return func(`gte`, left, right) +} + +function lt(left: BasicExpression, right: BasicExpression): Func { + return func(`lt`, left, right) +} + +function lte(left: BasicExpression, right: BasicExpression): Func { + return func(`lte`, left, right) +} + +function and(...args: Array): Func { + return func(`and`, ...args) +} + +function or(...args: Array): Func { + return func(`or`, ...args) +} + +function inOp(left: BasicExpression, values: Array): Func { + return func(`in`, left, val(values)) +} + +function orderByClause( + expression: BasicExpression, + direction: `asc` | `desc` = `asc` +): OrderByClause { + return { + expression, + compareOptions: { + direction, + nulls: `last`, + stringSort: `lexical`, + }, + } +} + +describe(`isWhereSubset`, () => { + describe(`basic cases`, () => { + it(`should return true for both undefined (all data is subset of all data)`, () => { + expect(isWhereSubset(undefined, undefined)).toBe(true) + }) + + it(`should return false for undefined subset with constrained superset`, () => { + // Requesting ALL data but only loaded SOME data = NOT subset + expect(isWhereSubset(undefined, gt(ref(`age`), val(10)))).toBe(false) + }) + + it(`should return true for constrained subset with undefined superset`, () => { + // Loaded ALL data, so any constrained subset is covered + expect(isWhereSubset(gt(ref(`age`), val(20)), undefined)).toBe(true) + }) + + it(`should return true for identical expressions`, () => { + const expr = gt(ref(`age`), val(10)) + expect(isWhereSubset(expr, expr)).toBe(true) + }) + + it(`should return true for structurally equal expressions`, () => { + expect( + isWhereSubset(gt(ref(`age`), val(10)), gt(ref(`age`), val(10))) + ).toBe(true) + }) + + it(`should return true when subset is false`, () => { + // When subset is false the result will always be the empty set + // and the empty set is a subset of any set + expect(isWhereSubset(val(false), gt(ref(`age`), val(10)))).toBe(true) + }) + }) + + describe(`comparison operators`, () => { + it(`should handle gt: age > 20 is subset of age > 10`, () => { + expect( + isWhereSubset(gt(ref(`age`), val(20)), gt(ref(`age`), val(10))) + ).toBe(true) + }) + + it(`should handle gt: age > 10 is NOT subset of age > 20`, () => { + expect( + isWhereSubset(gt(ref(`age`), val(10)), gt(ref(`age`), val(20))) + ).toBe(false) + }) + + it(`should handle gte: age >= 20 is subset of age >= 10`, () => { + expect( + isWhereSubset(gte(ref(`age`), val(20)), gte(ref(`age`), val(10))) + ).toBe(true) + }) + + it(`should handle lt: age < 10 is subset of age < 20`, () => { + expect( + isWhereSubset(lt(ref(`age`), val(10)), lt(ref(`age`), val(20))) + ).toBe(true) + }) + + it(`should handle lt: age < 20 is NOT subset of age < 10`, () => { + expect( + isWhereSubset(lt(ref(`age`), val(20)), lt(ref(`age`), val(10))) + ).toBe(false) + }) + + it(`should handle lte: age <= 10 is subset of age <= 20`, () => { + expect( + isWhereSubset(lte(ref(`age`), val(10)), lte(ref(`age`), val(20))) + ).toBe(true) + }) + + it(`should handle eq: age = 15 is subset of age > 10`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(15)), gt(ref(`age`), val(10))) + ).toBe(true) + }) + + it(`should handle eq: age = 5 is NOT subset of age > 10`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(5)), gt(ref(`age`), val(10))) + ).toBe(false) + }) + + it(`should handle eq: age = 15 is subset of age >= 15`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(15)), gte(ref(`age`), val(15))) + ).toBe(true) + }) + + it(`should handle eq: age = 15 is subset of age < 20`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(15)), lt(ref(`age`), val(20))) + ).toBe(true) + }) + + it(`should handle mixed operators: gt vs gte`, () => { + expect( + isWhereSubset(gt(ref(`age`), val(10)), gte(ref(`age`), val(10))) + ).toBe(true) + }) + + it(`should handle mixed operators: gte vs gt`, () => { + expect( + isWhereSubset(gte(ref(`age`), val(11)), gt(ref(`age`), val(10))) + ).toBe(true) + expect( + isWhereSubset(gte(ref(`age`), val(10)), gt(ref(`age`), val(10))) + ).toBe(false) + }) + }) + + describe(`IN operator`, () => { + it(`should handle eq vs in: age = 5 is subset of age IN [5, 10, 15]`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(5)), inOp(ref(`age`), [5, 10, 15])) + ).toBe(true) + }) + + it(`should handle eq vs in: age = 20 is NOT subset of age IN [5, 10, 15]`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(20)), inOp(ref(`age`), [5, 10, 15])) + ).toBe(false) + }) + + it(`should handle in vs in: [5, 10] is subset of [5, 10, 15]`, () => { + expect( + isWhereSubset(inOp(ref(`age`), [5, 10]), inOp(ref(`age`), [5, 10, 15])) + ).toBe(true) + }) + + it(`should handle in vs in: [5, 20] is NOT subset of [5, 10, 15]`, () => { + expect( + isWhereSubset(inOp(ref(`age`), [5, 20]), inOp(ref(`age`), [5, 10, 15])) + ).toBe(false) + }) + + it(`should handle empty IN array: age IN [] is subset of age IN []`, () => { + expect(isWhereSubset(inOp(ref(`age`), []), inOp(ref(`age`), []))).toBe( + true + ) + }) + + it(`should handle empty IN array: age IN [] is subset of age IN [5, 10]`, () => { + expect( + isWhereSubset(inOp(ref(`age`), []), inOp(ref(`age`), [5, 10])) + ).toBe(true) + }) + + it(`should handle empty IN array: age IN [5, 10] is NOT subset of age IN []`, () => { + expect( + isWhereSubset(inOp(ref(`age`), [5, 10]), inOp(ref(`age`), [])) + ).toBe(false) + }) + + it(`should handle singleton IN array: age = 5 is subset of age IN [5]`, () => { + expect(isWhereSubset(eq(ref(`age`), val(5)), inOp(ref(`age`), [5]))).toBe( + true + ) + }) + + it(`should handle singleton IN array: age = 10 is NOT subset of age IN [5]`, () => { + expect( + isWhereSubset(eq(ref(`age`), val(10)), inOp(ref(`age`), [5])) + ).toBe(false) + }) + + it(`should handle singleton IN array: age IN [5] is subset of age IN [5, 10, 15]`, () => { + expect( + isWhereSubset(inOp(ref(`age`), [5]), inOp(ref(`age`), [5, 10, 15])) + ).toBe(true) + }) + + it(`should handle singleton IN array: age IN [20] is NOT subset of age IN [5, 10, 15]`, () => { + expect( + isWhereSubset(inOp(ref(`age`), [20]), inOp(ref(`age`), [5, 10, 15])) + ).toBe(false) + }) + + it(`should handle singleton IN array: age IN [5, 10, 15] is NOT subset of age IN [5]`, () => { + expect( + isWhereSubset(inOp(ref(`age`), [5, 10, 15]), inOp(ref(`age`), [5])) + ).toBe(false) + }) + }) + + describe(`AND combinations`, () => { + it(`should handle AND in subset: (A AND B) is subset of A`, () => { + expect( + isWhereSubset( + and(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))), + gt(ref(`age`), val(10)) + ) + ).toBe(true) + }) + + it(`should handle AND in subset: (A AND B) is NOT subset of C (different field)`, () => { + expect( + isWhereSubset( + and(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))), + eq(ref(`name`), val(`John`)) + ) + ).toBe(false) + }) + + it(`should handle AND in superset: A is subset of (A AND B) is false (superset is more restrictive)`, () => { + expect( + isWhereSubset( + gt(ref(`age`), val(10)), + and(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))) + ) + ).toBe(false) + }) + + it(`should handle AND in both: (age > 20 AND status = 'active') is subset of (age > 10 AND status = 'active')`, () => { + expect( + isWhereSubset( + and(gt(ref(`age`), val(20)), eq(ref(`status`), val(`active`))), + and(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))) + ) + ).toBe(true) + }) + }) + + describe(`OR combinations`, () => { + it(`should handle OR in superset: A is subset of (A OR B)`, () => { + expect( + isWhereSubset( + gt(ref(`age`), val(10)), + or(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))) + ) + ).toBe(true) + }) + + it(`should return false when subset doesn't imply any branch of OR superset`, () => { + expect( + isWhereSubset( + eq(ref(`age`), val(10)), + or(gt(ref(`age`), val(10)), lt(ref(`age`), val(5))) + ) + ).toBe(false) + }) + + it(`should handle OR in subset: (A OR B) is subset of C only if both A and B are subsets of C`, () => { + expect( + isWhereSubset( + or(gt(ref(`age`), val(20)), gt(ref(`age`), val(30))), + gt(ref(`age`), val(10)) + ) + ).toBe(true) + }) + + it(`should handle OR in both: (age > 20 OR status = 'active') is subset of (age > 10 OR status = 'active')`, () => { + expect( + isWhereSubset( + or(gt(ref(`age`), val(20)), eq(ref(`status`), val(`active`))), + or(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))) + ) + ).toBe(true) + }) + + it(`should handle OR in subset: (A OR B) is NOT subset of C if either is not a subset`, () => { + expect( + isWhereSubset( + or(gt(ref(`age`), val(20)), lt(ref(`age`), val(5))), + gt(ref(`age`), val(10)) + ) + ).toBe(false) + }) + }) + + describe(`different fields`, () => { + it(`should return false for different fields with no relationship`, () => { + expect( + isWhereSubset(gt(ref(`age`), val(20)), gt(ref(`salary`), val(1000))) + ).toBe(false) + }) + }) + + describe(`Date support`, () => { + const date1 = new Date(`2024-01-01`) + const date2 = new Date(`2024-01-15`) + const date3 = new Date(`2024-02-01`) + + it(`should handle Date equality`, () => { + expect( + isWhereSubset( + eq(ref(`createdAt`), val(date2)), + eq(ref(`createdAt`), val(date2)) + ) + ).toBe(true) + }) + + it(`should handle Date range comparisons: date > 2024-01-15 is subset of date > 2024-01-01`, () => { + expect( + isWhereSubset( + gt(ref(`createdAt`), val(date2)), + gt(ref(`createdAt`), val(date1)) + ) + ).toBe(true) + }) + + it(`should handle Date range comparisons: date < 2024-01-15 is subset of date < 2024-02-01`, () => { + expect( + isWhereSubset( + lt(ref(`createdAt`), val(date2)), + lt(ref(`createdAt`), val(date3)) + ) + ).toBe(true) + }) + + it(`should handle Date equality vs range: date = 2024-01-15 is subset of date > 2024-01-01`, () => { + expect( + isWhereSubset( + eq(ref(`createdAt`), val(date2)), + gt(ref(`createdAt`), val(date1)) + ) + ).toBe(true) + }) + + it(`should handle Date equality vs IN: date = 2024-01-15 is subset of date IN [2024-01-01, 2024-01-15, 2024-02-01]`, () => { + expect( + isWhereSubset( + eq(ref(`createdAt`), val(date2)), + inOp(ref(`createdAt`), [date1, date2, date3]) + ) + ).toBe(true) + }) + + it(`should handle Date IN subset: date IN [2024-01-01, 2024-01-15] is subset of date IN [2024-01-01, 2024-01-15, 2024-02-01]`, () => { + expect( + isWhereSubset( + inOp(ref(`createdAt`), [date1, date2]), + inOp(ref(`createdAt`), [date1, date2, date3]) + ) + ).toBe(true) + }) + + it(`should return false when Date not in IN set`, () => { + expect( + isWhereSubset( + eq(ref(`createdAt`), val(date1)), + inOp(ref(`createdAt`), [date2, date3]) + ) + ).toBe(false) + }) + }) +}) + +describe(`unionWherePredicates`, () => { + describe(`basic cases`, () => { + it(`should return false for empty array`, () => { + const result = unionWherePredicates([]) + expect(result.type).toBe(`val`) + expect((result as Value).value).toBe(false) + }) + + it(`should return the single predicate as-is`, () => { + const pred = gt(ref(`age`), val(10)) + const result = unionWherePredicates([pred]) + expect(result).toBe(pred) + }) + }) + + describe(`same field comparisons`, () => { + it(`should take least restrictive for gt: age > 10 OR age > 20 → age > 10`, () => { + const result = unionWherePredicates([ + gt(ref(`age`), val(10)), + gt(ref(`age`), val(20)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`gt`) + const field = (result as Func).args[1] as Value + expect(field.value).toBe(10) + }) + + it(`should take least restrictive for gte: age >= 10 OR age >= 20 → age >= 10`, () => { + const result = unionWherePredicates([ + gte(ref(`age`), val(10)), + gte(ref(`age`), val(20)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`gte`) + const field = (result as Func).args[1] as Value + expect(field.value).toBe(10) + }) + + it(`should take least restrictive for lt: age < 20 OR age < 10 → age < 20`, () => { + const result = unionWherePredicates([ + lt(ref(`age`), val(20)), + lt(ref(`age`), val(10)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`lt`) + const field = (result as Func).args[1] as Value + expect(field.value).toBe(20) + }) + + it(`should combine eq into IN: age = 5 OR age = 10 → age IN [5, 10]`, () => { + const result = unionWherePredicates([ + eq(ref(`age`), val(5)), + eq(ref(`age`), val(10)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`in`) + const values = ((result as Func).args[1] as Value).value + expect(values).toContain(5) + expect(values).toContain(10) + expect(values.length).toBe(2) + }) + + it(`should fold IN and equality into single IN: age IN [1,2] OR age = 3 → age IN [1,2,3]`, () => { + const result = unionWherePredicates([ + inOp(ref(`age`), [1, 2]), + eq(ref(`age`), val(3)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`in`) + const values = ((result as Func).args[1] as Value).value + expect(values).toContain(1) + expect(values).toContain(2) + expect(values).toContain(3) + expect(values.length).toBe(3) + }) + + it(`should handle gte and gt together: age > 10 OR age >= 15 → age > 10`, () => { + const result = unionWherePredicates([ + gt(ref(`age`), val(10)), + gte(ref(`age`), val(15)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`gt`) + const field = (result as Func).args[1] as Value + expect(field.value).toBe(10) + }) + }) + + describe(`different fields`, () => { + it(`should combine with OR: age > 10 OR status = 'active'`, () => { + const result = unionWherePredicates([ + gt(ref(`age`), val(10)), + eq(ref(`status`), val(`active`)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`or`) + expect((result as Func).args.length).toBe(2) + }) + }) + + describe(`flatten OR`, () => { + it(`should flatten nested ORs`, () => { + const result = unionWherePredicates([ + or(gt(ref(`age`), val(10)), eq(ref(`status`), val(`active`))), + eq(ref(`name`), val(`John`)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`or`) + expect((result as Func).args.length).toBe(3) + }) + }) + + describe(`Date support`, () => { + const date1 = new Date(`2024-01-01`) + const date2 = new Date(`2024-01-15`) + const date3 = new Date(`2024-02-01`) + + it(`should combine Date equalities into IN: date = date1 OR date = date2 → date IN [date1, date2]`, () => { + const result = unionWherePredicates([ + eq(ref(`createdAt`), val(date1)), + eq(ref(`createdAt`), val(date2)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`in`) + const values = ((result as Func).args[1] as Value).value + expect(values.length).toBe(2) + expect(values).toContainEqual(date1) + expect(values).toContainEqual(date2) + }) + + it(`should fold Date IN and equality: date IN [date1,date2] OR date = date3 → date IN [date1,date2,date3]`, () => { + const result = unionWherePredicates([ + inOp(ref(`createdAt`), [date1, date2]), + eq(ref(`createdAt`), val(date3)), + ]) + expect(result.type).toBe(`func`) + expect((result as Func).name).toBe(`in`) + const values = ((result as Func).args[1] as Value).value + expect(values.length).toBe(3) + expect(values).toContainEqual(date1) + expect(values).toContainEqual(date2) + expect(values).toContainEqual(date3) + }) + }) +}) + +describe(`isOrderBySubset`, () => { + it(`should return true for undefined subset`, () => { + const orderBy: OrderBy = [orderByClause(ref(`age`), `asc`)] + expect(isOrderBySubset(undefined, orderBy)).toBe(true) + expect(isOrderBySubset([], orderBy)).toBe(true) + }) + + it(`should return false for undefined superset with non-empty subset`, () => { + const orderBy: OrderBy = [orderByClause(ref(`age`), `asc`)] + expect(isOrderBySubset(orderBy, undefined)).toBe(false) + expect(isOrderBySubset(orderBy, [])).toBe(false) + }) + + it(`should return true for identical orderBy`, () => { + const orderBy: OrderBy = [orderByClause(ref(`age`), `asc`)] + expect(isOrderBySubset(orderBy, orderBy)).toBe(true) + }) + + it(`should return true when subset is prefix of superset`, () => { + const subset: OrderBy = [orderByClause(ref(`age`), `asc`)] + const superset: OrderBy = [ + orderByClause(ref(`age`), `asc`), + orderByClause(ref(`name`), `desc`), + ] + expect(isOrderBySubset(subset, superset)).toBe(true) + }) + + it(`should return false when subset is not a prefix`, () => { + const subset: OrderBy = [orderByClause(ref(`name`), `desc`)] + const superset: OrderBy = [ + orderByClause(ref(`age`), `asc`), + orderByClause(ref(`name`), `desc`), + ] + expect(isOrderBySubset(subset, superset)).toBe(false) + }) + + it(`should return false when directions differ`, () => { + const subset: OrderBy = [orderByClause(ref(`age`), `desc`)] + const superset: OrderBy = [orderByClause(ref(`age`), `asc`)] + expect(isOrderBySubset(subset, superset)).toBe(false) + }) + + it(`should return false when subset is longer than superset`, () => { + const subset: OrderBy = [ + orderByClause(ref(`age`), `asc`), + orderByClause(ref(`name`), `desc`), + orderByClause(ref(`status`), `asc`), + ] + const superset: OrderBy = [ + orderByClause(ref(`age`), `asc`), + orderByClause(ref(`name`), `desc`), + ] + expect(isOrderBySubset(subset, superset)).toBe(false) + }) +}) + +describe(`isLimitSubset`, () => { + it(`should return false for undefined subset with limited superset (requesting all data but only have limited)`, () => { + expect(isLimitSubset(undefined, 10)).toBe(false) + }) + + it(`should return true for undefined subset with undefined superset (requesting all data and have all data)`, () => { + expect(isLimitSubset(undefined, undefined)).toBe(true) + }) + + it(`should return true for undefined superset`, () => { + expect(isLimitSubset(10, undefined)).toBe(true) + }) + + it(`should return true when subset <= superset`, () => { + expect(isLimitSubset(10, 20)).toBe(true) + expect(isLimitSubset(10, 10)).toBe(true) + }) + + it(`should return false when subset > superset`, () => { + expect(isLimitSubset(20, 10)).toBe(false) + }) +}) + +describe(`isPredicateSubset`, () => { + it(`should check all components`, () => { + const subset: LoadSubsetOptions = { + where: gt(ref(`age`), val(20)), + orderBy: [orderByClause(ref(`age`), `asc`)], + limit: 10, + } + const superset: LoadSubsetOptions = { + where: gt(ref(`age`), val(10)), + orderBy: [ + orderByClause(ref(`age`), `asc`), + orderByClause(ref(`name`), `desc`), + ], + limit: 20, + } + expect(isPredicateSubset(subset, superset)).toBe(true) + }) + + it(`should return false if where is not subset`, () => { + const subset: LoadSubsetOptions = { + where: gt(ref(`age`), val(5)), + limit: 10, + } + const superset: LoadSubsetOptions = { + where: gt(ref(`age`), val(10)), + limit: 20, + } + expect(isPredicateSubset(subset, superset)).toBe(false) + }) + + it(`should return false if orderBy is not subset`, () => { + const subset: LoadSubsetOptions = { + where: gt(ref(`age`), val(20)), + orderBy: [orderByClause(ref(`name`), `desc`)], + } + const superset: LoadSubsetOptions = { + where: gt(ref(`age`), val(10)), + orderBy: [orderByClause(ref(`age`), `asc`)], + } + expect(isPredicateSubset(subset, superset)).toBe(false) + }) + + it(`should return false if limit is not subset`, () => { + const subset: LoadSubsetOptions = { + where: gt(ref(`age`), val(20)), + limit: 30, + } + const superset: LoadSubsetOptions = { + where: gt(ref(`age`), val(10)), + limit: 20, + } + expect(isPredicateSubset(subset, superset)).toBe(false) + }) +}) + +describe(`minusWherePredicates`, () => { + describe(`basic cases`, () => { + it(`should return original predicate when nothing to subtract`, () => { + const pred = gt(ref(`age`), val(10)) + const result = minusWherePredicates(pred, undefined) + + expect(result).toEqual(pred) + }) + + it(`should return null when from is undefined (can't simplify NOT(B))`, () => { + const subtract = gt(ref(`age`), val(10)) + const result = minusWherePredicates(undefined, subtract) + + expect(result).toEqual({ + type: `func`, + name: `not`, + args: [subtract], + }) + }) + + it(`should return empty set when from is subset of subtract`, () => { + const from = gt(ref(`age`), val(20)) // age > 20 + const subtract = gt(ref(`age`), val(10)) // age > 10 + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ type: `val`, value: false }) + }) + + it(`should return null when predicates are on different fields`, () => { + const from = gt(ref(`age`), val(10)) + const subtract = eq(ref(`status`), val(`active`)) + const result = minusWherePredicates(from, subtract) + + expect(result).toBeNull() + }) + }) + + describe(`IN minus IN`, () => { + it(`should compute set difference: IN [A,B,C,D] - IN [B,C] = IN [A,D]`, () => { + const from = inOp(ref(`status`), [`A`, `B`, `C`, `D`]) + const subtract = inOp(ref(`status`), [`B`, `C`]) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `in`, + args: [ref(`status`), val([`A`, `D`])], + }) + }) + + it(`should return empty set when all values are subtracted`, () => { + const from = inOp(ref(`status`), [`A`, `B`]) + const subtract = inOp(ref(`status`), [`A`, `B`]) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ type: `val`, value: false }) + }) + + it(`should return original when no overlap`, () => { + const from = inOp(ref(`status`), [`A`, `B`]) + const subtract = inOp(ref(`status`), [`C`, `D`]) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual(from) + }) + + it(`should collapse to equality when one value remains`, () => { + const from = inOp(ref(`status`), [`A`, `B`]) + const subtract = inOp(ref(`status`), [`B`]) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `eq`, + args: [ref(`status`), val(`A`)], + }) + }) + }) + + describe(`IN minus equality`, () => { + it(`should remove value from IN: IN [A,B,C] - eq(B) = IN [A,C]`, () => { + const from = inOp(ref(`status`), [`A`, `B`, `C`]) + const subtract = eq(ref(`status`), val(`B`)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `in`, + args: [ref(`status`), val([`A`, `C`])], + }) + }) + + it(`should collapse to equality when one value remains`, () => { + const from = inOp(ref(`status`), [`A`, `B`]) + const subtract = eq(ref(`status`), val(`A`)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `eq`, + args: [ref(`status`), val(`B`)], + }) + }) + + it(`should return empty set when removing last value`, () => { + const from = inOp(ref(`status`), [`A`]) + const subtract = eq(ref(`status`), val(`A`)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ type: `val`, value: false }) + }) + }) + + describe(`equality minus equality`, () => { + it(`should return empty set when same value`, () => { + const from = eq(ref(`age`), val(15)) + const subtract = eq(ref(`age`), val(15)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ type: `val`, value: false }) + }) + + it(`should return original when different values`, () => { + const from = eq(ref(`age`), val(15)) + const subtract = eq(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual(from) + }) + }) + + describe(`range minus range - gt/gte`, () => { + it(`should compute difference: age > 10 - age > 20 = (age > 10 AND age <= 20)`, () => { + const from = gt(ref(`age`), val(10)) + const subtract = gt(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gt(ref(`age`), val(10)), lte(ref(`age`), val(20))], + }) + }) + + it(`should return original when no overlap: age > 20 - age > 10`, () => { + const from = gt(ref(`age`), val(20)) + const subtract = gt(ref(`age`), val(10)) + const result = minusWherePredicates(from, subtract) + + // age > 20 is subset of age > 10, so result is empty + expect(result).toEqual({ type: `val`, value: false }) + }) + + it(`should compute difference: age >= 10 - age >= 20 = (age >= 10 AND age < 20)`, () => { + const from = gte(ref(`age`), val(10)) + const subtract = gte(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gte(ref(`age`), val(10)), lt(ref(`age`), val(20))], + }) + }) + + it(`should compute difference: age > 10 - age >= 20 = (age > 10 AND age < 20)`, () => { + const from = gt(ref(`age`), val(10)) + const subtract = gte(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gt(ref(`age`), val(10)), lt(ref(`age`), val(20))], + }) + }) + + it(`should compute difference: age >= 10 - age > 20 = (age >= 10 AND age <= 20)`, () => { + const from = gte(ref(`age`), val(10)) + const subtract = gt(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gte(ref(`age`), val(10)), lte(ref(`age`), val(20))], + }) + }) + }) + + describe(`range minus range - lt/lte`, () => { + it(`should compute difference: age < 30 - age < 20 = (age >= 20 AND age < 30)`, () => { + const from = lt(ref(`age`), val(30)) + const subtract = lt(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gte(ref(`age`), val(20)), lt(ref(`age`), val(30))], + }) + }) + + it(`should return original when no overlap: age < 20 - age < 30`, () => { + const from = lt(ref(`age`), val(20)) + const subtract = lt(ref(`age`), val(30)) + const result = minusWherePredicates(from, subtract) + + // age < 20 is subset of age < 30, so result is empty + expect(result).toEqual({ type: `val`, value: false }) + }) + + it(`should compute difference: age <= 30 - age <= 20 = (age > 20 AND age <= 30)`, () => { + const from = lte(ref(`age`), val(30)) + const subtract = lte(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gt(ref(`age`), val(20)), lte(ref(`age`), val(30))], + }) + }) + + it(`should compute difference: age < 30 - age <= 20 = (age > 20 AND age < 30)`, () => { + const from = lt(ref(`age`), val(30)) + const subtract = lte(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gt(ref(`age`), val(20)), lt(ref(`age`), val(30))], + }) + }) + + it(`should compute difference: age <= 30 - age < 20 = (age >= 20 AND age <= 30)`, () => { + const from = lte(ref(`age`), val(30)) + const subtract = lt(ref(`age`), val(20)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [gte(ref(`age`), val(20)), lte(ref(`age`), val(30))], + }) + }) + }) + + describe(`common conditions`, () => { + it(`should handle common conditions: (age > 10 AND status = 'active') - (age > 20 AND status = 'active') = (age > 10 AND age <= 20 AND status = 'active')`, () => { + const from = and( + gt(ref(`age`), val(10)), + eq(ref(`status`), val(`active`)) + ) + const subtract = and( + gt(ref(`age`), val(20)), + eq(ref(`status`), val(`active`)) + ) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [ + eq(ref(`status`), val(`active`)), // common condition + gt(ref(`age`), val(10)), + lte(ref(`age`), val(20)), + ], + }) + }) + + it(`should handle multiple common conditions`, () => { + const from = and( + gt(ref(`age`), val(10)), + eq(ref(`status`), val(`active`)), + eq(ref(`department`), val(`engineering`)) + ) + const subtract = and( + gt(ref(`age`), val(20)), + eq(ref(`status`), val(`active`)), + eq(ref(`department`), val(`engineering`)) + ) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [ + eq(ref(`status`), val(`active`)), // common condition + eq(ref(`department`), val(`engineering`)), // common condition + gt(ref(`age`), val(10)), + lte(ref(`age`), val(20)), + ], + }) + }) + + it(`should handle IN with common conditions: (age IN [10,20,30] AND status = 'active') - (age IN [20,30] AND status = 'active') = (age IN [10] AND status = 'active')`, () => { + const from = and( + inOp(ref(`age`), [10, 20, 30]), + eq(ref(`status`), val(`active`)) + ) + const subtract = and( + inOp(ref(`age`), [20, 30]), + eq(ref(`status`), val(`active`)) + ) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [ + eq(ref(`status`), val(`active`)), // common condition + { + type: `func`, + name: `eq`, + args: [ref(`age`), val(10)], + }, + ], + }) + }) + + it(`should return null when common conditions exist but remaining difference cannot be simplified`, () => { + const from = and( + gt(ref(`age`), val(10)), + eq(ref(`status`), val(`active`)) + ) + const subtract = and( + gt(ref(`name`), val(`Z`)), + eq(ref(`status`), val(`active`)) + ) + const result = minusWherePredicates(from, subtract) + + // Can't simplify age > 10 - name > 'Z' (different fields), so returns null + expect(result).toBeNull() + }) + }) + + describe(`Date support`, () => { + it(`should handle Date IN minus Date IN`, () => { + const date1 = new Date(`2024-01-01`) + const date2 = new Date(`2024-01-15`) + const date3 = new Date(`2024-02-01`) + + const from = inOp(ref(`createdAt`), [date1, date2, date3]) + const subtract = inOp(ref(`createdAt`), [date2]) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `in`, + args: [ref(`createdAt`), val([date1, date3])], + }) + }) + + it(`should handle Date range difference: date > 2024-01-01 - date > 2024-01-15`, () => { + const date1 = new Date(`2024-01-01`) + const date15 = new Date(`2024-01-15`) + + const from = gt(ref(`createdAt`), val(date1)) + const subtract = gt(ref(`createdAt`), val(date15)) + const result = minusWherePredicates(from, subtract) + + expect(result).toEqual({ + type: `func`, + name: `and`, + args: [ + gt(ref(`createdAt`), val(date1)), + lte(ref(`createdAt`), val(date15)), + ], + }) + }) + }) + + describe(`real-world sync scenarios`, () => { + it(`should compute missing data range: need age > 10, already have age > 20`, () => { + const requested = gt(ref(`age`), val(10)) + const alreadyLoaded = gt(ref(`age`), val(20)) + const needToFetch = minusWherePredicates(requested, alreadyLoaded) + + // Need to fetch: 10 < age <= 20 + expect(needToFetch).toEqual({ + type: `func`, + name: `and`, + args: [gt(ref(`age`), val(10)), lte(ref(`age`), val(20))], + }) + }) + + it(`should compute missing IDs: need IN [1..100], already have IN [50..100]`, () => { + const allIds = Array.from({ length: 100 }, (_, i) => i + 1) + const loadedIds = Array.from({ length: 51 }, (_, i) => i + 50) + + const requested = inOp(ref(`id`), allIds) + const alreadyLoaded = inOp(ref(`id`), loadedIds) + const needToFetch = minusWherePredicates(requested, alreadyLoaded) + + // Need to fetch: ids 1..49 + const expectedIds = Array.from({ length: 49 }, (_, i) => i + 1) + expect(needToFetch).toEqual({ + type: `func`, + name: `in`, + args: [ref(`id`), val(expectedIds)], + }) + }) + + it(`should return empty when all requested data is already loaded`, () => { + const requested = gt(ref(`age`), val(20)) + const alreadyLoaded = gt(ref(`age`), val(10)) + const needToFetch = minusWherePredicates(requested, alreadyLoaded) + + // Requested is subset of already loaded - nothing more to fetch + expect(needToFetch).toEqual({ type: `val`, value: false }) + }) + }) +}) diff --git a/packages/db/tests/subset-dedupe.test.ts b/packages/db/tests/subset-dedupe.test.ts new file mode 100644 index 000000000..3417f95a5 --- /dev/null +++ b/packages/db/tests/subset-dedupe.test.ts @@ -0,0 +1,562 @@ +import { describe, expect, it } from "vitest" +import { + DeduplicatedLoadSubset, + cloneOptions, +} from "../src/query/subset-dedupe" +import { Func, PropRef, Value } from "../src/query/ir" +import { minusWherePredicates } from "../src/query/predicate-utils" +import type { BasicExpression, OrderBy } from "../src/query/ir" +import type { LoadSubsetOptions } from "../src/types" + +// Helper functions to build expressions more easily +function ref(path: string | Array): PropRef { + return new PropRef(typeof path === `string` ? [path] : path) +} + +function val(value: T): Value { + return new Value(value) +} + +function gt(left: BasicExpression, right: BasicExpression): Func { + return new Func(`gt`, [left, right]) +} + +function lt(left: BasicExpression, right: BasicExpression): Func { + return new Func(`lt`, [left, right]) +} + +function eq(left: BasicExpression, right: BasicExpression): Func { + return new Func(`eq`, [left, right]) +} + +function and(...expressions: Array>): Func { + return new Func(`and`, expressions) +} + +function inOp(left: BasicExpression, values: Array): Func { + return new Func(`in`, [left, new Value(values)]) +} + +function lte(left: BasicExpression, right: BasicExpression): Func { + return new Func(`lte`, [left, right]) +} + +function not(expression: BasicExpression): Func { + return new Func(`not`, [expression]) +} + +describe(`createDeduplicatedLoadSubset`, () => { + it(`should call underlying loadSubset on first call`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + + expect(callCount).toBe(1) + }) + + it(`should return true immediately for subset unlimited calls`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 10 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + expect(callCount).toBe(1) + + // Second call: age > 20 (subset of age > 10) + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(20)), + }) + expect(result).toBe(true) + expect(callCount).toBe(1) // Should not call underlying function + }) + + it(`should call underlying loadSubset for non-subset unlimited calls`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 20 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) + expect(callCount).toBe(1) + + // Second call: age > 10 (NOT a subset of age > 20) + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + expect(callCount).toBe(2) // Should call underlying function + }) + + it(`should combine unlimited calls with union`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 20 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) + expect(callCount).toBe(1) + + // Second call: age < 10 (different range) + await deduplicated.loadSubset({ where: lt(ref(`age`), val(10)) }) + expect(callCount).toBe(2) + + // Third call: age > 25 (subset of age > 20) + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(25)), + }) + expect(result).toBe(true) + expect(callCount).toBe(2) // Should not call - covered by first call + }) + + it(`should track limited calls separately`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + const orderBy1: OrderBy = [ + { + expression: ref(`age`), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // First call: age > 10, orderBy age asc, limit 10 + await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + limit: 10, + }) + expect(callCount).toBe(1) + + // Second call: age > 20, orderBy age asc, limit 5 (subset) + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(20)), + orderBy: orderBy1, + limit: 5, + }) + expect(result).toBe(true) + expect(callCount).toBe(1) // Should not call - subset of first + }) + + it(`should call underlying for non-subset limited calls`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + const orderBy1: OrderBy = [ + { + expression: ref(`age`), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // First call: age > 10, orderBy age asc, limit 10 + await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + limit: 10, + }) + expect(callCount).toBe(1) + + // Second call: age > 10, orderBy age asc, limit 20 (NOT a subset) + await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + limit: 20, + }) + expect(callCount).toBe(2) // Should call - limit is larger + }) + + it(`should check limited calls against unlimited combined predicate`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + const orderBy1: OrderBy = [ + { + expression: ref(`age`), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // First call: unlimited age > 10 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + expect(callCount).toBe(1) + + // Second call: limited age > 20 with orderBy + limit + // Even though it has a limit, it's covered by the unlimited call + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(20)), + orderBy: orderBy1, + limit: 10, + }) + expect(result).toBe(true) + expect(callCount).toBe(1) // Should not call - covered by unlimited + }) + + it(`should ignore orderBy for unlimited calls`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + const orderBy1: OrderBy = [ + { + expression: ref(`age`), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // First call: unlimited with orderBy + await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + }) + expect(callCount).toBe(1) + + // Second call: subset where, different orderBy, no limit + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(20)), + }) + expect(result).toBe(true) + expect(callCount).toBe(1) // Should not call - orderBy ignored for unlimited + }) + + it(`should handle undefined where clauses`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: no where clause (all data) + await deduplicated.loadSubset({}) + expect(callCount).toBe(1) + + // Second call: with where clause (should be covered) + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + }) + expect(result).toBe(true) + expect(callCount).toBe(1) // Should not call - all data already loaded + }) + + it(`should handle complex real-world scenario`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(options) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + const orderBy1: OrderBy = [ + { + expression: ref(`createdAt`), + compareOptions: { + direction: `desc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // Load all active users + await deduplicated.loadSubset({ where: eq(ref(`status`), val(`active`)) }) + expect(callCount).toBe(1) + + // Load top 10 active users by createdAt + const result1 = await deduplicated.loadSubset({ + where: eq(ref(`status`), val(`active`)), + orderBy: orderBy1, + limit: 10, + }) + expect(result1).toBe(true) // Covered by unlimited call + expect(callCount).toBe(1) + + // Load all inactive users + await deduplicated.loadSubset({ where: eq(ref(`status`), val(`inactive`)) }) + expect(callCount).toBe(2) + + // Load top 5 inactive users + const result2 = await deduplicated.loadSubset({ + where: eq(ref(`status`), val(`inactive`)), + orderBy: orderBy1, + limit: 5, + }) + expect(result2).toBe(true) // Covered by unlimited inactive call + expect(callCount).toBe(2) + + // Verify only 2 actual calls were made + expect(calls).toHaveLength(2) + expect(calls[0]).toEqual({ where: eq(ref(`status`), val(`active`)) }) + expect(calls[1]).toEqual({ where: eq(ref(`status`), val(`inactive`)) }) + }) + + describe(`subset deduplication with minusWherePredicates`, () => { + it(`should request only the difference for range predicates`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 20 (loads data for age > 20) + await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) + expect(callCount).toBe(1) + expect(calls[0]).toEqual({ where: gt(ref(`age`), val(20)) }) + + // Second call: age > 10 (should request only age > 10 AND age <= 20) + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + expect(callCount).toBe(2) + expect(calls[1]).toEqual({ + where: and(gt(ref(`age`), val(10)), lte(ref(`age`), val(20))), + }) + }) + + it(`should request only the difference for set predicates`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: status IN ['B', 'C'] (loads data for B and C) + await deduplicated.loadSubset({ + where: inOp(ref(`status`), [`B`, `C`]), + }) + expect(callCount).toBe(1) + expect(calls[0]).toEqual({ where: inOp(ref(`status`), [`B`, `C`]) }) + + // Second call: status IN ['A', 'B', 'C', 'D'] (should request only A and D) + await deduplicated.loadSubset({ + where: inOp(ref(`status`), [`A`, `B`, `C`, `D`]), + }) + expect(callCount).toBe(2) + expect(calls[1]).toEqual({ + where: inOp(ref(`status`), [`A`, `D`]), + }) + }) + + it(`should return true immediately for complete overlap`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 10 (loads data for age > 10) + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + expect(callCount).toBe(1) + + // Second call: age > 20 (completely covered by first call) + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(20)), + }) + expect(result).toBe(true) + expect(callCount).toBe(1) // Should not make additional call + }) + + it(`should handle complex predicate differences`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 20 AND status = 'active' + const firstPredicate = and( + gt(ref(`age`), val(20)), + eq(ref(`status`), val(`active`)) + ) + await deduplicated.loadSubset({ where: firstPredicate }) + expect(callCount).toBe(1) + expect(calls[0]).toEqual({ where: firstPredicate }) + + // Second call: age > 10 AND status = 'active' (should request only age > 10 AND age <= 20 AND status = 'active') + const secondPredicate = and( + gt(ref(`age`), val(10)), + eq(ref(`status`), val(`active`)) + ) + + const test = minusWherePredicates(secondPredicate, firstPredicate) + console.log(`test`, test) + + await deduplicated.loadSubset({ where: secondPredicate }) + expect(callCount).toBe(2) + expect(calls[1]).toEqual({ + where: and( + eq(ref(`status`), val(`active`)), + gt(ref(`age`), val(10)), + lte(ref(`age`), val(20)) + ), + }) + }) + + it(`should not apply subset logic to limited calls`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + const orderBy1: OrderBy = [ + { + expression: ref(`age`), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // First call: unlimited age > 20 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) + expect(callCount).toBe(1) + + // Second call: limited age > 10 with orderBy + limit + // Should request the full predicate, not the difference, because it's limited + await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + limit: 10, + }) + expect(callCount).toBe(2) + expect(calls[1]).toEqual({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + limit: 10, + }) + }) + + it(`should handle undefined where clauses in subset logic`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 20 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) + expect(callCount).toBe(1) + + // Second call: no where clause (all data) + // Should request all data except what we already loaded + // i.e. should request NOT (age > 20) + await deduplicated.loadSubset({}) + expect(callCount).toBe(2) + expect(calls[1]).toEqual({ where: not(gt(ref(`age`), val(20))) }) // Should request all data except what we already loaded + }) + + it(`should handle multiple overlapping unlimited calls`, async () => { + let callCount = 0 + const calls: Array = [] + const mockLoadSubset = (options: LoadSubsetOptions) => { + callCount++ + calls.push(cloneOptions(options)) + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + + // First call: age > 20 + await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) + expect(callCount).toBe(1) + + // Second call: age < 10 (different range) + await deduplicated.loadSubset({ where: lt(ref(`age`), val(10)) }) + expect(callCount).toBe(2) + + // Third call: age > 5 (should request only age >= 10 AND age <= 20, since age < 10 is already covered) + await deduplicated.loadSubset({ where: gt(ref(`age`), val(5)) }) + expect(callCount).toBe(3) + + // Ideally it would be smart enough to optimize it to request only age >= 10 AND age <= 20, since age < 10 is already covered + // However, it doesn't do that currently, so it will not optimize and execute the original query + expect(calls[2]).toEqual({ + where: gt(ref(`age`), val(5)), + }) + + /* + expect(calls[2]).toEqual({ + where: and(gte(ref(`age`), val(10)), lte(ref(`age`), val(20))), + }) + */ + }) + }) +}) From 2dd8c79eb6a33265409a2854beb86dc2278351a9 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 5 Nov 2025 12:48:35 +0100 Subject: [PATCH 2/5] Callback to inform about loadSubset deduplication (#694) * Callback that informs about deduplicated loadSubset calls * Unit tests for onDeduplicate callback + move dedupe test files to the right folder * changeset * Object params * Updated lockfile * fix lock file --------- Co-authored-by: Sam Willis --- .changeset/two-lamps-wave.md | 5 + packages/db/src/query/subset-dedupe.ts | 30 ++- .../tests/{ => query}/predicate-utils.test.ts | 12 +- .../tests/{ => query}/subset-dedupe.test.ts | 228 ++++++++++++++++-- 4 files changed, 243 insertions(+), 32 deletions(-) create mode 100644 .changeset/two-lamps-wave.md rename packages/db/tests/{ => query}/predicate-utils.test.ts (99%) rename packages/db/tests/{ => query}/subset-dedupe.test.ts (70%) diff --git a/.changeset/two-lamps-wave.md b/.changeset/two-lamps-wave.md new file mode 100644 index 000000000..ef2cd032a --- /dev/null +++ b/.changeset/two-lamps-wave.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Adds an onDeduplicate callback on the DeduplicatedLoadSubset class which is called when a loadSubset call is deduplicated diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts index fa8172559..a7c6d7c6a 100644 --- a/packages/db/src/query/subset-dedupe.ts +++ b/packages/db/src/query/subset-dedupe.ts @@ -12,8 +12,15 @@ import type { LoadSubsetOptions } from "../types.js" * Tracks what data has been loaded and avoids redundant calls by applying * subset logic to predicates. * + * @param opts - The options for the DeduplicatedLoadSubset + * @param opts.loadSubset - The underlying loadSubset function to wrap + * @param opts.onDeduplicate - An optional callback function that is invoked when a loadSubset call is deduplicated. + * If the call is deduplicated because the requested data is being loaded by an inflight request, + * then this callback is invoked when the inflight request completes successfully and the data is fully loaded. + * This callback is useful if you need to track rows per query, in which case you can't ignore deduplicated calls + * because you need to know which rows were loaded for each query. * @example - * const dedupe = new DeduplicatedLoadSubset(myLoadSubset) + * const dedupe = new DeduplicatedLoadSubset({ loadSubset: myLoadSubset, onDeduplicate: (opts) => console.log(`Call was deduplicated:`, opts) }) * * // First call - fetches data * await dedupe.loadSubset({ where: gt(ref('age'), val(10)) }) @@ -30,6 +37,11 @@ export class DeduplicatedLoadSubset { options: LoadSubsetOptions ) => true | Promise + // An optional callback function that is invoked when a loadSubset call is deduplicated. + private readonly onDeduplicate: + | ((options: LoadSubsetOptions) => void) + | undefined + // Combined where predicate for all unlimited calls (no limit) private unlimitedWhere: BasicExpression | undefined = undefined @@ -52,10 +64,12 @@ export class DeduplicatedLoadSubset { // check if their captured generation matches before updating tracking state private generation = 0 - constructor( + constructor(opts: { loadSubset: (options: LoadSubsetOptions) => true | Promise - ) { - this._loadSubset = loadSubset + onDeduplicate?: (options: LoadSubsetOptions) => void + }) { + this._loadSubset = opts.loadSubset + this.onDeduplicate = opts.onDeduplicate } /** @@ -71,6 +85,7 @@ export class DeduplicatedLoadSubset { loadSubset = (options: LoadSubsetOptions): true | Promise => { // If we've loaded all data, everything is covered if (this.hasLoadedAllData) { + this.onDeduplicate?.(options) return true } @@ -78,6 +93,7 @@ export class DeduplicatedLoadSubset { // If we've loaded all data matching a where clause, we don't need to refetch subsets if (this.unlimitedWhere !== undefined && options.where !== undefined) { if (isWhereSubset(options.where, this.unlimitedWhere)) { + this.onDeduplicate?.(options) return true // Data already loaded via unlimited call } } @@ -89,6 +105,7 @@ export class DeduplicatedLoadSubset { ) if (alreadyLoaded) { + this.onDeduplicate?.(options) return true // Already loaded } } @@ -103,7 +120,10 @@ export class DeduplicatedLoadSubset { // An in-flight call will load data that covers this request // Return the same promise so this caller waits for the data to load // The in-flight promise already handles tracking updates when it completes - return matchingInflight.promise + const prom = matchingInflight.promise + // Call `onDeduplicate` when the inflight request has loaded the data + prom.then(() => this.onDeduplicate?.(options)).catch() // ignore errors + return prom } // Not fully covered by existing data diff --git a/packages/db/tests/predicate-utils.test.ts b/packages/db/tests/query/predicate-utils.test.ts similarity index 99% rename from packages/db/tests/predicate-utils.test.ts rename to packages/db/tests/query/predicate-utils.test.ts index bf973f15c..d2373f1fb 100644 --- a/packages/db/tests/predicate-utils.test.ts +++ b/packages/db/tests/query/predicate-utils.test.ts @@ -6,10 +6,14 @@ import { isWhereSubset, minusWherePredicates, unionWherePredicates, -} from "../src/query/predicate-utils" -import { Func, PropRef, Value } from "../src/query/ir" -import type { BasicExpression, OrderBy, OrderByClause } from "../src/query/ir" -import type { LoadSubsetOptions } from "../src/types" +} from "../../src/query/predicate-utils" +import { Func, PropRef, Value } from "../../src/query/ir" +import type { + BasicExpression, + OrderBy, + OrderByClause, +} from "../../src/query/ir" +import type { LoadSubsetOptions } from "../../src/types" // Helper functions to build expressions more easily function ref(path: string | Array): PropRef { diff --git a/packages/db/tests/subset-dedupe.test.ts b/packages/db/tests/query/subset-dedupe.test.ts similarity index 70% rename from packages/db/tests/subset-dedupe.test.ts rename to packages/db/tests/query/subset-dedupe.test.ts index 3417f95a5..59aa8c6a3 100644 --- a/packages/db/tests/subset-dedupe.test.ts +++ b/packages/db/tests/query/subset-dedupe.test.ts @@ -1,12 +1,12 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { DeduplicatedLoadSubset, cloneOptions, -} from "../src/query/subset-dedupe" -import { Func, PropRef, Value } from "../src/query/ir" -import { minusWherePredicates } from "../src/query/predicate-utils" -import type { BasicExpression, OrderBy } from "../src/query/ir" -import type { LoadSubsetOptions } from "../src/types" +} from "../../src/query/subset-dedupe" +import { Func, PropRef, Value } from "../../src/query/ir" +import { minusWherePredicates } from "../../src/query/predicate-utils" +import type { BasicExpression, OrderBy } from "../../src/query/ir" +import type { LoadSubsetOptions } from "../../src/types" // Helper functions to build expressions more easily function ref(path: string | Array): PropRef { @@ -53,7 +53,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) expect(callCount).toBe(1) @@ -66,7 +68,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 10 await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) @@ -87,7 +91,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 20 await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) @@ -105,7 +111,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 20 await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) @@ -130,7 +138,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) const orderBy1: OrderBy = [ { @@ -168,7 +178,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) const orderBy1: OrderBy = [ { @@ -205,7 +217,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) const orderBy1: OrderBy = [ { @@ -240,7 +254,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) const orderBy1: OrderBy = [ { @@ -275,7 +291,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: no where clause (all data) await deduplicated.loadSubset({}) @@ -298,7 +316,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) const orderBy1: OrderBy = [ { @@ -353,7 +373,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 20 (loads data for age > 20) await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) @@ -377,7 +399,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: status IN ['B', 'C'] (loads data for B and C) await deduplicated.loadSubset({ @@ -405,7 +429,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 10 (loads data for age > 10) await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) @@ -428,7 +454,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 20 AND status = 'active' const firstPredicate = and( @@ -468,7 +496,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) const orderBy1: OrderBy = [ { @@ -509,7 +539,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 20 await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) @@ -532,7 +564,9 @@ describe(`createDeduplicatedLoadSubset`, () => { return Promise.resolve() } - const deduplicated = new DeduplicatedLoadSubset(mockLoadSubset) + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) // First call: age > 20 await deduplicated.loadSubset({ where: gt(ref(`age`), val(20)) }) @@ -559,4 +593,152 @@ describe(`createDeduplicatedLoadSubset`, () => { */ }) }) + + describe(`onDeduplicate callback`, () => { + it(`should call onDeduplicate when all data already loaded`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const onDeduplicate = vi.fn() + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + onDeduplicate, + }) + + // Load all data + await deduplicated.loadSubset({}) + expect(callCount).toBe(1) + + // Any subsequent request should be deduplicated + const subsetOptions = { where: gt(ref(`age`), val(10)) } + const result = await deduplicated.loadSubset(subsetOptions) + expect(result).toBe(true) + expect(callCount).toBe(1) + expect(onDeduplicate).toHaveBeenCalledTimes(1) + expect(onDeduplicate).toHaveBeenCalledWith(subsetOptions) + }) + + it(`should call onDeduplicate when unlimited superset already loaded`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const onDeduplicate = vi.fn() + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + onDeduplicate: onDeduplicate, + }) + + // First call loads a broader set + await deduplicated.loadSubset({ where: gt(ref(`age`), val(10)) }) + expect(callCount).toBe(1) + + // Second call is a subset of the first; should dedupe and call callback + const subsetOptions = { where: gt(ref(`age`), val(20)) } + const result = await deduplicated.loadSubset(subsetOptions) + expect(result).toBe(true) + expect(callCount).toBe(1) + expect(onDeduplicate).toHaveBeenCalledTimes(1) + expect(onDeduplicate).toHaveBeenCalledWith(subsetOptions) + }) + + it(`should call onDeduplicate for limited subset requests`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const onDeduplicate = vi.fn() + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + onDeduplicate, + }) + + const orderBy1: OrderBy = [ + { + expression: ref(`age`), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ] + + // First limited call + await deduplicated.loadSubset({ + where: gt(ref(`age`), val(10)), + orderBy: orderBy1, + limit: 10, + }) + expect(callCount).toBe(1) + + // Second limited call is a subset (stricter where and smaller limit) + const subsetOptions = { + where: gt(ref(`age`), val(20)), + orderBy: orderBy1, + limit: 5, + } + const result = await deduplicated.loadSubset(subsetOptions) + expect(result).toBe(true) + expect(callCount).toBe(1) + expect(onDeduplicate).toHaveBeenCalledTimes(1) + expect(onDeduplicate).toHaveBeenCalledWith(subsetOptions) + }) + + it(`should delay onDeduplicate until covering in-flight request completes`, async () => { + let resolveFirst: (() => void) | undefined + let callCount = 0 + const firstPromise = new Promise((resolve) => { + resolveFirst = () => resolve() + }) + + // First call will remain in-flight until we resolve it + let first = true + const mockLoadSubset = (_options: LoadSubsetOptions) => { + callCount++ + if (first) { + first = false + return firstPromise + } + return Promise.resolve() + } + + const onDeduplicate = vi.fn() + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + onDeduplicate: onDeduplicate, + }) + + // Start a broad in-flight request + const inflightOptions = { where: gt(ref(`age`), val(10)) } + const inflight = deduplicated.loadSubset(inflightOptions) + expect(inflight).toBeInstanceOf(Promise) + expect(callCount).toBe(1) + + // Issue a subset request while first is still in-flight + const subsetOptions = { where: gt(ref(`age`), val(20)) } + const subsetPromise = deduplicated.loadSubset(subsetOptions) + expect(subsetPromise).toBeInstanceOf(Promise) + + // onDeduplicate should NOT have fired yet + expect(onDeduplicate).not.toHaveBeenCalled() + + // Complete the first request + resolveFirst?.() + + // Wait for the subset promise to settle (which chains the first) + await subsetPromise + + // Now the callback should have been called exactly once, with the subset options + expect(onDeduplicate).toHaveBeenCalledTimes(1) + expect(onDeduplicate).toHaveBeenCalledWith(subsetOptions) + }) + }) }) From 05488329d77261e730599aec361b9322147cf278 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 5 Nov 2025 07:39:35 -0700 Subject: [PATCH 3/5] Handle pushed down predicates in query collection (#681) * Handle pushed down predicates in query collection Co-authored-by: Kevin De Porre Co-authored-by: Sam Willis * Deduplicate loadSubset requests in Query collection * Unit test for deduplicating limited ordered queries in query collection * GC tanstack query when subscription is unsubscribed --------- Co-authored-by: Sam Willis Co-authored-by: Kevin De Porre --- .changeset/silent-trains-tell.md | 5 + .../classes/DeduplicatedLoadSubset.md | 120 +++ docs/reference/functions/isLimitSubset.md | 43 ++ docs/reference/functions/isOrderBySubset.md | 42 + docs/reference/functions/isPredicateSubset.md | 44 ++ docs/reference/functions/isWhereSubset.md | 47 ++ .../functions/minusWherePredicates.md | 73 ++ .../functions/unionWherePredicates.md | 42 + docs/reference/index.md | 7 + .../functions/queryCollectionOptions.md | 8 +- .../interfaces/QueryCollectionConfig.md | 24 +- .../interfaces/QueryCollectionUtils.md | 32 +- packages/db/src/query/subset-dedupe.ts | 7 +- packages/query-db-collection/src/query.ts | 582 ++++++++++---- .../query-db-collection/tests/query.test.ts | 722 +++++++++++++++++- 15 files changed, 1596 insertions(+), 202 deletions(-) create mode 100644 .changeset/silent-trains-tell.md create mode 100644 docs/reference/classes/DeduplicatedLoadSubset.md create mode 100644 docs/reference/functions/isLimitSubset.md create mode 100644 docs/reference/functions/isOrderBySubset.md create mode 100644 docs/reference/functions/isPredicateSubset.md create mode 100644 docs/reference/functions/isWhereSubset.md create mode 100644 docs/reference/functions/minusWherePredicates.md create mode 100644 docs/reference/functions/unionWherePredicates.md diff --git a/.changeset/silent-trains-tell.md b/.changeset/silent-trains-tell.md new file mode 100644 index 000000000..414dd8c89 --- /dev/null +++ b/.changeset/silent-trains-tell.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Handle pushed-down predicates diff --git a/docs/reference/classes/DeduplicatedLoadSubset.md b/docs/reference/classes/DeduplicatedLoadSubset.md new file mode 100644 index 000000000..946c4097b --- /dev/null +++ b/docs/reference/classes/DeduplicatedLoadSubset.md @@ -0,0 +1,120 @@ +--- +id: DeduplicatedLoadSubset +title: DeduplicatedLoadSubset +--- + +# Class: DeduplicatedLoadSubset + +Defined in: [packages/db/src/query/subset-dedupe.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L34) + +Deduplicated wrapper for a loadSubset function. +Tracks what data has been loaded and avoids redundant calls by applying +subset logic to predicates. + +## Param + +The options for the DeduplicatedLoadSubset + +## Param + +The underlying loadSubset function to wrap + +## Param + +An optional callback function that is invoked when a loadSubset call is deduplicated. + If the call is deduplicated because the requested data is being loaded by an inflight request, + then this callback is invoked when the inflight request completes successfully and the data is fully loaded. + This callback is useful if you need to track rows per query, in which case you can't ignore deduplicated calls + because you need to know which rows were loaded for each query. + +## Example + +```ts +const dedupe = new DeduplicatedLoadSubset({ loadSubset: myLoadSubset, onDeduplicate: (opts) => console.log(`Call was deduplicated:`, opts) }) + +// First call - fetches data +await dedupe.loadSubset({ where: gt(ref('age'), val(10)) }) + +// Second call - subset of first, returns true immediately +await dedupe.loadSubset({ where: gt(ref('age'), val(20)) }) + +// Clear state to start fresh +dedupe.reset() +``` + +## Constructors + +### Constructor + +```ts +new DeduplicatedLoadSubset(opts): DeduplicatedLoadSubset; +``` + +Defined in: [packages/db/src/query/subset-dedupe.ts:67](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L67) + +#### Parameters + +##### opts + +###### loadSubset + +(`options`) => `true` \| `Promise`\<`void`\> + +###### onDeduplicate? + +(`options`) => `void` + +#### Returns + +`DeduplicatedLoadSubset` + +## Methods + +### loadSubset() + +```ts +loadSubset(options): true | Promise; +``` + +Defined in: [packages/db/src/query/subset-dedupe.ts:85](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L85) + +Load a subset of data, with automatic deduplication based on previously +loaded predicates and in-flight requests. + +This method is auto-bound, so it can be safely passed as a callback without +losing its `this` context (e.g., `loadSubset: dedupe.loadSubset` in a sync config). + +#### Parameters + +##### options + +[`LoadSubsetOptions`](../../type-aliases/LoadSubsetOptions.md) + +The predicate options (where, orderBy, limit) + +#### Returns + +`true` \| `Promise`\<`void`\> + +true if data is already loaded, or a Promise that resolves when data is loaded + +*** + +### reset() + +```ts +reset(): void; +``` + +Defined in: [packages/db/src/query/subset-dedupe.ts:198](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L198) + +Reset all tracking state. +Clears the history of loaded predicates and in-flight calls. +Use this when you want to start fresh, for example after clearing the underlying data store. + +Note: Any in-flight requests will still complete, but they will not update the tracking +state after the reset. This prevents old requests from repopulating cleared state. + +#### Returns + +`void` diff --git a/docs/reference/functions/isLimitSubset.md b/docs/reference/functions/isLimitSubset.md new file mode 100644 index 000000000..ac464fcb2 --- /dev/null +++ b/docs/reference/functions/isLimitSubset.md @@ -0,0 +1,43 @@ +--- +id: isLimitSubset +title: isLimitSubset +--- + +# Function: isLimitSubset() + +```ts +function isLimitSubset(subset, superset): boolean; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:768](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L768) + +Check if one limit is a subset of another. +Returns true if the subset limit requirements are satisfied by the superset limit. + +## Parameters + +### subset + +The limit requirement to check + +`number` | `undefined` + +### superset + +The limit that might satisfy the requirement + +`number` | `undefined` + +## Returns + +`boolean` + +true if subset is satisfied by superset + +## Example + +```ts +isLimitSubset(10, 20) // true (requesting 10 items when 20 are available) +isLimitSubset(20, 10) // false (requesting 20 items when only 10 are available) +isLimitSubset(10, undefined) // true (requesting 10 items when unlimited are available) +``` diff --git a/docs/reference/functions/isOrderBySubset.md b/docs/reference/functions/isOrderBySubset.md new file mode 100644 index 000000000..0a23a6505 --- /dev/null +++ b/docs/reference/functions/isOrderBySubset.md @@ -0,0 +1,42 @@ +--- +id: isOrderBySubset +title: isOrderBySubset +--- + +# Function: isOrderBySubset() + +```ts +function isOrderBySubset(subset, superset): boolean; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:713](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L713) + +Check if one orderBy clause is a subset of another. +Returns true if the subset ordering requirements are satisfied by the superset ordering. + +## Parameters + +### subset + +The ordering requirements to check + +[`OrderBy`](../../@tanstack/namespaces/IR/type-aliases/OrderBy.md) | `undefined` + +### superset + +The ordering that might satisfy the requirements + +[`OrderBy`](../../@tanstack/namespaces/IR/type-aliases/OrderBy.md) | `undefined` + +## Returns + +`boolean` + +true if subset is satisfied by superset + +## Example + +```ts +// Subset is prefix of superset +isOrderBySubset([{expr: age, asc}], [{expr: age, asc}, {expr: name, desc}]) // true +``` diff --git a/docs/reference/functions/isPredicateSubset.md b/docs/reference/functions/isPredicateSubset.md new file mode 100644 index 000000000..d898b144d --- /dev/null +++ b/docs/reference/functions/isPredicateSubset.md @@ -0,0 +1,44 @@ +--- +id: isPredicateSubset +title: isPredicateSubset +--- + +# Function: isPredicateSubset() + +```ts +function isPredicateSubset(subset, superset): boolean; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:801](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L801) + +Check if one predicate (where + orderBy + limit) is a subset of another. +Returns true if all aspects of the subset predicate are satisfied by the superset. + +## Parameters + +### subset + +[`LoadSubsetOptions`](../../type-aliases/LoadSubsetOptions.md) + +The predicate requirements to check + +### superset + +[`LoadSubsetOptions`](../../type-aliases/LoadSubsetOptions.md) + +The predicate that might satisfy the requirements + +## Returns + +`boolean` + +true if subset is satisfied by superset + +## Example + +```ts +isPredicateSubset( + { where: gt(ref('age'), val(20)), limit: 10 }, + { where: gt(ref('age'), val(10)), limit: 20 } +) // true +``` diff --git a/docs/reference/functions/isWhereSubset.md b/docs/reference/functions/isWhereSubset.md new file mode 100644 index 000000000..3c68dc250 --- /dev/null +++ b/docs/reference/functions/isWhereSubset.md @@ -0,0 +1,47 @@ +--- +id: isWhereSubset +title: isWhereSubset +--- + +# Function: isWhereSubset() + +```ts +function isWhereSubset(subset, superset): boolean; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:21](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L21) + +Check if one where clause is a logical subset of another. +Returns true if the subset predicate is more restrictive than (or equal to) the superset predicate. + +## Parameters + +### subset + +The potentially more restrictive predicate + +[`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\> | `undefined` + +### superset + +The potentially less restrictive predicate + +[`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\> | `undefined` + +## Returns + +`boolean` + +true if subset logically implies superset + +## Examples + +```ts +// age > 20 is subset of age > 10 (more restrictive) +isWhereSubset(gt(ref('age'), val(20)), gt(ref('age'), val(10))) // true +``` + +```ts +// age > 10 AND name = 'X' is subset of age > 10 (more conditions) +isWhereSubset(and(gt(ref('age'), val(10)), eq(ref('name'), val('X'))), gt(ref('age'), val(10))) // true +``` diff --git a/docs/reference/functions/minusWherePredicates.md b/docs/reference/functions/minusWherePredicates.md new file mode 100644 index 000000000..e5e33e6c7 --- /dev/null +++ b/docs/reference/functions/minusWherePredicates.md @@ -0,0 +1,73 @@ +--- +id: minusWherePredicates +title: minusWherePredicates +--- + +# Function: minusWherePredicates() + +```ts +function minusWherePredicates(fromPredicate, subtractPredicate): + | BasicExpression + | null; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:338](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L338) + +Compute the difference between two where predicates: `fromPredicate AND NOT(subtractPredicate)`. +Returns the simplified predicate, or null if the difference cannot be simplified +(in which case the caller should fetch the full fromPredicate). + +## Parameters + +### fromPredicate + +The predicate to subtract from + +[`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\> | `undefined` + +### subtractPredicate + +The predicate to subtract + +[`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\> | `undefined` + +## Returns + + \| [`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\> + \| `null` + +The simplified difference, or null if cannot be simplified + +## Examples + +```ts +// Range difference +minusWherePredicates( + gt(ref('age'), val(10)), // age > 10 + gt(ref('age'), val(20)) // age > 20 +) // → age > 10 AND age <= 20 +``` + +```ts +// Set difference +minusWherePredicates( + inOp(ref('status'), ['A', 'B', 'C', 'D']), // status IN ['A','B','C','D'] + inOp(ref('status'), ['B', 'C']) // status IN ['B','C'] +) // → status IN ['A', 'D'] +``` + +```ts +// Common conditions +minusWherePredicates( + and(gt(ref('age'), val(10)), eq(ref('status'), val('active'))), // age > 10 AND status = 'active' + and(gt(ref('age'), val(20)), eq(ref('status'), val('active'))) // age > 20 AND status = 'active' +) // → age > 10 AND age <= 20 AND status = 'active' +``` + +```ts +// Complete overlap - empty result +minusWherePredicates( + gt(ref('age'), val(20)), // age > 20 + gt(ref('age'), val(10)) // age > 10 +) // → {type: 'val', value: false} (empty set) +``` diff --git a/docs/reference/functions/unionWherePredicates.md b/docs/reference/functions/unionWherePredicates.md new file mode 100644 index 000000000..eb34b9775 --- /dev/null +++ b/docs/reference/functions/unionWherePredicates.md @@ -0,0 +1,42 @@ +--- +id: unionWherePredicates +title: unionWherePredicates +--- + +# Function: unionWherePredicates() + +```ts +function unionWherePredicates(predicates): BasicExpression; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:295](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L295) + +Combine multiple where predicates with OR logic (union). +Returns a predicate that is satisfied when any input predicate is satisfied. +Simplifies when possible (e.g., age > 10 OR age > 20 → age > 10). + +## Parameters + +### predicates + +[`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\>[] + +Array of where predicates to union + +## Returns + +[`BasicExpression`](../../@tanstack/namespaces/IR/type-aliases/BasicExpression.md)\<`boolean`\> + +Combined predicate representing the union + +## Examples + +```ts +// Take least restrictive +unionWherePredicates([gt(ref('age'), val(10)), gt(ref('age'), val(20))]) // age > 10 +``` + +```ts +// Combine equals into IN +unionWherePredicates([eq(ref('age'), val(5)), eq(ref('age'), val(10))]) // age IN [5, 10] +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 60f6ced0f..9fbdabea9 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -26,6 +26,7 @@ title: "@tanstack/db" - [CollectionRequiresConfigError](../classes/CollectionRequiresConfigError.md) - [CollectionRequiresSyncConfigError](../classes/CollectionRequiresSyncConfigError.md) - [CollectionStateError](../classes/CollectionStateError.md) +- [DeduplicatedLoadSubset](../classes/DeduplicatedLoadSubset.md) - [DeleteKeyNotFoundError](../classes/DeleteKeyNotFoundError.md) - [DistinctRequiresSelectError](../classes/DistinctRequiresSelectError.md) - [DuplicateAliasInSubqueryError](../classes/DuplicateAliasInSubqueryError.md) @@ -231,8 +232,12 @@ title: "@tanstack/db" - [gte](../functions/gte.md) - [ilike](../functions/ilike.md) - [inArray](../functions/inArray.md) +- [isLimitSubset](../functions/isLimitSubset.md) - [isNull](../functions/isNull.md) +- [isOrderBySubset](../functions/isOrderBySubset.md) +- [isPredicateSubset](../functions/isPredicateSubset.md) - [isUndefined](../functions/isUndefined.md) +- [isWhereSubset](../functions/isWhereSubset.md) - [length](../functions/length.md) - [like](../functions/like.md) - [liveQueryCollectionOptions](../functions/liveQueryCollectionOptions.md) @@ -243,11 +248,13 @@ title: "@tanstack/db" - [lte](../functions/lte.md) - [max](../functions/max.md) - [min](../functions/min.md) +- [minusWherePredicates](../functions/minusWherePredicates.md) - [not](../functions/not.md) - [or](../functions/or.md) - [queueStrategy](../functions/queueStrategy.md) - [sum](../functions/sum.md) - [throttleStrategy](../functions/throttleStrategy.md) +- [unionWherePredicates](../functions/unionWherePredicates.md) - [upper](../functions/upper.md) - [withArrayChangeTracking](../functions/withArrayChangeTracking.md) - [withChangeTracking](../functions/withChangeTracking.md) diff --git a/docs/reference/query-db-collection/functions/queryCollectionOptions.md b/docs/reference/query-db-collection/functions/queryCollectionOptions.md index 9e5c026c2..36fe817f4 100644 --- a/docs/reference/query-db-collection/functions/queryCollectionOptions.md +++ b/docs/reference/query-db-collection/functions/queryCollectionOptions.md @@ -11,7 +11,7 @@ title: queryCollectionOptions function queryCollectionOptions(config): CollectionConfig, TKey, T, QueryCollectionUtils, TKey, InferSchemaInput, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:370](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L370) +Defined in: [packages/query-db-collection/src/query.ts:393](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L393) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -151,7 +151,7 @@ const todosCollection = createCollection( function queryCollectionOptions(config): CollectionConfig> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:405](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L405) +Defined in: [packages/query-db-collection/src/query.ts:428](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L428) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -291,7 +291,7 @@ const todosCollection = createCollection( function queryCollectionOptions(config): CollectionConfig, TKey, T, QueryCollectionUtils, TKey, InferSchemaInput, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:438](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L438) +Defined in: [packages/query-db-collection/src/query.ts:461](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L461) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -423,7 +423,7 @@ const todosCollection = createCollection( function queryCollectionOptions(config): CollectionConfig> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:472](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L472) +Defined in: [packages/query-db-collection/src/query.ts:495](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L495) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. diff --git a/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md b/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md index dc04ee06a..f2c1b8498 100644 --- a/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md +++ b/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md @@ -5,7 +5,7 @@ title: QueryCollectionConfig # Interface: QueryCollectionConfig\ -Defined in: [packages/query-db-collection/src/query.ts:54](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L54) +Defined in: [packages/query-db-collection/src/query.ts:59](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L59) Configuration options for creating a Query Collection @@ -63,7 +63,7 @@ The schema type for validation optional enabled: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:80](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L80) +Defined in: [packages/query-db-collection/src/query.ts:85](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L85) Whether the query should automatically run (default: true) @@ -75,7 +75,7 @@ Whether the query should automatically run (default: true) optional meta: Record; ``` -Defined in: [packages/query-db-collection/src/query.ts:130](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L130) +Defined in: [packages/query-db-collection/src/query.ts:135](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L135) Metadata to pass to the query. Available in queryFn via context.meta @@ -107,7 +107,7 @@ meta: { queryClient: QueryClient; ``` -Defined in: [packages/query-db-collection/src/query.ts:76](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L76) +Defined in: [packages/query-db-collection/src/query.ts:81](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L81) The TanStack Query client instance @@ -119,7 +119,7 @@ The TanStack Query client instance queryFn: TQueryFn extends (context) => Promise ? (context) => Promise : TQueryFn; ``` -Defined in: [packages/query-db-collection/src/query.ts:68](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L68) +Defined in: [packages/query-db-collection/src/query.ts:73](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L73) Function that fetches data from the server. Must return the complete collection state @@ -128,10 +128,10 @@ Function that fetches data from the server. Must return the complete collection ### queryKey ```ts -queryKey: TQueryKey; +queryKey: TQueryKey | TQueryKeyBuilder; ``` -Defined in: [packages/query-db-collection/src/query.ts:66](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L66) +Defined in: [packages/query-db-collection/src/query.ts:71](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L71) The query key used by TanStack Query to identify this query @@ -143,7 +143,7 @@ The query key used by TanStack Query to identify this query optional refetchInterval: number | false | (query) => number | false | undefined; ``` -Defined in: [packages/query-db-collection/src/query.ts:81](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L81) +Defined in: [packages/query-db-collection/src/query.ts:86](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L86) *** @@ -153,7 +153,7 @@ Defined in: [packages/query-db-collection/src/query.ts:81](https://github.com/Ta optional retry: RetryValue; ``` -Defined in: [packages/query-db-collection/src/query.ts:88](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L88) +Defined in: [packages/query-db-collection/src/query.ts:93](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L93) *** @@ -163,7 +163,7 @@ Defined in: [packages/query-db-collection/src/query.ts:88](https://github.com/Ta optional retryDelay: RetryDelayValue; ``` -Defined in: [packages/query-db-collection/src/query.ts:95](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L95) +Defined in: [packages/query-db-collection/src/query.ts:100](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L100) *** @@ -173,7 +173,7 @@ Defined in: [packages/query-db-collection/src/query.ts:95](https://github.com/Ta optional select: (data) => T[]; ``` -Defined in: [packages/query-db-collection/src/query.ts:74](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L74) +Defined in: [packages/query-db-collection/src/query.ts:79](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L79) #### Parameters @@ -193,4 +193,4 @@ Defined in: [packages/query-db-collection/src/query.ts:74](https://github.com/Ta optional staleTime: StaleTimeFunction; ``` -Defined in: [packages/query-db-collection/src/query.ts:102](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L102) +Defined in: [packages/query-db-collection/src/query.ts:107](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L107) diff --git a/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md b/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md index 5f599e3de..47cb7ea56 100644 --- a/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md +++ b/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md @@ -5,7 +5,7 @@ title: QueryCollectionUtils # Interface: QueryCollectionUtils\ -Defined in: [packages/query-db-collection/src/query.ts:149](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L149) +Defined in: [packages/query-db-collection/src/query.ts:154](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L154) Utility methods available on Query Collections for direct writes and manual operations. Direct writes bypass the normal query/mutation flow and write directly to the synced data store. @@ -54,7 +54,7 @@ The type of errors that can occur during queries clearError: () => Promise; ``` -Defined in: [packages/query-db-collection/src/query.ts:194](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L194) +Defined in: [packages/query-db-collection/src/query.ts:199](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L199) Clear the error state and trigger a refetch of the query @@ -76,7 +76,7 @@ Error if the refetch fails dataUpdatedAt: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:185](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L185) +Defined in: [packages/query-db-collection/src/query.ts:190](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L190) Get timestamp of last successful data update (in milliseconds) @@ -88,7 +88,7 @@ Get timestamp of last successful data update (in milliseconds) errorCount: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:177](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L177) +Defined in: [packages/query-db-collection/src/query.ts:182](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L182) Get the number of consecutive sync failures. Incremented only when query fails completely (not per retry attempt); reset on success. @@ -101,7 +101,7 @@ Incremented only when query fails completely (not per retry attempt); reset on s fetchStatus: "idle" | "fetching" | "paused"; ``` -Defined in: [packages/query-db-collection/src/query.ts:187](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L187) +Defined in: [packages/query-db-collection/src/query.ts:192](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L192) Get current fetch status @@ -113,7 +113,7 @@ Get current fetch status isError: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:172](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L172) +Defined in: [packages/query-db-collection/src/query.ts:177](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L177) Check if the collection is in an error state @@ -125,7 +125,7 @@ Check if the collection is in an error state isFetching: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:179](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L179) +Defined in: [packages/query-db-collection/src/query.ts:184](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L184) Check if query is currently fetching (initial or background) @@ -137,7 +137,7 @@ Check if query is currently fetching (initial or background) isLoading: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:183](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L183) +Defined in: [packages/query-db-collection/src/query.ts:188](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L188) Check if query is loading for the first time (no data yet) @@ -149,7 +149,7 @@ Check if query is loading for the first time (no data yet) isRefetching: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:181](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L181) +Defined in: [packages/query-db-collection/src/query.ts:186](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L186) Check if query is refetching in background (not initial fetch) @@ -161,7 +161,7 @@ Check if query is refetching in background (not initial fetch) lastError: TError | undefined; ``` -Defined in: [packages/query-db-collection/src/query.ts:170](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L170) +Defined in: [packages/query-db-collection/src/query.ts:175](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L175) Get the last error encountered by the query (if any); reset on success @@ -173,7 +173,7 @@ Get the last error encountered by the query (if any); reset on success refetch: RefetchFn; ``` -Defined in: [packages/query-db-collection/src/query.ts:156](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L156) +Defined in: [packages/query-db-collection/src/query.ts:161](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L161) Manually trigger a refetch of the query @@ -185,7 +185,7 @@ Manually trigger a refetch of the query writeBatch: (callback) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:166](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L166) +Defined in: [packages/query-db-collection/src/query.ts:171](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L171) Execute multiple write operations as a single atomic batch to the synced data store @@ -207,7 +207,7 @@ Execute multiple write operations as a single atomic batch to the synced data st writeDelete: (keys) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:162](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L162) +Defined in: [packages/query-db-collection/src/query.ts:167](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L167) Delete one or more items directly from the synced data store without triggering a query refetch or optimistic update @@ -229,7 +229,7 @@ Delete one or more items directly from the synced data store without triggering writeInsert: (data) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:158](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L158) +Defined in: [packages/query-db-collection/src/query.ts:163](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L163) Insert one or more items directly into the synced data store without triggering a query refetch or optimistic update @@ -251,7 +251,7 @@ Insert one or more items directly into the synced data store without triggering writeUpdate: (updates) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:160](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L160) +Defined in: [packages/query-db-collection/src/query.ts:165](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L165) Update one or more items directly in the synced data store without triggering a query refetch or optimistic update @@ -273,7 +273,7 @@ Update one or more items directly in the synced data store without triggering a writeUpsert: (data) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:164](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L164) +Defined in: [packages/query-db-collection/src/query.ts:169](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L169) Insert or update one or more items directly in the synced data store without triggering a query refetch or optimistic update diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts index a7c6d7c6a..3f24154ac 100644 --- a/packages/db/src/query/subset-dedupe.ts +++ b/packages/db/src/query/subset-dedupe.ts @@ -239,10 +239,5 @@ export class DeduplicatedLoadSubset { * would reflect the mutated values rather than what was actually loaded. */ export function cloneOptions(options: LoadSubsetOptions): LoadSubsetOptions { - return { - where: options.where, - orderBy: options.orderBy, - limit: options.limit, - // Note: We don't clone subscription as it's not part of predicate matching - } + return { ...options } } diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 73bcdf43b..35a7f8ae8 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1,4 +1,5 @@ -import { QueryObserver } from "@tanstack/query-core" +import { QueryObserver, hashKey } from "@tanstack/query-core" +import { DeduplicatedLoadSubset } from "@tanstack/db" import { GetKeyRequiredError, QueryClientRequiredError, @@ -6,23 +7,25 @@ import { QueryKeyRequiredError, } from "./errors" import { createWriteUtils } from "./manual-sync" -import type { - QueryClient, - QueryFunctionContext, - QueryKey, - QueryObserverOptions, - QueryObserverResult, -} from "@tanstack/query-core" import type { BaseCollectionConfig, ChangeMessage, CollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, + LoadSubsetOptions, SyncConfig, UpdateMutationFnParams, UtilsRecord, } from "@tanstack/db" +import type { + QueryClient, + QueryFunctionContext, + QueryKey, + QueryObserverOptions, + QueryObserverResult, + FetchStatus, +} from "@tanstack/query-core" import type { StandardSchemaV1 } from "@standard-schema/spec" // Re-export for external use @@ -42,6 +45,8 @@ type InferSchemaInput = T extends StandardSchemaV1 : Record : Record +type TQueryKeyBuilder = (opts: LoadSubsetOptions) => TQueryKey + /** * Configuration options for creating a Query Collection * @template T - The explicit type of items stored in the collection @@ -63,7 +68,7 @@ export interface QueryCollectionConfig< TQueryData = Awaited>, > extends BaseCollectionConfig { /** The query key used by TanStack Query to identify this query */ - queryKey: TQueryKey + queryKey: TQueryKey | TQueryKeyBuilder /** Function that fetches data from the server. Must return the complete collection state */ queryFn: TQueryFn extends ( context: QueryFunctionContext @@ -201,9 +206,10 @@ interface QueryCollectionState { lastError: any errorCount: number lastErrorUpdatedAt: number - queryObserver: - | QueryObserver, any, Array, Array, any> - | undefined + observers: Map< + string, + QueryObserver, any, Array, Array, any> + > } /** @@ -261,23 +267,40 @@ class QueryCollectionUtilsImpl { // Getters for QueryObserver state public get isFetching() { - return this.state.queryObserver?.getCurrentResult().isFetching ?? false + // check if any observer is fetching + return Array.from(this.state.observers.values()).some( + (observer) => observer.getCurrentResult().isFetching + ) } public get isRefetching() { - return this.state.queryObserver?.getCurrentResult().isRefetching ?? false + // check if any observer is refetching + return Array.from(this.state.observers.values()).some( + (observer) => observer.getCurrentResult().isRefetching + ) } public get isLoading() { - return this.state.queryObserver?.getCurrentResult().isLoading ?? false + // check if any observer is loading + return Array.from(this.state.observers.values()).some( + (observer) => observer.getCurrentResult().isLoading + ) } public get dataUpdatedAt() { - return this.state.queryObserver?.getCurrentResult().dataUpdatedAt ?? 0 + // compute the max dataUpdatedAt of all observers + return Math.max( + 0, + ...Array.from(this.state.observers.values()).map( + (observer) => observer.getCurrentResult().dataUpdatedAt + ) + ) } - public get fetchStatus() { - return this.state.queryObserver?.getCurrentResult().fetchStatus ?? `idle` + public get fetchStatus(): Array { + return Array.from(this.state.observers.values()).map( + (observer) => observer.getCurrentResult().fetchStatus + ) } } @@ -522,6 +545,9 @@ export function queryCollectionOptions( ...baseCollectionConfig } = config + // Default to eager sync mode if not provided + const syncMode = baseCollectionConfig.syncMode ?? `eager` + // Validate required parameters // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -548,181 +574,414 @@ export function queryCollectionOptions( lastError: undefined as any, errorCount: 0, lastErrorUpdatedAt: 0, - queryObserver: undefined, + observers: new Map< + string, + QueryObserver, any, Array, Array, any> + >(), + } + + // hashedQueryKey → queryKey + const hashToQueryKey = new Map() + + // queryKey → Set + const queryToRows = new Map>() + + // RowKey → Set + const rowToQueries = new Map>() + + // queryKey → QueryObserver's unsubscribe function + const unsubscribes = new Map void>() + + // Helper function to add a row to the internal state + const addRow = (rowKey: string | number, hashedQueryKey: string) => { + const rowToQueriesSet = rowToQueries.get(rowKey) || new Set() + rowToQueriesSet.add(hashedQueryKey) + rowToQueries.set(rowKey, rowToQueriesSet) + + const queryToRowsSet = queryToRows.get(hashedQueryKey) || new Set() + queryToRowsSet.add(rowKey) + queryToRows.set(hashedQueryKey, queryToRowsSet) + } + + // Helper function to remove a row from the internal state + const removeRow = (rowKey: string | number, hashedQuerKey: string) => { + const rowToQueriesSet = rowToQueries.get(rowKey) || new Set() + rowToQueriesSet.delete(hashedQuerKey) + rowToQueries.set(rowKey, rowToQueriesSet) + + const queryToRowsSet = queryToRows.get(hashedQuerKey) || new Set() + queryToRowsSet.delete(rowKey) + queryToRows.set(hashedQuerKey, queryToRowsSet) + + return rowToQueriesSet.size === 0 } const internalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, markReady, collection } = params - const observerOptions: QueryObserverOptions< - Array, - any, - Array, - Array, - any - > = { - queryKey: queryKey, - queryFn: queryFn, - structuralSharing: true, - notifyOnChangeProps: `all`, - // Only include options that are explicitly defined to allow QueryClient defaultOptions to be used - ...(meta !== undefined && { meta }), - ...(enabled !== undefined && { enabled }), - ...(refetchInterval !== undefined && { refetchInterval }), - ...(retry !== undefined && { retry }), - ...(retryDelay !== undefined && { retryDelay }), - ...(staleTime !== undefined && { staleTime }), - } - - const localObserver = new QueryObserver< - Array, - any, - Array, - Array, - any - >(queryClient, observerOptions) - - // Store reference for imperative refetch - state.queryObserver = localObserver - - let isSubscribed = false - let actualUnsubscribeFn: (() => void) | null = null - - type UpdateHandler = Parameters[0] - const handleQueryResult: UpdateHandler = (result) => { - if (result.isSuccess) { - // Clear error state - state.lastError = undefined - state.errorCount = 0 - - const rawData = result.data - const newItemsArray = select ? select(rawData) : rawData - - if ( - !Array.isArray(newItemsArray) || - newItemsArray.some((item) => typeof item !== `object`) - ) { - const errorMessage = select - ? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` - : `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` - - console.error(errorMessage) - return + // Track whether sync has been started + let syncStarted = false + + const createQueryFromOpts = ( + opts: LoadSubsetOptions, + queryFunction: typeof queryFn = queryFn + ): true | Promise => { + // Push the predicates down to the queryKey and queryFn + const key = typeof queryKey === `function` ? queryKey(opts) : queryKey + const hashedQueryKey = hashKey(key) + const extendedMeta = { ...meta, loadSubsetOptions: opts } + + if (state.observers.has(hashedQueryKey)) { + // We already have a query for this queryKey + // Get the current result and return based on its state + const observer = state.observers.get(hashedQueryKey)! + const currentResult = observer.getCurrentResult() + + if (currentResult.isSuccess) { + // Data is already available, return true synchronously + return true + } else if (currentResult.isError) { + // Error already occurred, reject immediately + return Promise.reject(currentResult.error) + } else { + // Query is still loading, wait for the first result + return new Promise((resolve, reject) => { + const unsubscribe = observer.subscribe((result) => { + if (result.isSuccess) { + unsubscribe() + resolve() + } else if (result.isError) { + unsubscribe() + reject(result.error) + } + }) + }) } + } + + const observerOptions: QueryObserverOptions< + Array, + any, + Array, + Array, + any + > = { + queryKey: key, + queryFn: queryFunction, + meta: extendedMeta, + structuralSharing: true, + notifyOnChangeProps: `all`, + + // Only include options that are explicitly defined to allow QueryClient defaultOptions to be used + ...(enabled !== undefined && { enabled }), + ...(refetchInterval !== undefined && { refetchInterval }), + ...(retry !== undefined && { retry }), + ...(retryDelay !== undefined && { retryDelay }), + ...(staleTime !== undefined && { staleTime }), + } - const currentSyncedItems: Map = new Map( - collection._state.syncedData.entries() - ) - const newItemsMap = new Map() - newItemsArray.forEach((item) => { - const key = getKey(item) - newItemsMap.set(key, item) + const localObserver = new QueryObserver< + Array, + any, + Array, + Array, + any + >(queryClient, observerOptions) + + hashToQueryKey.set(hashedQueryKey, key) + state.observers.set(hashedQueryKey, localObserver) + + // Create a promise that resolves when the query result is first available + const readyPromise = new Promise((resolve, reject) => { + const unsubscribe = localObserver.subscribe((result) => { + if (result.isSuccess) { + unsubscribe() + resolve() + } else if (result.isError) { + unsubscribe() + reject(result.error) + } }) + }) - begin() - - // Helper function for shallow equality check of objects - const shallowEqual = ( - obj1: Record, - obj2: Record - ): boolean => { - // Get all keys from both objects - const keys1 = Object.keys(obj1) - const keys2 = Object.keys(obj2) - - // If number of keys is different, objects are not equal - if (keys1.length !== keys2.length) return false - - // Check if all keys in obj1 have the same values in obj2 - return keys1.every((key) => { - // Skip comparing functions and complex objects deeply - if (typeof obj1[key] === `function`) return true - return obj1[key] === obj2[key] - }) - } + // If sync has started or there are subscribers to the collection, subscribe to the query straight away + // This creates the main subscription that handles data updates + if (syncStarted || collection.subscriberCount > 0) { + subscribeToQuery(localObserver, hashedQueryKey) + } + + // Tell tanstack query to GC the query when the subscription is unsubscribed + // The subscription is unsubscribed when the live query is GCed. + const subscription = opts.subscription + subscription?.once(`unsubscribed`, () => { + queryClient.removeQueries({ queryKey: key, exact: true }) + }) + + return readyPromise + } + + type UpdateHandler = Parameters[0] + + const makeQueryResultHandler = (queryKey: QueryKey) => { + const hashedQueryKey = hashKey(queryKey) + const handleQueryResult: UpdateHandler = (result) => { + if (result.isSuccess) { + // Clear error state + state.lastError = undefined + state.errorCount = 0 - currentSyncedItems.forEach((oldItem, key) => { - const newItem = newItemsMap.get(key) - if (!newItem) { - write({ type: `delete`, value: oldItem }) - } else if ( - !shallowEqual( - oldItem as Record, - newItem as Record - ) + const rawData = result.data + const newItemsArray = select ? select(rawData) : rawData + + if ( + !Array.isArray(newItemsArray) || + newItemsArray.some((item) => typeof item !== `object`) ) { - // Only update if there are actual differences in the properties - write({ type: `update`, value: newItem }) + const errorMessage = select + ? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` + : `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}` + + console.error(errorMessage) + return } - }) - newItemsMap.forEach((newItem, key) => { - if (!currentSyncedItems.has(key)) { - write({ type: `insert`, value: newItem }) + const currentSyncedItems: Map = new Map( + collection._state.syncedData.entries() + ) + const newItemsMap = new Map() + newItemsArray.forEach((item) => { + const key = getKey(item) + newItemsMap.set(key, item) + }) + + begin() + + // Helper function for shallow equality check of objects + const shallowEqual = ( + obj1: Record, + obj2: Record + ): boolean => { + // Get all keys from both objects + const keys1 = Object.keys(obj1) + const keys2 = Object.keys(obj2) + + // If number of keys is different, objects are not equal + if (keys1.length !== keys2.length) return false + + // Check if all keys in obj1 have the same values in obj2 + return keys1.every((key) => { + // Skip comparing functions and complex objects deeply + if (typeof obj1[key] === `function`) return true + return obj1[key] === obj2[key] + }) } - }) - commit() + currentSyncedItems.forEach((oldItem, key) => { + const newItem = newItemsMap.get(key) + if (!newItem) { + const needToRemove = removeRow(key, hashedQueryKey) // returns true if the row is no longer referenced by any queries + if (needToRemove) { + write({ type: `delete`, value: oldItem }) + } + } else if ( + !shallowEqual( + oldItem as Record, + newItem as Record + ) + ) { + // Only update if there are actual differences in the properties + write({ type: `update`, value: newItem }) + } + }) - // Mark collection as ready after first successful query result - markReady() - } else if (result.isError) { - if (result.errorUpdatedAt !== state.lastErrorUpdatedAt) { - state.lastError = result.error - state.errorCount++ - state.lastErrorUpdatedAt = result.errorUpdatedAt - } + newItemsMap.forEach((newItem, key) => { + addRow(key, hashedQueryKey) + if (!currentSyncedItems.has(key)) { + write({ type: `insert`, value: newItem }) + } + }) + + commit() + + // Mark collection as ready after first successful query result + markReady() + } else if (result.isError) { + if (result.errorUpdatedAt !== state.lastErrorUpdatedAt) { + state.lastError = result.error + state.errorCount++ + state.lastErrorUpdatedAt = result.errorUpdatedAt + } - console.error( - `[QueryCollection] Error observing query ${String(queryKey)}:`, - result.error - ) + console.error( + `[QueryCollection] Error observing query ${String(queryKey)}:`, + result.error + ) - // Mark collection as ready even on error to avoid blocking apps - markReady() + // Mark collection as ready even on error to avoid blocking apps + markReady() + } } + return handleQueryResult } - const subscribeToQuery = () => { - if (!isSubscribed) { - actualUnsubscribeFn = localObserver.subscribe(handleQueryResult) - isSubscribed = true + // This function is called when a loadSubset call is deduplicated + // meaning that we have all the data locally available to answer the query + // so we execute the query locally + const createLocalQuery = (opts: LoadSubsetOptions) => { + const queryFn = ({ meta }: QueryFunctionContext) => { + const inserts = collection.currentStateAsChanges( + meta!.loadSubsetOptions as LoadSubsetOptions + )! + const data = inserts.map(({ value }) => value) + return Promise.resolve(data) } + + createQueryFromOpts(opts, queryFn) + } + + const isSubscribed = (hashedQueryKey: string) => { + return unsubscribes.has(hashedQueryKey) } - const unsubscribeFromQuery = () => { - if (isSubscribed && actualUnsubscribeFn) { - actualUnsubscribeFn() - actualUnsubscribeFn = null - isSubscribed = false + const subscribeToQuery = ( + observer: QueryObserver, any, Array, Array, any>, + hashedQueryKey: string + ) => { + if (!isSubscribed(hashedQueryKey)) { + const queryKey = hashToQueryKey.get(hashedQueryKey)! + const handleQueryResult = makeQueryResultHandler(queryKey) + const unsubscribeFn = observer.subscribe(handleQueryResult) + unsubscribes.set(hashedQueryKey, unsubscribeFn) } } - // Always subscribe when sync starts (this could be from preload(), startSync config, or first subscriber) - // We'll dynamically unsubscribe/resubscribe based on subscriber count to maintain staleTime behavior - subscribeToQuery() + const subscribeToQueries = () => { + state.observers.forEach(subscribeToQuery) + } + + const unsubscribeFromQueries = () => { + unsubscribes.forEach((unsubscribeFn) => { + unsubscribeFn() + }) + unsubscribes.clear() + } + + // Mark that sync has started + syncStarted = true // Set up event listener for subscriber changes const unsubscribeFromCollectionEvents = collection.on( `subscribers:change`, ({ subscriberCount }) => { if (subscriberCount > 0) { - subscribeToQuery() + subscribeToQueries() } else if (subscriberCount === 0) { - unsubscribeFromQuery() + unsubscribeFromQueries() } } ) - // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial - // state) - handleQueryResult(localObserver.getCurrentResult()) + // If syncMode is eager, create the initial query without any predicates + if (syncMode === `eager`) { + // Catch any errors to prevent unhandled rejections + const initialResult = createQueryFromOpts({}) + if (initialResult instanceof Promise) { + initialResult.catch(() => { + // Errors are already handled by the query result handler + }) + } + } else { + // In on-demand mode, mark ready immediately since there's no initial query + markReady() + } + + // Always subscribe when sync starts (this could be from preload(), startSync config, or first subscriber) + // We'll dynamically unsubscribe/resubscribe based on subscriber count to maintain staleTime behavior + subscribeToQueries() + + // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial state) + state.observers.forEach((observer, hashedQueryKey) => { + const queryKey = hashToQueryKey.get(hashedQueryKey)! + const handleQueryResult = makeQueryResultHandler(queryKey) + handleQueryResult(observer.getCurrentResult()) + }) + + // Subscribe to the query client's cache to handle queries that are GCed by tanstack query + const unsubscribeQueryCache = queryClient + .getQueryCache() + .subscribe((event) => { + const hashedKey = event.query.queryHash + if (event.type === `removed`) { + cleanupQuery(hashedKey) + } + }) + + function cleanupQuery(hashedQueryKey: string) { + // Unsubscribe from the query's observer + unsubscribes.get(hashedQueryKey)?.() + + // Get all the rows that are in the result of this query + const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set() + + // Remove the query from these rows + rowKeys.forEach((rowKey) => { + const queries = rowToQueries.get(rowKey) // set of queries that reference this row + if (queries && queries.size > 0) { + queries.delete(hashedQueryKey) + if (queries.size === 0) { + // Reference count dropped to 0, we can GC the row + rowToQueries.delete(rowKey) + + if (collection.has(rowKey)) { + begin() + write({ type: `delete`, value: collection.get(rowKey) }) + commit() + } + } + } + }) + + // Remove the query from the internal state + unsubscribes.delete(hashedQueryKey) + state.observers.delete(hashedQueryKey) + queryToRows.delete(hashedQueryKey) + hashToQueryKey.delete(hashedQueryKey) + } - return async () => { + const cleanup = async () => { unsubscribeFromCollectionEvents() - unsubscribeFromQuery() - await queryClient.cancelQueries({ queryKey }) - queryClient.removeQueries({ queryKey }) + unsubscribeFromQueries() + + const queryKeys = [...hashToQueryKey.values()] + + hashToQueryKey.clear() + queryToRows.clear() + rowToQueries.clear() + state.observers.clear() + unsubscribeQueryCache() + + await Promise.all( + queryKeys.map(async (queryKey) => { + await queryClient.cancelQueries({ queryKey }) + queryClient.removeQueries({ queryKey }) + }) + ) + } + + // Create deduplicated loadSubset wrapper for non-eager modes + // This prevents redundant snapshot requests when multiple concurrent + // live queries request overlapping or subset predicates + const loadSubsetDedupe = + syncMode === `eager` + ? undefined + : new DeduplicatedLoadSubset({ + loadSubset: createQueryFromOpts, + onDeduplicate: createLocalQuery, + }) + + return { + loadSubset: loadSubsetDedupe?.loadSubset, + cleanup, } } @@ -745,15 +1004,15 @@ export function queryCollectionOptions( * @returns Promise that resolves when the refetch is complete, with QueryObserverResult */ const refetch: RefetchFn = async (opts) => { - // Observer is created when sync starts. If never synced, nothing to refetch. - - if (!state.queryObserver) { - return - } - // Return the QueryObserverResult for users to inspect - return state.queryObserver.refetch({ - throwOnError: opts?.throwOnError, + const queryKeys = [...hashToQueryKey.values()] + const refetchPromises = queryKeys.map((queryKey) => { + const queryObserver = state.observers.get(hashKey(queryKey))! + return queryObserver.refetch({ + throwOnError: opts?.throwOnError, + }) }) + + await Promise.all(refetchPromises) } // Create write context for manual write operations @@ -840,6 +1099,7 @@ export function queryCollectionOptions( return { ...baseCollectionConfig, getKey, + syncMode, sync: { sync: enhancedInternalSync }, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 7d2547270..57dbd87ae 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { QueryClient } from "@tanstack/query-core" -import { createCollection } from "@tanstack/db" +import { + createCollection, + createLiveQueryCollection, + eq, + or, +} from "@tanstack/db" import { queryCollectionOptions } from "../src/query" import type { QueryFunctionContext } from "@tanstack/query-core" import type { @@ -18,6 +23,12 @@ interface TestItem { value?: number } +interface CategorisedItem { + id: string + name: string + category: string +} + const getKey = (item: TestItem) => item.id // Helper to advance timers and allow microtasks to flush @@ -431,7 +442,9 @@ describe(`QueryCollection`, () => { }) // Verify queryFn was called with the correct context, including the meta object - expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ meta })) + expect(queryFn).toHaveBeenCalledWith( + expect.objectContaining({ meta: { ...meta, loadSubsetOptions: {} } }) + ) }) describe(`Select method testing`, () => { @@ -2606,7 +2619,6 @@ describe(`QueryCollection`, () => { expect(collection.status).toBe(`ready`) expect(collection.size).toBe(items.length) }) - it(`should allow writeDelete in onDelete handler to write to synced store`, async () => { const queryKey = [`writeDelete-in-onDelete-test`] const items: Array = [ @@ -2650,6 +2662,54 @@ describe(`QueryCollection`, () => { expect(collection.has(`1`)).toBe(false) expect(collection.size).toBe(1) }) + + it(`should transition to ready immediately in on-demand mode without loading data`, async () => { + const queryKey = [`preload-on-demand-test`] + const items: Array = [ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Item 2` }, + ] + + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `preload-on-demand-test`, + queryClient, + queryKey, + queryFn, + getKey, + syncMode: `on-demand`, // No initial query in on-demand mode + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should be idle initially + expect(collection.status).toBe(`idle`) + expect(queryFn).not.toHaveBeenCalled() + expect(collection.size).toBe(0) + + // Preload should resolve immediately without calling queryFn + // since there's no initial query in on-demand mode + await collection.preload() + + // After preload, collection should be ready + // but queryFn should NOT have been called and collection should still be empty + expect(collection.status).toBe(`ready`) + expect(queryFn).not.toHaveBeenCalled() + expect(collection.size).toBe(0) + + // Now if we call loadSubset, it should actually load data + await collection._sync.loadSubset({}) + + await vi.waitFor(() => { + expect(collection.size).toBe(items.length) + }) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.get(`1`)).toEqual(items[0]) + expect(collection.get(`2`)).toEqual(items[1]) + }) }) describe(`QueryClient defaultOptions`, () => { @@ -2788,4 +2848,660 @@ describe(`QueryCollection`, () => { customQueryClient.clear() }) }) + + describe(`Query Garbage Collection`, () => { + const isCategory = (category: `A` | `B` | `C`, where: any) => { + return ( + where && + where.type === `func` && + where.name === `eq` && + where.args[0].path[0] === `category` && + where.args[1].value === category + ) + } + + it(`should delete all rows when a single query is garbage collected`, async () => { + const queryKey = [`single-query-gc-test`] + const items: Array = [ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Item 2` }, + { id: `3`, name: `Item 3` }, + ] + + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `single-query-gc-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) + expect(collection.get(`1`)).toEqual(items[0]) + expect(collection.get(`2`)).toEqual(items[1]) + expect(collection.get(`3`)).toEqual(items[2]) + }) + + // Verify all items are in the collection + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + + // Simulate query garbage collection by removing the query from the cache + await collection.cleanup() + + // Verify all items are removed + expect(collection.has(`1`)).toBe(false) + expect(collection.has(`2`)).toBe(false) + expect(collection.has(`3`)).toBe(false) + }) + + it(`should only delete non-shared rows when one of multiple overlapping queries is GCed`, async () => { + const baseQueryKey = [`overlapping-query-test`] + + // Mock queryFn to return different data based on predicates + const queryFn = vi.fn().mockImplementation((context) => { + const { meta } = context + const loadSubsetOptions = meta?.loadSubsetOptions ?? {} + const { where } = loadSubsetOptions + + console.log(`In queryFn:\n`, JSON.stringify(where, null, 2)) + + // Query 1: items 1, 2, 3 (where: { category: 'A' }) + if (isCategory(`A`, where)) { + console.log(`Is category A`) + return Promise.resolve([ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Item 2` }, + { id: `3`, name: `Item 3` }, + ]) + } + + // Query 2: items 2, 3, 4 (where: { category: 'B' }) + if (isCategory(`B`, where)) { + return Promise.resolve([ + { id: `2`, name: `Item 2` }, + { id: `3`, name: `Item 3` }, + { id: `4`, name: `Item 4` }, + ]) + } + + // Query 3: items 3, 4, 5 (where: { category: 'C' }) + if (isCategory(`C`, where)) { + return Promise.resolve([ + { id: `3`, name: `Item 3` }, + { id: `4`, name: `Item 4` }, + { id: `5`, name: `Item 5` }, + ]) + } + return Promise.resolve([]) + }) + + const queryKey = (ctx: any) => { + if (ctx.where) { + return [...baseQueryKey, ctx.where] + } + return baseQueryKey + } + + const config: QueryCollectionConfig< + TestItem & { category: `A` | `B` | `C` } + > = { + id: `overlapping-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + syncMode: `on-demand`, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should start empty with on-demand sync mode + expect(collection.size).toBe(0) + + // Load query 1 with no predicates (items 1, 2, 3) + const query1 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query1.preload() + + // Wait for query 1 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) + }) + + // Add query 2 with different predicates (items 2, 3, 4) + // We abuse the `where` clause being typed as `any` to pass a category + // but in real usage this would be some Intermediate Representation of the where clause + const query2 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `B`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query2.preload() + + // Wait for query 2 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(4) // Should have items 1, 2, 3, 4 + }) + + // Add query 3 with different predicates + const query3 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `C`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query3.preload() + + // Wait for query 3 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(5) // Should have items 1, 2, 3, 4, 5 + }) + + // Verify all items are present + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + expect(collection.has(`4`)).toBe(true) + expect(collection.has(`5`)).toBe(true) + + // GC query 1 (no predicates) - should only remove item 1 (unique to query 1) + // Items 2 and 3 should remain because they're shared with other queries + await query1.cleanup() + + expect(collection.size).toBe(4) // Should have items 2, 3, 4, 5 + + // Verify item 1 is removed (it was only in query 1) + expect(collection.has(`1`)).toBe(false) + + // Verify shared items are still present + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + expect(collection.has(`4`)).toBe(true) + expect(collection.has(`5`)).toBe(true) + + // GC query 2 (where: { category: 'B' }) - should remove item 2 + // Items 3 and 4 should remain because they are shared with query 3 + await query2.cleanup() + + expect(collection.size).toBe(3) // Should have items 3, 4, 5 + + // Verify item 2 is removed (it was only in query 2) + expect(collection.has(`2`)).toBe(false) + + // Verify items 3 and 4 are still present (shared with query 3) + expect(collection.has(`3`)).toBe(true) + expect(collection.has(`4`)).toBe(true) + expect(collection.has(`5`)).toBe(true) + + // GC query 3 (where: { category: 'C' }) - should remove all remaining items + await query3.cleanup() + + expect(collection.size).toBe(0) + + // Verify all items are now removed + expect(collection.has(`3`)).toBe(false) + expect(collection.has(`4`)).toBe(false) + expect(collection.has(`5`)).toBe(false) + }) + + it(`should handle GC of queries with identical data`, async () => { + const baseQueryKey = [`identical-query-test`] + + // Mock queryFn to return the same data for all queries + const queryFn = vi.fn().mockImplementation(() => { + // All queries return the same data regardless of predicates + return Promise.resolve([ + { id: `1`, name: `Item 1`, category: `A` }, + { id: `2`, name: `Item 2`, category: `A` }, + { id: `3`, name: `Item 3`, category: `A` }, + ]) + }) + + const config: QueryCollectionConfig = { + id: `identical-test`, + queryClient, + queryKey: (ctx) => { + if (ctx.where) { + return [...baseQueryKey, ctx.where] + } + return baseQueryKey + }, + queryFn, + getKey, + startSync: true, + syncMode: `on-demand`, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should start empty with on-demand sync mode + expect(collection.size).toBe(0) + + // Load query 1 with no predicates (items 1, 2, 3) + const query1 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query1.preload() + + // Wait for query 1 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) + }) + + // Add query 2 with different predicates (but returns same data) + const query2 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query2.preload() + + // Wait for query 2 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) // Same data, no new items + }) + + // Add query 3 with different predicates (but returns same data) + const query3 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => + or(eq(item.category, `A`), eq(item.category, `B`)) + ) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query3.preload() + + // Wait for query 3 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) // Same data, no new items + }) + + // GC query 1 - should not remove any items (all items are shared with other queries) + await query1.cleanup() + + expect(collection.size).toBe(3) // Items still present due to other queries + + // All items should still be present + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + + // GC query 2 - should still not remove any items (all items are shared with query 3) + await query2.cleanup() + + expect(collection.size).toBe(3) // Items still present due to query 3 + + // All items should still be present + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + + // GC query 3 - should remove all items (no more queries reference them) + await query3.cleanup() + + expect(collection.size).toBe(0) + + // All items should now be removed + expect(collection.has(`1`)).toBe(false) + expect(collection.has(`2`)).toBe(false) + expect(collection.has(`3`)).toBe(false) + }) + + it(`should handle GC of empty queries gracefully`, async () => { + const baseQueryKey = [`empty-query-test`] + + // Mock queryFn to return different data based on predicates + const queryFn = vi.fn().mockImplementation((context) => { + const { meta } = context + const loadSubsetOptions = meta?.loadSubsetOptions || {} + const { where } = loadSubsetOptions + + // Query 2: some items (where: { category: 'B' }) + if (isCategory(`B`, where)) { + return Promise.resolve([ + { id: `1`, name: `Item 1`, category: `B` }, + { id: `2`, name: `Item 2`, category: `B` }, + ]) + } + + return Promise.resolve([]) + }) + + const config: QueryCollectionConfig = + { + id: `empty-test`, + queryClient, + queryKey: (ctx) => { + if (ctx.where) { + return [...baseQueryKey, ctx.where] + } + return baseQueryKey + }, + queryFn, + getKey, + startSync: true, + syncMode: `on-demand`, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should start empty with on-demand sync mode + expect(collection.size).toBe(0) + + // Load query 1 (returns empty array) + const query1 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + + await query1.preload() + + // Wait for query 1 data to load (still empty) + await vi.waitFor(() => { + expect(collection.size).toBe(0) // Empty query + }) + + // Add query 2 with different predicates (items 1, 2) + const query2 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `B`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query2.preload() + + // Wait for query 2 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(2) // Should have items 1, 2 + }) + + // Verify items are present + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + + // GC empty query 1 - should not affect the collection + await query1.cleanup() + + // Collection should still have items from query 2 + expect(collection.size).toBe(2) + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + + // GC non-empty query 2 - should remove its items + await query2.cleanup() + + await vi.waitFor(() => { + expect(collection.size).toBe(0) + }) + + expect(collection.has(`1`)).toBe(false) + expect(collection.has(`2`)).toBe(false) + }) + + it(`should handle concurrent GC of multiple queries`, async () => { + const baseQueryKey = [`concurrent-query-test`] + + // Mock queryFn to return different data based on predicates + const queryFn = vi.fn().mockImplementation((context) => { + const { meta } = context + const loadSubsetOptions = meta?.loadSubsetOptions || {} + const { where } = loadSubsetOptions + + // Query 1: items 1, 2 (no predicates) + if (isCategory(`C`, where)) { + return Promise.resolve([ + { id: `1`, name: `Item 1`, category: `C` }, + { id: `2`, name: `Item 2`, category: `C` }, + ]) + } + + // Query 2: items 2, 3 (where: { type: 'A' }) + if (isCategory(`A`, where)) { + return Promise.resolve([ + { id: `2`, name: `Item 2`, category: `A` }, + { id: `3`, name: `Item 3`, category: `A` }, + ]) + } + + // Query 3: items 3, 4 (where: { type: 'B' }) + if (isCategory(`B`, where)) { + return Promise.resolve([ + { id: `3`, name: `Item 3`, category: `B` }, + { id: `4`, name: `Item 4`, category: `B` }, + ]) + } + + return Promise.resolve([]) + }) + + const config: QueryCollectionConfig< + TestItem & { category: `A` | `B` | `C` } + > = { + id: `concurrent-test`, + queryClient, + queryKey: (ctx) => { + if (ctx.where) { + return [...baseQueryKey, ctx.where] + } + return baseQueryKey + }, + queryFn, + getKey, + startSync: true, + syncMode: `on-demand`, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should start empty with on-demand sync mode + expect(collection.size).toBe(0) + + // Load query 1 with no predicates (items 1, 2) + const query1 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `C`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query1.preload() + + // Wait for query 1 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(2) + }) + + // Add query 2 with different predicates (items 2, 3) + const query2 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query2.preload() + + // Wait for query 2 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) // Should have items 1, 2, 3 + }) + + // Add query 3 with different predicates + const query3 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `B`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query3.preload() + + // Wait for query 3 data to load + await vi.waitFor(() => { + expect(collection.size).toBe(4) // Should have items 1, 2, 3, 4 + }) + + // GC all queries concurrently + const queries = [query1, query2, query3] + const proms = queries.map((query) => query.cleanup()) + await Promise.all(proms) + + // Collection should be empty after all queries are GCed + expect(collection.size).toBe(0) + + // Verify all items are removed + expect(collection.has(`1`)).toBe(false) + expect(collection.has(`2`)).toBe(false) + expect(collection.has(`3`)).toBe(false) + expect(collection.has(`4`)).toBe(false) + }) + + it(`should deduplicate queries and handle GC correctly when queries are ordered and have a LIMIT`, async () => { + const baseQueryKey = [`deduplication-gc-test`] + + // Mock queryFn to return different data based on predicates + const queryFn = vi.fn().mockImplementation((context) => { + const { meta } = context + const loadSubsetOptions = meta?.loadSubsetOptions ?? {} + const { where, limit } = loadSubsetOptions + + // Query 1: all items with category A (no limit) + if (isCategory(`A`, where) && !limit) { + return Promise.resolve([ + { id: `1`, name: `Item 1`, category: `A` }, + { id: `2`, name: `Item 2`, category: `A` }, + { id: `3`, name: `Item 3`, category: `A` }, + ]) + } + + return Promise.resolve([]) + }) + + const config: QueryCollectionConfig = { + id: `deduplication-test`, + queryClient, + queryKey: (ctx) => { + const key = [...baseQueryKey] + if (ctx.where) { + key.push(`where`, JSON.stringify(ctx.where)) + } + if (ctx.limit) { + key.push(`limit`, ctx.limit.toString()) + } + if (ctx.orderBy) { + key.push(`orderBy`, JSON.stringify(ctx.orderBy)) + } + return key + }, + queryFn, + getKey, + startSync: true, + syncMode: `on-demand`, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Collection should start empty with on-demand sync mode + expect(collection.size).toBe(0) + + // Execute first query: load all rows that belong to category A (returns 3 rows) + const query1 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query1.preload() + + // Wait for first query data to load + await vi.waitFor(() => { + expect(collection.size).toBe(3) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + // Verify all 3 items are present + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + + // Execute second query: load rows with category A, limit 2, ordered by ID + // This should be deduplicated since we already have all category A data + // So it will load the data from the local collection + const query2 = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .orderBy(({ item }) => item.id, `asc`) + .limit(2) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + await query2.preload() + + await flushPromises() + + // Second query should still only have been called once + // since query2 is deduplicated so it is executed against the local collection + // and not via queryFn + expect(queryFn).toHaveBeenCalledTimes(1) + + // Collection should still have all 3 items (deduplication doesn't remove data) + expect(collection.size).toBe(3) + expect(collection.has(`1`)).toBe(true) + expect(collection.has(`2`)).toBe(true) + expect(collection.has(`3`)).toBe(true) + + // GC the first query (all category A without limit) + await query1.cleanup() + + expect(collection.size).toBe(2) // Should only have items 1 and 2 because they are still referenced by query 2 + + // Verify that only row 3 is removed (it was only referenced by query 1) + expect(collection.has(`1`)).toBe(true) // Still present (referenced by query 2) + expect(collection.has(`2`)).toBe(true) // Still present (referenced by query 2) + expect(collection.has(`3`)).toBe(false) // Removed (only referenced by query 1) + + // GC the second query (category A with limit 2) + await query2.cleanup() + + // Wait for final GC to process + expect(collection.size).toBe(0) + }) + }) }) From 394e88e97698bf993cf31a717308d46b960e6401 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 5 Nov 2025 16:29:57 +0100 Subject: [PATCH 4/5] Handle pushed down predicates in Electric collection (#618) * Handle pushed down predicates in Electric collection Co-authored-by: Kevin De Porre Co-authored-by: Sam Willis * use the subsetDuduper for electric * Leave fixme * fix DeduplicatedLoadSubset call * fix tests --------- Co-authored-by: Sam Willis --- .changeset/tender-carpets-cheat.md | 5 + .../electric-db-collection/src/electric.ts | 95 +- .../src/pg-serializer.ts | 27 + .../src/sql-compiler.ts | 163 ++++ .../tests/electric-live-query.test.ts | 833 ++++++++++++++++++ .../tests/electric.test-d.ts | 3 +- .../tests/electric.test.ts | 684 +++++++++++++- packages/electric-db-collection/tsconfig.json | 4 +- 8 files changed, 1793 insertions(+), 21 deletions(-) create mode 100644 .changeset/tender-carpets-cheat.md create mode 100644 packages/electric-db-collection/src/pg-serializer.ts create mode 100644 packages/electric-db-collection/src/sql-compiler.ts diff --git a/.changeset/tender-carpets-cheat.md b/.changeset/tender-carpets-cheat.md new file mode 100644 index 000000000..77c9dfd73 --- /dev/null +++ b/.changeset/tender-carpets-cheat.md @@ -0,0 +1,5 @@ +--- +"@tanstack/electric-db-collection": patch +--- + +Handle predicates that are pushed down. diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index bdd6f34a7..22403dbdf 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -6,18 +6,22 @@ import { } from "@electric-sql/client" import { Store } from "@tanstack/store" import DebugModule from "debug" +import { DeduplicatedLoadSubset } from "@tanstack/db" import { ExpectedNumberInAwaitTxIdError, StreamAbortedError, TimeoutWaitingForMatchError, TimeoutWaitingForTxIdError, } from "./errors" +import { compileSQL } from "./sql-compiler" import type { BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, + LoadSubsetOptions, SyncConfig, + SyncMode, UpdateMutationFnParams, UtilsRecord, } from "@tanstack/db" @@ -72,6 +76,24 @@ type InferSchemaOutput = T extends StandardSchemaV1 : Record : Record +/** + * The mode of sync to use for the collection. + * @default `eager` + * @description + * - `eager`: + * - syncs all data immediately on preload + * - collection will be marked as ready once the sync is complete + * - there is no incremental sync + * - `on-demand`: + * - syncs data in incremental snapshots when the collection is queried + * - collection will be marked as ready immediately after the first snapshot is synced + * - `progressive`: + * - syncs all data for the collection in the background + * - uses incremental snapshots during the initial sync to provide a fast path to the data required for queries + * - collection will be marked as ready once the full sync is complete + */ +export type ElectricSyncMode = SyncMode | `progressive` + /** * Configuration interface for Electric collection options * @template T - The type of items in the collection @@ -82,12 +104,13 @@ export interface ElectricCollectionConfig< TSchema extends StandardSchemaV1 = never, > extends Omit< BaseCollectionConfig, - `onInsert` | `onUpdate` | `onDelete` + `onInsert` | `onUpdate` | `onDelete` | `syncMode` > { /** * Configuration options for the ElectricSQL ShapeStream */ shapeOptions: ShapeStreamOptions> + syncMode?: ElectricSyncMode /** * Optional asynchronous handler function called before an insert operation @@ -281,6 +304,9 @@ export function electricCollectionOptions( } { const seenTxids = new Store>(new Set([])) const seenSnapshots = new Store>([]) + const internalSyncMode = config.syncMode ?? `eager` + const finalSyncMode = + internalSyncMode === `progressive` ? `on-demand` : internalSyncMode const pendingMatches = new Store< Map< string, @@ -331,6 +357,7 @@ export function electricCollectionOptions( const sync = createElectricSync(config.shapeOptions, { seenTxids, seenSnapshots, + syncMode: internalSyncMode, pendingMatches, currentBatchMessages, removePendingMatches, @@ -550,6 +577,7 @@ export function electricCollectionOptions( return { ...restConfig, + syncMode: finalSyncMode, sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, @@ -567,6 +595,7 @@ export function electricCollectionOptions( function createElectricSync>( shapeOptions: ShapeStreamOptions>, options: { + syncMode: ElectricSyncMode seenTxids: Store> seenSnapshots: Store> pendingMatches: Store< @@ -590,6 +619,7 @@ function createElectricSync>( const { seenTxids, seenSnapshots, + syncMode, pendingMatches, currentBatchMessages, removePendingMatches, @@ -653,6 +683,12 @@ function createElectricSync>( const stream = new ShapeStream({ ...shapeOptions, + // In on-demand mode, we only want to sync changes, so we set the log to `changes_only` + log: syncMode === `on-demand` ? `changes_only` : undefined, + // In on-demand mode, we only need the changes from the point of time the collection was created + // so we default to `now` when there is no saved offset. + offset: + shapeOptions.offset ?? (syncMode === `on-demand` ? `now` : undefined), signal: abortController.signal, onError: (errorParams) => { // Just immediately mark ready if there's an error to avoid blocking @@ -679,9 +715,28 @@ function createElectricSync>( let transactionStarted = false const newTxids = new Set() const newSnapshots: Array = [] + let hasReceivedUpToDate = false // Track if we've completed initial sync in progressive mode + + // Create deduplicated loadSubset wrapper for non-eager modes + // This prevents redundant snapshot requests when multiple concurrent + // live queries request overlapping or subset predicates + const loadSubsetDedupe = + syncMode === `eager` + ? null + : new DeduplicatedLoadSubset({ + loadSubset: async (opts: LoadSubsetOptions) => { + // In progressive mode, stop requesting snapshots once full sync is complete + if (syncMode === `progressive` && hasReceivedUpToDate) { + return + } + const snapshotParams = compileSQL(opts) + await stream.requestSnapshot(snapshotParams) + }, + }) unsubscribeStream = stream.subscribe((messages: Array>) => { let hasUpToDate = false + let hasSnapshotEnd = false for (const message of messages) { // Add message to current batch buffer (for race condition handling) @@ -746,6 +801,7 @@ function createElectricSync>( }) } else if (isSnapshotEndMessage(message)) { newSnapshots.push(parseSnapshotMessage(message)) + hasSnapshotEnd = true } else if (isUpToDateMessage(message)) { hasUpToDate = true } else if (isMustRefetchMessage(message)) { @@ -761,12 +817,18 @@ function createElectricSync>( truncate() - // Reset hasUpToDate so we continue accumulating changes until next up-to-date + // Reset the loadSubset deduplication state since we're starting fresh + // This ensures that previously loaded predicates don't prevent refetching after truncate + loadSubsetDedupe?.reset() + + // Reset flags so we continue accumulating changes until next up-to-date hasUpToDate = false + hasSnapshotEnd = false + hasReceivedUpToDate = false // Reset for progressive mode - we're starting a new sync } } - if (hasUpToDate) { + if (hasUpToDate || hasSnapshotEnd) { // Clear the current batch buffer since we're now up-to-date currentBatchMessages.setState(() => []) @@ -776,8 +838,15 @@ function createElectricSync>( transactionStarted = false } - // Mark the collection as ready now that sync is up to date - markReady() + if (hasUpToDate || (hasSnapshotEnd && syncMode === `on-demand`)) { + // Mark the collection as ready now that sync is up to date + markReady() + } + + // Track that we've received the first up-to-date for progressive mode + if (hasUpToDate) { + hasReceivedUpToDate = true + } // Always commit txids when we receive up-to-date, regardless of transaction state seenTxids.setState((currentTxids) => { @@ -811,12 +880,16 @@ function createElectricSync>( } }) - // Return the unsubscribe function - return () => { - // Unsubscribe from the stream - unsubscribeStream() - // Abort the abort controller to stop the stream - abortController.abort() + // Return the deduplicated loadSubset if available (on-demand or progressive mode) + // The loadSubset method is auto-bound, so it can be safely returned directly + return { + loadSubset: loadSubsetDedupe?.loadSubset, + cleanup: () => { + // Unsubscribe from the stream + unsubscribeStream() + // Abort the abort controller to stop the stream + abortController.abort() + }, } }, // Expose the getSyncMetadata function diff --git a/packages/electric-db-collection/src/pg-serializer.ts b/packages/electric-db-collection/src/pg-serializer.ts new file mode 100644 index 000000000..707c4e1b8 --- /dev/null +++ b/packages/electric-db-collection/src/pg-serializer.ts @@ -0,0 +1,27 @@ +export function serialize(value: unknown): string { + if (typeof value === `string`) { + return `'${value}'` + } + + if (typeof value === `number`) { + return value.toString() + } + + if (value === null || value === undefined) { + return `NULL` + } + + if (typeof value === `boolean`) { + return value ? `true` : `false` + } + + if (value instanceof Date) { + return `'${value.toISOString()}'` + } + + if (Array.isArray(value)) { + return `ARRAY[${value.map(serialize).join(`,`)}]` + } + + throw new Error(`Cannot serialize value: ${JSON.stringify(value)}`) +} diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts new file mode 100644 index 000000000..078840da3 --- /dev/null +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -0,0 +1,163 @@ +import { serialize } from "./pg-serializer" +import type { SubsetParams } from "@electric-sql/client" +import type { IR, LoadSubsetOptions } from "@tanstack/db" + +export type CompiledSqlRecord = Omit & { + params?: Array +} + +export function compileSQL(options: LoadSubsetOptions): SubsetParams { + const { where, orderBy, limit } = options + + const params: Array = [] + const compiledSQL: CompiledSqlRecord = { params } + + if (where) { + // TODO: this only works when the where expression's PropRefs directly reference a column of the collection + // doesn't work if it goes through aliases because then we need to know the entire query to be able to follow the reference until the base collection (cf. followRef function) + compiledSQL.where = compileBasicExpression(where, params) + } + + if (orderBy) { + compiledSQL.orderBy = compileOrderBy(orderBy, params) + } + + if (limit) { + compiledSQL.limit = limit + } + + // Serialize the values in the params array into PG formatted strings + // and transform the array into a Record + const paramsRecord = params.reduce( + (acc, param, index) => { + acc[`${index + 1}`] = serialize(param) + return acc + }, + {} as Record + ) + + return { + ...compiledSQL, + params: paramsRecord, + } +} + +/** + * Compiles the expression to a SQL string and mutates the params array with the values. + * @param exp - The expression to compile + * @param params - The params array + * @returns The compiled SQL string + */ +function compileBasicExpression( + exp: IR.BasicExpression, + params: Array +): string { + switch (exp.type) { + case `val`: + params.push(exp.value) + return `$${params.length}` + case `ref`: + // TODO: doesn't yet support JSON(B) values which could be accessed with nested props + if (exp.path.length !== 1) { + throw new Error( + `Compiler can't handle nested properties: ${exp.path.join(`.`)}` + ) + } + return exp.path[0]! + case `func`: + return compileFunction(exp, params) + default: + throw new Error(`Unknown expression type`) + } +} + +function compileOrderBy(orderBy: IR.OrderBy, params: Array): string { + const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) => + compileOrderByClause(clause, params) + ) + return compiledOrderByClauses.join(`,`) +} + +function compileOrderByClause( + clause: IR.OrderByClause, + params: Array +): string { + // FIXME: We should handle stringSort and locale. + // Correctly supporting them is tricky as it depends on Postgres' collation + const { expression, compareOptions } = clause + let sql = compileBasicExpression(expression, params) + + if (compareOptions.direction === `desc`) { + sql = `${sql} DESC` + } + + if (compareOptions.nulls === `first`) { + sql = `${sql} NULLS FIRST` + } + + if (compareOptions.nulls === `last`) { + sql = `${sql} NULLS LAST` + } + + return sql +} + +function compileFunction( + exp: IR.Func, + params: Array = [] +): string { + const { name, args } = exp + + const opName = getOpName(name) + + const compiledArgs = args.map((arg: IR.BasicExpression) => + compileBasicExpression(arg, params) + ) + + if (isBinaryOp(name)) { + if (compiledArgs.length !== 2) { + throw new Error(`Binary operator ${name} expects 2 arguments`) + } + const [lhs, rhs] = compiledArgs + return `${lhs} ${opName} ${rhs}` + } + + return `${opName}(${compiledArgs.join(`,`)})` +} + +function isBinaryOp(name: string): boolean { + const binaryOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `and`, `or`] + return binaryOps.includes(name) +} + +function getOpName(name: string): string { + const opNames = { + eq: `=`, + gt: `>`, + gte: `>=`, + lt: `<`, + lte: `<=`, + add: `+`, + and: `AND`, + or: `OR`, + not: `NOT`, + isUndefined: `IS NULL`, + isNull: `IS NULL`, + in: `IN`, + like: `LIKE`, + ilike: `ILIKE`, + upper: `UPPER`, + lower: `LOWER`, + length: `LENGTH`, + concat: `CONCAT`, + coalesce: `COALESCE`, + } + + const opName = opNames[name as keyof typeof opNames] + + if (!opName) { + throw new Error(`Unknown operator/function: ${name}`) + } + + return opName +} diff --git a/packages/electric-db-collection/tests/electric-live-query.test.ts b/packages/electric-db-collection/tests/electric-live-query.test.ts index b387f1756..b8047e8cf 100644 --- a/packages/electric-db-collection/tests/electric-live-query.test.ts +++ b/packages/electric-db-collection/tests/electric-live-query.test.ts @@ -4,6 +4,7 @@ import { createLiveQueryCollection, eq, gt, + lt, } from "@tanstack/db" import { electricCollectionOptions } from "../src/electric" import type { ElectricCollectionUtils } from "../src/electric" @@ -54,10 +55,39 @@ const sampleUsers: Array = [ // Mock the ShapeStream module const mockSubscribe = vi.fn() +const mockRequestSnapshot = vi.fn() const mockStream = { subscribe: mockSubscribe, + requestSnapshot: async (...args: any) => { + const result = await mockRequestSnapshot(...args) + const subscribers = mockSubscribe.mock.calls.map((call) => call[0]) + const data = [...result.data] + + const messages: Array> = data.map((row: any) => ({ + value: row.value, + key: row.key, + headers: row.headers, + })) + + if (messages.length > 0) { + // add an up-to-date message + messages.push({ + headers: { control: `up-to-date` }, + }) + } + + subscribers.forEach((subscriber) => subscriber(messages)) + return result + }, } +// Mock the requestSnapshot method +// to return an empty array of data +// since most tests don't use it +mockRequestSnapshot.mockResolvedValue({ + data: [], +}) + vi.mock(`@electric-sql/client`, async () => { const actual = await vi.importActual(`@electric-sql/client`) return { @@ -437,4 +467,807 @@ describe.each([ // Clean up subscription.unsubscribe() }) + if (autoIndex === `eager`) { + it(`should load more data via requestSnapshot when creating live query with higher limit`, async () => { + // Create a new electric collection with on-demand syncMode for this test + vi.clearAllMocks() + + let testSubscriber: (messages: Array>) => void = () => {} + mockSubscribe.mockImplementation((callback) => { + testSubscriber = callback + return () => {} + }) + + const testElectricCollection = createCollection( + electricCollectionOptions({ + id: `test-incremental-loading`, + shapeOptions: { + url: `http://test-url`, + params: { table: `users` }, + }, + syncMode: `on-demand`, + getKey: (user: User) => user.id, + startSync: true, + autoIndex: `eager` as const, + }) + ) + + mockRequestSnapshot.mockResolvedValue({ + data: [], + }) + + // Initial sync with limited data + testSubscriber([ + ...sampleUsers.map((user) => ({ + key: user.id.toString(), + value: user, + headers: { operation: `insert` as const }, + })), + { headers: { control: `up-to-date` as const } }, + ]) + + expect(testElectricCollection.status).toBe(`ready`) + expect(testElectricCollection.size).toBe(4) + expect(mockRequestSnapshot).toHaveBeenCalledTimes(0) + + // Create first live query with limit of 2 + const limitedLiveQuery = createLiveQueryCollection({ + id: `limited-users-live-query`, + startSync: true, + query: (q) => + q + .from({ user: testElectricCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + active: user.active, + age: user.age, + })) + .orderBy(({ user }) => user.age, `asc`) + .limit(2), + }) + + expect(limitedLiveQuery.status).toBe(`ready`) + expect(limitedLiveQuery.size).toBe(2) // Only first 2 active users + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + + const callArgs = (index: number) => + mockRequestSnapshot.mock.calls[index]?.[0] + expect(callArgs(0)).toMatchObject({ + params: { "1": `true` }, + where: `active = $1`, + orderBy: `age NULLS FIRST`, + limit: 2, + }) + + // Next call will return a snapshot containing 2 rows + // Calls after that will return the default empty snapshot + mockRequestSnapshot.mockResolvedValueOnce({ + data: [ + { + headers: { operation: `insert` }, + key: 5, + value: { + id: 5, + name: `Eve`, + age: 30, + email: `eve@example.com`, + active: true, + }, + }, + { + headers: { operation: `insert` }, + key: 6, + value: { + id: 6, + name: `Frank`, + age: 35, + email: `frank@example.com`, + active: true, + }, + }, + ], + }) + + // Create second live query with higher limit of 6 + const expandedLiveQuery = createLiveQueryCollection({ + id: `expanded-users-live-query`, + startSync: true, + query: (q) => + q + .from({ user: testElectricCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + active: user.active, + })) + .orderBy(({ user }) => user.age, `asc`) + .limit(6), + }) + + // Wait for the live query to process + await new Promise((resolve) => setTimeout(resolve, 0)) + + // With deduplication, the expanded query (limit 6) is NOT a subset of the limited query (limit 2), + // so it will trigger a new requestSnapshot call. However, some of the recursive + // calls may be deduped if they're covered by the union of previous unlimited calls. + // We expect at least 4 calls: 2x for the initial limit 2 and 2x for the initial limit 6. + // TODO: Once we have cursor based pagination with the PK as a tiebreaker, we can reduce this to 2 calls. + expect(mockRequestSnapshot).toHaveBeenCalledTimes(4) + + // Check that first it requested a limit of 2 users (from first query) + expect(callArgs(0)).toMatchObject({ + params: { "1": `true` }, + where: `active = $1`, + orderBy: `age NULLS FIRST`, + limit: 2, + }) + + // Check that second it requested a limit of 6 users (from second query) + expect(callArgs(1)).toMatchObject({ + params: { "1": `true` }, + where: `active = $1`, + orderBy: `age NULLS FIRST`, + limit: 6, + }) + + // The expanded live query should have the locally available data + expect(expandedLiveQuery.status).toBe(`ready`) + // The mock returned 2 additional users (Eve and Frank) in response to the limit 6 request, + // plus the initial 3 active users (Alice, Bob, Dave) from the initial sync + expect(expandedLiveQuery.size).toBe(5) + }) + } +}) + +// Tests specifically for syncMode behavior with live queries +describe(`Electric Collection with Live Query - syncMode integration`, () => { + let subscriber: (messages: Array>) => void + + function createElectricCollectionWithSyncMode( + syncMode: `eager` | `on-demand` | `progressive` + ) { + vi.clearAllMocks() + + mockSubscribe.mockImplementation((callback) => { + subscriber = callback + return () => {} + }) + + mockRequestSnapshot.mockResolvedValue({ + data: [], + }) + + const config = { + id: `electric-users-${syncMode}`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `users`, + }, + }, + syncMode, + getKey: (user: User) => user.id, + } + + const options = electricCollectionOptions(config) + return createCollection({ + ...options, + startSync: true, + autoIndex: `eager` as const, + }) + } + + function simulateInitialSync(users: Array = sampleUsers) { + const messages: Array> = users.map((user) => ({ + key: user.id.toString(), + value: user, + headers: { operation: `insert` }, + })) + + messages.push({ + headers: { control: `up-to-date` }, + }) + + subscriber(messages) + } + + it(`should trigger requestSnapshot in on-demand mode when live query needs more data`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + // Initial sync with limited data + simulateInitialSync([sampleUsers[0]!, sampleUsers[1]!]) // Only Alice and Bob + expect(electricCollection.status).toBe(`ready`) + expect(electricCollection.size).toBe(2) + expect(mockRequestSnapshot).toHaveBeenCalledTimes(0) + + // Mock requestSnapshot to return additional data + mockRequestSnapshot.mockResolvedValueOnce({ + data: [ + { + headers: { operation: `insert` }, + key: 3, + value: sampleUsers[2]!, // Charlie + }, + { + headers: { operation: `insert` }, + key: 4, + value: sampleUsers[3]!, // Dave + }, + ], + }) + + // Create live query with limit that exceeds available data + const liveQuery = createLiveQueryCollection({ + id: `on-demand-live-query`, + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`) + .limit(5), + }) + + // Wait for the live query to process + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have requested more data from Electric with correct parameters + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 5, // Requests full limit from Electric + orderBy: `age NULLS FIRST`, + where: `active = $1`, + params: { 1: `true` }, // Parameters are stringified + }) + ) + expect(liveQuery.size).toBeGreaterThan(2) + }) + + it(`should trigger requestSnapshot in progressive mode when live query needs more data`, async () => { + const electricCollection = + createElectricCollectionWithSyncMode(`progressive`) + + // Send initial snapshot with limited data (using snapshot-end, not up-to-date) + // This keeps the collection in "loading" state, simulating progressive mode still syncing + subscriber([ + { + key: sampleUsers[0]!.id.toString(), + value: sampleUsers[0]!, + headers: { operation: `insert` }, + }, + { + key: sampleUsers[1]!.id.toString(), + value: sampleUsers[1]!, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + expect(electricCollection.status).toBe(`loading`) // Still syncing in progressive mode + expect(electricCollection.size).toBe(2) + + // Mock requestSnapshot to return additional data + mockRequestSnapshot.mockResolvedValueOnce({ + data: [ + { + headers: { operation: `insert` }, + key: 3, + value: sampleUsers[2]!, // Charlie + }, + ], + }) + + // Create live query that needs more data + createLiveQueryCollection({ + id: `progressive-live-query`, + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .orderBy(({ user }) => user.id, `asc`) + .limit(3), + }) + + // Wait for the live query to process + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have requested more data from Electric with correct parameters + // First request asks for the full limit + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 3, // Requests full limit from Electric + orderBy: `id NULLS FIRST`, + params: {}, + }) + ) + }) + + it(`should NOT trigger requestSnapshot in eager mode even when live query needs more data`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`eager`) + + // Initial sync with limited data + simulateInitialSync([sampleUsers[0]!, sampleUsers[1]!]) // Only Alice and Bob + expect(electricCollection.status).toBe(`ready`) + expect(electricCollection.size).toBe(2) + expect(mockRequestSnapshot).toHaveBeenCalledTimes(0) + + // Create live query with limit that exceeds available data + const liveQuery = createLiveQueryCollection({ + id: `eager-live-query`, + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`) + .limit(5), + }) + + // Wait for the live query to process + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should NOT have requested more data (eager mode doesn't support incremental loading) + expect(mockRequestSnapshot).not.toHaveBeenCalled() + expect(liveQuery.size).toBe(2) // Only has the initially synced data + }) + + it(`should request additional snapshots progressively as live query expands in on-demand mode`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + // Initial sync with just Alice + simulateInitialSync([sampleUsers[0]!]) + expect(electricCollection.size).toBe(1) + + // First snapshot returns Bob and Charlie + mockRequestSnapshot.mockResolvedValueOnce({ + data: [ + { + headers: { operation: `insert` }, + key: 2, + value: sampleUsers[1]!, // Bob + }, + { + headers: { operation: `insert` }, + key: 3, + value: sampleUsers[2]!, // Charlie + }, + ], + }) + + // Create live query with limit of 3 + createLiveQueryCollection({ + id: `expanding-live-query`, + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .orderBy(({ user }) => user.age, `asc`) + .limit(3), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have requested snapshot for limit 3 + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 3, + orderBy: `age NULLS FIRST`, + }) + ) + + // With deduplication, the unlimited where predicate (no where clause) is tracked, + // and subsequent calls for the same unlimited predicate may be deduped. + // After receiving Bob and Charlie, we have 3 users total, which satisfies the limit of 3, + // so no additional requests should be made. + // TODO: Once we have cursor based pagination with the PK as a tiebreaker, we can reduce this to 1 call. + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + }) + + it(`should pass correct WHERE clause to requestSnapshot when live query has filters`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync([]) + expect(electricCollection.size).toBe(0) + + // Create filtered live query + createLiveQueryCollection({ + id: `filtered-live-query`, + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.name, `desc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have requested snapshot with WHERE clause + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + where: `active = $1`, + params: { "1": `true` }, + orderBy: `name DESC NULLS FIRST`, + limit: 10, + }) + ) + }) + + it(`should handle complex filters in requestSnapshot`, async () => { + const electricCollection = + createElectricCollectionWithSyncMode(`progressive`) + + // Send snapshot-end (not up-to-date) to keep collection in loading state + subscriber([ + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + expect(electricCollection.status).toBe(`loading`) // Still syncing in progressive mode + + // Create live query with complex WHERE clause + createLiveQueryCollection({ + id: `complex-filter-live-query`, + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 20)) + .orderBy(({ user }) => user.age, `asc`) + .limit(5), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have requested snapshot with complex WHERE clause + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + where: `age > $1`, + params: { "1": `20` }, + orderBy: `age NULLS FIRST`, + limit: 5, + }) + ) + }) +}) + +// Tests specifically for loadSubset deduplication +describe(`Electric Collection - loadSubset deduplication`, () => { + let subscriber: (messages: Array>) => void + + function createElectricCollectionWithSyncMode( + syncMode: `on-demand` | `progressive` + ) { + vi.clearAllMocks() + + mockSubscribe.mockImplementation((callback) => { + subscriber = callback + return () => {} + }) + + mockRequestSnapshot.mockResolvedValue({ + data: [], + }) + + const config = { + id: `electric-dedupe-test-${syncMode}`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `users`, + }, + }, + syncMode, + getKey: (user: User) => user.id, + } + + const options = electricCollectionOptions(config) + return createCollection({ + ...options, + startSync: true, + autoIndex: `eager` as const, + }) + } + + function simulateInitialSync(users: Array = sampleUsers) { + const messages: Array> = users.map((user) => ({ + key: user.id.toString(), + value: user, + headers: { operation: `insert` }, + })) + + messages.push({ + headers: { control: `up-to-date` }, + }) + + subscriber(messages) + } + + it(`should deduplicate identical concurrent loadSubset requests`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync([]) + expect(electricCollection.status).toBe(`ready`) + + // Create three identical live queries concurrently + // Without deduplication, this would trigger 3 requestSnapshot calls + // With deduplication, only 1 should be made + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // With deduplication, only 1 requestSnapshot call should be made + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + where: `active = $1`, + params: { "1": `true` }, + orderBy: `age NULLS FIRST`, + limit: 10, + }) + ) + }) + + it(`should deduplicate subset loadSubset requests`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync([]) + expect(electricCollection.status).toBe(`ready`) + + // Create a live query with a broader predicate + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 10)) + .orderBy(({ user }) => user.age, `asc`) + .limit(20), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + + // Create a live query with a subset predicate (age > 20 is subset of age > 10) + // This should be deduped - no additional requestSnapshot call + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 20)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Still only 1 call - the second was deduped as a subset + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + }) + + it(`should NOT deduplicate non-subset loadSubset requests`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync([]) + expect(electricCollection.status).toBe(`ready`) + + // Create a live query with a narrower predicate + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 30)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + + // Create a live query with a broader predicate (age > 20 is NOT subset of age > 30) + // This should NOT be deduped - should trigger another requestSnapshot + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 20)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have 2 calls - the second was not a subset + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + }) + + it(`should reset deduplication state on must-refetch/truncate`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync(sampleUsers) + expect(electricCollection.status).toBe(`ready`) + + // Create a live query + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // TODO: Once we have cursor based pagination with the PK as a tiebreaker, we can reduce this to 1 call. + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + + // Simulate a must-refetch (which triggers truncate and reset) + subscriber([{ headers: { control: `must-refetch` } }]) + subscriber([{ headers: { control: `up-to-date` } }]) + + // Wait for the existing live query to re-request data after truncate + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The existing live query re-requests its data after truncate (call 2) + // TODO: Once we have cursor based pagination with the PK as a tiebreaker, we can reduce this to 1 call. + expect(mockRequestSnapshot).toHaveBeenCalledTimes(4) + + // Create the same live query again after reset + // This should NOT be deduped because the reset cleared the deduplication state, + // but it WILL be deduped because the existing live query just made the same request (call 2) + // So creating a different query to ensure we test the reset + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, false)) + .orderBy(({ user }) => user.age, `asc`) + .limit(10), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Should have 5 calls - the different query triggered a new request + // TODO: Once we have cursor based pagination with the PK as a tiebreaker, we can reduce this to <=3 calls. + expect(mockRequestSnapshot).toHaveBeenCalledTimes(5) + }) + + it(`should deduplicate unlimited queries regardless of orderBy`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync([]) + expect(electricCollection.status).toBe(`ready`) + + // Create a live query without limit (unlimited) + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.age, `asc`), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + + // Create another unlimited query with same where but different orderBy + // This should be deduped - orderBy is ignored for unlimited queries + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.name, `desc`), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Still only 1 call - different orderBy doesn't matter for unlimited queries + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + }) + + it(`should combine multiple unlimited queries with union`, async () => { + const electricCollection = createElectricCollectionWithSyncMode(`on-demand`) + + simulateInitialSync([]) + expect(electricCollection.status).toBe(`ready`) + + // Create first unlimited query (age > 30) + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 30)), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + + // Create second unlimited query (age < 20) - different range + // This should trigger a new request + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => lt(user.age, 20)), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + + // Create third query (age > 35) - this is a subset of (age > 30) + // This should be deduped + createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: electricCollection }) + .where(({ user }) => gt(user.age, 35)), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Still 2 calls - third was covered by the union of first two + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index b45d47370..27f90918d 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -1,6 +1,7 @@ import { describe, expectTypeOf, it } from "vitest" import { z } from "zod" import { + and, createCollection, createLiveQueryCollection, eq, @@ -200,7 +201,7 @@ describe(`Electric collection type resolution tests`, () => { query: (q) => q .from({ user: usersCollection }) - .where(({ user }) => eq(user.active, true) && gt(user.age, 18)) + .where(({ user }) => and(eq(user.active, true), gt(user.age, 18))) .select(({ user }) => ({ id: user.id, name: user.name, diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index bf059a021..032e42033 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -19,8 +19,10 @@ import type { StandardSchemaV1 } from "@standard-schema/spec" // Mock the ShapeStream module const mockSubscribe = vi.fn() +const mockRequestSnapshot = vi.fn() const mockStream = { subscribe: mockSubscribe, + requestSnapshot: mockRequestSnapshot, } vi.mock(`@electric-sql/client`, async () => { @@ -50,6 +52,9 @@ describe(`Electric Integration`, () => { return () => {} }) + // Reset mock requestSnapshot + mockRequestSnapshot.mockResolvedValue(undefined) + // Create collection with Electric configuration const config = { id: `test`, @@ -728,6 +733,9 @@ describe(`Electric Integration`, () => { expect(testCollection.has(1)).toBe(true) }) + // NOTE: This test has a known issue with unhandled rejection warnings + // This is a pre-existing issue from main branch (not caused by merge) + // The test functionality works correctly, but vitest reports unhandled rejections it(`should timeout with custom match function when no match found`, async () => { vi.useFakeTimers() @@ -754,14 +762,16 @@ describe(`Electric Integration`, () => { const testCollection = createCollection(electricCollectionOptions(config)) const tx = testCollection.insert({ id: 1, name: `Timeout Test` }) - // Add catch handler to prevent global unhandled rejection detection - tx.isPersisted.promise.catch(() => {}) + // Capture the rejection promise before advancing timers + const rejectionPromise = expect(tx.isPersisted.promise).rejects.toThrow( + `Timeout waiting for custom match function` + ) // Advance timers to trigger timeout await vi.runOnlyPendingTimersAsync() // Should timeout and fail - await expect(tx.isPersisted.promise).rejects.toThrow() + await rejectionPromise vi.useRealTimers() }) @@ -834,6 +844,9 @@ describe(`Electric Integration`, () => { expect(options.onDelete).toBeDefined() }) + // NOTE: This test has a known issue with unhandled rejection warnings + // This is a pre-existing issue from main branch (not caused by merge) + // The test functionality works correctly, but vitest reports unhandled rejections it(`should cleanup pending matches on timeout without memory leaks`, async () => { vi.useFakeTimers() @@ -862,16 +875,16 @@ describe(`Electric Integration`, () => { // Start insert that will timeout const tx = testCollection.insert({ id: 1, name: `Timeout Test` }) - // Add catch handler to prevent global unhandled rejection detection - tx.isPersisted.promise.catch(() => {}) + // Capture the rejection promise before advancing timers + const rejectionPromise = expect(tx.isPersisted.promise).rejects.toThrow( + `Timeout waiting for custom match function` + ) // Advance timers to trigger timeout await vi.runOnlyPendingTimersAsync() // Should timeout and fail - await expect(tx.isPersisted.promise).rejects.toThrow( - `Timeout waiting for custom match function` - ) + await rejectionPromise // Send a message after timeout - should not cause any side effects // This verifies that the pending match was properly cleaned up @@ -1601,7 +1614,662 @@ describe(`Electric Integration`, () => { // Snapshot txid should also resolve await expect(testCollection.utils.awaitTxId(105)).resolves.toBe(true) }) + }) + + // Tests for syncMode configuration + describe(`syncMode configuration`, () => { + it(`should not request snapshots during subscription in eager mode`, () => { + vi.clearAllMocks() + + const config = { + id: `eager-no-snapshot-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `eager` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Subscribe and try to get more data + const subscription = testCollection.subscribeChanges(() => {}) + + // In eager mode, requestSnapshot should not be called + expect(mockRequestSnapshot).not.toHaveBeenCalled() + + subscription.unsubscribe() + }) + + it(`should request incremental snapshots in on-demand mode when loadSubset is called`, async () => { + vi.clearAllMocks() + + const config = { + id: `on-demand-snapshot-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `on-demand` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send up-to-date to mark collection as ready + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + // In on-demand mode, calling loadSubset should request a snapshot + await testCollection._sync.loadSubset({ limit: 10 }) + + // Verify requestSnapshot was called + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + params: {}, + }) + ) + }) + + it(`should request incremental snapshots in progressive mode when loadSubset is called before sync completes`, async () => { + vi.clearAllMocks() + + const config = { + id: `progressive-snapshot-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `progressive` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send initial data with snapshot-end (but not up-to-date yet - still syncing) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + expect(testCollection.status).toBe(`loading`) // Not ready yet + + // In progressive mode, calling loadSubset should request a snapshot BEFORE full sync completes + await testCollection._sync.loadSubset({ limit: 20 }) + + // Verify requestSnapshot was called + expect(mockRequestSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 20, + params: {}, + }) + ) + }) + + it(`should not request snapshots when loadSubset is called in eager mode`, async () => { + vi.clearAllMocks() + + const config = { + id: `eager-no-loadsubset-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `eager` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send up-to-date to mark collection as ready + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + // In eager mode, loadSubset should do nothing + await testCollection._sync.loadSubset({ limit: 10 }) + + // Verify requestSnapshot was NOT called + expect(mockRequestSnapshot).not.toHaveBeenCalled() + }) + + it(`should handle progressive mode syncing in background`, async () => { + vi.clearAllMocks() + + const config = { + id: `progressive-background-sync-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `progressive` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send initial data with snapshot-end (but not up-to-date - still syncing) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Initial User` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + // Collection should have data but not be ready yet + expect(testCollection.status).toBe(`loading`) + expect(testCollection.has(1)).toBe(true) + + // Should be able to request more data incrementally before full sync completes + await testCollection._sync.loadSubset({ limit: 10 }) + expect(mockRequestSnapshot).toHaveBeenCalled() + + // Now send up-to-date to complete the sync + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + expect(testCollection.status).toBe(`ready`) + }) + + it(`should stop requesting snapshots in progressive mode after first up-to-date`, async () => { + vi.clearAllMocks() + + const config = { + id: `progressive-stop-after-sync-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `progressive` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send initial data with snapshot-end (not up-to-date yet) + subscriber([ + { + key: `1`, + value: { id: 1, name: `User 1` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + expect(testCollection.status).toBe(`loading`) // Not ready yet in progressive + expect(testCollection.has(1)).toBe(true) + + // Should be able to request more data before up-to-date + vi.clearAllMocks() + await testCollection._sync.loadSubset({ limit: 10 }) + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + + // Now send up-to-date to complete the full sync + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + expect(testCollection.status).toBe(`ready`) + + // Try to request more data - should NOT make a request since full sync is complete + vi.clearAllMocks() + await testCollection._sync.loadSubset({ limit: 10 }) + expect(mockRequestSnapshot).not.toHaveBeenCalled() + }) + + it(`should allow snapshots in on-demand mode even after up-to-date`, async () => { + vi.clearAllMocks() + + const config = { + id: `on-demand-after-sync-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + syncMode: `on-demand` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send initial data with up-to-date + subscriber([ + { + key: `1`, + value: { id: 1, name: `User 1` }, + headers: { operation: `insert` }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + expect(testCollection.status).toBe(`ready`) + + // Should STILL be able to request more data in on-demand mode + vi.clearAllMocks() + await testCollection._sync.loadSubset({ limit: 10 }) + expect(mockRequestSnapshot).toHaveBeenCalled() + }) + + it(`should default offset to 'now' in on-demand mode when no offset provided`, async () => { + vi.clearAllMocks() + + // Import ShapeStream to check constructor calls + const { ShapeStream } = await import(`@electric-sql/client`) + + const config = { + id: `on-demand-offset-now-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + // No offset provided + }, + syncMode: `on-demand` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + createCollection(electricCollectionOptions(config)) + + // Check that ShapeStream was called with offset: 'now' + expect(ShapeStream).toHaveBeenCalledWith( + expect.objectContaining({ + offset: `now`, + }) + ) + }) + + it(`should use undefined offset in eager mode when no offset provided`, async () => { + vi.clearAllMocks() + + const { ShapeStream } = await import(`@electric-sql/client`) + + const config = { + id: `eager-offset-undefined-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + // No offset provided + }, + syncMode: `eager` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + createCollection(electricCollectionOptions(config)) + + // Check that ShapeStream was called with offset: undefined + expect(ShapeStream).toHaveBeenCalledWith( + expect.objectContaining({ + offset: undefined, + }) + ) + }) + + it(`should use undefined offset in progressive mode when no offset provided`, async () => { + vi.clearAllMocks() + + const { ShapeStream } = await import(`@electric-sql/client`) + + const config = { + id: `progressive-offset-undefined-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + // No offset provided + }, + syncMode: `progressive` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + createCollection(electricCollectionOptions(config)) + + // Check that ShapeStream was called with offset: undefined + expect(ShapeStream).toHaveBeenCalledWith( + expect.objectContaining({ + offset: undefined, + }) + ) + }) + + it(`should use explicit offset when provided regardless of syncMode`, async () => { + vi.clearAllMocks() + + const { ShapeStream } = await import(`@electric-sql/client`) + + const config = { + id: `explicit-offset-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + offset: -1 as any, // Explicit offset + }, + syncMode: `on-demand` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + createCollection(electricCollectionOptions(config)) + + // Check that ShapeStream was called with the explicit offset + expect(ShapeStream).toHaveBeenCalledWith( + expect.objectContaining({ + offset: -1, + }) + ) + }) + }) + + // Tests for commit and ready behavior with snapshot-end and up-to-date messages + describe(`Commit and ready behavior`, () => { + it(`should commit on snapshot-end in eager mode but not mark ready`, () => { + const config = { + id: `eager-snapshot-end-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `eager` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send data followed by snapshot-end (but no up-to-date) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + // Data should be committed (available in state) + expect(testCollection.has(1)).toBe(true) + expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + + // But collection should NOT be marked as ready yet in eager mode + expect(testCollection.status).toBe(`loading`) + + // Now send up-to-date + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + // Now it should be ready + expect(testCollection.status).toBe(`ready`) + }) + + it(`should commit and mark ready on snapshot-end in on-demand mode`, () => { + const config = { + id: `on-demand-snapshot-end-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `on-demand` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send data followed by snapshot-end (but no up-to-date) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + // Data should be committed (available in state) + expect(testCollection.has(1)).toBe(true) + expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + + // Collection SHOULD be marked as ready in on-demand mode + expect(testCollection.status).toBe(`ready`) + }) + + it(`should commit on snapshot-end in progressive mode but not mark ready`, () => { + const config = { + id: `progressive-snapshot-end-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `progressive` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send data followed by snapshot-end (but no up-to-date) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + // Data should be committed (available in state) + expect(testCollection.has(1)).toBe(true) + expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + + // But collection should NOT be marked as ready yet in progressive mode + expect(testCollection.status).toBe(`loading`) + + // Now send up-to-date + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + // Now it should be ready + expect(testCollection.status).toBe(`ready`) + }) + + it(`should commit multiple snapshot-end messages before up-to-date in eager mode`, () => { + const config = { + id: `eager-multiple-snapshots-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `eager` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // First snapshot with data + subscriber([ + { + key: `1`, + value: { id: 1, name: `User 1` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + ]) + + // First data should be committed + expect(testCollection.has(1)).toBe(true) + expect(testCollection.status).toBe(`loading`) + + // Second snapshot with more data + subscriber([ + { + key: `2`, + value: { id: 2, name: `User 2` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `110`, + xmax: `120`, + xip_list: [], + }, + }, + ]) + + // Second data should also be committed + expect(testCollection.has(2)).toBe(true) + expect(testCollection.size).toBe(2) + expect(testCollection.status).toBe(`loading`) + + // Finally send up-to-date + subscriber([ + { + headers: { control: `up-to-date` }, + }, + ]) + + // Now should be ready + expect(testCollection.status).toBe(`ready`) + }) + + it(`should handle up-to-date without snapshot-end (traditional behavior)`, () => { + const config = { + id: `traditional-up-to-date-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `eager` as const, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send data followed by up-to-date (no snapshot-end) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { operation: `insert` }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Data should be committed and collection ready + expect(testCollection.has(1)).toBe(true) + expect(testCollection.status).toBe(`ready`) + }) + }) + describe(`syncMode configuration - GC and resync`, () => { it(`should resync after garbage collection and new subscription`, () => { // Use fake timers for this test vi.useFakeTimers() diff --git a/packages/electric-db-collection/tsconfig.json b/packages/electric-db-collection/tsconfig.json index 7e586bab3..fc6368937 100644 --- a/packages/electric-db-collection/tsconfig.json +++ b/packages/electric-db-collection/tsconfig.json @@ -12,7 +12,9 @@ "forceConsistentCasingInFileNames": true, "jsx": "react", "paths": { - "@tanstack/store": ["../store/src"] + "@tanstack/store": ["../store/src"], + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/db": ["../db/src"] } }, "include": ["src", "tests", "vite.config.ts"], From 685af96b9108f976f37f8531bd7895d80343e61d Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 6 Nov 2025 16:23:49 +0100 Subject: [PATCH 5/5] =?UTF-8?q?Add=20optional=20compareOptions=20to=20the?= =?UTF-8?q?=20Collection=20Config=20which=20act=20as=20col=E2=80=A6=20(#76?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add optional compareOptions to the Collection Config which act as collection-wide config and allow queries to override those. * Fix import * changeset * Ran prettier * update docs * Live queries inherit compare options from collection in the FROM clause * Explicit compare options for live queries * Rename compareOptions to defaultStringCollation in the collection config and live query collection config * Docs update --------- Co-authored-by: Sam Willis --- .changeset/all-meals-follow.md | 5 + docs/reference/classes/CollectionImpl.md | 106 +++++++----- .../functions/electricCollectionOptions.md | 4 +- .../interfaces/ElectricCollectionConfig.md | 22 ++- .../interfaces/ElectricCollectionUtils.md | 6 +- .../type-aliases/AwaitTxIdFn.md | 2 +- .../type-aliases/Txid.md | 2 +- docs/reference/functions/createCollection.md | 8 +- docs/reference/index.md | 2 + .../interfaces/BaseCollectionConfig.md | 41 +++-- docs/reference/interfaces/ChangeMessage.md | 12 +- docs/reference/interfaces/Collection.md | 110 ++++++++----- docs/reference/interfaces/CollectionConfig.md | 47 ++++-- docs/reference/interfaces/CollectionLike.md | 147 +++++++++++++++++ .../CreateOptimisticActionsOptions.md | 12 +- .../CurrentStateAsChangesOptions.md | 10 +- docs/reference/interfaces/InsertConfig.md | 6 +- .../interfaces/LiveQueryCollectionConfig.md | 35 ++-- .../interfaces/LocalOnlyCollectionConfig.md | 41 +++-- .../LocalStorageCollectionConfig.md | 43 +++-- docs/reference/interfaces/OperationConfig.md | 6 +- .../interfaces/OptimisticChangeMessage.md | 14 +- docs/reference/interfaces/PendingMutation.md | 28 ++-- .../interfaces/SubscribeChangesOptions.md | 6 +- .../SubscribeChangesSnapshotOptions.md | 8 +- docs/reference/interfaces/Subscription.md | 4 +- .../SubscriptionStatusChangeEvent.md | 10 +- .../interfaces/SubscriptionStatusEvent.md | 10 +- .../SubscriptionUnsubscribedEvent.md | 6 +- docs/reference/interfaces/SyncConfig.md | 8 +- .../reference/interfaces/TransactionConfig.md | 10 +- docs/reference/type-aliases/ChangeListener.md | 2 +- docs/reference/type-aliases/ChangesPayload.md | 2 +- docs/reference/type-aliases/CleanupFn.md | 2 +- .../CollectionConfigSingleRowOption.md | 2 +- .../type-aliases/CollectionStatus.md | 2 +- .../type-aliases/DeleteMutationFn.md | 2 +- .../type-aliases/DeleteMutationFnParams.md | 6 +- docs/reference/type-aliases/Fn.md | 2 +- docs/reference/type-aliases/GetResult.md | 2 +- .../reference/type-aliases/InferResultType.md | 2 +- .../type-aliases/InferSchemaInput.md | 2 +- .../type-aliases/InferSchemaOutput.md | 2 +- docs/reference/type-aliases/InputRow.md | 2 +- .../type-aliases/InsertMutationFn.md | 2 +- .../type-aliases/InsertMutationFnParams.md | 6 +- .../type-aliases/KeyedNamespacedRow.md | 2 +- docs/reference/type-aliases/KeyedStream.md | 2 +- .../type-aliases/LiveQueryCollectionUtils.md | 2 +- docs/reference/type-aliases/LoadSubsetFn.md | 2 +- .../type-aliases/LoadSubsetOptions.md | 10 +- .../type-aliases/MaybeSingleResult.md | 4 +- docs/reference/type-aliases/MutationFn.md | 2 +- .../type-aliases/MutationFnParams.md | 4 +- .../type-aliases/NamespacedAndKeyedStream.md | 2 +- docs/reference/type-aliases/NamespacedRow.md | 2 +- docs/reference/type-aliases/NonEmptyArray.md | 2 +- .../reference/type-aliases/NonSingleResult.md | 4 +- docs/reference/type-aliases/OperationType.md | 2 +- docs/reference/type-aliases/Ref.md | 2 +- .../type-aliases/ResolveTransactionChanges.md | 2 +- docs/reference/type-aliases/ResultStream.md | 2 +- docs/reference/type-aliases/Row.md | 2 +- docs/reference/type-aliases/SingleResult.md | 4 +- docs/reference/type-aliases/StandardSchema.md | 2 +- .../type-aliases/StandardSchemaAlias.md | 2 +- .../type-aliases/StringCollationConfig.md | 28 ++++ .../type-aliases/SubscriptionEvents.md | 10 +- .../type-aliases/SubscriptionStatus.md | 2 +- docs/reference/type-aliases/SyncConfigRes.md | 6 +- docs/reference/type-aliases/SyncMode.md | 2 +- .../type-aliases/TransactionState.md | 2 +- .../type-aliases/TransactionWithMutations.md | 2 +- .../type-aliases/UpdateMutationFn.md | 2 +- .../type-aliases/UpdateMutationFnParams.md | 6 +- docs/reference/type-aliases/UtilsRecord.md | 2 +- docs/reference/type-aliases/WritableDeep.md | 2 +- packages/db/src/collection/change-events.ts | 27 +-- packages/db/src/collection/index.ts | 28 ++++ packages/db/src/indexes/auto-index.ts | 12 +- packages/db/src/query/builder/index.ts | 4 +- packages/db/src/query/builder/types.ts | 28 +--- packages/db/src/query/compiler/order-by.ts | 40 ++++- .../query/live/collection-config-builder.ts | 27 +++ packages/db/src/query/live/types.ts | 12 +- packages/db/src/types.ts | 39 +++++ packages/db/src/utils/index-optimization.ts | 140 +++++++++------- .../db/tests/query/builder/order-by.test.ts | 20 ++- .../tests/query/live-query-collection.test.ts | 133 +++++++++++++++ packages/db/tests/query/order-by.test.ts | 155 ++++++++++++++++++ packages/db/tests/utils.ts | 2 + 91 files changed, 1158 insertions(+), 438 deletions(-) create mode 100644 .changeset/all-meals-follow.md create mode 100644 docs/reference/interfaces/CollectionLike.md create mode 100644 docs/reference/type-aliases/StringCollationConfig.md diff --git a/.changeset/all-meals-follow.md b/.changeset/all-meals-follow.md new file mode 100644 index 000000000..22237a818 --- /dev/null +++ b/.changeset/all-meals-follow.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Add optional compareOptions to collection configuration. diff --git a/docs/reference/classes/CollectionImpl.md b/docs/reference/classes/CollectionImpl.md index 216db731a..365ba0651 100644 --- a/docs/reference/classes/CollectionImpl.md +++ b/docs/reference/classes/CollectionImpl.md @@ -5,7 +5,7 @@ title: CollectionImpl # Class: CollectionImpl\ -Defined in: [packages/db/src/collection/index.ts:202](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L202) +Defined in: [packages/db/src/collection/index.ts:203](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L203) ## Extended by @@ -42,7 +42,7 @@ Defined in: [packages/db/src/collection/index.ts:202](https://github.com/TanStac new CollectionImpl(config): CollectionImpl; ``` -Defined in: [packages/db/src/collection/index.ts:239](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L239) +Defined in: [packages/db/src/collection/index.ts:242](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L242) Creates a new Collection instance @@ -70,7 +70,7 @@ Error if sync config is missing _lifecycle: CollectionLifecycleManager; ``` -Defined in: [packages/db/src/collection/index.ts:219](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L219) +Defined in: [packages/db/src/collection/index.ts:220](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L220) *** @@ -80,7 +80,7 @@ Defined in: [packages/db/src/collection/index.ts:219](https://github.com/TanStac _state: CollectionStateManager; ``` -Defined in: [packages/db/src/collection/index.ts:231](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L231) +Defined in: [packages/db/src/collection/index.ts:232](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L232) *** @@ -90,7 +90,7 @@ Defined in: [packages/db/src/collection/index.ts:231](https://github.com/TanStac _sync: CollectionSyncManager; ``` -Defined in: [packages/db/src/collection/index.ts:220](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L220) +Defined in: [packages/db/src/collection/index.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L221) *** @@ -100,7 +100,7 @@ Defined in: [packages/db/src/collection/index.ts:220](https://github.com/TanStac config: CollectionConfig; ``` -Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L210) +Defined in: [packages/db/src/collection/index.ts:211](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L211) *** @@ -110,7 +110,7 @@ Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStac id: string; ``` -Defined in: [packages/db/src/collection/index.ts:209](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L209) +Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L210) *** @@ -120,10 +120,26 @@ Defined in: [packages/db/src/collection/index.ts:209](https://github.com/TanStac utils: Record = {}; ``` -Defined in: [packages/db/src/collection/index.ts:214](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L214) +Defined in: [packages/db/src/collection/index.ts:215](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L215) ## Accessors +### compareOptions + +#### Get Signature + +```ts +get compareOptions(): StringCollationConfig; +``` + +Defined in: [packages/db/src/collection/index.ts:516](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L516) + +##### Returns + +[`StringCollationConfig`](../../type-aliases/StringCollationConfig.md) + +*** + ### indexes #### Get Signature @@ -132,7 +148,7 @@ Defined in: [packages/db/src/collection/index.ts:214](https://github.com/TanStac get indexes(): Map>; ``` -Defined in: [packages/db/src/collection/index.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L496) +Defined in: [packages/db/src/collection/index.ts:501](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L501) Get resolved indexes for query optimization @@ -150,7 +166,7 @@ Get resolved indexes for query optimization get isLoadingSubset(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:362](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L362) +Defined in: [packages/db/src/collection/index.ts:367](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L367) Check if the collection is currently loading more data @@ -170,7 +186,7 @@ true if the collection has pending load more operations, false otherwise get size(): number; ``` -Defined in: [packages/db/src/collection/index.ts:399](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L399) +Defined in: [packages/db/src/collection/index.ts:404](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L404) Get the current size of the collection (cached) @@ -188,7 +204,7 @@ Get the current size of the collection (cached) get state(): Map; ``` -Defined in: [packages/db/src/collection/index.ts:683](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L683) +Defined in: [packages/db/src/collection/index.ts:693](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L693) Gets the current state of the collection as a Map @@ -224,7 +240,7 @@ Map containing all items in the collection, with keys as identifiers get status(): CollectionStatus; ``` -Defined in: [packages/db/src/collection/index.ts:317](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L317) +Defined in: [packages/db/src/collection/index.ts:322](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L322) Gets the current status of the collection @@ -242,7 +258,7 @@ Gets the current status of the collection get subscriberCount(): number; ``` -Defined in: [packages/db/src/collection/index.ts:324](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L324) +Defined in: [packages/db/src/collection/index.ts:329](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L329) Get the number of subscribers to the collection @@ -260,7 +276,7 @@ Get the number of subscribers to the collection get toArray(): TOutput[]; ``` -Defined in: [packages/db/src/collection/index.ts:712](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L712) +Defined in: [packages/db/src/collection/index.ts:722](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L722) Gets the current state of the collection as an Array @@ -278,7 +294,7 @@ An Array containing all items in the collection iterator: IterableIterator<[TKey, TOutput]>; ``` -Defined in: [packages/db/src/collection/index.ts:427](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L427) +Defined in: [packages/db/src/collection/index.ts:432](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L432) Get all entries (virtual derived state) @@ -294,7 +310,7 @@ Get all entries (virtual derived state) cleanup(): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:846](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L846) +Defined in: [packages/db/src/collection/index.ts:856](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L856) Clean up the collection by stopping sync and clearing data This can be called manually or automatically by garbage collection @@ -311,7 +327,7 @@ This can be called manually or automatically by garbage collection createIndex(indexCallback, config): IndexProxy; ``` -Defined in: [packages/db/src/collection/index.ts:486](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L486) +Defined in: [packages/db/src/collection/index.ts:491](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L491) Creates an index on a collection for faster queries. Indexes significantly improve query performance by allowing constant time lookups @@ -381,7 +397,7 @@ currentStateAsChanges(options): | ChangeMessage[]; ``` -Defined in: [packages/db/src/collection/index.ts:750](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L750) +Defined in: [packages/db/src/collection/index.ts:760](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L760) Returns the current state of the collection as an array of changes @@ -425,7 +441,7 @@ const activeChanges = collection.currentStateAsChanges({ delete(keys, config?): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:660](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L660) +Defined in: [packages/db/src/collection/index.ts:670](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L670) Deletes one or more items from the collection @@ -488,7 +504,7 @@ try { entries(): IterableIterator<[TKey, TOutput]>; ``` -Defined in: [packages/db/src/collection/index.ts:420](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L420) +Defined in: [packages/db/src/collection/index.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L425) Get all entries (virtual derived state) @@ -504,7 +520,7 @@ Get all entries (virtual derived state) forEach(callbackfn): void; ``` -Defined in: [packages/db/src/collection/index.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L434) +Defined in: [packages/db/src/collection/index.ts:439](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L439) Execute a callback for each entry in the collection @@ -526,7 +542,7 @@ Execute a callback for each entry in the collection get(key): TOutput | undefined; ``` -Defined in: [packages/db/src/collection/index.ts:385](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L385) +Defined in: [packages/db/src/collection/index.ts:390](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L390) Get the current value for a key (virtual derived state) @@ -548,7 +564,7 @@ Get the current value for a key (virtual derived state) getKeyFromItem(item): TKey; ``` -Defined in: [packages/db/src/collection/index.ts:449](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L449) +Defined in: [packages/db/src/collection/index.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L454) #### Parameters @@ -568,7 +584,7 @@ Defined in: [packages/db/src/collection/index.ts:449](https://github.com/TanStac has(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:392](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L392) +Defined in: [packages/db/src/collection/index.ts:397](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L397) Check if a key exists in the collection (virtual derived state) @@ -592,7 +608,7 @@ insert(data, config?): | Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:547](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L547) +Defined in: [packages/db/src/collection/index.ts:557](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L557) Inserts one or more items into the collection @@ -663,7 +679,7 @@ try { isReady(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:354](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L354) +Defined in: [packages/db/src/collection/index.ts:359](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L359) Check if the collection is ready for use Returns true if the collection has been marked as ready by its sync implementation @@ -693,7 +709,7 @@ if (collection.isReady()) { keys(): IterableIterator; ``` -Defined in: [packages/db/src/collection/index.ts:406](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L406) +Defined in: [packages/db/src/collection/index.ts:411](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L411) Get all keys (virtual derived state) @@ -709,7 +725,7 @@ Get all keys (virtual derived state) map(callbackfn): U[]; ``` -Defined in: [packages/db/src/collection/index.ts:443](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L443) +Defined in: [packages/db/src/collection/index.ts:448](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L448) Create a new array with the results of calling a function for each entry in the collection @@ -737,7 +753,7 @@ Create a new array with the results of calling a function for each entry in the off(event, callback): void; ``` -Defined in: [packages/db/src/collection/index.ts:825](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L825) +Defined in: [packages/db/src/collection/index.ts:835](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L835) Unsubscribe from a collection event @@ -777,7 +793,7 @@ Unsubscribe from a collection event on(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:805](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L805) +Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) Subscribe to a collection event @@ -823,7 +839,7 @@ Subscribe to a collection event once(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) +Defined in: [packages/db/src/collection/index.ts:825](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L825) Subscribe to a collection event once @@ -869,7 +885,7 @@ Subscribe to a collection event once onFirstReady(callback): void; ``` -Defined in: [packages/db/src/collection/index.ts:338](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L338) +Defined in: [packages/db/src/collection/index.ts:343](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L343) Register a callback to be executed when the collection first becomes ready Useful for preloading collections @@ -903,7 +919,7 @@ collection.onFirstReady(() => { preload(): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:378](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L378) +Defined in: [packages/db/src/collection/index.ts:383](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L383) Preload the collection data by starting sync if not already started Multiple concurrent calls will share the same promise @@ -920,7 +936,7 @@ Multiple concurrent calls will share the same promise startSyncImmediate(): void; ``` -Defined in: [packages/db/src/collection/index.ts:370](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L370) +Defined in: [packages/db/src/collection/index.ts:375](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L375) Start sync immediately - internal method for compiled queries This bypasses lazy loading for special cases like live query results @@ -937,7 +953,7 @@ This bypasses lazy loading for special cases like live query results stateWhenReady(): Promise>; ``` -Defined in: [packages/db/src/collection/index.ts:697](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L697) +Defined in: [packages/db/src/collection/index.ts:707](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L707) Gets the current state of the collection as a Map, but only resolves when data is available Waits for the first sync commit to complete before resolving @@ -956,7 +972,7 @@ Promise that resolves to a Map containing all items in the collection subscribeChanges(callback, options): CollectionSubscription; ``` -Defined in: [packages/db/src/collection/index.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L795) +Defined in: [packages/db/src/collection/index.ts:805](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L805) Subscribe to changes in the collection @@ -1028,7 +1044,7 @@ const subscription = collection.subscribeChanges((changes) => { toArrayWhenReady(): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:722](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L722) +Defined in: [packages/db/src/collection/index.ts:732](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L732) Gets the current state of the collection as an Array, but only resolves when data is available Waits for the first sync commit to complete before resolving @@ -1049,7 +1065,7 @@ Promise that resolves to an Array containing all items in the collection update(key, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:592](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L592) +Defined in: [packages/db/src/collection/index.ts:602](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L602) Updates one or more items in the collection using a callback function @@ -1120,7 +1136,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:598](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L598) +Defined in: [packages/db/src/collection/index.ts:608](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L608) Updates one or more items in the collection using a callback function @@ -1194,7 +1210,7 @@ try { update(id, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L605) +Defined in: [packages/db/src/collection/index.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L615) Updates one or more items in the collection using a callback function @@ -1265,7 +1281,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:611](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L611) +Defined in: [packages/db/src/collection/index.ts:621](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L621) Updates one or more items in the collection using a callback function @@ -1342,7 +1358,7 @@ validateData( key?): TOutput; ``` -Defined in: [packages/db/src/collection/index.ts:503](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L503) +Defined in: [packages/db/src/collection/index.ts:508](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L508) Validates the data against the schema @@ -1372,7 +1388,7 @@ Validates the data against the schema values(): IterableIterator; ``` -Defined in: [packages/db/src/collection/index.ts:413](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L413) +Defined in: [packages/db/src/collection/index.ts:418](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L418) Get all values (virtual derived state) @@ -1388,7 +1404,7 @@ Get all values (virtual derived state) waitFor(event, timeout?): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:835](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L835) +Defined in: [packages/db/src/collection/index.ts:845](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L845) Wait for a collection event diff --git a/docs/reference/electric-db-collection/functions/electricCollectionOptions.md b/docs/reference/electric-db-collection/functions/electricCollectionOptions.md index b2d1ecbac..1d12a115d 100644 --- a/docs/reference/electric-db-collection/functions/electricCollectionOptions.md +++ b/docs/reference/electric-db-collection/functions/electricCollectionOptions.md @@ -11,7 +11,7 @@ title: electricCollectionOptions function electricCollectionOptions(config): CollectionConfig, string | number, T, UtilsRecord> & object; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:254](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L254) +Defined in: [packages/electric-db-collection/src/electric.ts:277](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L277) Creates Electric collection options for use with a standard Collection @@ -43,7 +43,7 @@ Collection options with utilities function electricCollectionOptions(config): CollectionConfig & object; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:265](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L265) +Defined in: [packages/electric-db-collection/src/electric.ts:288](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L288) Creates Electric collection options for use with a standard Collection diff --git a/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md b/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md index 8af918787..5bf6b0af6 100644 --- a/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md +++ b/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md @@ -5,13 +5,13 @@ title: ElectricCollectionConfig # Interface: ElectricCollectionConfig\ -Defined in: [packages/electric-db-collection/src/electric.ts:80](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L80) +Defined in: [packages/electric-db-collection/src/electric.ts:102](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L102) Configuration interface for Electric collection options ## Extends -- `Omit`\<`BaseCollectionConfig`\<`T`, `string` \| `number`, `TSchema`, `UtilsRecord`, `any`\>, `"onInsert"` \| `"onUpdate"` \| `"onDelete"`\> +- `Omit`\<`BaseCollectionConfig`\<`T`, `string` \| `number`, `TSchema`, `UtilsRecord`, `any`\>, `"onInsert"` \| `"onUpdate"` \| `"onDelete"` \| `"syncMode"`\> ## Type Parameters @@ -35,7 +35,7 @@ The schema type for validation optional onDelete: (params) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:185](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L185) +Defined in: [packages/electric-db-collection/src/electric.ts:208](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L208) Optional asynchronous handler function called before a delete operation @@ -87,7 +87,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: (params) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:128](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L128) +Defined in: [packages/electric-db-collection/src/electric.ts:151](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L151) Optional asynchronous handler function called before an insert operation @@ -150,7 +150,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: (params) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:157](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L157) +Defined in: [packages/electric-db-collection/src/electric.ts:180](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L180) Optional asynchronous handler function called before an update operation @@ -203,6 +203,16 @@ onUpdate: async ({ transaction, collection }) => { shapeOptions: ShapeStreamOptions>; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:90](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L90) +Defined in: [packages/electric-db-collection/src/electric.ts:112](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L112) Configuration options for the ElectricSQL ShapeStream + +*** + +### syncMode? + +```ts +optional syncMode: ElectricSyncMode; +``` + +Defined in: [packages/electric-db-collection/src/electric.ts:113](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L113) diff --git a/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md b/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md index ed955d29a..569809ba5 100644 --- a/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md +++ b/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md @@ -5,7 +5,7 @@ title: ElectricCollectionUtils # Interface: ElectricCollectionUtils\ -Defined in: [packages/electric-db-collection/src/electric.ts:237](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L237) +Defined in: [packages/electric-db-collection/src/electric.ts:260](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L260) Electric collection utilities type @@ -33,7 +33,7 @@ Electric collection utilities type awaitMatch: AwaitMatchFn; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:240](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L240) +Defined in: [packages/electric-db-collection/src/electric.ts:263](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L263) *** @@ -43,4 +43,4 @@ Defined in: [packages/electric-db-collection/src/electric.ts:240](https://github awaitTxId: AwaitTxIdFn; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:239](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L239) +Defined in: [packages/electric-db-collection/src/electric.ts:262](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L262) diff --git a/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md b/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md index 4501b500b..f2a8589aa 100644 --- a/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md +++ b/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md @@ -9,7 +9,7 @@ title: AwaitTxIdFn type AwaitTxIdFn = (txId, timeout?) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:224](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L224) +Defined in: [packages/electric-db-collection/src/electric.ts:247](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L247) Type for the awaitTxId utility function diff --git a/docs/reference/electric-db-collection/type-aliases/Txid.md b/docs/reference/electric-db-collection/type-aliases/Txid.md index 09c5cc47f..306d044da 100644 --- a/docs/reference/electric-db-collection/type-aliases/Txid.md +++ b/docs/reference/electric-db-collection/type-aliases/Txid.md @@ -9,6 +9,6 @@ title: Txid type Txid = number; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:42](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L42) +Defined in: [packages/electric-db-collection/src/electric.ts:46](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L46) Type representing a transaction ID in ElectricSQL diff --git a/docs/reference/functions/createCollection.md b/docs/reference/functions/createCollection.md index 1404c7300..6e039a885 100644 --- a/docs/reference/functions/createCollection.md +++ b/docs/reference/functions/createCollection.md @@ -11,7 +11,7 @@ title: createCollection function createCollection(options): Collection, TKey, TUtils, T, InferSchemaInput> & NonSingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:130](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L130) +Defined in: [packages/db/src/collection/index.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L131) Creates a new Collection instance with the given configuration @@ -120,7 +120,7 @@ const todos = createCollection({ function createCollection(options): Collection, TKey, TUtils, T, InferSchemaInput> & SingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:143](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L143) +Defined in: [packages/db/src/collection/index.ts:144](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L144) Creates a new Collection instance with the given configuration @@ -229,7 +229,7 @@ const todos = createCollection({ function createCollection(options): Collection & NonSingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:157](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L157) +Defined in: [packages/db/src/collection/index.ts:158](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L158) Creates a new Collection instance with the given configuration @@ -338,7 +338,7 @@ const todos = createCollection({ function createCollection(options): Collection & SingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:170](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L170) +Defined in: [packages/db/src/collection/index.ts:171](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L171) Creates a new Collection instance with the given configuration diff --git a/docs/reference/index.md b/docs/reference/index.md index 9fbdabea9..dbe51248d 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -110,6 +110,7 @@ title: "@tanstack/db" - [ChangeMessage](../interfaces/ChangeMessage.md) - [Collection](../interfaces/Collection.md) - [CollectionConfig](../interfaces/CollectionConfig.md) +- [CollectionLike](../interfaces/CollectionLike.md) - [Context](../interfaces/Context.md) - [CreateOptimisticActionsOptions](../interfaces/CreateOptimisticActionsOptions.md) - [CurrentStateAsChangesOptions](../interfaces/CurrentStateAsChangesOptions.md) @@ -193,6 +194,7 @@ title: "@tanstack/db" - [StorageEventApi](../type-aliases/StorageEventApi.md) - [Strategy](../type-aliases/Strategy.md) - [StrategyOptions](../type-aliases/StrategyOptions.md) +- [StringCollationConfig](../type-aliases/StringCollationConfig.md) - [SubscriptionEvents](../type-aliases/SubscriptionEvents.md) - [SubscriptionStatus](../type-aliases/SubscriptionStatus.md) - [SyncConfigRes](../type-aliases/SyncConfigRes.md) diff --git a/docs/reference/interfaces/BaseCollectionConfig.md b/docs/reference/interfaces/BaseCollectionConfig.md index 15da7ae5a..862d8819d 100644 --- a/docs/reference/interfaces/BaseCollectionConfig.md +++ b/docs/reference/interfaces/BaseCollectionConfig.md @@ -5,7 +5,7 @@ title: BaseCollectionConfig # Interface: BaseCollectionConfig\ -Defined in: [packages/db/src/types.ts:385](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L385) +Defined in: [packages/db/src/types.ts:416](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L416) ## Extended by @@ -42,7 +42,7 @@ Defined in: [packages/db/src/types.ts:385](https://github.com/TanStack/db/blob/m optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L434) +Defined in: [packages/db/src/types.ts:465](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L465) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -66,7 +66,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) +Defined in: [packages/db/src/types.ts:476](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L476) Optional function to compare two items. This is used to order the items in the collection. @@ -100,13 +100,28 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() *** +### defaultStringCollation? + +```ts +optional defaultStringCollation: StringCollationConfig; +``` + +Defined in: [packages/db/src/types.ts:622](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L622) + +Specifies how to compare data in the collection. +This should be configured to match data ordering on the backend. +E.g., when using the Electric DB collection these options + should match the database's collation settings. + +*** + ### gcTime? ```ts optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L414) +Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -119,7 +134,7 @@ when it has no active subscribers. Defaults to 5 minutes (300000ms). getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L409) +Defined in: [packages/db/src/types.ts:440](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L440) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -153,7 +168,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L398) +Defined in: [packages/db/src/types.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L429) *** @@ -163,7 +178,7 @@ Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/m optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L583) +Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) Optional asynchronous handler function called before a delete operation @@ -227,7 +242,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L496) +Defined in: [packages/db/src/types.ts:527](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L527) Optional asynchronous handler function called before an insert operation @@ -290,7 +305,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L540) +Defined in: [packages/db/src/types.ts:571](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L571) Optional asynchronous handler function called before an update operation @@ -354,7 +369,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L399) +Defined in: [packages/db/src/types.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L430) *** @@ -364,7 +379,7 @@ Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L425) +Defined in: [packages/db/src/types.ts:456](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L456) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -387,7 +402,7 @@ false optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L454) +Defined in: [packages/db/src/types.ts:485](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L485) The mode of sync to use for the collection. @@ -409,4 +424,4 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: TUtils; ``` -Defined in: [packages/db/src/types.ts:585](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L585) +Defined in: [packages/db/src/types.ts:624](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L624) diff --git a/docs/reference/interfaces/ChangeMessage.md b/docs/reference/interfaces/ChangeMessage.md index 81c59300d..91c256af8 100644 --- a/docs/reference/interfaces/ChangeMessage.md +++ b/docs/reference/interfaces/ChangeMessage.md @@ -5,7 +5,7 @@ title: ChangeMessage # Interface: ChangeMessage\ -Defined in: [packages/db/src/types.ts:261](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L261) +Defined in: [packages/db/src/types.ts:292](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L292) ## Extended by @@ -29,7 +29,7 @@ Defined in: [packages/db/src/types.ts:261](https://github.com/TanStack/db/blob/m key: TKey; ``` -Defined in: [packages/db/src/types.ts:265](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L265) +Defined in: [packages/db/src/types.ts:296](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L296) *** @@ -39,7 +39,7 @@ Defined in: [packages/db/src/types.ts:265](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:269](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L269) +Defined in: [packages/db/src/types.ts:300](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L300) *** @@ -49,7 +49,7 @@ Defined in: [packages/db/src/types.ts:269](https://github.com/TanStack/db/blob/m optional previousValue: T; ``` -Defined in: [packages/db/src/types.ts:267](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L267) +Defined in: [packages/db/src/types.ts:298](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L298) *** @@ -59,7 +59,7 @@ Defined in: [packages/db/src/types.ts:267](https://github.com/TanStack/db/blob/m type: OperationType; ``` -Defined in: [packages/db/src/types.ts:268](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L268) +Defined in: [packages/db/src/types.ts:299](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L299) *** @@ -69,4 +69,4 @@ Defined in: [packages/db/src/types.ts:268](https://github.com/TanStack/db/blob/m value: T; ``` -Defined in: [packages/db/src/types.ts:266](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L266) +Defined in: [packages/db/src/types.ts:297](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L297) diff --git a/docs/reference/interfaces/Collection.md b/docs/reference/interfaces/Collection.md index c2badec06..bd28ab080 100644 --- a/docs/reference/interfaces/Collection.md +++ b/docs/reference/interfaces/Collection.md @@ -5,7 +5,7 @@ title: Collection # Interface: Collection\ -Defined in: [packages/db/src/collection/index.ts:47](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L47) +Defined in: [packages/db/src/collection/index.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L48) Enhanced Collection interface that includes both data type T and utilities TUtils @@ -51,7 +51,7 @@ The type for insert operations (can be different from T for schemas with default _lifecycle: CollectionLifecycleManager; ``` -Defined in: [packages/db/src/collection/index.ts:219](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L219) +Defined in: [packages/db/src/collection/index.ts:220](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L220) #### Inherited from @@ -65,7 +65,7 @@ Defined in: [packages/db/src/collection/index.ts:219](https://github.com/TanStac _state: CollectionStateManager; ``` -Defined in: [packages/db/src/collection/index.ts:231](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L231) +Defined in: [packages/db/src/collection/index.ts:232](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L232) #### Inherited from @@ -79,7 +79,7 @@ Defined in: [packages/db/src/collection/index.ts:231](https://github.com/TanStac _sync: CollectionSyncManager; ``` -Defined in: [packages/db/src/collection/index.ts:220](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L220) +Defined in: [packages/db/src/collection/index.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L221) #### Inherited from @@ -93,7 +93,7 @@ Defined in: [packages/db/src/collection/index.ts:220](https://github.com/TanStac config: CollectionConfig; ``` -Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L210) +Defined in: [packages/db/src/collection/index.ts:211](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L211) #### Inherited from @@ -107,7 +107,7 @@ Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStac id: string; ``` -Defined in: [packages/db/src/collection/index.ts:209](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L209) +Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L210) #### Inherited from @@ -121,7 +121,7 @@ Defined in: [packages/db/src/collection/index.ts:209](https://github.com/TanStac readonly optional singleResult: true; ``` -Defined in: [packages/db/src/collection/index.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L55) +Defined in: [packages/db/src/collection/index.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L56) *** @@ -131,7 +131,7 @@ Defined in: [packages/db/src/collection/index.ts:55](https://github.com/TanStack readonly utils: TUtils; ``` -Defined in: [packages/db/src/collection/index.ts:54](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L54) +Defined in: [packages/db/src/collection/index.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L55) #### Overrides @@ -139,6 +139,26 @@ Defined in: [packages/db/src/collection/index.ts:54](https://github.com/TanStack ## Accessors +### compareOptions + +#### Get Signature + +```ts +get compareOptions(): StringCollationConfig; +``` + +Defined in: [packages/db/src/collection/index.ts:516](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L516) + +##### Returns + +[`StringCollationConfig`](../../type-aliases/StringCollationConfig.md) + +#### Inherited from + +[`CollectionImpl`](../../classes/CollectionImpl.md).[`compareOptions`](../../classes/CollectionImpl.md#compareoptions) + +*** + ### indexes #### Get Signature @@ -147,7 +167,7 @@ Defined in: [packages/db/src/collection/index.ts:54](https://github.com/TanStack get indexes(): Map>; ``` -Defined in: [packages/db/src/collection/index.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L496) +Defined in: [packages/db/src/collection/index.ts:501](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L501) Get resolved indexes for query optimization @@ -169,7 +189,7 @@ Get resolved indexes for query optimization get isLoadingSubset(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:362](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L362) +Defined in: [packages/db/src/collection/index.ts:367](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L367) Check if the collection is currently loading more data @@ -193,7 +213,7 @@ true if the collection has pending load more operations, false otherwise get size(): number; ``` -Defined in: [packages/db/src/collection/index.ts:399](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L399) +Defined in: [packages/db/src/collection/index.ts:404](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L404) Get the current size of the collection (cached) @@ -215,7 +235,7 @@ Get the current size of the collection (cached) get state(): Map; ``` -Defined in: [packages/db/src/collection/index.ts:683](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L683) +Defined in: [packages/db/src/collection/index.ts:693](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L693) Gets the current state of the collection as a Map @@ -255,7 +275,7 @@ Map containing all items in the collection, with keys as identifiers get status(): CollectionStatus; ``` -Defined in: [packages/db/src/collection/index.ts:317](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L317) +Defined in: [packages/db/src/collection/index.ts:322](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L322) Gets the current status of the collection @@ -277,7 +297,7 @@ Gets the current status of the collection get subscriberCount(): number; ``` -Defined in: [packages/db/src/collection/index.ts:324](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L324) +Defined in: [packages/db/src/collection/index.ts:329](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L329) Get the number of subscribers to the collection @@ -299,7 +319,7 @@ Get the number of subscribers to the collection get toArray(): TOutput[]; ``` -Defined in: [packages/db/src/collection/index.ts:712](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L712) +Defined in: [packages/db/src/collection/index.ts:722](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L722) Gets the current state of the collection as an Array @@ -321,7 +341,7 @@ An Array containing all items in the collection iterator: IterableIterator<[TKey, T]>; ``` -Defined in: [packages/db/src/collection/index.ts:427](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L427) +Defined in: [packages/db/src/collection/index.ts:432](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L432) Get all entries (virtual derived state) @@ -341,7 +361,7 @@ Get all entries (virtual derived state) cleanup(): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:846](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L846) +Defined in: [packages/db/src/collection/index.ts:856](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L856) Clean up the collection by stopping sync and clearing data This can be called manually or automatically by garbage collection @@ -362,7 +382,7 @@ This can be called manually or automatically by garbage collection createIndex(indexCallback, config): IndexProxy; ``` -Defined in: [packages/db/src/collection/index.ts:486](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L486) +Defined in: [packages/db/src/collection/index.ts:491](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L491) Creates an index on a collection for faster queries. Indexes significantly improve query performance by allowing constant time lookups @@ -436,7 +456,7 @@ currentStateAsChanges(options): | ChangeMessage[]; ``` -Defined in: [packages/db/src/collection/index.ts:750](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L750) +Defined in: [packages/db/src/collection/index.ts:760](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L760) Returns the current state of the collection as an array of changes @@ -484,7 +504,7 @@ const activeChanges = collection.currentStateAsChanges({ delete(keys, config?): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:660](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L660) +Defined in: [packages/db/src/collection/index.ts:670](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L670) Deletes one or more items from the collection @@ -551,7 +571,7 @@ try { entries(): IterableIterator<[TKey, T]>; ``` -Defined in: [packages/db/src/collection/index.ts:420](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L420) +Defined in: [packages/db/src/collection/index.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L425) Get all entries (virtual derived state) @@ -571,7 +591,7 @@ Get all entries (virtual derived state) forEach(callbackfn): void; ``` -Defined in: [packages/db/src/collection/index.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L434) +Defined in: [packages/db/src/collection/index.ts:439](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L439) Execute a callback for each entry in the collection @@ -597,7 +617,7 @@ Execute a callback for each entry in the collection get(key): T | undefined; ``` -Defined in: [packages/db/src/collection/index.ts:385](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L385) +Defined in: [packages/db/src/collection/index.ts:390](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L390) Get the current value for a key (virtual derived state) @@ -623,7 +643,7 @@ Get the current value for a key (virtual derived state) getKeyFromItem(item): TKey; ``` -Defined in: [packages/db/src/collection/index.ts:449](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L449) +Defined in: [packages/db/src/collection/index.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L454) #### Parameters @@ -647,7 +667,7 @@ Defined in: [packages/db/src/collection/index.ts:449](https://github.com/TanStac has(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:392](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L392) +Defined in: [packages/db/src/collection/index.ts:397](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L397) Check if a key exists in the collection (virtual derived state) @@ -675,7 +695,7 @@ insert(data, config?): | Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:547](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L547) +Defined in: [packages/db/src/collection/index.ts:557](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L557) Inserts one or more items into the collection @@ -750,7 +770,7 @@ try { isReady(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:354](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L354) +Defined in: [packages/db/src/collection/index.ts:359](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L359) Check if the collection is ready for use Returns true if the collection has been marked as ready by its sync implementation @@ -784,7 +804,7 @@ if (collection.isReady()) { keys(): IterableIterator; ``` -Defined in: [packages/db/src/collection/index.ts:406](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L406) +Defined in: [packages/db/src/collection/index.ts:411](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L411) Get all keys (virtual derived state) @@ -804,7 +824,7 @@ Get all keys (virtual derived state) map(callbackfn): U[]; ``` -Defined in: [packages/db/src/collection/index.ts:443](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L443) +Defined in: [packages/db/src/collection/index.ts:448](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L448) Create a new array with the results of calling a function for each entry in the collection @@ -836,7 +856,7 @@ Create a new array with the results of calling a function for each entry in the off(event, callback): void; ``` -Defined in: [packages/db/src/collection/index.ts:825](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L825) +Defined in: [packages/db/src/collection/index.ts:835](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L835) Unsubscribe from a collection event @@ -880,7 +900,7 @@ Unsubscribe from a collection event on(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:805](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L805) +Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) Subscribe to a collection event @@ -930,7 +950,7 @@ Subscribe to a collection event once(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) +Defined in: [packages/db/src/collection/index.ts:825](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L825) Subscribe to a collection event once @@ -980,7 +1000,7 @@ Subscribe to a collection event once onFirstReady(callback): void; ``` -Defined in: [packages/db/src/collection/index.ts:338](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L338) +Defined in: [packages/db/src/collection/index.ts:343](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L343) Register a callback to be executed when the collection first becomes ready Useful for preloading collections @@ -1018,7 +1038,7 @@ collection.onFirstReady(() => { preload(): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:378](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L378) +Defined in: [packages/db/src/collection/index.ts:383](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L383) Preload the collection data by starting sync if not already started Multiple concurrent calls will share the same promise @@ -1039,7 +1059,7 @@ Multiple concurrent calls will share the same promise startSyncImmediate(): void; ``` -Defined in: [packages/db/src/collection/index.ts:370](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L370) +Defined in: [packages/db/src/collection/index.ts:375](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L375) Start sync immediately - internal method for compiled queries This bypasses lazy loading for special cases like live query results @@ -1060,7 +1080,7 @@ This bypasses lazy loading for special cases like live query results stateWhenReady(): Promise>; ``` -Defined in: [packages/db/src/collection/index.ts:697](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L697) +Defined in: [packages/db/src/collection/index.ts:707](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L707) Gets the current state of the collection as a Map, but only resolves when data is available Waits for the first sync commit to complete before resolving @@ -1083,7 +1103,7 @@ Promise that resolves to a Map containing all items in the collection subscribeChanges(callback, options): CollectionSubscription; ``` -Defined in: [packages/db/src/collection/index.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L795) +Defined in: [packages/db/src/collection/index.ts:805](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L805) Subscribe to changes in the collection @@ -1159,7 +1179,7 @@ const subscription = collection.subscribeChanges((changes) => { toArrayWhenReady(): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:722](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L722) +Defined in: [packages/db/src/collection/index.ts:732](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L732) Gets the current state of the collection as an Array, but only resolves when data is available Waits for the first sync commit to complete before resolving @@ -1184,7 +1204,7 @@ Promise that resolves to an Array containing all items in the collection update(key, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:592](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L592) +Defined in: [packages/db/src/collection/index.ts:602](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L602) Updates one or more items in the collection using a callback function @@ -1259,7 +1279,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:598](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L598) +Defined in: [packages/db/src/collection/index.ts:608](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L608) Updates one or more items in the collection using a callback function @@ -1337,7 +1357,7 @@ try { update(id, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L605) +Defined in: [packages/db/src/collection/index.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L615) Updates one or more items in the collection using a callback function @@ -1412,7 +1432,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:611](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L611) +Defined in: [packages/db/src/collection/index.ts:621](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L621) Updates one or more items in the collection using a callback function @@ -1493,7 +1513,7 @@ validateData( key?): T; ``` -Defined in: [packages/db/src/collection/index.ts:503](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L503) +Defined in: [packages/db/src/collection/index.ts:508](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L508) Validates the data against the schema @@ -1527,7 +1547,7 @@ Validates the data against the schema values(): IterableIterator; ``` -Defined in: [packages/db/src/collection/index.ts:413](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L413) +Defined in: [packages/db/src/collection/index.ts:418](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L418) Get all values (virtual derived state) @@ -1547,7 +1567,7 @@ Get all values (virtual derived state) waitFor(event, timeout?): Promise; ``` -Defined in: [packages/db/src/collection/index.ts:835](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L835) +Defined in: [packages/db/src/collection/index.ts:845](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L845) Wait for a collection event diff --git a/docs/reference/interfaces/CollectionConfig.md b/docs/reference/interfaces/CollectionConfig.md index 78702c4d3..f08dbf1d1 100644 --- a/docs/reference/interfaces/CollectionConfig.md +++ b/docs/reference/interfaces/CollectionConfig.md @@ -5,7 +5,7 @@ title: CollectionConfig # Interface: CollectionConfig\ -Defined in: [packages/db/src/types.ts:588](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L588) +Defined in: [packages/db/src/types.ts:627](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L627) ## Extends @@ -37,7 +37,7 @@ Defined in: [packages/db/src/types.ts:588](https://github.com/TanStack/db/blob/m optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L434) +Defined in: [packages/db/src/types.ts:465](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L465) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -65,7 +65,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) +Defined in: [packages/db/src/types.ts:476](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L476) Optional function to compare two items. This is used to order the items in the collection. @@ -103,13 +103,32 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() *** +### defaultStringCollation? + +```ts +optional defaultStringCollation: StringCollationConfig; +``` + +Defined in: [packages/db/src/types.ts:622](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L622) + +Specifies how to compare data in the collection. +This should be configured to match data ordering on the backend. +E.g., when using the Electric DB collection these options + should match the database's collation settings. + +#### Inherited from + +[`BaseCollectionConfig`](../BaseCollectionConfig.md).[`defaultStringCollation`](../BaseCollectionConfig.md#defaultstringcollation) + +*** + ### gcTime? ```ts optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L414) +Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -126,7 +145,7 @@ when it has no active subscribers. Defaults to 5 minutes (300000ms). getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L409) +Defined in: [packages/db/src/types.ts:440](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L440) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -164,7 +183,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L398) +Defined in: [packages/db/src/types.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L429) #### Inherited from @@ -178,7 +197,7 @@ Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/m optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L583) +Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) Optional asynchronous handler function called before a delete operation @@ -246,7 +265,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L496) +Defined in: [packages/db/src/types.ts:527](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L527) Optional asynchronous handler function called before an insert operation @@ -313,7 +332,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L540) +Defined in: [packages/db/src/types.ts:571](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L571) Optional asynchronous handler function called before an update operation @@ -381,7 +400,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L399) +Defined in: [packages/db/src/types.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L430) #### Inherited from @@ -395,7 +414,7 @@ Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L425) +Defined in: [packages/db/src/types.ts:456](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L456) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -422,7 +441,7 @@ false sync: SyncConfig; ``` -Defined in: [packages/db/src/types.ts:594](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L594) +Defined in: [packages/db/src/types.ts:633](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L633) *** @@ -432,7 +451,7 @@ Defined in: [packages/db/src/types.ts:594](https://github.com/TanStack/db/blob/m optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L454) +Defined in: [packages/db/src/types.ts:485](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L485) The mode of sync to use for the collection. @@ -458,7 +477,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: TUtils; ``` -Defined in: [packages/db/src/types.ts:585](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L585) +Defined in: [packages/db/src/types.ts:624](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L624) #### Inherited from diff --git a/docs/reference/interfaces/CollectionLike.md b/docs/reference/interfaces/CollectionLike.md new file mode 100644 index 000000000..fd9f7575b --- /dev/null +++ b/docs/reference/interfaces/CollectionLike.md @@ -0,0 +1,147 @@ +--- +id: CollectionLike +title: CollectionLike +--- + +# Interface: CollectionLike\ + +Defined in: [packages/db/src/types.ts:12](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L12) + +Interface for a collection-like object that provides the necessary methods +for the change events system to work + +## Extends + +- `Pick`\<[`Collection`](../Collection.md)\<`T`, `TKey`\>, `"get"` \| `"has"` \| `"entries"` \| `"indexes"` \| `"id"` \| `"compareOptions"`\> + +## Type Parameters + +### T + +`T` *extends* `object` = `Record`\<`string`, `unknown`\> + +### TKey + +`TKey` *extends* `string` \| `number` = `string` \| `number` + +## Properties + +### compareOptions + +```ts +compareOptions: StringCollationConfig; +``` + +Defined in: [packages/db/src/collection/index.ts:516](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L516) + +#### Inherited from + +[`CollectionImpl`](../../classes/CollectionImpl.md).[`compareOptions`](../../classes/CollectionImpl.md#compareoptions) + +*** + +### id + +```ts +id: string; +``` + +Defined in: [packages/db/src/collection/index.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L210) + +#### Inherited from + +[`CollectionImpl`](../../classes/CollectionImpl.md).[`id`](../../classes/CollectionImpl.md#id) + +*** + +### indexes + +```ts +indexes: Map>; +``` + +Defined in: [packages/db/src/collection/index.ts:501](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L501) + +#### Inherited from + +```ts +Pick.indexes +``` + +## Methods + +### entries() + +```ts +entries(): IterableIterator<[TKey, T]>; +``` + +Defined in: [packages/db/src/collection/index.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L425) + +Get all entries (virtual derived state) + +#### Returns + +`IterableIterator`\<\[`TKey`, `T`\]\> + +#### Inherited from + +```ts +Pick.entries +``` + +*** + +### get() + +```ts +get(key): T | undefined; +``` + +Defined in: [packages/db/src/collection/index.ts:390](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L390) + +Get the current value for a key (virtual derived state) + +#### Parameters + +##### key + +`TKey` + +#### Returns + +`T` \| `undefined` + +#### Inherited from + +```ts +Pick.get +``` + +*** + +### has() + +```ts +has(key): boolean; +``` + +Defined in: [packages/db/src/collection/index.ts:397](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L397) + +Check if a key exists in the collection (virtual derived state) + +#### Parameters + +##### key + +`TKey` + +#### Returns + +`boolean` + +#### Inherited from + +```ts +Pick.has +``` diff --git a/docs/reference/interfaces/CreateOptimisticActionsOptions.md b/docs/reference/interfaces/CreateOptimisticActionsOptions.md index 48ae64848..a7256f738 100644 --- a/docs/reference/interfaces/CreateOptimisticActionsOptions.md +++ b/docs/reference/interfaces/CreateOptimisticActionsOptions.md @@ -5,7 +5,7 @@ title: CreateOptimisticActionsOptions # Interface: CreateOptimisticActionsOptions\ -Defined in: [packages/db/src/types.ts:128](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L128) +Defined in: [packages/db/src/types.ts:159](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L159) Options for the createOptimisticAction helper @@ -31,7 +31,7 @@ Options for the createOptimisticAction helper optional autoCommit: boolean; ``` -Defined in: [packages/db/src/types.ts:119](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L119) +Defined in: [packages/db/src/types.ts:150](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L150) #### Inherited from @@ -45,7 +45,7 @@ Defined in: [packages/db/src/types.ts:119](https://github.com/TanStack/db/blob/m optional id: string; ``` -Defined in: [packages/db/src/types.ts:117](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L117) +Defined in: [packages/db/src/types.ts:148](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L148) Unique identifier for the transaction @@ -63,7 +63,7 @@ Omit.id optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L122) +Defined in: [packages/db/src/types.ts:153](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L153) Custom metadata to associate with the transaction @@ -81,7 +81,7 @@ Omit.metadata mutationFn: (vars, params) => Promise; ``` -Defined in: [packages/db/src/types.ts:135](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L135) +Defined in: [packages/db/src/types.ts:166](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L166) Function to execute the mutation on the server @@ -107,7 +107,7 @@ Function to execute the mutation on the server onMutate: (vars) => void; ``` -Defined in: [packages/db/src/types.ts:133](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L133) +Defined in: [packages/db/src/types.ts:164](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L164) Function to apply optimistic updates locally before the mutation completes diff --git a/docs/reference/interfaces/CurrentStateAsChangesOptions.md b/docs/reference/interfaces/CurrentStateAsChangesOptions.md index d54d5107b..f7526d9bb 100644 --- a/docs/reference/interfaces/CurrentStateAsChangesOptions.md +++ b/docs/reference/interfaces/CurrentStateAsChangesOptions.md @@ -5,7 +5,7 @@ title: CurrentStateAsChangesOptions # Interface: CurrentStateAsChangesOptions -Defined in: [packages/db/src/types.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L678) +Defined in: [packages/db/src/types.ts:717](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L717) Options for getting current state as changes @@ -17,7 +17,7 @@ Options for getting current state as changes optional limit: number; ``` -Defined in: [packages/db/src/types.ts:682](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L682) +Defined in: [packages/db/src/types.ts:721](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L721) *** @@ -27,7 +27,7 @@ Defined in: [packages/db/src/types.ts:682](https://github.com/TanStack/db/blob/m optional optimizedOnly: boolean; ``` -Defined in: [packages/db/src/types.ts:683](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L683) +Defined in: [packages/db/src/types.ts:722](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L722) *** @@ -37,7 +37,7 @@ Defined in: [packages/db/src/types.ts:683](https://github.com/TanStack/db/blob/m optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:681](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L681) +Defined in: [packages/db/src/types.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L720) *** @@ -47,6 +47,6 @@ Defined in: [packages/db/src/types.ts:681](https://github.com/TanStack/db/blob/m optional where: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:680](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L680) +Defined in: [packages/db/src/types.ts:719](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L719) Pre-compiled expression for filtering the current state diff --git a/docs/reference/interfaces/InsertConfig.md b/docs/reference/interfaces/InsertConfig.md index 79b7fbda3..e28d179e9 100644 --- a/docs/reference/interfaces/InsertConfig.md +++ b/docs/reference/interfaces/InsertConfig.md @@ -5,7 +5,7 @@ title: InsertConfig # Interface: InsertConfig -Defined in: [packages/db/src/types.ts:303](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L303) +Defined in: [packages/db/src/types.ts:334](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L334) ## Properties @@ -15,7 +15,7 @@ Defined in: [packages/db/src/types.ts:303](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:304](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L304) +Defined in: [packages/db/src/types.ts:335](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L335) *** @@ -25,6 +25,6 @@ Defined in: [packages/db/src/types.ts:304](https://github.com/TanStack/db/blob/m optional optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:306](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L306) +Defined in: [packages/db/src/types.ts:337](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L337) Whether to apply optimistic updates immediately. Defaults to true. diff --git a/docs/reference/interfaces/LiveQueryCollectionConfig.md b/docs/reference/interfaces/LiveQueryCollectionConfig.md index ce7da1c8e..1c3a8ed77 100644 --- a/docs/reference/interfaces/LiveQueryCollectionConfig.md +++ b/docs/reference/interfaces/LiveQueryCollectionConfig.md @@ -5,7 +5,7 @@ title: LiveQueryCollectionConfig # Interface: LiveQueryCollectionConfig\ -Defined in: [packages/db/src/query/live/types.ts:49](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L49) +Defined in: [packages/db/src/query/live/types.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L53) Configuration interface for live query collection options @@ -43,13 +43,26 @@ const config: LiveQueryCollectionConfig = { ## Properties +### defaultStringCollation? + +```ts +optional defaultStringCollation: StringCollationConfig; +``` + +Defined in: [packages/db/src/query/live/types.ts:107](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L107) + +Optional compare options for string sorting. +If provided, these will be used instead of inheriting from the FROM collection. + +*** + ### gcTime? ```ts optional gcTime: number; ``` -Defined in: [packages/db/src/query/live/types.ts:92](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L92) +Defined in: [packages/db/src/query/live/types.ts:96](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L96) GC time for the collection @@ -61,7 +74,7 @@ GC time for the collection optional getKey: (item) => string | number; ``` -Defined in: [packages/db/src/query/live/types.ts:70](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L70) +Defined in: [packages/db/src/query/live/types.ts:74](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L74) Function to extract the key from result items If not provided, defaults to using the key from the D2 stream @@ -84,7 +97,7 @@ If not provided, defaults to using the key from the D2 stream optional id: string; ``` -Defined in: [packages/db/src/query/live/types.ts:57](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L57) +Defined in: [packages/db/src/query/live/types.ts:61](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L61) Unique identifier for the collection If not provided, defaults to `live-query-${number}` with auto-incrementing number @@ -97,7 +110,7 @@ If not provided, defaults to `live-query-${number}` with auto-incrementing numbe optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/query/live/types.ts:82](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L82) +Defined in: [packages/db/src/query/live/types.ts:86](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L86) *** @@ -107,7 +120,7 @@ Defined in: [packages/db/src/query/live/types.ts:82](https://github.com/TanStack optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/query/live/types.ts:80](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L80) +Defined in: [packages/db/src/query/live/types.ts:84](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L84) Optional mutation handlers @@ -119,7 +132,7 @@ Optional mutation handlers optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/query/live/types.ts:81](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L81) +Defined in: [packages/db/src/query/live/types.ts:85](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L85) *** @@ -131,7 +144,7 @@ query: | (q) => QueryBuilder; ``` -Defined in: [packages/db/src/query/live/types.ts:62](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L62) +Defined in: [packages/db/src/query/live/types.ts:66](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L66) Query builder function that defines the live query @@ -143,7 +156,7 @@ Query builder function that defines the live query optional schema: undefined; ``` -Defined in: [packages/db/src/query/live/types.ts:75](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L75) +Defined in: [packages/db/src/query/live/types.ts:79](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L79) Optional schema for validation @@ -155,7 +168,7 @@ Optional schema for validation optional singleResult: true; ``` -Defined in: [packages/db/src/query/live/types.ts:97](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L97) +Defined in: [packages/db/src/query/live/types.ts:101](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L101) If enabled the collection will return a single object instead of an array @@ -167,6 +180,6 @@ If enabled the collection will return a single object instead of an array optional startSync: boolean; ``` -Defined in: [packages/db/src/query/live/types.ts:87](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L87) +Defined in: [packages/db/src/query/live/types.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/types.ts#L91) Start sync / the query immediately diff --git a/docs/reference/interfaces/LocalOnlyCollectionConfig.md b/docs/reference/interfaces/LocalOnlyCollectionConfig.md index d34d42f52..bb212c602 100644 --- a/docs/reference/interfaces/LocalOnlyCollectionConfig.md +++ b/docs/reference/interfaces/LocalOnlyCollectionConfig.md @@ -41,7 +41,7 @@ The type of the key returned by `getKey` optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L434) +Defined in: [packages/db/src/types.ts:465](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L465) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -69,7 +69,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) +Defined in: [packages/db/src/types.ts:476](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L476) Optional function to compare two items. This is used to order the items in the collection. @@ -109,13 +109,34 @@ Omit.compare *** +### defaultStringCollation? + +```ts +optional defaultStringCollation: StringCollationConfig; +``` + +Defined in: [packages/db/src/types.ts:622](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L622) + +Specifies how to compare data in the collection. +This should be configured to match data ordering on the backend. +E.g., when using the Electric DB collection these options + should match the database's collation settings. + +#### Inherited from + +```ts +Omit.defaultStringCollation +``` + +*** + ### getKey() ```ts getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L409) +Defined in: [packages/db/src/types.ts:440](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L440) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -155,7 +176,7 @@ Omit.getKey optional id: string; ``` -Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L398) +Defined in: [packages/db/src/types.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L429) #### Inherited from @@ -182,7 +203,7 @@ This data will be applied during the initial sync process optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L583) +Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) Optional asynchronous handler function called before a delete operation @@ -252,7 +273,7 @@ Omit.onDelete optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L496) +Defined in: [packages/db/src/types.ts:527](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L527) Optional asynchronous handler function called before an insert operation @@ -321,7 +342,7 @@ Omit.onInsert optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L540) +Defined in: [packages/db/src/types.ts:571](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L571) Optional asynchronous handler function called before an update operation @@ -391,7 +412,7 @@ Omit.onUpdate optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L399) +Defined in: [packages/db/src/types.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L430) #### Inherited from @@ -407,7 +428,7 @@ Omit.schema optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L454) +Defined in: [packages/db/src/types.ts:485](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L485) The mode of sync to use for the collection. @@ -433,7 +454,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: LocalOnlyCollectionUtils; ``` -Defined in: [packages/db/src/types.ts:585](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L585) +Defined in: [packages/db/src/types.ts:624](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L624) #### Inherited from diff --git a/docs/reference/interfaces/LocalStorageCollectionConfig.md b/docs/reference/interfaces/LocalStorageCollectionConfig.md index 52909c7f4..adfecd035 100644 --- a/docs/reference/interfaces/LocalStorageCollectionConfig.md +++ b/docs/reference/interfaces/LocalStorageCollectionConfig.md @@ -41,7 +41,7 @@ The type of the key returned by `getKey` optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L434) +Defined in: [packages/db/src/types.ts:465](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L465) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -69,7 +69,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) +Defined in: [packages/db/src/types.ts:476](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L476) Optional function to compare two items. This is used to order the items in the collection. @@ -107,13 +107,32 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() *** +### defaultStringCollation? + +```ts +optional defaultStringCollation: StringCollationConfig; +``` + +Defined in: [packages/db/src/types.ts:622](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L622) + +Specifies how to compare data in the collection. +This should be configured to match data ordering on the backend. +E.g., when using the Electric DB collection these options + should match the database's collation settings. + +#### Inherited from + +[`BaseCollectionConfig`](../BaseCollectionConfig.md).[`defaultStringCollation`](../BaseCollectionConfig.md#defaultstringcollation) + +*** + ### gcTime? ```ts optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L414) +Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L445) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -130,7 +149,7 @@ when it has no active subscribers. Defaults to 5 minutes (300000ms). getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L409) +Defined in: [packages/db/src/types.ts:440](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L440) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -168,7 +187,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L398) +Defined in: [packages/db/src/types.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L429) #### Inherited from @@ -182,7 +201,7 @@ Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/m optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L583) +Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) Optional asynchronous handler function called before a delete operation @@ -250,7 +269,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L496) +Defined in: [packages/db/src/types.ts:527](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L527) Optional asynchronous handler function called before an insert operation @@ -317,7 +336,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L540) +Defined in: [packages/db/src/types.ts:571](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L571) Optional asynchronous handler function called before an update operation @@ -398,7 +417,7 @@ Defaults to JSON optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L399) +Defined in: [packages/db/src/types.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L430) #### Inherited from @@ -412,7 +431,7 @@ Defined in: [packages/db/src/types.ts:399](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L425) +Defined in: [packages/db/src/types.ts:456](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L456) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -477,7 +496,7 @@ The key to use for storing the collection data in localStorage/sessionStorage optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L454) +Defined in: [packages/db/src/types.ts:485](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L485) The mode of sync to use for the collection. @@ -503,7 +522,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: UtilsRecord; ``` -Defined in: [packages/db/src/types.ts:585](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L585) +Defined in: [packages/db/src/types.ts:624](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L624) #### Inherited from diff --git a/docs/reference/interfaces/OperationConfig.md b/docs/reference/interfaces/OperationConfig.md index e5c2774da..b75a2e0ed 100644 --- a/docs/reference/interfaces/OperationConfig.md +++ b/docs/reference/interfaces/OperationConfig.md @@ -5,7 +5,7 @@ title: OperationConfig # Interface: OperationConfig -Defined in: [packages/db/src/types.ts:297](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L297) +Defined in: [packages/db/src/types.ts:328](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L328) ## Properties @@ -15,7 +15,7 @@ Defined in: [packages/db/src/types.ts:297](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:298](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L298) +Defined in: [packages/db/src/types.ts:329](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L329) *** @@ -25,6 +25,6 @@ Defined in: [packages/db/src/types.ts:298](https://github.com/TanStack/db/blob/m optional optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:300](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L300) +Defined in: [packages/db/src/types.ts:331](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L331) Whether to apply optimistic updates immediately. Defaults to true. diff --git a/docs/reference/interfaces/OptimisticChangeMessage.md b/docs/reference/interfaces/OptimisticChangeMessage.md index f0a365281..e1acaeebc 100644 --- a/docs/reference/interfaces/OptimisticChangeMessage.md +++ b/docs/reference/interfaces/OptimisticChangeMessage.md @@ -5,7 +5,7 @@ title: OptimisticChangeMessage # Interface: OptimisticChangeMessage\ -Defined in: [packages/db/src/types.ts:272](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L272) +Defined in: [packages/db/src/types.ts:303](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L303) ## Extends @@ -25,7 +25,7 @@ Defined in: [packages/db/src/types.ts:272](https://github.com/TanStack/db/blob/m optional isActive: boolean; ``` -Defined in: [packages/db/src/types.ts:276](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L276) +Defined in: [packages/db/src/types.ts:307](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L307) *** @@ -35,7 +35,7 @@ Defined in: [packages/db/src/types.ts:276](https://github.com/TanStack/db/blob/m key: string | number; ``` -Defined in: [packages/db/src/types.ts:265](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L265) +Defined in: [packages/db/src/types.ts:296](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L296) #### Inherited from @@ -49,7 +49,7 @@ Defined in: [packages/db/src/types.ts:265](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:269](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L269) +Defined in: [packages/db/src/types.ts:300](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L300) #### Inherited from @@ -63,7 +63,7 @@ Defined in: [packages/db/src/types.ts:269](https://github.com/TanStack/db/blob/m optional previousValue: T; ``` -Defined in: [packages/db/src/types.ts:267](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L267) +Defined in: [packages/db/src/types.ts:298](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L298) #### Inherited from @@ -77,7 +77,7 @@ Defined in: [packages/db/src/types.ts:267](https://github.com/TanStack/db/blob/m type: OperationType; ``` -Defined in: [packages/db/src/types.ts:268](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L268) +Defined in: [packages/db/src/types.ts:299](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L299) #### Inherited from @@ -91,7 +91,7 @@ Defined in: [packages/db/src/types.ts:268](https://github.com/TanStack/db/blob/m value: T; ``` -Defined in: [packages/db/src/types.ts:266](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L266) +Defined in: [packages/db/src/types.ts:297](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L297) #### Inherited from diff --git a/docs/reference/interfaces/PendingMutation.md b/docs/reference/interfaces/PendingMutation.md index 909f848f6..2a5264f25 100644 --- a/docs/reference/interfaces/PendingMutation.md +++ b/docs/reference/interfaces/PendingMutation.md @@ -5,7 +5,7 @@ title: PendingMutation # Interface: PendingMutation\ -Defined in: [packages/db/src/types.ts:57](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L57) +Defined in: [packages/db/src/types.ts:88](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L88) Represents a pending mutation within a transaction Contains information about the original and modified data, as well as metadata @@ -32,7 +32,7 @@ Contains information about the original and modified data, as well as metadata changes: ResolveTransactionChanges; ``` -Defined in: [packages/db/src/types.ts:74](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L74) +Defined in: [packages/db/src/types.ts:105](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L105) *** @@ -42,7 +42,7 @@ Defined in: [packages/db/src/types.ts:74](https://github.com/TanStack/db/blob/ma collection: TCollection; ``` -Defined in: [packages/db/src/types.ts:85](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L85) +Defined in: [packages/db/src/types.ts:116](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L116) *** @@ -52,7 +52,7 @@ Defined in: [packages/db/src/types.ts:85](https://github.com/TanStack/db/blob/ma createdAt: Date; ``` -Defined in: [packages/db/src/types.ts:83](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L83) +Defined in: [packages/db/src/types.ts:114](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L114) *** @@ -62,7 +62,7 @@ Defined in: [packages/db/src/types.ts:83](https://github.com/TanStack/db/blob/ma globalKey: string; ``` -Defined in: [packages/db/src/types.ts:75](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L75) +Defined in: [packages/db/src/types.ts:106](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L106) *** @@ -72,7 +72,7 @@ Defined in: [packages/db/src/types.ts:75](https://github.com/TanStack/db/blob/ma key: any; ``` -Defined in: [packages/db/src/types.ts:77](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L77) +Defined in: [packages/db/src/types.ts:108](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L108) *** @@ -82,7 +82,7 @@ Defined in: [packages/db/src/types.ts:77](https://github.com/TanStack/db/blob/ma metadata: unknown; ``` -Defined in: [packages/db/src/types.ts:79](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L79) +Defined in: [packages/db/src/types.ts:110](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L110) *** @@ -92,7 +92,7 @@ Defined in: [packages/db/src/types.ts:79](https://github.com/TanStack/db/blob/ma modified: T; ``` -Defined in: [packages/db/src/types.ts:72](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L72) +Defined in: [packages/db/src/types.ts:103](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L103) *** @@ -102,7 +102,7 @@ Defined in: [packages/db/src/types.ts:72](https://github.com/TanStack/db/blob/ma mutationId: string; ``` -Defined in: [packages/db/src/types.ts:68](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L68) +Defined in: [packages/db/src/types.ts:99](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L99) *** @@ -112,7 +112,7 @@ Defined in: [packages/db/src/types.ts:68](https://github.com/TanStack/db/blob/ma optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:82](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L82) +Defined in: [packages/db/src/types.ts:113](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L113) Whether this mutation should be applied optimistically (defaults to true) @@ -124,7 +124,7 @@ Whether this mutation should be applied optimistically (defaults to true) original: TOperation extends "insert" ? object : T; ``` -Defined in: [packages/db/src/types.ts:70](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L70) +Defined in: [packages/db/src/types.ts:101](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L101) *** @@ -134,7 +134,7 @@ Defined in: [packages/db/src/types.ts:70](https://github.com/TanStack/db/blob/ma syncMetadata: Record; ``` -Defined in: [packages/db/src/types.ts:80](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L80) +Defined in: [packages/db/src/types.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L111) *** @@ -144,7 +144,7 @@ Defined in: [packages/db/src/types.ts:80](https://github.com/TanStack/db/blob/ma type: TOperation; ``` -Defined in: [packages/db/src/types.ts:78](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L78) +Defined in: [packages/db/src/types.ts:109](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L109) *** @@ -154,4 +154,4 @@ Defined in: [packages/db/src/types.ts:78](https://github.com/TanStack/db/blob/ma updatedAt: Date; ``` -Defined in: [packages/db/src/types.ts:84](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L84) +Defined in: [packages/db/src/types.ts:115](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L115) diff --git a/docs/reference/interfaces/SubscribeChangesOptions.md b/docs/reference/interfaces/SubscribeChangesOptions.md index c453c9b5c..704e5308d 100644 --- a/docs/reference/interfaces/SubscribeChangesOptions.md +++ b/docs/reference/interfaces/SubscribeChangesOptions.md @@ -5,7 +5,7 @@ title: SubscribeChangesOptions # Interface: SubscribeChangesOptions -Defined in: [packages/db/src/types.ts:662](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L662) +Defined in: [packages/db/src/types.ts:701](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L701) Options for subscribing to collection changes @@ -17,7 +17,7 @@ Options for subscribing to collection changes optional includeInitialState: boolean; ``` -Defined in: [packages/db/src/types.ts:664](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L664) +Defined in: [packages/db/src/types.ts:703](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L703) Whether to include the current state as initial changes @@ -29,6 +29,6 @@ Whether to include the current state as initial changes optional whereExpression: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) +Defined in: [packages/db/src/types.ts:705](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L705) Pre-compiled expression for filtering changes diff --git a/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md b/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md index b0eb573f6..60d5cbefb 100644 --- a/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md +++ b/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md @@ -5,7 +5,7 @@ title: SubscribeChangesSnapshotOptions # Interface: SubscribeChangesSnapshotOptions -Defined in: [packages/db/src/types.ts:669](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L669) +Defined in: [packages/db/src/types.ts:708](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L708) ## Extends @@ -19,7 +19,7 @@ Defined in: [packages/db/src/types.ts:669](https://github.com/TanStack/db/blob/m optional limit: number; ``` -Defined in: [packages/db/src/types.ts:672](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L672) +Defined in: [packages/db/src/types.ts:711](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L711) *** @@ -29,7 +29,7 @@ Defined in: [packages/db/src/types.ts:672](https://github.com/TanStack/db/blob/m optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:671](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L671) +Defined in: [packages/db/src/types.ts:710](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L710) *** @@ -39,7 +39,7 @@ Defined in: [packages/db/src/types.ts:671](https://github.com/TanStack/db/blob/m optional whereExpression: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) +Defined in: [packages/db/src/types.ts:705](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L705) Pre-compiled expression for filtering changes diff --git a/docs/reference/interfaces/Subscription.md b/docs/reference/interfaces/Subscription.md index 5af5857aa..a021fd198 100644 --- a/docs/reference/interfaces/Subscription.md +++ b/docs/reference/interfaces/Subscription.md @@ -5,7 +5,7 @@ title: Subscription # Interface: Subscription -Defined in: [packages/db/src/types.ts:201](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L201) +Defined in: [packages/db/src/types.ts:232](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L232) Public interface for a collection subscription Used by sync implementations to track subscription lifecycle @@ -22,7 +22,7 @@ Used by sync implementations to track subscription lifecycle readonly status: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:203](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L203) +Defined in: [packages/db/src/types.ts:234](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L234) Current status of the subscription diff --git a/docs/reference/interfaces/SubscriptionStatusChangeEvent.md b/docs/reference/interfaces/SubscriptionStatusChangeEvent.md index ecdc7fa48..4422c2f61 100644 --- a/docs/reference/interfaces/SubscriptionStatusChangeEvent.md +++ b/docs/reference/interfaces/SubscriptionStatusChangeEvent.md @@ -5,7 +5,7 @@ title: SubscriptionStatusChangeEvent # Interface: SubscriptionStatusChangeEvent -Defined in: [packages/db/src/types.ts:162](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L162) +Defined in: [packages/db/src/types.ts:193](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L193) Event emitted when subscription status changes @@ -17,7 +17,7 @@ Event emitted when subscription status changes previousStatus: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:165](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L165) +Defined in: [packages/db/src/types.ts:196](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L196) *** @@ -27,7 +27,7 @@ Defined in: [packages/db/src/types.ts:165](https://github.com/TanStack/db/blob/m status: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:166](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L166) +Defined in: [packages/db/src/types.ts:197](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L197) *** @@ -37,7 +37,7 @@ Defined in: [packages/db/src/types.ts:166](https://github.com/TanStack/db/blob/m subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:164](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L164) +Defined in: [packages/db/src/types.ts:195](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L195) *** @@ -47,4 +47,4 @@ Defined in: [packages/db/src/types.ts:164](https://github.com/TanStack/db/blob/m type: "status:change"; ``` -Defined in: [packages/db/src/types.ts:163](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L163) +Defined in: [packages/db/src/types.ts:194](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L194) diff --git a/docs/reference/interfaces/SubscriptionStatusEvent.md b/docs/reference/interfaces/SubscriptionStatusEvent.md index e36ea1dab..a62f43aaa 100644 --- a/docs/reference/interfaces/SubscriptionStatusEvent.md +++ b/docs/reference/interfaces/SubscriptionStatusEvent.md @@ -5,7 +5,7 @@ title: SubscriptionStatusEvent # Interface: SubscriptionStatusEvent\ -Defined in: [packages/db/src/types.ts:172](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L172) +Defined in: [packages/db/src/types.ts:203](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L203) Event emitted when subscription status changes to a specific status @@ -23,7 +23,7 @@ Event emitted when subscription status changes to a specific status previousStatus: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:175](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L175) +Defined in: [packages/db/src/types.ts:206](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L206) *** @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:175](https://github.com/TanStack/db/blob/m status: T; ``` -Defined in: [packages/db/src/types.ts:176](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L176) +Defined in: [packages/db/src/types.ts:207](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L207) *** @@ -43,7 +43,7 @@ Defined in: [packages/db/src/types.ts:176](https://github.com/TanStack/db/blob/m subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:174](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L174) +Defined in: [packages/db/src/types.ts:205](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L205) *** @@ -53,4 +53,4 @@ Defined in: [packages/db/src/types.ts:174](https://github.com/TanStack/db/blob/m type: `status:${T}`; ``` -Defined in: [packages/db/src/types.ts:173](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L173) +Defined in: [packages/db/src/types.ts:204](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L204) diff --git a/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md b/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md index 12e3dae67..8d3ea205c 100644 --- a/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md +++ b/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md @@ -5,7 +5,7 @@ title: SubscriptionUnsubscribedEvent # Interface: SubscriptionUnsubscribedEvent -Defined in: [packages/db/src/types.ts:182](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L182) +Defined in: [packages/db/src/types.ts:213](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L213) Event emitted when subscription is unsubscribed @@ -17,7 +17,7 @@ Event emitted when subscription is unsubscribed subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:184](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L184) +Defined in: [packages/db/src/types.ts:215](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L215) *** @@ -27,4 +27,4 @@ Defined in: [packages/db/src/types.ts:184](https://github.com/TanStack/db/blob/m type: "unsubscribed"; ``` -Defined in: [packages/db/src/types.ts:183](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L183) +Defined in: [packages/db/src/types.ts:214](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L214) diff --git a/docs/reference/interfaces/SyncConfig.md b/docs/reference/interfaces/SyncConfig.md index 3db6db853..633b2e55f 100644 --- a/docs/reference/interfaces/SyncConfig.md +++ b/docs/reference/interfaces/SyncConfig.md @@ -5,7 +5,7 @@ title: SyncConfig # Interface: SyncConfig\ -Defined in: [packages/db/src/types.ts:232](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L232) +Defined in: [packages/db/src/types.ts:263](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L263) ## Type Parameters @@ -25,7 +25,7 @@ Defined in: [packages/db/src/types.ts:232](https://github.com/TanStack/db/blob/m optional getSyncMetadata: () => Record; ``` -Defined in: [packages/db/src/types.ts:249](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L249) +Defined in: [packages/db/src/types.ts:280](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L280) Get the sync metadata for insert operations @@ -43,7 +43,7 @@ Record containing relation information optional rowUpdateMode: "full" | "partial"; ``` -Defined in: [packages/db/src/types.ts:258](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L258) +Defined in: [packages/db/src/types.ts:289](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L289) The row update mode used to sync to the collection. @@ -67,7 +67,7 @@ sync: (params) => | SyncConfigRes; ``` -Defined in: [packages/db/src/types.ts:236](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L236) +Defined in: [packages/db/src/types.ts:267](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L267) #### Parameters diff --git a/docs/reference/interfaces/TransactionConfig.md b/docs/reference/interfaces/TransactionConfig.md index 062f78947..542212386 100644 --- a/docs/reference/interfaces/TransactionConfig.md +++ b/docs/reference/interfaces/TransactionConfig.md @@ -5,7 +5,7 @@ title: TransactionConfig # Interface: TransactionConfig\ -Defined in: [packages/db/src/types.ts:115](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L115) +Defined in: [packages/db/src/types.ts:146](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L146) ## Type Parameters @@ -21,7 +21,7 @@ Defined in: [packages/db/src/types.ts:115](https://github.com/TanStack/db/blob/m optional autoCommit: boolean; ``` -Defined in: [packages/db/src/types.ts:119](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L119) +Defined in: [packages/db/src/types.ts:150](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L150) *** @@ -31,7 +31,7 @@ Defined in: [packages/db/src/types.ts:119](https://github.com/TanStack/db/blob/m optional id: string; ``` -Defined in: [packages/db/src/types.ts:117](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L117) +Defined in: [packages/db/src/types.ts:148](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L148) Unique identifier for the transaction @@ -43,7 +43,7 @@ Unique identifier for the transaction optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L122) +Defined in: [packages/db/src/types.ts:153](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L153) Custom metadata to associate with the transaction @@ -55,4 +55,4 @@ Custom metadata to associate with the transaction mutationFn: MutationFn; ``` -Defined in: [packages/db/src/types.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L120) +Defined in: [packages/db/src/types.ts:151](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L151) diff --git a/docs/reference/type-aliases/ChangeListener.md b/docs/reference/type-aliases/ChangeListener.md index 7fb302ac4..d65158cb8 100644 --- a/docs/reference/type-aliases/ChangeListener.md +++ b/docs/reference/type-aliases/ChangeListener.md @@ -9,7 +9,7 @@ title: ChangeListener type ChangeListener = (changes) => void; ``` -Defined in: [packages/db/src/types.ts:717](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L717) +Defined in: [packages/db/src/types.ts:756](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L756) Function type for listening to collection changes diff --git a/docs/reference/type-aliases/ChangesPayload.md b/docs/reference/type-aliases/ChangesPayload.md index 92f1f161c..b516e1ead 100644 --- a/docs/reference/type-aliases/ChangesPayload.md +++ b/docs/reference/type-aliases/ChangesPayload.md @@ -9,7 +9,7 @@ title: ChangesPayload type ChangesPayload = ChangeMessage[]; ``` -Defined in: [packages/db/src/types.ts:620](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L620) +Defined in: [packages/db/src/types.ts:659](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L659) ## Type Parameters diff --git a/docs/reference/type-aliases/CleanupFn.md b/docs/reference/type-aliases/CleanupFn.md index 71ba43416..d2bbb31de 100644 --- a/docs/reference/type-aliases/CleanupFn.md +++ b/docs/reference/type-aliases/CleanupFn.md @@ -9,7 +9,7 @@ title: CleanupFn type CleanupFn = () => void; ``` -Defined in: [packages/db/src/types.ts:226](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L226) +Defined in: [packages/db/src/types.ts:257](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L257) ## Returns diff --git a/docs/reference/type-aliases/CollectionConfigSingleRowOption.md b/docs/reference/type-aliases/CollectionConfigSingleRowOption.md index c498f5aec..353c751fa 100644 --- a/docs/reference/type-aliases/CollectionConfigSingleRowOption.md +++ b/docs/reference/type-aliases/CollectionConfigSingleRowOption.md @@ -9,7 +9,7 @@ title: CollectionConfigSingleRowOption type CollectionConfigSingleRowOption = CollectionConfig & MaybeSingleResult; ``` -Defined in: [packages/db/src/types.ts:613](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L613) +Defined in: [packages/db/src/types.ts:652](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L652) ## Type Parameters diff --git a/docs/reference/type-aliases/CollectionStatus.md b/docs/reference/type-aliases/CollectionStatus.md index d4d9e4443..fc603cc50 100644 --- a/docs/reference/type-aliases/CollectionStatus.md +++ b/docs/reference/type-aliases/CollectionStatus.md @@ -9,7 +9,7 @@ title: CollectionStatus type CollectionStatus = "idle" | "loading" | "ready" | "error" | "cleaned-up"; ``` -Defined in: [packages/db/src/types.ts:371](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L371) +Defined in: [packages/db/src/types.ts:402](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L402) Collection status values for lifecycle management diff --git a/docs/reference/type-aliases/DeleteMutationFn.md b/docs/reference/type-aliases/DeleteMutationFn.md index ef9da1116..d80634faa 100644 --- a/docs/reference/type-aliases/DeleteMutationFn.md +++ b/docs/reference/type-aliases/DeleteMutationFn.md @@ -9,7 +9,7 @@ title: DeleteMutationFn type DeleteMutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:349](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L349) +Defined in: [packages/db/src/types.ts:380](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L380) ## Type Parameters diff --git a/docs/reference/type-aliases/DeleteMutationFnParams.md b/docs/reference/type-aliases/DeleteMutationFnParams.md index 949b12bd3..66c6c56c2 100644 --- a/docs/reference/type-aliases/DeleteMutationFnParams.md +++ b/docs/reference/type-aliases/DeleteMutationFnParams.md @@ -9,7 +9,7 @@ title: DeleteMutationFnParams type DeleteMutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:326](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L326) +Defined in: [packages/db/src/types.ts:357](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L357) ## Type Parameters @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:326](https://github.com/TanStack/db/blob/m collection: Collection; ``` -Defined in: [packages/db/src/types.ts:332](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L332) +Defined in: [packages/db/src/types.ts:363](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L363) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:332](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:331](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L331) +Defined in: [packages/db/src/types.ts:362](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L362) diff --git a/docs/reference/type-aliases/Fn.md b/docs/reference/type-aliases/Fn.md index 208899f6b..d4cc38176 100644 --- a/docs/reference/type-aliases/Fn.md +++ b/docs/reference/type-aliases/Fn.md @@ -9,7 +9,7 @@ title: Fn type Fn = (...args) => any; ``` -Defined in: [packages/db/src/types.ts:35](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L35) +Defined in: [packages/db/src/types.ts:66](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L66) Represents a utility function that can be attached to a collection diff --git a/docs/reference/type-aliases/GetResult.md b/docs/reference/type-aliases/GetResult.md index c0c631fab..fde3475a2 100644 --- a/docs/reference/type-aliases/GetResult.md +++ b/docs/reference/type-aliases/GetResult.md @@ -9,7 +9,7 @@ title: GetResult type GetResult = Prettify; ``` -Defined in: [packages/db/src/query/builder/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L675) +Defined in: [packages/db/src/query/builder/types.ts:653](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L653) GetResult - Determines the final result type of a query diff --git a/docs/reference/type-aliases/InferResultType.md b/docs/reference/type-aliases/InferResultType.md index 49cc53887..851862eee 100644 --- a/docs/reference/type-aliases/InferResultType.md +++ b/docs/reference/type-aliases/InferResultType.md @@ -9,7 +9,7 @@ title: InferResultType type InferResultType = TContext extends SingleResult ? GetResult | undefined : GetResult[]; ``` -Defined in: [packages/db/src/query/builder/types.ts:645](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L645) +Defined in: [packages/db/src/query/builder/types.ts:623](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L623) Utility type to infer the query result size (single row or an array) diff --git a/docs/reference/type-aliases/InferSchemaInput.md b/docs/reference/type-aliases/InferSchemaInput.md index 57dc50542..e209e5475 100644 --- a/docs/reference/type-aliases/InferSchemaInput.md +++ b/docs/reference/type-aliases/InferSchemaInput.md @@ -9,7 +9,7 @@ title: InferSchemaInput type InferSchemaInput = T extends StandardSchemaV1 ? StandardSchemaV1.InferInput extends object ? StandardSchemaV1.InferInput : Record : Record; ``` -Defined in: [packages/db/src/types.ts:24](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L24) +Defined in: [packages/db/src/types.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L55) **`Internal`** diff --git a/docs/reference/type-aliases/InferSchemaOutput.md b/docs/reference/type-aliases/InferSchemaOutput.md index 5bb8ccc91..9069db64b 100644 --- a/docs/reference/type-aliases/InferSchemaOutput.md +++ b/docs/reference/type-aliases/InferSchemaOutput.md @@ -9,7 +9,7 @@ title: InferSchemaOutput type InferSchemaOutput = T extends StandardSchemaV1 ? StandardSchemaV1.InferOutput extends object ? StandardSchemaV1.InferOutput : Record : Record; ``` -Defined in: [packages/db/src/types.ts:13](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L13) +Defined in: [packages/db/src/types.ts:44](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L44) **`Internal`** diff --git a/docs/reference/type-aliases/InputRow.md b/docs/reference/type-aliases/InputRow.md index 950b9af2f..aa24c47f9 100644 --- a/docs/reference/type-aliases/InputRow.md +++ b/docs/reference/type-aliases/InputRow.md @@ -9,6 +9,6 @@ title: InputRow type InputRow = [unknown, Record]; ``` -Defined in: [packages/db/src/types.ts:627](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L627) +Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) An input row from a collection diff --git a/docs/reference/type-aliases/InsertMutationFn.md b/docs/reference/type-aliases/InsertMutationFn.md index 62c812d71..2f1e6b7b3 100644 --- a/docs/reference/type-aliases/InsertMutationFn.md +++ b/docs/reference/type-aliases/InsertMutationFn.md @@ -9,7 +9,7 @@ title: InsertMutationFn type InsertMutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:335](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L335) +Defined in: [packages/db/src/types.ts:366](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L366) ## Type Parameters diff --git a/docs/reference/type-aliases/InsertMutationFnParams.md b/docs/reference/type-aliases/InsertMutationFnParams.md index e17032a45..10debb9a3 100644 --- a/docs/reference/type-aliases/InsertMutationFnParams.md +++ b/docs/reference/type-aliases/InsertMutationFnParams.md @@ -9,7 +9,7 @@ title: InsertMutationFnParams type InsertMutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:318](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L318) +Defined in: [packages/db/src/types.ts:349](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L349) ## Type Parameters @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:318](https://github.com/TanStack/db/blob/m collection: Collection; ``` -Defined in: [packages/db/src/types.ts:324](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L324) +Defined in: [packages/db/src/types.ts:355](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L355) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:324](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:323](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L323) +Defined in: [packages/db/src/types.ts:354](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L354) diff --git a/docs/reference/type-aliases/KeyedNamespacedRow.md b/docs/reference/type-aliases/KeyedNamespacedRow.md index 01348ec06..68997d9f6 100644 --- a/docs/reference/type-aliases/KeyedNamespacedRow.md +++ b/docs/reference/type-aliases/KeyedNamespacedRow.md @@ -9,7 +9,7 @@ title: KeyedNamespacedRow type KeyedNamespacedRow = [unknown, NamespacedRow]; ``` -Defined in: [packages/db/src/types.ts:650](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L650) +Defined in: [packages/db/src/types.ts:689](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L689) A keyed namespaced row is a row with a key and a namespaced row This is the main representation of a row in a query pipeline diff --git a/docs/reference/type-aliases/KeyedStream.md b/docs/reference/type-aliases/KeyedStream.md index a979f48f5..0ace8b650 100644 --- a/docs/reference/type-aliases/KeyedStream.md +++ b/docs/reference/type-aliases/KeyedStream.md @@ -9,7 +9,7 @@ title: KeyedStream type KeyedStream = IStreamBuilder; ``` -Defined in: [packages/db/src/types.ts:633](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L633) +Defined in: [packages/db/src/types.ts:672](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L672) A keyed stream is a stream of rows This is used as the inputs from a collection to a query diff --git a/docs/reference/type-aliases/LiveQueryCollectionUtils.md b/docs/reference/type-aliases/LiveQueryCollectionUtils.md index ac8b3a894..468fa1bf5 100644 --- a/docs/reference/type-aliases/LiveQueryCollectionUtils.md +++ b/docs/reference/type-aliases/LiveQueryCollectionUtils.md @@ -9,7 +9,7 @@ title: LiveQueryCollectionUtils type LiveQueryCollectionUtils = UtilsRecord & object; ``` -Defined in: [packages/db/src/query/live/collection-config-builder.ts:38](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/collection-config-builder.ts#L38) +Defined in: [packages/db/src/query/live/collection-config-builder.ts:39](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/collection-config-builder.ts#L39) ## Type Declaration diff --git a/docs/reference/type-aliases/LoadSubsetFn.md b/docs/reference/type-aliases/LoadSubsetFn.md index 672b10849..d35f81e4b 100644 --- a/docs/reference/type-aliases/LoadSubsetFn.md +++ b/docs/reference/type-aliases/LoadSubsetFn.md @@ -9,7 +9,7 @@ title: LoadSubsetFn type LoadSubsetFn = (options) => true | Promise; ``` -Defined in: [packages/db/src/types.ts:224](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L224) +Defined in: [packages/db/src/types.ts:255](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L255) ## Parameters diff --git a/docs/reference/type-aliases/LoadSubsetOptions.md b/docs/reference/type-aliases/LoadSubsetOptions.md index 68130510e..91d48ed11 100644 --- a/docs/reference/type-aliases/LoadSubsetOptions.md +++ b/docs/reference/type-aliases/LoadSubsetOptions.md @@ -9,7 +9,7 @@ title: LoadSubsetOptions type LoadSubsetOptions = object; ``` -Defined in: [packages/db/src/types.ts:206](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L206) +Defined in: [packages/db/src/types.ts:237](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L237) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/types.ts:206](https://github.com/TanStack/db/blob/m optional limit: number; ``` -Defined in: [packages/db/src/types.ts:212](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L212) +Defined in: [packages/db/src/types.ts:243](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L243) The limit of the data to load @@ -31,7 +31,7 @@ The limit of the data to load optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L210) +Defined in: [packages/db/src/types.ts:241](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L241) The order by clause to sort the data @@ -43,7 +43,7 @@ The order by clause to sort the data optional subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L221) +Defined in: [packages/db/src/types.ts:252](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L252) The subscription that triggered the load. Advanced sync implementations can use this for: @@ -63,6 +63,6 @@ Available when called from CollectionSubscription, may be undefined for direct c optional where: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:208](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L208) +Defined in: [packages/db/src/types.ts:239](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L239) The where expression to filter the data diff --git a/docs/reference/type-aliases/MaybeSingleResult.md b/docs/reference/type-aliases/MaybeSingleResult.md index 7760f69d5..ca1d94fb3 100644 --- a/docs/reference/type-aliases/MaybeSingleResult.md +++ b/docs/reference/type-aliases/MaybeSingleResult.md @@ -9,7 +9,7 @@ title: MaybeSingleResult type MaybeSingleResult = object; ``` -Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) +Defined in: [packages/db/src/types.ts:644](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L644) ## Properties @@ -19,6 +19,6 @@ Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/m optional singleResult: true; ``` -Defined in: [packages/db/src/types.ts:609](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L609) +Defined in: [packages/db/src/types.ts:648](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L648) If enabled the collection will return a single object instead of an array diff --git a/docs/reference/type-aliases/MutationFn.md b/docs/reference/type-aliases/MutationFn.md index 3e5d48e9e..146de19e0 100644 --- a/docs/reference/type-aliases/MutationFn.md +++ b/docs/reference/type-aliases/MutationFn.md @@ -9,7 +9,7 @@ title: MutationFn type MutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L95) +Defined in: [packages/db/src/types.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L126) ## Type Parameters diff --git a/docs/reference/type-aliases/MutationFnParams.md b/docs/reference/type-aliases/MutationFnParams.md index ed423fdbf..e7b51681f 100644 --- a/docs/reference/type-aliases/MutationFnParams.md +++ b/docs/reference/type-aliases/MutationFnParams.md @@ -9,7 +9,7 @@ title: MutationFnParams type MutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L91) +Defined in: [packages/db/src/types.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L122) Configuration options for creating a new transaction @@ -27,4 +27,4 @@ Configuration options for creating a new transaction transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:92](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L92) +Defined in: [packages/db/src/types.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L123) diff --git a/docs/reference/type-aliases/NamespacedAndKeyedStream.md b/docs/reference/type-aliases/NamespacedAndKeyedStream.md index c6aa2fb4d..16c58168b 100644 --- a/docs/reference/type-aliases/NamespacedAndKeyedStream.md +++ b/docs/reference/type-aliases/NamespacedAndKeyedStream.md @@ -9,7 +9,7 @@ title: NamespacedAndKeyedStream type NamespacedAndKeyedStream = IStreamBuilder; ``` -Defined in: [packages/db/src/types.ts:657](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L657) +Defined in: [packages/db/src/types.ts:696](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L696) A namespaced and keyed stream is a stream of rows This is used throughout a query pipeline and as the output from a query without diff --git a/docs/reference/type-aliases/NamespacedRow.md b/docs/reference/type-aliases/NamespacedRow.md index 6dceb7967..1d8326a35 100644 --- a/docs/reference/type-aliases/NamespacedRow.md +++ b/docs/reference/type-aliases/NamespacedRow.md @@ -9,6 +9,6 @@ title: NamespacedRow type NamespacedRow = Record>; ``` -Defined in: [packages/db/src/types.ts:644](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L644) +Defined in: [packages/db/src/types.ts:683](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L683) A namespaced row is a row withing a pipeline that had each table wrapped in its alias diff --git a/docs/reference/type-aliases/NonEmptyArray.md b/docs/reference/type-aliases/NonEmptyArray.md index 85f886519..563202223 100644 --- a/docs/reference/type-aliases/NonEmptyArray.md +++ b/docs/reference/type-aliases/NonEmptyArray.md @@ -9,7 +9,7 @@ title: NonEmptyArray type NonEmptyArray = [T, ...T[]]; ``` -Defined in: [packages/db/src/types.ts:102](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L102) +Defined in: [packages/db/src/types.ts:133](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L133) Represents a non-empty array (at least one element) diff --git a/docs/reference/type-aliases/NonSingleResult.md b/docs/reference/type-aliases/NonSingleResult.md index 18472c3c5..cfba546f5 100644 --- a/docs/reference/type-aliases/NonSingleResult.md +++ b/docs/reference/type-aliases/NonSingleResult.md @@ -9,7 +9,7 @@ title: NonSingleResult type NonSingleResult = object; ``` -Defined in: [packages/db/src/types.ts:601](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L601) +Defined in: [packages/db/src/types.ts:640](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L640) ## Properties @@ -19,4 +19,4 @@ Defined in: [packages/db/src/types.ts:601](https://github.com/TanStack/db/blob/m optional singleResult: never; ``` -Defined in: [packages/db/src/types.ts:602](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L602) +Defined in: [packages/db/src/types.ts:641](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L641) diff --git a/docs/reference/type-aliases/OperationType.md b/docs/reference/type-aliases/OperationType.md index 43893ca58..a8c37d55a 100644 --- a/docs/reference/type-aliases/OperationType.md +++ b/docs/reference/type-aliases/OperationType.md @@ -9,4 +9,4 @@ title: OperationType type OperationType = "insert" | "update" | "delete"; ``` -Defined in: [packages/db/src/types.ts:152](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L152) +Defined in: [packages/db/src/types.ts:183](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L183) diff --git a/docs/reference/type-aliases/Ref.md b/docs/reference/type-aliases/Ref.md index 1340dc750..3e54d7780 100644 --- a/docs/reference/type-aliases/Ref.md +++ b/docs/reference/type-aliases/Ref.md @@ -9,7 +9,7 @@ title: Ref type Ref = { [K in keyof T]: IsNonExactOptional extends true ? IsNonExactNullable extends true ? IsPlainObject> extends true ? Ref> | undefined : RefLeaf> | undefined : IsPlainObject> extends true ? Ref> | undefined : RefLeaf> | undefined : IsNonExactNullable extends true ? IsPlainObject> extends true ? Ref> | null : RefLeaf> | null : IsPlainObject extends true ? Ref : RefLeaf } & RefLeaf; ``` -Defined in: [packages/db/src/query/builder/types.ts:493](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L493) +Defined in: [packages/db/src/query/builder/types.ts:471](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L471) Ref - The user-facing ref interface for the query builder diff --git a/docs/reference/type-aliases/ResolveTransactionChanges.md b/docs/reference/type-aliases/ResolveTransactionChanges.md index cbf36e6f8..d8349caab 100644 --- a/docs/reference/type-aliases/ResolveTransactionChanges.md +++ b/docs/reference/type-aliases/ResolveTransactionChanges.md @@ -9,7 +9,7 @@ title: ResolveTransactionChanges type ResolveTransactionChanges = TOperation extends "delete" ? T : Partial; ``` -Defined in: [packages/db/src/types.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L48) +Defined in: [packages/db/src/types.ts:79](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L79) ## Type Parameters diff --git a/docs/reference/type-aliases/ResultStream.md b/docs/reference/type-aliases/ResultStream.md index f61b629ac..2eafdb685 100644 --- a/docs/reference/type-aliases/ResultStream.md +++ b/docs/reference/type-aliases/ResultStream.md @@ -9,7 +9,7 @@ title: ResultStream type ResultStream = IStreamBuilder<[unknown, [any, string | undefined]]>; ``` -Defined in: [packages/db/src/types.ts:639](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L639) +Defined in: [packages/db/src/types.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L678) Result stream type representing the output of compiled queries Always returns [key, [result, orderByIndex]] where orderByIndex is undefined for unordered queries diff --git a/docs/reference/type-aliases/Row.md b/docs/reference/type-aliases/Row.md index 4473d7712..837e903e9 100644 --- a/docs/reference/type-aliases/Row.md +++ b/docs/reference/type-aliases/Row.md @@ -9,7 +9,7 @@ title: Row type Row = Record>; ``` -Defined in: [packages/db/src/types.ts:150](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L150) +Defined in: [packages/db/src/types.ts:181](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L181) ## Type Parameters diff --git a/docs/reference/type-aliases/SingleResult.md b/docs/reference/type-aliases/SingleResult.md index 5d7a47e42..75981ce55 100644 --- a/docs/reference/type-aliases/SingleResult.md +++ b/docs/reference/type-aliases/SingleResult.md @@ -9,7 +9,7 @@ title: SingleResult type SingleResult = object; ``` -Defined in: [packages/db/src/types.ts:597](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L597) +Defined in: [packages/db/src/types.ts:636](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L636) ## Properties @@ -19,4 +19,4 @@ Defined in: [packages/db/src/types.ts:597](https://github.com/TanStack/db/blob/m singleResult: true; ``` -Defined in: [packages/db/src/types.ts:598](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L598) +Defined in: [packages/db/src/types.ts:637](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L637) diff --git a/docs/reference/type-aliases/StandardSchema.md b/docs/reference/type-aliases/StandardSchema.md index fcdf7607c..34417b624 100644 --- a/docs/reference/type-aliases/StandardSchema.md +++ b/docs/reference/type-aliases/StandardSchema.md @@ -9,7 +9,7 @@ title: StandardSchema type StandardSchema = StandardSchemaV1 & object; ``` -Defined in: [packages/db/src/types.ts:283](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L283) +Defined in: [packages/db/src/types.ts:314](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L314) The Standard Schema interface. This follows the standard-schema specification: https://github.com/standard-schema/standard-schema diff --git a/docs/reference/type-aliases/StandardSchemaAlias.md b/docs/reference/type-aliases/StandardSchemaAlias.md index 7551f9bdb..e12d92243 100644 --- a/docs/reference/type-aliases/StandardSchemaAlias.md +++ b/docs/reference/type-aliases/StandardSchemaAlias.md @@ -9,7 +9,7 @@ title: StandardSchemaAlias type StandardSchemaAlias = StandardSchema; ``` -Defined in: [packages/db/src/types.ts:295](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L295) +Defined in: [packages/db/src/types.ts:326](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L326) Type alias for StandardSchema diff --git a/docs/reference/type-aliases/StringCollationConfig.md b/docs/reference/type-aliases/StringCollationConfig.md new file mode 100644 index 000000000..9ca2c7b15 --- /dev/null +++ b/docs/reference/type-aliases/StringCollationConfig.md @@ -0,0 +1,28 @@ +--- +id: StringCollationConfig +title: StringCollationConfig +--- + +# Type Alias: StringCollationConfig + +```ts +type StringCollationConfig = + | { + stringSort?: "lexical"; +} + | { + locale?: string; + localeOptions?: object; + stringSort?: "locale"; +}; +``` + +Defined in: [packages/db/src/types.ts:29](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L29) + +StringSortOpts - Options for string sorting behavior + +This discriminated union allows for two types of string sorting: +- **Lexical**: Simple character-by-character comparison (default) +- **Locale**: Locale-aware sorting with optional customization + +The union ensures that locale options are only available when locale sorting is selected. diff --git a/docs/reference/type-aliases/SubscriptionEvents.md b/docs/reference/type-aliases/SubscriptionEvents.md index 224829d18..5bfbad031 100644 --- a/docs/reference/type-aliases/SubscriptionEvents.md +++ b/docs/reference/type-aliases/SubscriptionEvents.md @@ -9,7 +9,7 @@ title: SubscriptionEvents type SubscriptionEvents = object; ``` -Defined in: [packages/db/src/types.ts:190](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L190) +Defined in: [packages/db/src/types.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L221) All subscription events @@ -21,7 +21,7 @@ All subscription events status:change: SubscriptionStatusChangeEvent; ``` -Defined in: [packages/db/src/types.ts:191](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L191) +Defined in: [packages/db/src/types.ts:222](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L222) *** @@ -31,7 +31,7 @@ Defined in: [packages/db/src/types.ts:191](https://github.com/TanStack/db/blob/m status:loadingSubset: SubscriptionStatusEvent<"loadingSubset">; ``` -Defined in: [packages/db/src/types.ts:193](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L193) +Defined in: [packages/db/src/types.ts:224](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L224) *** @@ -41,7 +41,7 @@ Defined in: [packages/db/src/types.ts:193](https://github.com/TanStack/db/blob/m status:ready: SubscriptionStatusEvent<"ready">; ``` -Defined in: [packages/db/src/types.ts:192](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L192) +Defined in: [packages/db/src/types.ts:223](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L223) *** @@ -51,4 +51,4 @@ Defined in: [packages/db/src/types.ts:192](https://github.com/TanStack/db/blob/m unsubscribed: SubscriptionUnsubscribedEvent; ``` -Defined in: [packages/db/src/types.ts:194](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L194) +Defined in: [packages/db/src/types.ts:225](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L225) diff --git a/docs/reference/type-aliases/SubscriptionStatus.md b/docs/reference/type-aliases/SubscriptionStatus.md index 179b3e5dc..0ec757e27 100644 --- a/docs/reference/type-aliases/SubscriptionStatus.md +++ b/docs/reference/type-aliases/SubscriptionStatus.md @@ -9,6 +9,6 @@ title: SubscriptionStatus type SubscriptionStatus = "ready" | "loadingSubset"; ``` -Defined in: [packages/db/src/types.ts:157](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L157) +Defined in: [packages/db/src/types.ts:188](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L188) Subscription status values diff --git a/docs/reference/type-aliases/SyncConfigRes.md b/docs/reference/type-aliases/SyncConfigRes.md index 87c58dbce..2cf63c380 100644 --- a/docs/reference/type-aliases/SyncConfigRes.md +++ b/docs/reference/type-aliases/SyncConfigRes.md @@ -9,7 +9,7 @@ title: SyncConfigRes type SyncConfigRes = object; ``` -Defined in: [packages/db/src/types.ts:228](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L228) +Defined in: [packages/db/src/types.ts:259](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L259) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/types.ts:228](https://github.com/TanStack/db/blob/m optional cleanup: CleanupFn; ``` -Defined in: [packages/db/src/types.ts:229](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L229) +Defined in: [packages/db/src/types.ts:260](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L260) *** @@ -29,4 +29,4 @@ Defined in: [packages/db/src/types.ts:229](https://github.com/TanStack/db/blob/m optional loadSubset: LoadSubsetFn; ``` -Defined in: [packages/db/src/types.ts:230](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L230) +Defined in: [packages/db/src/types.ts:261](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L261) diff --git a/docs/reference/type-aliases/SyncMode.md b/docs/reference/type-aliases/SyncMode.md index 1f9614a96..4715f1866 100644 --- a/docs/reference/type-aliases/SyncMode.md +++ b/docs/reference/type-aliases/SyncMode.md @@ -9,4 +9,4 @@ title: SyncMode type SyncMode = "eager" | "on-demand"; ``` -Defined in: [packages/db/src/types.ts:383](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L383) +Defined in: [packages/db/src/types.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L414) diff --git a/docs/reference/type-aliases/TransactionState.md b/docs/reference/type-aliases/TransactionState.md index 9fb9ed6b2..7f3c41897 100644 --- a/docs/reference/type-aliases/TransactionState.md +++ b/docs/reference/type-aliases/TransactionState.md @@ -9,4 +9,4 @@ title: TransactionState type TransactionState = "pending" | "persisting" | "completed" | "failed"; ``` -Defined in: [packages/db/src/types.ts:30](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L30) +Defined in: [packages/db/src/types.ts:61](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L61) diff --git a/docs/reference/type-aliases/TransactionWithMutations.md b/docs/reference/type-aliases/TransactionWithMutations.md index f442f136d..adea0ae5d 100644 --- a/docs/reference/type-aliases/TransactionWithMutations.md +++ b/docs/reference/type-aliases/TransactionWithMutations.md @@ -9,7 +9,7 @@ title: TransactionWithMutations type TransactionWithMutations = Transaction & object; ``` -Defined in: [packages/db/src/types.ts:108](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L108) +Defined in: [packages/db/src/types.ts:139](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L139) Utility type for a Transaction with at least one mutation This is used internally by the Transaction.commit method diff --git a/docs/reference/type-aliases/UpdateMutationFn.md b/docs/reference/type-aliases/UpdateMutationFn.md index f58d1c6f4..8fa98ca8d 100644 --- a/docs/reference/type-aliases/UpdateMutationFn.md +++ b/docs/reference/type-aliases/UpdateMutationFn.md @@ -9,7 +9,7 @@ title: UpdateMutationFn type UpdateMutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:342](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L342) +Defined in: [packages/db/src/types.ts:373](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L373) ## Type Parameters diff --git a/docs/reference/type-aliases/UpdateMutationFnParams.md b/docs/reference/type-aliases/UpdateMutationFnParams.md index 9fafe2e43..1f7513ed2 100644 --- a/docs/reference/type-aliases/UpdateMutationFnParams.md +++ b/docs/reference/type-aliases/UpdateMutationFnParams.md @@ -9,7 +9,7 @@ title: UpdateMutationFnParams type UpdateMutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:309](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L309) +Defined in: [packages/db/src/types.ts:340](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L340) ## Type Parameters @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:309](https://github.com/TanStack/db/blob/m collection: Collection; ``` -Defined in: [packages/db/src/types.ts:315](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L315) +Defined in: [packages/db/src/types.ts:346](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L346) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:315](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:314](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L314) +Defined in: [packages/db/src/types.ts:345](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L345) diff --git a/docs/reference/type-aliases/UtilsRecord.md b/docs/reference/type-aliases/UtilsRecord.md index 309a1cfd9..b2835a0a8 100644 --- a/docs/reference/type-aliases/UtilsRecord.md +++ b/docs/reference/type-aliases/UtilsRecord.md @@ -9,6 +9,6 @@ title: UtilsRecord type UtilsRecord = Record; ``` -Defined in: [packages/db/src/types.ts:40](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L40) +Defined in: [packages/db/src/types.ts:71](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L71) A record of utilities (functions or getters) that can be attached to a collection diff --git a/docs/reference/type-aliases/WritableDeep.md b/docs/reference/type-aliases/WritableDeep.md index 342aea753..6acc5731e 100644 --- a/docs/reference/type-aliases/WritableDeep.md +++ b/docs/reference/type-aliases/WritableDeep.md @@ -9,7 +9,7 @@ title: WritableDeep type WritableDeep = T extends BuiltIns ? T : T extends (...arguments_) => unknown ? object extends WritableObjectDeep ? T : HasMultipleCallSignatures extends true ? T : (...arguments_) => ReturnType & WritableObjectDeep : T extends ReadonlyMap ? WritableMapDeep : T extends ReadonlySet ? WritableSetDeep : T extends ReadonlyArray ? WritableArrayDeep : T extends object ? WritableObjectDeep : unknown; ``` -Defined in: [packages/db/src/types.ts:777](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L777) +Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) ## Type Parameters diff --git a/packages/db/src/collection/change-events.ts b/packages/db/src/collection/change-events.ts index 3f2e17035..c9929e170 100644 --- a/packages/db/src/collection/change-events.ts +++ b/packages/db/src/collection/change-events.ts @@ -12,27 +12,17 @@ import { } from "../utils/index-optimization.js" import { ensureIndexForField } from "../indexes/auto-index.js" import { makeComparator } from "../utils/comparison.js" +import { buildCompareOptions } from "../query/compiler/order-by" import type { ChangeMessage, + CollectionLike, CurrentStateAsChangesOptions, SubscribeChangesOptions, } from "../types" -import type { Collection, CollectionImpl } from "./index.js" +import type { CollectionImpl } from "./index.js" import type { SingleRowRefProxy } from "../query/builder/ref-proxy" import type { BasicExpression, OrderBy } from "../query/ir.js" -/** - * Interface for a collection-like object that provides the necessary methods - * for the change events system to work - */ -export interface CollectionLike< - T extends object = Record, - TKey extends string | number = string | number, -> extends Pick< - Collection, - `get` | `has` | `entries` | `indexes` | `id` - > {} - /** * Returns the current state of the collection as an array of changes * @param collection - The collection to get changes from @@ -143,7 +133,7 @@ export function currentStateAsChanges< // Try to optimize the query using indexes const optimizationResult = optimizeExpressionWithIndexes( expression, - collection.indexes + collection ) if (optimizationResult.canOptimize) { @@ -329,21 +319,18 @@ function getOrderedKeys( if (orderByExpression.type === `ref`) { const propRef = orderByExpression const fieldPath = propRef.path + const compareOpts = buildCompareOptions(clause, collection) // Ensure index exists for this field ensureIndexForField( fieldPath[0]!, fieldPath, collection as CollectionImpl, - clause.compareOptions + compareOpts ) // Find the index - const index = findIndexForField( - collection.indexes, - fieldPath, - clause.compareOptions - ) + const index = findIndexForField(collection, fieldPath, compareOpts) if (index && index.supports(`gt`)) { // Use index optimization diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 638c0e451..add2fc4d4 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -27,6 +27,7 @@ import type { NonSingleResult, OperationConfig, SingleResult, + StringCollationConfig, SubscribeChangesOptions, Transaction as TransactionType, UtilsRecord, @@ -230,6 +231,8 @@ export class CollectionImpl< // and for debugging public _state: CollectionStateManager + private comparisonOpts: StringCollationConfig + /** * Creates a new Collection instance * @@ -267,6 +270,8 @@ export class CollectionImpl< this._state = new CollectionStateManager(config) this._sync = new CollectionSyncManager(config, this.id) + this.comparisonOpts = buildCompareOptionsFromConfig(config) + this._changes.setDeps({ collection: this, // Required for passing to CollectionSubscription lifecycle: this._lifecycle, @@ -508,6 +513,11 @@ export class CollectionImpl< return this._mutations.validateData(data, type, key) } + get compareOptions(): StringCollationConfig { + // return a copy such that no one can mutate the internal comparison object + return { ...this.comparisonOpts } + } + /** * Inserts one or more items into the collection * @param items - Single item or array of items to insert @@ -848,3 +858,21 @@ export class CollectionImpl< return Promise.resolve() } } + +function buildCompareOptionsFromConfig( + config: CollectionConfig +): StringCollationConfig { + if (config.defaultStringCollation) { + const options = config.defaultStringCollation + return { + stringSort: options.stringSort ?? `locale`, + locale: options.stringSort === `locale` ? options.locale : undefined, + localeOptions: + options.stringSort === `locale` ? options.localeOptions : undefined, + } + } else { + return { + stringSort: `locale`, + } + } +} diff --git a/packages/db/src/indexes/auto-index.ts b/packages/db/src/indexes/auto-index.ts index 56c352b51..491053af0 100644 --- a/packages/db/src/indexes/auto-index.ts +++ b/packages/db/src/indexes/auto-index.ts @@ -24,18 +24,22 @@ export function ensureIndexForField< fieldName: string, fieldPath: Array, collection: CollectionImpl, - compareOptions: CompareOptions = DEFAULT_COMPARE_OPTIONS, + compareOptions?: CompareOptions, compareFn?: (a: any, b: any) => number ) { if (!shouldAutoIndex(collection)) { return } + const compareOpts = compareOptions ?? { + ...DEFAULT_COMPARE_OPTIONS, + ...collection.compareOptions, + } + // Check if we already have an index for this field const existingIndex = Array.from(collection.indexes.values()).find( (index) => - index.matchesField(fieldPath) && - index.matchesCompareOptions(compareOptions) + index.matchesField(fieldPath) && index.matchesCompareOptions(compareOpts) ) if (existingIndex) { @@ -57,7 +61,7 @@ export function ensureIndexForField< { name: `auto:${fieldPath.join(`.`)}`, indexType: BTreeIndex, - options: compareFn ? { compareFn, compareOptions } : {}, + options: compareFn ? { compareFn, compareOptions: compareOpts } : {}, } ) } catch (error) { diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index f6b52e5ed..bd8b95178 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -469,11 +469,11 @@ export class BaseQueryBuilder { const opts: CompareOptions = typeof options === `string` - ? { direction: options, nulls: `first`, stringSort: `locale` } + ? { direction: options, nulls: `first` } : { direction: options.direction ?? `asc`, nulls: options.nulls ?? `first`, - stringSort: options.stringSort ?? `locale`, + stringSort: options.stringSort, locale: options.stringSort === `locale` ? options.locale : undefined, localeOptions: diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 2cd3a4872..8e4ec2f59 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -1,5 +1,5 @@ import type { CollectionImpl } from "../../collection/index.js" -import type { SingleResult } from "../../types.js" +import type { SingleResult, StringCollationConfig } from "../../types.js" import type { Aggregate, BasicExpression, @@ -303,26 +303,7 @@ export type OrderByCallback = ( export type OrderByOptions = { direction?: OrderByDirection nulls?: `first` | `last` -} & StringSortOpts - -/** - * StringSortOpts - Options for string sorting behavior - * - * This discriminated union allows for two types of string sorting: - * - **Lexical**: Simple character-by-character comparison (default) - * - **Locale**: Locale-aware sorting with optional customization - * - * The union ensures that locale options are only available when locale sorting is selected. - */ -export type StringSortOpts = - | { - stringSort?: `lexical` - } - | { - stringSort?: `locale` - locale?: string - localeOptions?: object - } +} & StringCollationConfig /** * CompareOptions - Final resolved options for comparison operations @@ -331,12 +312,9 @@ export type StringSortOpts = * to their concrete values. Unlike OrderByOptions, all fields are required * since defaults have been applied. */ -export type CompareOptions = { +export type CompareOptions = StringCollationConfig & { direction: OrderByDirection nulls: `first` | `last` - stringSort: `lexical` | `locale` - locale?: string - localeOptions?: object } /** diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index 6156ae2ce..355f922bf 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -5,10 +5,15 @@ import { ensureIndexForField } from "../../indexes/auto-index.js" import { findIndexForField } from "../../utils/index-optimization.js" import { compileExpression } from "./evaluators.js" import { replaceAggregatesByRefs } from "./group-by.js" +import type { CompareOptions } from "../builder/types.js" import type { WindowOptions } from "./types.js" import type { CompiledSingleRowExpression } from "./evaluators.js" import type { OrderBy, OrderByClause, QueryIR, Select } from "../ir.js" -import type { NamespacedAndKeyedStream, NamespacedRow } from "../../types.js" +import type { + CollectionLike, + NamespacedAndKeyedStream, + NamespacedRow, +} from "../../types.js" import type { IStreamBuilder, KeyValue } from "@tanstack/db-ivm" import type { IndexInterface } from "../../indexes/base-index.js" import type { Collection } from "../../collection/index.js" @@ -50,9 +55,10 @@ export function processOrderBy( selectClause, `__select_results` ) + return { compiledExpression: compileExpression(clauseWithoutAggregates), - compareOptions: clause.compareOptions, + compareOptions: buildCompareOptions(clause, collection), } }) @@ -87,7 +93,7 @@ export function processOrderBy( const arrayA = a as Array const arrayB = b as Array for (let i = 0; i < orderByClause.length; i++) { - const clause = orderByClause[i]! + const clause = compiledOrderBy[i]! const compareFn = makeComparator(clause.compareOptions) const result = compareFn(arrayA[i], arrayB[i]) if (result !== 0) { @@ -99,7 +105,7 @@ export function processOrderBy( // Single property comparison if (orderByClause.length === 1) { - const clause = orderByClause[0]! + const clause = compiledOrderBy[0]! const compareFn = makeComparator(clause.compareOptions) return compareFn(a, b) } @@ -127,12 +133,13 @@ export function processOrderBy( const followRefCollection = followRefResult.collection const fieldName = followRefResult.path[0] + const compareOpts = buildCompareOptions(clause, followRefCollection) if (fieldName) { ensureIndexForField( fieldName, followRefResult.path, followRefCollection, - clause.compareOptions, + compareOpts, compare ) } @@ -153,9 +160,9 @@ export function processOrderBy( const index: IndexInterface | undefined = findIndexForField( - followRefCollection.indexes, + followRefCollection, followRefResult.path, - clause.compareOptions + compareOpts ) if (index && index.supports(`gt`)) { @@ -217,3 +224,22 @@ export function processOrderBy( // orderByWithFractionalIndex returns [key, [value, index]] - we keep this format ) } + +/** + * Builds a comparison configuration object that uses the values provided in the orderBy clause. + * If no string sort configuration is provided it defaults to the collection's string sort configuration. + */ +export function buildCompareOptions( + clause: OrderByClause, + collection: CollectionLike +): CompareOptions { + if (clause.compareOptions.stringSort !== undefined) { + return clause.compareOptions + } + + return { + ...collection.compareOptions, + direction: clause.compareOptions.direction, + nulls: clause.compareOptions.nulls, + } +} diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 021c6ed68..af3cb2400 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -21,6 +21,7 @@ import type { CollectionConfigSingleRowOption, KeyedStream, ResultStream, + StringCollationConfig, SyncConfig, UtilsRecord, } from "../../types.js" @@ -83,6 +84,7 @@ export class CollectionConfigBuilder< private readonly orderByIndices = new WeakMap() private readonly compare?: (val1: TResult, val2: TResult) => number + private readonly compareOptions?: StringCollationConfig private isGraphRunning = false private runCount = 0 @@ -170,6 +172,11 @@ export class CollectionConfigBuilder< this.compare = createOrderByComparator(this.orderByIndices) } + // Use explicitly provided compareOptions if available, otherwise inherit from FROM collection + this.compareOptions = + this.config.defaultStringCollation ?? + extractCollectionFromSource(this.query).compareOptions + // Compile the base pipeline once initially // This is done to ensure that any errors are thrown immediately and synchronously this.compileBasePipeline() @@ -204,6 +211,7 @@ export class CollectionConfigBuilder< ((item) => this.resultKeys.get(item) as string | number), sync: this.getSyncConfig(), compare: this.compare, + defaultStringCollation: this.compareOptions, gcTime: this.config.gcTime || 5000, // 5 seconds by default for live queries schema: this.config.schema, onInsert: this.config.onInsert, @@ -951,6 +959,25 @@ function extractCollectionsFromQuery( return collections } +/** + * Helper function to extract the collection that is referenced in the query's FROM clause. + * The FROM clause may refer directly to a collection or indirectly to a subquery. + */ +function extractCollectionFromSource(query: any): Collection { + const from = query.from + + if (from.type === `collectionRef`) { + return from.collection + } else if (from.type === `queryRef`) { + // Recursively extract from subquery + return extractCollectionFromSource(from.query) + } + + throw new Error( + `Failed to extract collection. Invalid FROM clause: ${JSON.stringify(query)}` + ) +} + /** * Extracts all aliases used for each collection across the entire query tree. * diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 3149b3a66..74c84a05f 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -1,5 +1,9 @@ import type { D2, RootStreamBuilder } from "@tanstack/db-ivm" -import type { CollectionConfig, ResultStream } from "../../types.js" +import type { + CollectionConfig, + ResultStream, + StringCollationConfig, +} from "../../types.js" import type { InitialQueryBuilder, QueryBuilder } from "../builder/index.js" import type { Context, GetResult } from "../builder/types.js" @@ -95,4 +99,10 @@ export interface LiveQueryCollectionConfig< * If enabled the collection will return a single object instead of an array */ singleResult?: true + + /** + * Optional compare options for string sorting. + * If provided, these will be used instead of inheriting from the FROM collection. + */ + defaultStringCollation?: StringCollationConfig } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 04fadef51..d5e82c41f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -5,6 +5,37 @@ import type { Transaction } from "./transactions" import type { BasicExpression, OrderBy } from "./query/ir.js" import type { EventEmitter } from "./event-emitter.js" +/** + * Interface for a collection-like object that provides the necessary methods + * for the change events system to work + */ +export interface CollectionLike< + T extends object = Record, + TKey extends string | number = string | number, +> extends Pick< + Collection, + `get` | `has` | `entries` | `indexes` | `id` | `compareOptions` + > {} + +/** + * StringSortOpts - Options for string sorting behavior + * + * This discriminated union allows for two types of string sorting: + * - **Lexical**: Simple character-by-character comparison (default) + * - **Locale**: Locale-aware sorting with optional customization + * + * The union ensures that locale options are only available when locale sorting is selected. + */ +export type StringCollationConfig = + | { + stringSort?: `lexical` + } + | { + stringSort?: `locale` + locale?: string + localeOptions?: object + } + /** * Helper type to extract the output type from a standard schema * @@ -582,6 +613,14 @@ export interface BaseCollectionConfig< */ onDelete?: DeleteMutationFn + /** + * Specifies how to compare data in the collection. + * This should be configured to match data ordering on the backend. + * E.g., when using the Electric DB collection these options + * should match the database's collation settings. + */ + defaultStringCollation?: StringCollationConfig + utils?: TUtils } diff --git a/packages/db/src/utils/index-optimization.ts b/packages/db/src/utils/index-optimization.ts index 77c8861ed..a42b5086c 100644 --- a/packages/db/src/utils/index-optimization.ts +++ b/packages/db/src/utils/index-optimization.ts @@ -18,12 +18,9 @@ import { DEFAULT_COMPARE_OPTIONS } from "../utils.js" import { ReverseIndex } from "../indexes/reverse-index.js" import type { CompareOptions } from "../query/builder/types.js" -import type { - BaseIndex, - IndexInterface, - IndexOperation, -} from "../indexes/base-index.js" +import type { IndexInterface, IndexOperation } from "../indexes/base-index.js" import type { BasicExpression } from "../query/ir.js" +import type { CollectionLike } from "../types.js" /** * Result of index-based query optimization @@ -37,16 +34,21 @@ export interface OptimizationResult { * Finds an index that matches a given field path */ export function findIndexForField( - indexes: Map>, + collection: CollectionLike, fieldPath: Array, - compareOptions: CompareOptions = DEFAULT_COMPARE_OPTIONS + compareOptions?: CompareOptions ): IndexInterface | undefined { - for (const index of indexes.values()) { + const compareOpts = compareOptions ?? { + ...DEFAULT_COMPARE_OPTIONS, + ...collection.compareOptions, + } + + for (const index of collection.indexes.values()) { if ( index.matchesField(fieldPath) && - index.matchesCompareOptions(compareOptions) + index.matchesCompareOptions(compareOpts) ) { - if (!index.matchesDirection(compareOptions.direction)) { + if (!index.matchesDirection(compareOpts.direction)) { return new ReverseIndex(index) } return index @@ -91,19 +93,22 @@ export function unionSets(sets: Array>): Set { /** * Optimizes a query expression using available indexes to find matching keys */ -export function optimizeExpressionWithIndexes( +export function optimizeExpressionWithIndexes< + T extends object, + TKey extends string | number, +>( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { - return optimizeQueryRecursive(expression, indexes) + return optimizeQueryRecursive(expression, collection) } /** * Recursively optimizes query expressions */ -function optimizeQueryRecursive( +function optimizeQueryRecursive( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { if (expression.type === `func`) { switch (expression.name) { @@ -112,16 +117,16 @@ function optimizeQueryRecursive( case `gte`: case `lt`: case `lte`: - return optimizeSimpleComparison(expression, indexes) + return optimizeSimpleComparison(expression, collection) case `and`: - return optimizeAndExpression(expression, indexes) + return optimizeAndExpression(expression, collection) case `or`: - return optimizeOrExpression(expression, indexes) + return optimizeOrExpression(expression, collection) case `in`: - return optimizeInArrayExpression(expression, indexes) + return optimizeInArrayExpression(expression, collection) } } @@ -131,10 +136,10 @@ function optimizeQueryRecursive( /** * Checks if an expression can be optimized */ -export function canOptimizeExpression( - expression: BasicExpression, - indexes: Map> -): boolean { +export function canOptimizeExpression< + T extends object, + TKey extends string | number, +>(expression: BasicExpression, collection: CollectionLike): boolean { if (expression.type === `func`) { switch (expression.name) { case `eq`: @@ -142,16 +147,16 @@ export function canOptimizeExpression( case `gte`: case `lt`: case `lte`: - return canOptimizeSimpleComparison(expression, indexes) + return canOptimizeSimpleComparison(expression, collection) case `and`: - return canOptimizeAndExpression(expression, indexes) + return canOptimizeAndExpression(expression, collection) case `or`: - return canOptimizeOrExpression(expression, indexes) + return canOptimizeOrExpression(expression, collection) case `in`: - return canOptimizeInArrayExpression(expression, indexes) + return canOptimizeInArrayExpression(expression, collection) } } @@ -162,9 +167,12 @@ export function canOptimizeExpression( * Optimizes compound range queries on the same field * Example: WHERE age > 5 AND age < 10 */ -function optimizeCompoundRangeQuery( +function optimizeCompoundRangeQuery< + T extends object, + TKey extends string | number, +>( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { if (expression.type !== `func` || expression.args.length < 2) { return { canOptimize: false, matchingKeys: new Set() } @@ -236,7 +244,7 @@ function optimizeCompoundRangeQuery( for (const [fieldKey, operations] of fieldOperations) { if (operations.length >= 2) { const fieldPath = fieldKey.split(`.`) - const index = findIndexForField(indexes, fieldPath) + const index = findIndexForField(collection, fieldPath) if (index && index.supports(`gt`) && index.supports(`lt`)) { // Build range query options @@ -292,9 +300,12 @@ function optimizeCompoundRangeQuery( /** * Optimizes simple comparison expressions (eq, gt, gte, lt, lte) */ -function optimizeSimpleComparison( +function optimizeSimpleComparison< + T extends object, + TKey extends string | number, +>( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { if (expression.type !== `func` || expression.args.length !== 2) { return { canOptimize: false, matchingKeys: new Set() } @@ -337,7 +348,7 @@ function optimizeSimpleComparison( if (fieldArg && valueArg) { const fieldPath = (fieldArg as any).path - const index = findIndexForField(indexes, fieldPath) + const index = findIndexForField(collection, fieldPath) if (index) { const queryValue = (valueArg as any).value @@ -361,10 +372,10 @@ function optimizeSimpleComparison( /** * Checks if a simple comparison can be optimized */ -function canOptimizeSimpleComparison( - expression: BasicExpression, - indexes: Map> -): boolean { +function canOptimizeSimpleComparison< + T extends object, + TKey extends string | number, +>(expression: BasicExpression, collection: CollectionLike): boolean { if (expression.type !== `func` || expression.args.length !== 2) { return false } @@ -382,7 +393,7 @@ function canOptimizeSimpleComparison( } if (fieldPath) { - const index = findIndexForField(indexes, fieldPath) + const index = findIndexForField(collection, fieldPath) return index !== undefined } @@ -392,16 +403,16 @@ function canOptimizeSimpleComparison( /** * Optimizes AND expressions */ -function optimizeAndExpression( +function optimizeAndExpression( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { if (expression.type !== `func` || expression.args.length < 2) { return { canOptimize: false, matchingKeys: new Set() } } // First, try to optimize compound range queries on the same field - const compoundRangeResult = optimizeCompoundRangeQuery(expression, indexes) + const compoundRangeResult = optimizeCompoundRangeQuery(expression, collection) if (compoundRangeResult.canOptimize) { return compoundRangeResult } @@ -410,7 +421,7 @@ function optimizeAndExpression( // Try to optimize each part, keep the optimizable ones for (const arg of expression.args) { - const result = optimizeQueryRecursive(arg, indexes) + const result = optimizeQueryRecursive(arg, collection) if (result.canOptimize) { results.push(result) } @@ -429,24 +440,24 @@ function optimizeAndExpression( /** * Checks if an AND expression can be optimized */ -function canOptimizeAndExpression( - expression: BasicExpression, - indexes: Map> -): boolean { +function canOptimizeAndExpression< + T extends object, + TKey extends string | number, +>(expression: BasicExpression, collection: CollectionLike): boolean { if (expression.type !== `func` || expression.args.length < 2) { return false } // If any argument can be optimized, we can gain some speedup - return expression.args.some((arg) => canOptimizeExpression(arg, indexes)) + return expression.args.some((arg) => canOptimizeExpression(arg, collection)) } /** * Optimizes OR expressions */ -function optimizeOrExpression( +function optimizeOrExpression( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { if (expression.type !== `func` || expression.args.length < 2) { return { canOptimize: false, matchingKeys: new Set() } @@ -456,7 +467,7 @@ function optimizeOrExpression( // Try to optimize each part, keep the optimizable ones for (const arg of expression.args) { - const result = optimizeQueryRecursive(arg, indexes) + const result = optimizeQueryRecursive(arg, collection) if (result.canOptimize) { results.push(result) } @@ -475,24 +486,27 @@ function optimizeOrExpression( /** * Checks if an OR expression can be optimized */ -function canOptimizeOrExpression( - expression: BasicExpression, - indexes: Map> -): boolean { +function canOptimizeOrExpression< + T extends object, + TKey extends string | number, +>(expression: BasicExpression, collection: CollectionLike): boolean { if (expression.type !== `func` || expression.args.length < 2) { return false } // If any argument can be optimized, we can gain some speedup - return expression.args.some((arg) => canOptimizeExpression(arg, indexes)) + return expression.args.some((arg) => canOptimizeExpression(arg, collection)) } /** * Optimizes IN array expressions */ -function optimizeInArrayExpression( +function optimizeInArrayExpression< + T extends object, + TKey extends string | number, +>( expression: BasicExpression, - indexes: Map> + collection: CollectionLike ): OptimizationResult { if (expression.type !== `func` || expression.args.length !== 2) { return { canOptimize: false, matchingKeys: new Set() } @@ -508,7 +522,7 @@ function optimizeInArrayExpression( ) { const fieldPath = (fieldArg as any).path const values = (arrayArg as any).value - const index = findIndexForField(indexes, fieldPath) + const index = findIndexForField(collection, fieldPath) if (index) { // Check if the index supports IN operation @@ -535,10 +549,10 @@ function optimizeInArrayExpression( /** * Checks if an IN array expression can be optimized */ -function canOptimizeInArrayExpression( - expression: BasicExpression, - indexes: Map> -): boolean { +function canOptimizeInArrayExpression< + T extends object, + TKey extends string | number, +>(expression: BasicExpression, collection: CollectionLike): boolean { if (expression.type !== `func` || expression.args.length !== 2) { return false } @@ -552,7 +566,7 @@ function canOptimizeInArrayExpression( Array.isArray((arrayArg as any).value) ) { const fieldPath = (fieldArg as any).path - const index = findIndexForField(indexes, fieldPath) + const index = findIndexForField(collection, fieldPath) return index !== undefined } diff --git a/packages/db/tests/query/builder/order-by.test.ts b/packages/db/tests/query/builder/order-by.test.ts index 378371d9e..51f7c075a 100644 --- a/packages/db/tests/query/builder/order-by.test.ts +++ b/packages/db/tests/query/builder/order-by.test.ts @@ -40,7 +40,7 @@ describe(`QueryBuilder.orderBy`, () => { ]) expect(builtQuery.orderBy![0]!.compareOptions.direction).toBe(`asc`) expect(builtQuery.orderBy![0]!.compareOptions.nulls).toBe(`first`) - expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBe(`locale`) + expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBeUndefined() }) it(`supports descending order`, () => { @@ -62,7 +62,7 @@ describe(`QueryBuilder.orderBy`, () => { ]) expect(builtQuery.orderBy![0]!.compareOptions.direction).toBe(`desc`) expect(builtQuery.orderBy![0]!.compareOptions.nulls).toBe(`first`) - expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBe(`locale`) + expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBeUndefined() }) it(`supports ascending order explicitly`, () => { @@ -94,7 +94,7 @@ describe(`QueryBuilder.orderBy`, () => { expect(builtQuery.orderBy).toHaveLength(1) expect(builtQuery.orderBy![0]!.compareOptions.direction).toBe(`asc`) expect(builtQuery.orderBy![0]!.compareOptions.nulls).toBe(`last`) - expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBe(`locale`) + expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBeUndefined() }) it(`supports stringSort`, () => { @@ -136,7 +136,7 @@ describe(`QueryBuilder.orderBy`, () => { expect(builtQuery.orderBy).toBeDefined() expect(builtQuery.orderBy).toHaveLength(1) expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBe(`locale`) - expect(builtQuery.orderBy![0]!.compareOptions.locale).toBe(`de-DE`) + expect((builtQuery.orderBy![0]!.compareOptions as any).locale).toBe(`de-DE`) expect(builtQuery.orderBy![0]!.compareOptions.nulls).toBe(`first`) expect(builtQuery.orderBy![0]!.compareOptions.direction).toBe(`asc`) }) @@ -160,8 +160,10 @@ describe(`QueryBuilder.orderBy`, () => { expect(builtQuery.orderBy).toBeDefined() expect(builtQuery.orderBy).toHaveLength(1) expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBe(`locale`) - expect(builtQuery.orderBy![0]!.compareOptions.locale).toBe(`de-DE`) - expect(builtQuery.orderBy![0]!.compareOptions.localeOptions).toEqual({ + expect((builtQuery.orderBy![0]!.compareOptions as any).locale).toBe(`de-DE`) + expect( + (builtQuery.orderBy![0]!.compareOptions as any).localeOptions + ).toEqual({ sensitivity: `base`, }) expect(builtQuery.orderBy![0]!.compareOptions.nulls).toBe(`first`) @@ -202,7 +204,7 @@ describe(`QueryBuilder.orderBy`, () => { expect(orderByClause.expression.type).toBeDefined() expect(orderByClause.compareOptions.direction).toBe(`asc`) expect(orderByClause.compareOptions.nulls).toBe(`first`) - expect(orderByClause.compareOptions.stringSort).toBe(`locale`) + expect(orderByClause.compareOptions.stringSort).toBeUndefined() }) it(`can be combined with other clauses`, () => { @@ -241,14 +243,14 @@ describe(`QueryBuilder.orderBy`, () => { ]) expect(builtQuery.orderBy![0]!.compareOptions.direction).toBe(`asc`) expect(builtQuery.orderBy![0]!.compareOptions.nulls).toBe(`first`) - expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBe(`locale`) + expect(builtQuery.orderBy![0]!.compareOptions.stringSort).toBeUndefined() expect((builtQuery.orderBy![1]!.expression as any).path).toEqual([ `employees`, `salary`, ]) expect(builtQuery.orderBy![1]!.compareOptions.direction).toBe(`desc`) expect(builtQuery.orderBy![1]!.compareOptions.nulls).toBe(`first`) - expect(builtQuery.orderBy![1]!.compareOptions.stringSort).toBe(`locale`) + expect(builtQuery.orderBy![1]!.compareOptions.stringSort).toBeUndefined() }) it(`supports limit and offset with order by`, () => { diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 24cbd0ee4..42a6d1c04 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -100,6 +100,139 @@ describe(`createLiveQueryCollection`, () => { expect(activeUsers2.size).toBe(2) }) + describe(`compareOptions inheritance`, () => { + it(`should inherit compareOptions from FROM collection`, async () => { + // Create a collection with non-default compareOptions + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `source-with-lexical`, + getKey: (user) => user.id, + initialData: sampleUsers, + defaultStringCollation: { + stringSort: `lexical`, + }, + }) + ) + + // Create a live query collection from the source collection + const liveQuery = createLiveQueryCollection((q) => + q.from({ user: sourceCollection }) + ) + + // The live query should inherit the compareOptions from the source collection + expect(liveQuery.compareOptions).toEqual({ + stringSort: `lexical`, + }) + expect(sourceCollection.compareOptions).toEqual({ + stringSort: `lexical`, + }) + }) + + it(`should inherit compareOptions from FROM collection via subquery`, async () => { + // Create a collection with non-default compareOptions + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `source-with-locale`, + getKey: (user) => user.id, + initialData: sampleUsers, + defaultStringCollation: { + stringSort: `locale`, + locale: `de-DE`, + }, + }) + ) + + // Create a live query collection with a subquery + const liveQuery = createLiveQueryCollection((q) => { + // Build the subquery first + const filteredUsers = q + .from({ user: sourceCollection }) + .where(({ user }) => eq(user.active, true)) + + // Use the subquery in the main query + return q.from({ filteredUser: filteredUsers }) + }) + + // The live query should inherit the compareOptions from the source collection + // (which is the FROM collection of the subquery) + expect(liveQuery.compareOptions).toEqual({ + stringSort: `locale`, + locale: `de-DE`, + }) + expect(sourceCollection.compareOptions).toEqual({ + stringSort: `locale`, + locale: `de-DE`, + }) + }) + + it(`should use default compareOptions when FROM collection has no compareOptions`, async () => { + // Create a collection without compareOptions (uses defaults) + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `source-with-defaults`, + getKey: (user) => user.id, + initialData: sampleUsers, + // No compareOptions specified - uses defaults + }) + ) + + // Create a live query collection with a subquery + const liveQuery = createLiveQueryCollection((q) => { + // Build the subquery first + const filteredUsers = q + .from({ user: sourceCollection }) + .where(({ user }) => eq(user.active, true)) + + // Use the subquery in the main query + return q.from({ filteredUser: filteredUsers }) + }) + + // The live query should use default compareOptions (locale) + // when the source collection doesn't specify compareOptions + expect(liveQuery.compareOptions).toEqual({ + stringSort: `locale`, + }) + expect(sourceCollection.compareOptions).toEqual({ + stringSort: `locale`, + }) + }) + + it(`should use explicitly provided compareOptions instead of inheriting from FROM collection`, async () => { + // Create a collection with non-default compareOptions + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `source-with-lexical`, + getKey: (user) => user.id, + initialData: sampleUsers, + defaultStringCollation: { + stringSort: `lexical`, + }, + }) + ) + + // Create a live query collection with explicitly provided compareOptions + // that differ from the source collection's compareOptions + const liveQuery = createLiveQueryCollection({ + query: (q) => q.from({ user: sourceCollection }), + defaultStringCollation: { + stringSort: `locale`, + locale: `en-US`, + }, + }) + + // The live query should use the explicitly provided compareOptions, + // not the inherited ones from the source collection + expect(liveQuery.compareOptions).toEqual({ + stringSort: `locale`, + locale: `en-US`, + }) + // The source collection should still have its original compareOptions + expect(sourceCollection.compareOptions).toEqual({ + stringSort: `lexical`, + }) + }) + }) + it(`should call markReady when source collection returns empty array`, async () => { // Create an empty source collection using the mock sync options const emptyUsersCollection = createCollection( diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index acf6d5248..67d5a75c4 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -2226,6 +2226,161 @@ describe(`Query2 OrderBy Compiler`, () => { createOrderByTests(`eager`) }) +describe(`OrderBy with collection-level StringSortOpts`, () => { + type StringItem = { + id: number + name: string + } + + const stringItemsData: Array = [ + { id: 1, name: `Charlie` }, + { id: 2, name: `alice` }, + { id: 3, name: `bob` }, + ] + + it(`should use collection's compareOptions when query doesn't specify stringSort`, async () => { + // Create collection with lexical string sorting as default + const collectionWithLexical = createCollection( + mockSyncCollectionOptions({ + id: `test-lexical-collection`, + getKey: (item) => item.id, + initialData: stringItemsData, + defaultStringCollation: { + stringSort: `lexical`, + }, + }) + ) + + // Query without specifying stringSort should use collection's lexical default + const lexicalQuery = createLiveQueryCollection((q) => + q + .from({ items: collectionWithLexical }) + .orderBy(({ items }) => items.name, `asc`) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })) + ) + await lexicalQuery.preload() + + // In lexical comparison, uppercase letters come before lowercase letters + const lexicalResults = Array.from(lexicalQuery.values()) + expect(lexicalResults.map((r) => r.name)).toEqual([ + `Charlie`, + `alice`, + `bob`, + ]) + }) + + it(`should override collection's compareOptions when query specifies stringSort`, async () => { + // Create collection with lexical string sorting as default + const collectionWithLexical = createCollection( + mockSyncCollectionOptions({ + id: `test-lexical-collection-override`, + getKey: (item) => item.id, + initialData: stringItemsData, + defaultStringCollation: { + stringSort: `lexical`, + }, + }) + ) + + // Query with explicit locale stringSort should override collection's lexical default + const localeQuery = createLiveQueryCollection((q) => + q + .from({ items: collectionWithLexical }) + .orderBy(({ items }) => items.name, { + direction: `asc`, + stringSort: `locale`, + locale: `en-US`, + }) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })) + ) + await localeQuery.preload() + + // In locale comparison, case-insensitive sorting puts lowercase first + const localeResults = Array.from(localeQuery.values()) + expect(localeResults.map((r) => r.name)).toEqual([ + `alice`, + `bob`, + `Charlie`, + ]) + }) + + it(`should use collection default and allow query overrides`, async () => { + // Create collection with lexical string sorting as default + const collectionWithLexical = createCollection( + mockSyncCollectionOptions({ + id: `test-lexical-collection-sequence`, + getKey: (item) => item.id, + initialData: stringItemsData, + defaultStringCollation: { + stringSort: `lexical`, + }, + }) + ) + + // First query without specifying stringSort should use collection's lexical default + const firstQuery = createLiveQueryCollection((q) => + q + .from({ items: collectionWithLexical }) + .orderBy(({ items }) => items.name, `asc`) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })) + ) + await firstQuery.preload() + + // In lexical comparison, uppercase letters come before lowercase letters + const firstResults = Array.from(firstQuery.values()) + expect(firstResults.map((r) => r.name)).toEqual([`Charlie`, `alice`, `bob`]) + + // Second query with explicit locale stringSort should override collection's lexical default + const secondQuery = createLiveQueryCollection((q) => + q + .from({ items: collectionWithLexical }) + .orderBy(({ items }) => items.name, { + direction: `asc`, + stringSort: `locale`, + locale: `en-US`, + }) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })) + ) + await secondQuery.preload() + + // Should use locale sorting (case-insensitive) for this query + const secondResults = Array.from(secondQuery.values()) + expect(secondResults.map((r) => r.name)).toEqual([ + `alice`, + `bob`, + `Charlie`, + ]) + + // Third query without specifying stringSort should use collection's lexical default again + const thirdQuery = createLiveQueryCollection((q) => + q + .from({ items: collectionWithLexical }) + .orderBy(({ items }) => items.name, `desc`) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })) + ) + await thirdQuery.preload() + + // Should revert back to lexical sorting (collection default) + const thirdResults = Array.from(thirdQuery.values()) + expect(thirdResults.map((r) => r.name)).toEqual([`bob`, `alice`, `Charlie`]) + }) +}) + describe(`OrderBy with collection alias conflicts`, () => { type EmailSchema = { email: string diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 98909b22f..26dad9c22 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -2,6 +2,7 @@ import { expect } from "vitest" import type { CollectionConfig, MutationFnParams, + StringCollationConfig, SyncConfig, } from "../src/index.js" @@ -179,6 +180,7 @@ type MockSyncCollectionConfig> = { autoIndex?: `off` | `eager` sync?: SyncConfig syncMode?: `eager` | `on-demand` + defaultStringCollation?: StringCollationConfig } export function mockSyncCollectionOptions<