@mobx-query/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.gitattributes +2 -0
  2. package/README.md +98 -0
  3. package/eslint.config.js +29 -0
  4. package/index.html +13 -0
  5. package/package.json +60 -0
  6. package/public/vite.svg +1 -0
  7. package/src/App.css +0 -0
  8. package/src/App.tsx +5 -0
  9. package/src/api/constants.ts +1 -0
  10. package/src/api/fetch.ts +29 -0
  11. package/src/api/todos.ts +14 -0
  12. package/src/api/types.ts +30 -0
  13. package/src/api/users.ts +19 -0
  14. package/src/assets/react.svg +1 -0
  15. package/src/index.css +60 -0
  16. package/src/libs/mobx-query/client/MQClient.ts +75 -0
  17. package/src/libs/mobx-query/client/MQClientAccessor.ts +74 -0
  18. package/src/libs/mobx-query/client/index.ts +8 -0
  19. package/src/libs/mobx-query/client/types.ts +37 -0
  20. package/src/libs/mobx-query/entity/Entity.ts +232 -0
  21. package/src/libs/mobx-query/entity/EntityCollection.ts +285 -0
  22. package/src/libs/mobx-query/entity/constants.ts +7 -0
  23. package/src/libs/mobx-query/entity/index.ts +15 -0
  24. package/src/libs/mobx-query/entity/types.ts +11 -0
  25. package/src/libs/mobx-query/mutations/BatchMutationBase.ts +105 -0
  26. package/src/libs/mobx-query/mutations/BatchUpdateMutation.ts +48 -0
  27. package/src/libs/mobx-query/mutations/CreateMutation.ts +172 -0
  28. package/src/libs/mobx-query/mutations/DeleteMutation.ts +94 -0
  29. package/src/libs/mobx-query/mutations/EntityMutationBase.ts +110 -0
  30. package/src/libs/mobx-query/mutations/MutationBase.ts +40 -0
  31. package/src/libs/mobx-query/mutations/OptimisticMutationStrategy.ts +122 -0
  32. package/src/libs/mobx-query/mutations/UpdateMutation.ts +76 -0
  33. package/src/libs/mobx-query/mutations/constants.ts +16 -0
  34. package/src/libs/mobx-query/mutations/index.ts +44 -0
  35. package/src/libs/mobx-query/mutations/types.ts +205 -0
  36. package/src/libs/mobx-query/queries/QueryBase.ts +65 -0
  37. package/src/libs/mobx-query/queries/QueryFragmentMany.ts +31 -0
  38. package/src/libs/mobx-query/queries/QueryFragmentOne.ts +35 -0
  39. package/src/libs/mobx-query/queries/QueryMany.ts +80 -0
  40. package/src/libs/mobx-query/queries/QueryManyBase.ts +135 -0
  41. package/src/libs/mobx-query/queries/QueryOne.ts +84 -0
  42. package/src/libs/mobx-query/queries/QueryOneBase.ts +93 -0
  43. package/src/libs/mobx-query/queries/index.ts +33 -0
  44. package/src/libs/mobx-query/queries/types.ts +60 -0
  45. package/src/libs/mobx-query/react/createReactContext.tsx +23 -0
  46. package/src/libs/mobx-query/react/index.ts +3 -0
  47. package/src/libs/mobx-query/utils/generateEntityId.ts +12 -0
  48. package/src/libs/mobx-query/utils/index.ts +8 -0
  49. package/src/libs/mobx-query/utils/invalidateQueryByHash.ts +18 -0
  50. package/src/libs/mobx-query/utils/types.ts +18 -0
  51. package/src/libs/react-query.ts +11 -0
  52. package/src/main.tsx +16 -0
  53. package/src/utils.ts +3 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.app.json +27 -0
  56. package/tsconfig.json +7 -0
  57. package/tsconfig.node.json +25 -0
  58. package/vite.config.ts +52 -0
@@ -0,0 +1,232 @@
1
+ import {
2
+ action,
3
+ type IObjectDidChange,
4
+ isObservableArray,
5
+ isObservableMap,
6
+ observable,
7
+ observe,
8
+ toJS,
9
+ } from "mobx";
10
+ import type { EntityData, EntityEvents, EntityId } from "./types";
11
+ import { MQClientAccessor } from "../client";
12
+ import { invalidateQueryByHash } from "../utils";
13
+ import { EntityState } from "./constants";
14
+
15
+ export type EntityConstructor<
16
+ TData = unknown,
17
+ TEntityId extends EntityId = string,
18
+ E extends Entity<TData, TEntityId> = Entity<TData, TEntityId>,
19
+ > = new () => E;
20
+
21
+ export type EntityConstructorAny = EntityConstructor<
22
+ EntityData,
23
+ any,
24
+ EntityAny
25
+ >;
26
+
27
+ export type EntityAny = Entity<any, any>;
28
+
29
+ const IGNORE_FIELDS: PropertyKey[] = ["isDirty", "state"] as const;
30
+
31
+ export type EntityValueKeys<T> = {
32
+ [K in keyof T]: T[K] extends Function
33
+ ? never
34
+ : K extends "isDirty" | "state" | "queryHashes"
35
+ ? never
36
+ : K;
37
+ }[keyof T] &
38
+ string;
39
+
40
+ export abstract class Entity<
41
+ TData = unknown,
42
+ TEntityId extends EntityId = string,
43
+ > extends MQClientAccessor {
44
+ abstract id: TEntityId;
45
+
46
+ abstract hydrate(data: TData): void;
47
+
48
+ readonly queryHashes = new Set<string>();
49
+
50
+ private events!: EntityEvents<TEntityId>;
51
+
52
+ @observable accessor state: EntityState = EntityState.CONFIRMED;
53
+ @observable accessor isDirty = false;
54
+
55
+ private readonly initValuesSnapshot: Map<string | number | symbol, unknown> =
56
+ new Map();
57
+
58
+ private isHydrated = false;
59
+
60
+ _init(queryHashes: string[], events: EntityEvents<TEntityId>) {
61
+ this.events = events;
62
+
63
+ for (const hash of queryHashes) {
64
+ this.queryHashes.add(hash);
65
+ }
66
+
67
+ observe(this, (change) => this.onObservableChange(change));
68
+ }
69
+
70
+ // @computed get isDirty() {
71
+ // return this._isDirty
72
+ // }
73
+
74
+ _removeQueryHashes(hashes: string[]) {
75
+ for (const hash of hashes) {
76
+ this.queryHashes.delete(hash);
77
+ }
78
+
79
+ if (this.queryHashes.size === 0) {
80
+ this.events.onAllQueryHashesRemoved(this.id);
81
+ }
82
+ }
83
+
84
+ _markAsHydrated() {
85
+ this.isHydrated = true;
86
+ }
87
+
88
+ @action private onObservableChange(change: IObjectDidChange) {
89
+ if (change.type !== "update") {
90
+ return;
91
+ }
92
+
93
+ if (!this.isHydrated) {
94
+ return;
95
+ }
96
+
97
+ if (IGNORE_FIELDS.includes(change.name as any)) {
98
+ return;
99
+ }
100
+
101
+ // Store the initial value (deep cloned for complex data structures)
102
+ if (!this.initValuesSnapshot.has(change.name)) {
103
+ // Deep clone complex data structures (objects, arrays, nested observables)
104
+ // toJS converts observables to plain JS, effectively creating a deep clone
105
+ this.initValuesSnapshot.set(
106
+ change.name,
107
+ this.deepCloneValue(change.oldValue),
108
+ );
109
+ }
110
+
111
+ if (this.isDirty === false) {
112
+ this.isDirty = true;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Deep clones a value to ensure we store a snapshot, not a reference.
118
+ * Handles primitives, objects, arrays, and MobX observables.
119
+ */
120
+ private deepCloneValue(value: unknown): unknown {
121
+ // Handle null and undefined
122
+ if (value === null || value === undefined) {
123
+ return value;
124
+ }
125
+
126
+ // Handle primitives (string, number, boolean, symbol, bigint)
127
+ const valueType = typeof value;
128
+ if (
129
+ valueType === "string" ||
130
+ valueType === "number" ||
131
+ valueType === "boolean" ||
132
+ valueType === "symbol" ||
133
+ valueType === "bigint"
134
+ ) {
135
+ return value;
136
+ }
137
+
138
+ // Handle functions - store as-is (though they shouldn't typically be observable)
139
+ if (valueType === "function") {
140
+ return value;
141
+ }
142
+
143
+ if (value instanceof Date) {
144
+ return new Date(value.getTime());
145
+ }
146
+
147
+ if (value instanceof RegExp) {
148
+ return new RegExp(value);
149
+ }
150
+
151
+ if (value instanceof Set) {
152
+ return new Set(
153
+ Array.from(value).map((item) => this.deepCloneValue(item)),
154
+ );
155
+ }
156
+
157
+ if (value instanceof Map) {
158
+ const clonedMap = new Map();
159
+ for (const [key, val] of value.entries()) {
160
+ clonedMap.set(this.deepCloneValue(key), this.deepCloneValue(val));
161
+ }
162
+ return clonedMap;
163
+ }
164
+
165
+ // For objects and arrays (including MobX observables), use toJS to convert
166
+ // observables to plain JS and create a deep clone
167
+ try {
168
+ return toJS(value);
169
+ } catch (error) {
170
+ // Fallback: if toJS fails (e.g., circular reference), return as-is
171
+ // This is a safety measure, though it may not fully solve the problem
172
+ console.warn(
173
+ `Failed to deep clone value for property, using reference:`,
174
+ error,
175
+ );
176
+ return value;
177
+ }
178
+ }
179
+
180
+ @action reset() {
181
+ for (const [key, value] of this.initValuesSnapshot.entries()) {
182
+ // Deep clone the stored value before restoring to ensure we're not
183
+ // assigning a reference that might have been mutated
184
+ const clonedValue = this.deepCloneValue(value);
185
+
186
+ // @ts-expect-error: unknown field
187
+ const currentValue = this[key];
188
+
189
+ // Handle observable arrays and maps specially, similar to createViewModel
190
+ // This ensures proper restoration of MobX observable collections
191
+ if (isObservableArray(currentValue)) {
192
+ // For observable arrays, use replace() to restore the original array
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ currentValue.replace(clonedValue as any);
195
+ } else if (isObservableMap(currentValue)) {
196
+ // For observable maps, clear and merge to restore the original map
197
+ currentValue.clear();
198
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
+ currentValue.merge(clonedValue as any);
200
+ } else {
201
+ // For other types, assign directly
202
+ // @ts-expect-error: unknown field
203
+ this[key] = clonedValue;
204
+ }
205
+ }
206
+
207
+ this._clearDirty();
208
+ }
209
+
210
+ @action _clearDirty() {
211
+ this.isDirty = false;
212
+ this.initValuesSnapshot.clear();
213
+ }
214
+
215
+ // isDirtyField(field: EntityValueKeys<this>) {
216
+ // return this.initValuesSnapshot.has(field)
217
+ // }
218
+
219
+ // getOriginalValue<K extends EntityValueKeys<this>>(
220
+ // field: K,
221
+ // ): this[K] | undefined {
222
+ // return this.initValuesSnapshot.get(field) as this[K] | undefined
223
+ // }
224
+
225
+ invalidateRelatedQueries() {
226
+ const cache = this.queryClient.getQueryCache();
227
+
228
+ for (const hash of this.queryHashes) {
229
+ invalidateQueryByHash(hash, cache, () => this._removeQueryHashes([hash]));
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,285 @@
1
+ import { action, computed, observable } from "mobx";
2
+ import type { EntityConstructor, EntityConstructorAny } from "./Entity";
3
+ import { QueryClient } from "@tanstack/react-query";
4
+ import type { EntityDataAny, EntityId } from "./types";
5
+
6
+ export class EntityCollection<
7
+ TEntityConstructor extends EntityConstructorAny = EntityConstructorAny,
8
+ > {
9
+ @observable accessor collection = new Map<
10
+ EntityId,
11
+ InstanceType<TEntityConstructor>
12
+ >();
13
+ @observable accessor deletedRecordIds = new Set<EntityId>();
14
+ @observable accessor clientOnlyEntityIds = new Set<EntityId>();
15
+
16
+ constructor(
17
+ private readonly entityConstructor: EntityConstructor<
18
+ any,
19
+ any,
20
+ InstanceType<TEntityConstructor>
21
+ >,
22
+ private readonly queryClient: QueryClient,
23
+ ) {
24
+ this.initQueryClientCacheListener();
25
+ }
26
+
27
+ @computed get size() {
28
+ return this.collection.size;
29
+ }
30
+
31
+ @computed get entities() {
32
+ const entities: InstanceType<TEntityConstructor>[] = [];
33
+
34
+ for (const entity of this.collection.values()) {
35
+ if (this.deletedRecordIds.has(entity.id)) continue;
36
+ entities.push(entity);
37
+ }
38
+
39
+ return entities;
40
+ }
41
+
42
+ @computed get clientOnlyEntitiesMap() {
43
+ const entities = new Map<EntityId, InstanceType<TEntityConstructor>>();
44
+
45
+ for (const entityId of this.clientOnlyEntityIds.values()) {
46
+ const entity = this.collection.get(entityId);
47
+
48
+ if (!entity) {
49
+ continue;
50
+ }
51
+
52
+ entities.set(entityId, entity);
53
+ }
54
+
55
+ return entities;
56
+ }
57
+
58
+ @computed get clientOnlyEntities() {
59
+ const entities: InstanceType<TEntityConstructor>[] = [];
60
+
61
+ for (const entityId of this.clientOnlyEntityIds.values()) {
62
+ const entity = this.collection.get(entityId);
63
+
64
+ if (!entity) {
65
+ continue;
66
+ }
67
+
68
+ entities.push(entity);
69
+ }
70
+
71
+ return entities;
72
+ }
73
+
74
+ @action setEntity(data: EntityDataAny, queryHashes?: string[]) {
75
+ // if (clearQueryHashes && queryHashes) {
76
+ // console.log('clearQueryHashes', queryHashes)
77
+ // this.removeQueryHashesFromAllEntities(queryHashes)
78
+ // }
79
+
80
+ const entity =
81
+ new this.entityConstructor() as InstanceType<TEntityConstructor>;
82
+ entity.hydrate(data);
83
+ entity._markAsHydrated();
84
+
85
+ const prevEntity = this.collection.get(entity.id) as
86
+ | InstanceType<TEntityConstructor>
87
+ | undefined;
88
+
89
+ const updatedQueryHashes = prevEntity
90
+ ? [...prevEntity.queryHashes, ...(queryHashes || [])]
91
+ : [...(queryHashes || [])];
92
+
93
+ if (prevEntity) {
94
+ if (queryHashes) {
95
+ for (const hash of queryHashes) {
96
+ prevEntity.queryHashes.add(hash);
97
+ }
98
+ }
99
+
100
+ prevEntity.hydrate(data);
101
+ prevEntity._clearDirty();
102
+
103
+ return prevEntity;
104
+ }
105
+
106
+ entity._init(updatedQueryHashes, {
107
+ onAllQueryHashesRemoved: (entityId: EntityId) =>
108
+ this.deleteEntity(entityId),
109
+ });
110
+
111
+ this.collection.set(entity.id, entity);
112
+ return entity;
113
+ }
114
+
115
+ @action setEntities(entityData: EntityDataAny[], queryHashes: string[]) {
116
+ const entities: InstanceType<TEntityConstructor>[] = [];
117
+ const ids: EntityId[] = [];
118
+
119
+ if (!Array.isArray(entityData)) {
120
+ throw new Error("setEntities: entityData must be an array");
121
+ }
122
+
123
+ if (queryHashes) {
124
+ this.removeQueryHashesFromAllEntities(queryHashes);
125
+ }
126
+
127
+ for (const record of entityData) {
128
+ const entity = this.setEntity(record, queryHashes);
129
+ entities.push(entity);
130
+ ids.push(entity.id);
131
+ }
132
+
133
+ return [entities, ids] as const;
134
+ }
135
+
136
+ @action deleteEntity(entityId: EntityId) {
137
+ this.collection.delete(entityId);
138
+ this.clientOnlyEntityIds.delete(entityId);
139
+ this.deletedRecordIds.delete(entityId);
140
+ }
141
+
142
+ getEntityById(entityId: EntityId) {
143
+ return this.collection.get(entityId);
144
+ }
145
+
146
+ filter(
147
+ predicate: (
148
+ value: InstanceType<TEntityConstructor>,
149
+ index: number,
150
+ ) => boolean,
151
+ ): InstanceType<TEntityConstructor>[] {
152
+ const result: InstanceType<TEntityConstructor>[] = [];
153
+ let i = 0;
154
+ for (const entity of this.collection.values()) {
155
+ if (this.deletedRecordIds.has(entity.id)) continue;
156
+ if (predicate(entity, i++)) {
157
+ result.push(entity);
158
+ }
159
+ }
160
+ return result;
161
+ }
162
+
163
+ find(
164
+ predicate: (
165
+ value: InstanceType<TEntityConstructor>,
166
+ index: number,
167
+ ) => boolean,
168
+ ): InstanceType<TEntityConstructor> | undefined {
169
+ let i = 0;
170
+ for (const entity of this.collection.values()) {
171
+ if (this.deletedRecordIds.has(entity.id)) continue;
172
+ if (predicate(entity, i++)) {
173
+ return entity;
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ findIndex(
180
+ predicate: (
181
+ value: InstanceType<TEntityConstructor>,
182
+ index: number,
183
+ ) => boolean,
184
+ ): number {
185
+ let i = 0;
186
+ for (const entity of this.collection.values()) {
187
+ if (this.deletedRecordIds.has(entity.id)) continue;
188
+ if (predicate(entity, i)) {
189
+ return i;
190
+ }
191
+ i++;
192
+ }
193
+ return -1;
194
+ }
195
+
196
+ findLast(
197
+ predicate: (
198
+ value: InstanceType<TEntityConstructor>,
199
+ index: number,
200
+ ) => boolean,
201
+ ): InstanceType<TEntityConstructor> | undefined {
202
+ const entities = this.entities;
203
+ for (let i = entities.length - 1; i >= 0; i--) {
204
+ const entity = entities[i];
205
+ if (predicate(entity, i)) {
206
+ return entity;
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ findLastIndex(
213
+ predicate: (
214
+ value: InstanceType<TEntityConstructor>,
215
+ index: number,
216
+ ) => boolean,
217
+ ): number {
218
+ const entities = this.entities;
219
+ for (let i = entities.length - 1; i >= 0; i--) {
220
+ const entity = entities[i];
221
+ if (predicate(entity, i)) {
222
+ return i;
223
+ }
224
+ }
225
+ return -1;
226
+ }
227
+
228
+ some(
229
+ predicate: (
230
+ value: InstanceType<TEntityConstructor>,
231
+ index: number,
232
+ ) => boolean,
233
+ ): boolean {
234
+ let i = 0;
235
+ for (const entity of this.collection.values()) {
236
+ if (this.deletedRecordIds.has(entity.id)) continue;
237
+ if (predicate(entity, i++)) {
238
+ return true;
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+
244
+ every(
245
+ predicate: (
246
+ value: InstanceType<TEntityConstructor>,
247
+ index: number,
248
+ ) => boolean,
249
+ ): boolean {
250
+ let i = 0;
251
+ for (const entity of this.collection.values()) {
252
+ if (this.deletedRecordIds.has(entity.id)) continue;
253
+ if (!predicate(entity, i++)) {
254
+ return false;
255
+ }
256
+ }
257
+ return true;
258
+ }
259
+
260
+ map<T>(
261
+ predicate: (value: InstanceType<TEntityConstructor>, index: number) => T,
262
+ ): T[] {
263
+ const result: T[] = [];
264
+ let i = 0;
265
+ for (const entity of this.collection.values()) {
266
+ if (this.deletedRecordIds.has(entity.id)) continue;
267
+ result.push(predicate(entity, i++));
268
+ }
269
+ return observable.array(result);
270
+ }
271
+
272
+ private initQueryClientCacheListener() {
273
+ this.queryClient.getQueryCache().subscribe((event) => {
274
+ if (event.type === "removed") {
275
+ this.removeQueryHashesFromAllEntities([event.query.queryHash]);
276
+ }
277
+ });
278
+ }
279
+
280
+ private removeQueryHashesFromAllEntities(hashes: string[]) {
281
+ for (const entity of this.collection.values()) {
282
+ entity._removeQueryHashes(hashes);
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,7 @@
1
+ export const EntityState = {
2
+ PENDING: "pending",
3
+ CONFIRMED: "confirmed",
4
+ FAILED: "failed",
5
+ } as const;
6
+
7
+ export type EntityState = (typeof EntityState)[keyof typeof EntityState];
@@ -0,0 +1,15 @@
1
+ import { Entity, type EntityConstructorAny, type EntityAny } from "./Entity";
2
+ import { EntityCollection } from "./EntityCollection";
3
+ import { EntityState } from "./constants";
4
+
5
+ import type { EntityId, EntityData, EntityDataAny } from "./types";
6
+
7
+ export { Entity, EntityState, EntityCollection };
8
+
9
+ export type {
10
+ EntityConstructorAny,
11
+ EntityAny,
12
+ EntityData,
13
+ EntityDataAny,
14
+ EntityId,
15
+ };
@@ -0,0 +1,11 @@
1
+ export type EntityId = string | number;
2
+
3
+ export interface EntityData<TEntityId extends EntityId = string> {
4
+ id: TEntityId;
5
+ }
6
+
7
+ export type EntityDataAny = EntityData<EntityId>;
8
+
9
+ export interface EntityEvents<TEntityId extends EntityId = string> {
10
+ onAllQueryHashesRemoved: (entityId: TEntityId) => void;
11
+ }
@@ -0,0 +1,105 @@
1
+ import type {
2
+ DefaultError,
3
+ MutationFunctionContext,
4
+ } from "@tanstack/react-query";
5
+ import type { EntityConstructorAny, EntityCollection } from "../entity";
6
+ import type {
7
+ BatchMutationInputInternal,
8
+ UseBatchMutationHookOptions,
9
+ } from "./types";
10
+ import { MutationBase } from "./MutationBase";
11
+
12
+ export class BatchMutationBase<
13
+ TEntityConstructor extends EntityConstructorAny,
14
+ TError = DefaultError,
15
+ TMutateResult = unknown,
16
+ > extends MutationBase<TEntityConstructor> {
17
+ protected readonly collection: EntityCollection<TEntityConstructor>;
18
+
19
+ constructor(
20
+ mutationPrefix: string,
21
+ protected readonly options: UseBatchMutationHookOptions<
22
+ TEntityConstructor,
23
+ TError,
24
+ TMutateResult
25
+ >,
26
+ ) {
27
+ super(mutationPrefix, options.entity);
28
+
29
+ this.collection = this.getEntityCollection(options.entity);
30
+ }
31
+
32
+ protected async onMutateBase(
33
+ input: BatchMutationInputInternal<TEntityConstructor>,
34
+ context: MutationFunctionContext,
35
+ ): Promise<TMutateResult> {
36
+ input.strategy.onMutate();
37
+ const result = await this.options.onMutate?.(input.entities, context);
38
+ return result ?? ({} as TMutateResult);
39
+ }
40
+
41
+ protected async onSuccessBase(
42
+ input: BatchMutationInputInternal<TEntityConstructor>,
43
+ onMutateResult: TMutateResult | undefined,
44
+ context: MutationFunctionContext,
45
+ ): Promise<void> {
46
+ input.strategy.onSuccess();
47
+ await this.options.onSuccess?.(input.entities, context, onMutateResult);
48
+ }
49
+
50
+ protected async onErrorBase(
51
+ error: TError,
52
+ input: BatchMutationInputInternal<TEntityConstructor>,
53
+ onMutateResult: TMutateResult | undefined,
54
+ context: MutationFunctionContext,
55
+ ): Promise<void> {
56
+ input.strategy.onError();
57
+ await this.options.onError?.(
58
+ error,
59
+ input.entities,
60
+ context,
61
+ onMutateResult,
62
+ );
63
+ }
64
+
65
+ protected async onSettledBase(
66
+ error: TError | null,
67
+ input: BatchMutationInputInternal<TEntityConstructor>,
68
+ onMutateResult: TMutateResult | undefined,
69
+ context: MutationFunctionContext,
70
+ ): Promise<void> {
71
+ await this.options.onSettled?.(
72
+ input.entities,
73
+ context,
74
+ onMutateResult,
75
+ error,
76
+ );
77
+ }
78
+
79
+ protected createInternalInput(
80
+ entities: InstanceType<TEntityConstructor>[],
81
+ ): BatchMutationInputInternal<TEntityConstructor> {
82
+ const dirtyEntities: InstanceType<TEntityConstructor>[] = [];
83
+
84
+ for (const entity of entities) {
85
+ if (!entity.isDirty) {
86
+ console.warn(
87
+ "Entity values has not been changed, mutation is skipped.",
88
+ );
89
+
90
+ continue;
91
+ }
92
+
93
+ dirtyEntities.push(entity);
94
+ }
95
+
96
+ return {
97
+ entities: dirtyEntities,
98
+ strategy: this.createMutationStrategy(dirtyEntities, {
99
+ invalidationStrategy: this.options.invalidationStrategy,
100
+ errorStrategy: this.options.errorStrategy,
101
+ invalidateOnError: this.options.invalidateOnError,
102
+ }),
103
+ };
104
+ }
105
+ }
@@ -0,0 +1,48 @@
1
+ import { type DefaultError, useMutation } from "@tanstack/react-query";
2
+ import type { EntityConstructorAny } from "../entity";
3
+ import { BatchMutationBase } from "./BatchMutationBase";
4
+ import type {
5
+ BatchMutationInputInternal,
6
+ UseBatchMutationHookOptions,
7
+ } from "./types";
8
+
9
+ export class BatchUpdateMutation<
10
+ TEntityConstructor extends EntityConstructorAny,
11
+ TError = DefaultError,
12
+ TMutateResult = unknown,
13
+ > extends BatchMutationBase<TEntityConstructor, TError, TMutateResult> {
14
+ static readonly mutationPrefix = "__mutation__batch__update__";
15
+
16
+ constructor(
17
+ options: UseBatchMutationHookOptions<
18
+ TEntityConstructor,
19
+ TError,
20
+ TMutateResult
21
+ >,
22
+ ) {
23
+ super(BatchUpdateMutation.mutationPrefix, options);
24
+ }
25
+
26
+ useMutation() {
27
+ const mutation = useMutation<
28
+ void,
29
+ TError,
30
+ BatchMutationInputInternal<TEntityConstructor>,
31
+ TMutateResult
32
+ >({
33
+ mutationFn: (input) =>
34
+ this.options.mutationFn(input.entities, this.context),
35
+ onMutate: (input, context) => this.onMutateBase(input, context),
36
+ onSuccess: (_, input, onMutateResult, context) =>
37
+ this.onSuccessBase(input, onMutateResult, context),
38
+ onError: (error, input, onMutateResult, context) =>
39
+ this.onErrorBase(error, input, onMutateResult, context),
40
+ onSettled: (_, error, input, onMutateResult, context) =>
41
+ this.onSettledBase(error, input, onMutateResult, context),
42
+ });
43
+
44
+ return (entities: InstanceType<TEntityConstructor>[]) => {
45
+ mutation.mutate(this.createInternalInput(entities));
46
+ };
47
+ }
48
+ }