authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-11 15:53:59-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-11 16:27:27-07:00
log47304f56e98bf841b757d16e472fb64a01c99fd2
tree3ad08b3c5212b52b46b0f33e396d5c1928fce6fb
parent4b09ff4dba5fad5b7e2b44a0bdc2789918f74561
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

chore: arrayUpsert and objArrayUpsert


3 files changed, 300 insertions(+), 11 deletions(-)

readme.md+2
...@@ -191,6 +191,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list...@@ -191,6 +191,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
191 - `arrayRemove` - remove items by a `filter` function191 - `arrayRemove` - remove items by a `filter` function
192 - `arrayFilter` - preserve items by a `filter` function192 - `arrayFilter` - preserve items by a `filter` function
193 - `arrayUpdate` - update items by a `filter` + `update` function193 - `arrayUpdate` - update items by a `filter` + `update` function
194 - `arrayUpsert` - update items by a `filter`, or insert when there is no match
194 - `arrayInsertIndex` - insert an item at an index195 - `arrayInsertIndex` - insert an item at an index
195- Queries that are complex objects. Each function takes a type-safe object path to196- Queries that are complex objects. Each function takes a type-safe object path to
196 evaluate.197 evaluate.
...@@ -204,6 +205,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list...@@ -204,6 +205,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
204 - `objArrayRemove` - remove items from array by `filter`205 - `objArrayRemove` - remove items from array by `filter`
205 - `objArrayFilter` - preserve items from array by `filter`206 - `objArrayFilter` - preserve items from array by `filter`
206 - `objArrayUpdate` - update items in array by `filter` + `update`207 - `objArrayUpdate` - update items in array by `filter` + `update`
208 - `objArrayUpsert` - update items in array by `filter`, or insert when there is no match
207 - `objArrayInsertIndex` - insert an item in an array at an index209 - `objArrayInsertIndex` - insert an item in an array at an index
208210
209## Debouncing211## Debouncing
src/tanstack-query.ts+106-11
...@@ -7,6 +7,10 @@ export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {...@@ -7,6 +7,10 @@ export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {
7 queryFn?: QueryFunction<T, any, never> | undefined;7 queryFn?: QueryFunction<T, any, never> | undefined;
8};8};
99
10type RequireAtLeastOneKey<T> = {
11 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
12}[keyof T];
13
10export function boundQueryClientGet(14export function boundQueryClientGet(
11 queryClient: QueryClient,15 queryClient: QueryClient,
12): <T>({ queryKey }: QueryKeyAndFn<T>) => T | undefined {16): <T>({ queryKey }: QueryKeyAndFn<T>) => T | undefined {
...@@ -50,7 +54,7 @@ class TanstackQueryOptimisticHelpers {...@@ -50,7 +54,7 @@ class TanstackQueryOptimisticHelpers {
50 set = <Data>(54 set = <Data>(
51 queryKey: QueryKeyAndFn<Data>,55 queryKey: QueryKeyAndFn<Data>,
52 value: Data | ((prev: Data | undefined) => Data | undefined),56 value: Data | ((prev: Data | undefined) => Data | undefined),
53 ) => {57 ): void => {
54 const prev = this.#get(queryKey);58 const prev = this.#get(queryKey);
5559
56 const newValue = typeof value === "function"60 const newValue = typeof value === "function"
...@@ -378,8 +382,11 @@ class TanstackQueryOptimisticHelpers {...@@ -378,8 +382,11 @@ class TanstackQueryOptimisticHelpers {
378 >(382 >(
379 queryKey: QueryKeyAndFn<Data>,383 queryKey: QueryKeyAndFn<Data>,
380 path: Path,384 path: Path,
381 { filter, update }: {385 {
382 filter: (386 filter,
387 update,
388 }: {
389 filter?: (
383 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,390 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
384 index: number,391 index: number,
385 ) => boolean;392 ) => boolean;
...@@ -387,13 +394,62 @@ class TanstackQueryOptimisticHelpers {...@@ -387,13 +394,62 @@ class TanstackQueryOptimisticHelpers {
387 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,394 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
388 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;395 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
389 },396 },
390 ) => {397 ): { inserted: boolean } => {
398 return this.objArrayUpsert(queryKey, path, {
399 filter: filter ?? (() => true),
400 update,
401 });
402 };
403
404 /**
405 * Update items in an array that match a `filter`, or insert a new one if there was no match.
406 * If the query or path doesn't exist, the updater is skipped.
407 */
408 objArrayUpsert = <
409 Data extends object,
410 const Path extends AllObjectPaths<Data>,
411 >(
412 queryKey: QueryKeyAndFn<Data>,
413 path: Path,
414 {
415 filter,
416 update,
417 insert,
418 }:
419 & {
420 filter: (
421 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
422 index: number,
423 ) => boolean;
424 }
425 & RequireAtLeastOneKey<{
426 /** Defaults to the identity function */
427 update: (
428 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
429 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
430 /** Defaults to not inserting */
431 insert: () => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
432 }>,
433 ): { inserted: boolean } => {
391 const prev = this.#get(queryKey);434 const prev = this.#get(queryKey);
392 if (!prev) return;435 if (!prev) return { inserted: false };
393 const { value: original, exists } = getPath(prev, path);436 const { value: original, exists } = getPath(prev, path);
394 if (!exists || !Array.isArray(original)) return;437 if (!exists || !Array.isArray(original)) return { inserted: false };
395438
396 const newArray = original.map((item, index) => filter(item, index) ? update(item) : item);439 let matched = false;
440 const newArray = original.map((item, index) => {
441 if (filter(item, index)) {
442 matched = true;
443 return update ? update(item) : item;
444 } else {
445 return item;
446 }
447 });
448 let inserted = false;
449 if (!matched && insert) {
450 newArray.push(insert());
451 inserted = true;
452 }
397 this.#set(453 this.#set(
398 queryKey,454 queryKey,
399 (obj) => obj ? setPath(obj, path, newArray as any) : obj,455 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
...@@ -402,6 +458,7 @@ class TanstackQueryOptimisticHelpers {...@@ -402,6 +458,7 @@ class TanstackQueryOptimisticHelpers {
402 // TODO: splice items back in case original changed458 // TODO: splice items back in case original changed
403 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);459 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
404 });460 });
461 return { inserted };
405 };462 };
406463
407 /**464 /**
...@@ -439,7 +496,7 @@ class TanstackQueryOptimisticHelpers {...@@ -439,7 +496,7 @@ class TanstackQueryOptimisticHelpers {
439 };496 };
440497
441 /**498 /**
442 * Push item(s) to the end of an array at an object path.499 * Push item(s) to the end of an array.
443 * If the query or path doesn't exist, the updater is skipped.500 * If the query or path doesn't exist, the updater is skipped.
444 */501 */
445 arrayPush = <Data>(queryKey: QueryKeyAndFn<Data[] | null>, ...items: Data[]) => {502 arrayPush = <Data>(queryKey: QueryKeyAndFn<Data[] | null>, ...items: Data[]) => {
...@@ -531,16 +588,54 @@ class TanstackQueryOptimisticHelpers {...@@ -531,16 +588,54 @@ class TanstackQueryOptimisticHelpers {
531 filter?: (item: Data, index: number) => boolean;588 filter?: (item: Data, index: number) => boolean;
532 update: (item: Data) => Data;589 update: (item: Data) => Data;
533 },590 },
534 ) => {591 ): { inserted: boolean } => {
592 return this.arrayUpsert(queryKey, { filter: filter ?? (() => true), update });
593 };
594
595 /**
596 * Update items in an array that match a `filter`, or insert a new one if there was no match.
597 * If the query doesn't exist, the updater is skipped.
598 */
599 arrayUpsert = <Data>(
600 queryKey: QueryKeyAndFn<Data[] | null>,
601 {
602 filter,
603 update,
604 insert,
605 }:
606 & {
607 filter: (item: Data, index: number) => boolean;
608 }
609 & RequireAtLeastOneKey<{
610 /** Defaults to the identity function */
611 update: (item: Data) => Data;
612 /** Defaults to not inserting */
613 insert: () => Data;
614 }>,
615 ): { inserted: boolean } => {
535 const prev = this.#get(queryKey);616 const prev = this.#get(queryKey);
536 if (!prev || !Array.isArray(prev)) return;617 if (!prev || !Array.isArray(prev)) return { inserted: false };
537618
538 const newArray = prev.map((item, index) => (filter ? filter(item, index) : true) ? update(item) : item);619 let matched = false;
620 const newArray = prev.map((item, index) => {
621 if (filter(item, index)) {
622 matched = true;
623 return update ? update(item) : item;
624 } else {
625 return item;
626 }
627 });
628 let inserted = false;
629 if (!matched && insert) {
630 newArray.push(insert());
631 inserted = true;
632 }
539 this.#set(queryKey, newArray);633 this.#set(queryKey, newArray);
540 this.#onRestore(() => {634 this.#onRestore(() => {
541 // TODO: splice items back in case original changed635 // TODO: splice items back in case original changed
542 this.#set(queryKey, prev);636 this.#set(queryKey, prev);
543 });637 });
638 return { inserted };
544 };639 };
545640
546 /**641 /**
test/tanstack-query-helpers.test.ts+192
...@@ -15,6 +15,11 @@ interface TestData {...@@ -15,6 +15,11 @@ interface TestData {
15 tags: string[];15 tags: string[];
16}16}
1717
18interface TestArrayItem {
19 id: number;
20 label: string;
21}
22
18const initialData: TestData = {23const initialData: TestData = {
19 name: "Test",24 name: "Test",
20 count: 10,25 count: 10,
...@@ -31,6 +36,12 @@ const initialData: TestData = {...@@ -31,6 +36,12 @@ const initialData: TestData = {
31 tags: ["alpha", "beta", "gamma"],36 tags: ["alpha", "beta", "gamma"],
32};37};
3338
39const initialArrayItems: TestArrayItem[] = [
40 { id: 1, label: "first" },
41 { id: 2, label: "second" },
42 { id: 3, label: "third" },
43];
44
34function createTestQueryClient() {45function createTestQueryClient() {
35 const client = new QueryClient({46 const client = new QueryClient({
36 defaultOptions: { queries: { retry: false } },47 defaultOptions: { queries: { retry: false } },
...@@ -46,6 +57,21 @@ function createTestQueryClient() {...@@ -46,6 +57,21 @@ function createTestQueryClient() {
46 return { client, queryTest };57 return { client, queryTest };
47}58}
4859
60function createArrayQueryClient() {
61 const client = new QueryClient({
62 defaultOptions: { queries: { retry: false } },
63 });
64
65 const queryArray = queryOptions({
66 queryKey: ["test-array"],
67 queryFn: (): TestArrayItem[] => initialArrayItems,
68 });
69
70 client.setQueryData(queryArray.queryKey, structuredClone(initialArrayItems));
71
72 return { client, queryArray };
73}
74
49test("helpers can be spread and retain bound this", () => {75test("helpers can be spread and retain bound this", () => {
50 const { client, queryTest } = createTestQueryClient();76 const { client, queryTest } = createTestQueryClient();
5177
...@@ -663,6 +689,172 @@ test("arrayUpdateItem - predicate receives index", () => {...@@ -663,6 +689,172 @@ test("arrayUpdateItem - predicate receives index", () => {
663 assertEquals(result?.items[0].label, "FIRST");689 assertEquals(result?.items[0].label, "FIRST");
664});690});
665691
692test("arrayUpdateItem - should update all items when filter is omitted", () => {
693 const { client, queryTest } = createTestQueryClient();
694
695 const helpers = queryClientOptimisticHelpers(client)({
696 onRestore: () => {},
697 onRefetch: () => {},
698 });
699
700 helpers.objArrayUpdate(
701 queryTest,
702 ["items"],
703 {
704 update: (item) => ({ ...item, label: item.label.toUpperCase() }),
705 },
706 );
707
708 const result = client.getQueryData<TestData>(queryTest.queryKey);
709 assertEquals(result?.items.map((item) => item.label), ["FIRST", "SECOND", "THIRD"]);
710});
711
712// ============================================================================
713// objArrayUpsert() tests
714// ============================================================================
715
716test("objArrayUpsert - should update matching items without inserting", () => {
717 const { client, queryTest } = createTestQueryClient();
718 const restoreFns: Array<() => void> = [];
719
720 const helpers = queryClientOptimisticHelpers(client)({
721 onRestore: (fn) => restoreFns.push(fn),
722 onRefetch: () => {},
723 });
724
725 const upsertResult = helpers.objArrayUpsert(
726 queryTest,
727 ["items"],
728 {
729 filter: (item) => item.id === 2,
730 update: (item) => ({ ...item, label: "UPDATED" }),
731 insert: () => ({ id: 4, label: "fourth" }),
732 },
733 );
734
735 const result = client.getQueryData<TestData>(queryTest.queryKey);
736 assertEquals(upsertResult, { inserted: false });
737 assertEquals(result?.items.length, 3);
738 assertEquals(result?.items[1], { id: 2, label: "UPDATED" });
739
740 restoreFns[0]();
741 const restored = client.getQueryData<TestData>(queryTest.queryKey);
742 assertEquals(restored?.items, initialData.items);
743});
744
745test("objArrayUpsert - should insert a new item when nothing matches", () => {
746 const { client, queryTest } = createTestQueryClient();
747 const restoreFns: Array<() => void> = [];
748
749 const helpers = queryClientOptimisticHelpers(client)({
750 onRestore: (fn) => restoreFns.push(fn),
751 onRefetch: () => {},
752 });
753
754 const upsertResult = helpers.objArrayUpsert(
755 queryTest,
756 ["items"],
757 {
758 filter: (item) => item.id === 4,
759 insert: () => ({ id: 4, label: "fourth" }),
760 },
761 );
762
763 const result = client.getQueryData<TestData>(queryTest.queryKey);
764 assertEquals(upsertResult, { inserted: true });
765 assertEquals(result?.items.length, 4);
766 assertEquals(result?.items[3], { id: 4, label: "fourth" });
767
768 restoreFns[0]();
769 const restored = client.getQueryData<TestData>(queryTest.queryKey);
770 assertEquals(restored?.items, initialData.items);
771});
772
773// ============================================================================
774// arrayUpsert() tests
775// ============================================================================
776
777test("arrayUpsert - should update matching items without inserting", () => {
778 const { client, queryArray } = createArrayQueryClient();
779 const restoreFns: Array<() => void> = [];
780
781 const helpers = queryClientOptimisticHelpers(client)({
782 onRestore: (fn) => restoreFns.push(fn),
783 onRefetch: () => {},
784 });
785
786 const upsertResult = helpers.arrayUpsert(
787 queryArray,
788 {
789 filter: (item) => item.id === 2,
790 update: (item) => ({ ...item, label: "UPDATED" }),
791 insert: () => ({ id: 4, label: "fourth" }),
792 },
793 );
794
795 const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
796 assertEquals(upsertResult, { inserted: false });
797 assertEquals(result?.length, 3);
798 assertEquals(result?.[1], { id: 2, label: "UPDATED" });
799
800 restoreFns[0]();
801 const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
802 assertEquals(restored, initialArrayItems);
803});
804
805test("arrayUpsert - should insert a new item when nothing matches", () => {
806 const { client, queryArray } = createArrayQueryClient();
807 const restoreFns: Array<() => void> = [];
808
809 const helpers = queryClientOptimisticHelpers(client)({
810 onRestore: (fn) => restoreFns.push(fn),
811 onRefetch: () => {},
812 });
813
814 const upsertResult = helpers.arrayUpsert(
815 queryArray,
816 {
817 filter: (item) => item.id === 4,
818 insert: () => ({ id: 4, label: "fourth" }),
819 },
820 );
821
822 const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
823 assertEquals(upsertResult, { inserted: true });
824 assertEquals(result?.length, 4);
825 assertEquals(result?.[3], { id: 4, label: "fourth" });
826
827 restoreFns[0]();
828 const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
829 assertEquals(restored, initialArrayItems);
830});
831
832test("arrayUpsert - should skip if query doesn't exist", () => {
833 const { client } = createTestQueryClient();
834 const restoreFns: Array<() => void> = [];
835
836 const helpers = queryClientOptimisticHelpers(client)({
837 onRestore: (fn) => restoreFns.push(fn),
838 onRefetch: () => {},
839 });
840
841 const queryNonexistent = queryOptions({
842 queryKey: ["nonexistent-array"],
843 queryFn: (): TestArrayItem[] => initialArrayItems,
844 });
845
846 const upsertResult = helpers.arrayUpsert(
847 queryNonexistent,
848 {
849 filter: (item) => item.id === 4,
850 insert: () => ({ id: 4, label: "fourth" }),
851 },
852 );
853
854 assertEquals(upsertResult, { inserted: false });
855 assertEquals(restoreFns.length, 0);
856});
857
666// ============================================================================858// ============================================================================
667// arrayInsertIndex() tests859// arrayInsertIndex() tests
668// ============================================================================860// ============================================================================