From 34d9c08cc94cb647f1bdafc40ff7cb779e92be56 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 21 May 2026 14:24:33 -0600 Subject: [PATCH 1/4] docs(db): clarify caseWhen, coalesce, manual transactions, and multi-endpoint behavior Address documentation feedback: add caseWhen operator docs, clarify coalesce handles null/undefined, document manual transaction handler bypass semantics, and warn about multi-endpoint Query Collection pitfalls. Fix categorization of utility functions and reference index ordering. Co-Authored-By: Claude Opus 4.6 --- docs/collections/query-collection.md | 20 ++++++++ docs/guides/live-queries.md | 47 ++++++++++++++++++- docs/guides/mutations.md | 37 +++++++++++++++ docs/reference/index.md | 1 + docs/reference/variables/operators.md | 4 +- .../db/skills/db-core/live-queries/SKILL.md | 37 +++++++++++++-- .../db-core/mutations-optimistic/SKILL.md | 8 ++++ 7 files changed, 148 insertions(+), 6 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 266545adec..404e35db79 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -471,6 +471,26 @@ The query collection treats the `queryFn` result as the **complete state** of th - Items in the query result but not in the collection will be inserted - Items present in both will be updated if they differ +This is important when the same entity type can be loaded from multiple REST +endpoints. For example, do not point the same Query Collection at +`/api/documents/preview` for one load and `/api/documents/deleted` for another +unless each result represents the complete state for that collection +scope. A narrower endpoint can otherwise remove rows that were loaded from a +different endpoint. + +For multiple endpoint or subset-loading use cases, choose the pattern that +matches your API semantics: + +- Use `syncMode: 'on-demand'` when one logical collection can serve different + subsets of data. In this mode, query predicates (`where`, `orderBy`, `limit`, + and `offset`) are passed to your `queryFn` via `ctx.meta.loadSubsetOptions`, + letting you translate them into API parameters. +- Use separate Query Collections when endpoints represent distinct server scopes + whose results should not replace each other. +- Use direct writes such as `writeUpsert`/`writeBatch` for lower-level + incremental loading when you intentionally want to merge server responses into + the synced store yourself. + ### Empty Array Behavior When `queryFn` returns an empty array, **all items in the collection will be deleted**. This is because the collection interprets an empty array as "the server has no items". diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 5d2c59ee71..0780871a3b 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -2551,12 +2551,57 @@ Add two numbers: add(user.salary, user.bonus) ``` +### Utility Functions + #### `coalesce(...values)` -Return the first non-null value: +Return the first non-null/undefined value: ```ts coalesce(user.displayName, user.name, 'Unknown') ``` +This is useful for display fallbacks when the stored value is `null` or +`undefined`: + +```ts +.select(({ document }) => ({ + ...document, + displayTitle: coalesce(document.title, 'Untitled document'), +})) +``` + +If you also need to treat another value, such as an empty string, as missing, +use `caseWhen` to express that condition explicitly. + +#### `caseWhen(condition, value, ...)` +Return the value for the first matching condition, similar to SQL `CASE WHEN`. +Arguments are provided as condition/value pairs followed by an optional default +value: + +```ts +caseWhen(gt(user.age, 65), 'senior', gt(user.age, 18), 'adult', 'minor') +``` + +If no condition matches and no default value is provided, scalar expressions +return `null`. + +Use `caseWhen` when a computed field needs conditional logic that cannot be +represented with `coalesce` alone. For example, to display a fallback for both +`null`/`undefined` and empty-string titles: + +```ts +.select(({ document }) => ({ + ...document, + displayTitle: caseWhen( + eq(coalesce(document.title, ''), ''), + 'Untitled document', + document.title, + ), +})) +``` + +`caseWhen` can also be used in expression contexts such as `select`, `where`, +`orderBy`, `groupBy`, `having`, and equality join operands. + ### Aggregate Functions #### `count(value)` diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index b3f6980840..d27a776d6f 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -788,6 +788,43 @@ await tx.commit() tx.rollback() ``` +When you call collection methods inside `tx.mutate()`, the mutations are +captured by the manual transaction. The collection's `onInsert`, `onUpdate`, and +`onDelete` handlers are not invoked for those mutations; the +manual transaction's `mutationFn` is responsible for persisting +`transaction.mutations`. + +This makes manual transactions a good fit for draft-style workflows where local +state should update immediately, but persistence should wait for a later user +action such as Save or Blur: + +```ts +const editTx = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + await api.updateTodos( + transaction.mutations.map((mutation) => ({ + id: mutation.key, + changes: mutation.changes, + })) + ) + }, +}) + +// Update local optimistic state while the user edits. +editTx.mutate(() => { + todoCollection.update(todoId, (draft) => { + draft.text = nextText + }) +}) + +// Later, when the user saves or the input blurs: +await editTx.commit() + +// Or discard the local optimistic changes: +// editTx.rollback() +``` + ### Multi-Step Workflows Manual transactions excel at complex workflows: diff --git a/docs/reference/index.md b/docs/reference/index.md index 64f9109a31..ce09d71916 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -265,6 +265,7 @@ title: "@tanstack/db" - [add](functions/add.md) - [and](functions/and.md) - [avg](functions/avg.md) +- [caseWhen](functions/caseWhen.md) - [clearQueryPatterns](functions/clearQueryPatterns.md) - [coalesce](functions/coalesce.md) - [compileExpression](functions/compileExpression.md) diff --git a/docs/reference/variables/operators.md b/docs/reference/variables/operators.md index ba035e48ec..84e9a00189 100644 --- a/docs/reference/variables/operators.md +++ b/docs/reference/variables/operators.md @@ -6,9 +6,9 @@ title: operators # Variable: operators ```ts -const operators: readonly ["eq", "gt", "gte", "lt", "lte", "in", "like", "ilike", "and", "or", "not", "isNull", "isUndefined", "upper", "lower", "length", "concat", "add", "coalesce", "count", "avg", "sum", "min", "max"]; +const operators: readonly ["eq", "gt", "gte", "lt", "lte", "in", "like", "ilike", "and", "or", "not", "isNull", "isUndefined", "upper", "lower", "length", "concat", "add", "coalesce", "caseWhen", "count", "avg", "sum", "min", "max"]; ``` -Defined in: [packages/db/src/query/builder/functions.ts:403](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/functions.ts#L403) +Defined in: [packages/db/src/query/builder/functions.ts](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/functions.ts) All supported operator names in TanStack DB expressions diff --git a/packages/db/skills/db-core/live-queries/SKILL.md b/packages/db/skills/db-core/live-queries/SKILL.md index 5409887075..fe55967ca9 100644 --- a/packages/db/skills/db-core/live-queries/SKILL.md +++ b/packages/db/skills/db-core/live-queries/SKILL.md @@ -5,8 +5,8 @@ description: > fullJoin, select, fn.select, groupBy, having, orderBy, limit, offset, distinct, findOne. Operators: eq, gt, gte, lt, lte, like, ilike, inArray, isNull, isUndefined, and, or, not. Aggregates: count, sum, avg, min, max. String - functions: upper, lower, length, concat, coalesce. Math: add. $selected - namespace. createLiveQueryCollection. Derived collections. Predicate push-down. + functions: upper, lower, length, concat. Utility: coalesce, caseWhen. Math: add. + $selected namespace. createLiveQueryCollection. Derived collections. Predicate push-down. Incremental view maintenance via differential dataflow (d2ts). Virtual properties ($synced, $origin, $key, $collectionId). Includes subqueries for hierarchical data. toArray and concat(toArray(...)) scalar includes. @@ -385,7 +385,7 @@ const { data } = useLiveQuery((q) => ### HIGH: Not using the full operator set -The library provides string functions (`upper`, `lower`, `length`, `concat`), math (`add`), utility (`coalesce`), and aggregates (`count`, `sum`, `avg`, `min`, `max`). All are incrementally maintained. Prefer them over JS equivalents. +The library provides string functions (`upper`, `lower`, `length`, `concat`), math (`add`), utility functions (`coalesce`, `caseWhen`), and aggregates (`count`, `sum`, `avg`, `min`, `max`). All are incrementally maintained. Prefer them over JS equivalents. ```ts // WRONG @@ -398,9 +398,40 @@ The library provides string functions (`upper`, `lower`, `length`, `concat`), ma .select(({ user, order }) => ({ name: upper(user.name), total: add(order.price, order.tax), + displayName: coalesce(user.displayName, user.name, 'Unknown'), })) ``` +### HIGH: Missing conditional expression helpers + +Use `coalesce()` for null/undefined fallbacks and `caseWhen()` for conditional +computed fields. JavaScript operators like `||` or ternaries do not build query +expressions inside standard `.select()` callbacks. + +```ts +// WRONG -- document.title is a query ref, not a runtime string +.select(({ document }) => ({ + displayTitle: document.title || 'Untitled document', +})) + +// CORRECT -- fallback for null/undefined +.select(({ document }) => ({ + displayTitle: coalesce(document.title, 'Untitled document'), +})) + +// CORRECT -- fallback for null/undefined and empty string +.select(({ document }) => ({ + displayTitle: caseWhen( + eq(coalesce(document.title, ''), ''), + 'Untitled document', + document.title, + ), +})) +``` + +Use `fn.select()` only when you genuinely need arbitrary JavaScript; it cannot +be optimized like expression-based `.select()`. + ### HIGH: .distinct() without .select() `distinct()` deduplicates by the selected columns. Without `select()`, throws `DistinctRequiresSelectError`. diff --git a/packages/db/skills/db-core/mutations-optimistic/SKILL.md b/packages/db/skills/db-core/mutations-optimistic/SKILL.md index 0713251c31..eced517738 100644 --- a/packages/db/skills/db-core/mutations-optimistic/SKILL.md +++ b/packages/db/skills/db-core/mutations-optimistic/SKILL.md @@ -209,6 +209,14 @@ Inside `tx.mutate(() => { ... })`, the transaction is pushed onto an ambient stack. Any `collection.insert/update/delete` call joins the ambient transaction automatically via `getActiveTransaction()`. +For mutations captured by a manual transaction, collection-level +`onInsert`/`onUpdate`/`onDelete` handlers are not invoked automatically. The +manual transaction's `mutationFn` is responsible for persisting +`transaction.mutations`. This makes `createTransaction({ autoCommit: false })` +a good fit for draft-style flows where local state updates immediately but the +server call waits for Save/Blur; call `tx.rollback()` to discard the optimistic +changes. + ### 4. Mutation handler with refetch (QueryCollection pattern) ```ts From 38906d9b561af0be87f32714bcad0e48344e2647 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 21 May 2026 14:25:23 -0600 Subject: [PATCH 2/4] chore: add changeset for docs clarifications Co-Authored-By: Claude Opus 4.6 --- .changeset/docs-feedback-clarifications.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/docs-feedback-clarifications.md diff --git a/.changeset/docs-feedback-clarifications.md b/.changeset/docs-feedback-clarifications.md new file mode 100644 index 0000000000..fa8768254e --- /dev/null +++ b/.changeset/docs-feedback-clarifications.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Clarify documentation for caseWhen, coalesce, manual transactions, and multi-endpoint Query Collection behavior. Add utility function categorization and fix reference index ordering. From 5cb0574de09fd3056f032d3069251afd1ae48e5f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 21 May 2026 14:42:10 -0600 Subject: [PATCH 3/4] docs(db): mention unionAll as option for multi-endpoint collections Co-Authored-By: Claude Opus 4.6 --- docs/collections/query-collection.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 404e35db79..73f3511edc 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -486,7 +486,8 @@ matches your API semantics: and `offset`) are passed to your `queryFn` via `ctx.meta.loadSubsetOptions`, letting you translate them into API parameters. - Use separate Query Collections when endpoints represent distinct server scopes - whose results should not replace each other. + whose results should not replace each other. Use `unionAll` to combine them + into a single query when you need a unified view across endpoints. - Use direct writes such as `writeUpsert`/`writeBatch` for lower-level incremental loading when you intentionally want to merge server responses into the synced store yourself. From 83cfa981bf9cd8d0654cc95263182be07ee68986 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 11:17:39 -0600 Subject: [PATCH 4/4] docs(db): replace redundant manual transaction example with explanatory prose Co-Authored-By: Claude Opus 4.6 --- docs/guides/mutations.md | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index d27a776d6f..9a60c47dc5 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -796,34 +796,11 @@ manual transaction's `mutationFn` is responsible for persisting This makes manual transactions a good fit for draft-style workflows where local state should update immediately, but persistence should wait for a later user -action such as Save or Blur: - -```ts -const editTx = createTransaction({ - autoCommit: false, - mutationFn: async ({ transaction }) => { - await api.updateTodos( - transaction.mutations.map((mutation) => ({ - id: mutation.key, - changes: mutation.changes, - })) - ) - }, -}) - -// Update local optimistic state while the user edits. -editTx.mutate(() => { - todoCollection.update(todoId, (draft) => { - draft.text = nextText - }) -}) - -// Later, when the user saves or the input blurs: -await editTx.commit() - -// Or discard the local optimistic changes: -// editTx.rollback() -``` +action such as Save or Blur. Each `tx.mutate()` call updates the optimistic +state instantly, so the UI reflects changes as the user types. When the user +triggers Save, `tx.commit()` fires the `mutationFn` to persist all accumulated +mutations in a single batch. If the user cancels instead, `tx.rollback()` +discards the optimistic changes and reverts the UI. ### Multi-Step Workflows