@spoosh/plugin-optimistic 0.2.0 → 0.4.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.
- package/README.md +42 -45
- package/dist/index.d.mts +221 -55
- package/dist/index.d.ts +221 -55
- package/dist/index.js +100 -67
- package/dist/index.mjs +101 -72
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -27,50 +27,45 @@ const client = new Spoosh<ApiSchema, Error>("/api").use([
|
|
|
27
27
|
optimisticPlugin(),
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
-
const { trigger } = useWrite((api) => api
|
|
30
|
+
const { trigger } = useWrite((api) => api("posts/:id").DELETE);
|
|
31
31
|
|
|
32
32
|
trigger({
|
|
33
|
+
params: { id },
|
|
33
34
|
// Optimistic delete - instantly remove item from list
|
|
34
|
-
optimistic: (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
rollbackOnError: true,
|
|
39
|
-
}),
|
|
35
|
+
optimistic: (api) =>
|
|
36
|
+
api("posts")
|
|
37
|
+
.GET()
|
|
38
|
+
.UPDATE_CACHE((posts) => posts.filter((p) => p.id !== id)),
|
|
40
39
|
});
|
|
41
40
|
|
|
42
|
-
// Optimistic update with response data
|
|
41
|
+
// Optimistic update with response data (onSuccess timing)
|
|
43
42
|
trigger({
|
|
44
|
-
optimistic: (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}),
|
|
43
|
+
optimistic: (api) =>
|
|
44
|
+
api("posts")
|
|
45
|
+
.GET()
|
|
46
|
+
.ON_SUCCESS()
|
|
47
|
+
.UPDATE_CACHE((posts, newPost) => [newPost!, ...posts]),
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
// Multiple targets
|
|
53
51
|
trigger({
|
|
54
|
-
optimistic: (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
updater: (stats) => ({ ...stats, count: stats.count - 1 }),
|
|
62
|
-
}),
|
|
52
|
+
optimistic: (api) => [
|
|
53
|
+
api("posts")
|
|
54
|
+
.GET()
|
|
55
|
+
.UPDATE_CACHE((posts) => posts.filter((p) => p.id !== id)),
|
|
56
|
+
api("stats")
|
|
57
|
+
.GET()
|
|
58
|
+
.UPDATE_CACHE((stats) => ({ ...stats, count: stats.count - 1 })),
|
|
63
59
|
],
|
|
64
60
|
});
|
|
65
61
|
|
|
66
62
|
// Filter by request params
|
|
67
63
|
trigger({
|
|
68
|
-
optimistic: (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}),
|
|
64
|
+
optimistic: (api) =>
|
|
65
|
+
api("posts")
|
|
66
|
+
.GET()
|
|
67
|
+
.WHERE((request) => request.query?.page === 1)
|
|
68
|
+
.UPDATE_CACHE((posts, newPost) => [newPost!, ...posts]),
|
|
74
69
|
});
|
|
75
70
|
```
|
|
76
71
|
|
|
@@ -78,20 +73,22 @@ trigger({
|
|
|
78
73
|
|
|
79
74
|
### Per-Request Options
|
|
80
75
|
|
|
81
|
-
| Option | Type
|
|
82
|
-
| ------------ |
|
|
83
|
-
| `optimistic` | `(
|
|
76
|
+
| Option | Type | Description |
|
|
77
|
+
| ------------ | ------------------------------- | ------------------------------------- |
|
|
78
|
+
| `optimistic` | `(api) => builder \| builder[]` | Callback to define optimistic updates |
|
|
84
79
|
|
|
85
|
-
###
|
|
80
|
+
### Builder Methods (DSL)
|
|
86
81
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
82
|
+
Chain methods to configure optimistic updates:
|
|
83
|
+
|
|
84
|
+
| Method | Description |
|
|
85
|
+
| ------------------- | ----------------------------------------- |
|
|
86
|
+
| `.GET()` | Select the GET endpoint to update |
|
|
87
|
+
| `.WHERE(fn)` | Filter which cache entries to update |
|
|
88
|
+
| `.UPDATE_CACHE(fn)` | Update cache immediately (default timing) |
|
|
89
|
+
| `.ON_SUCCESS()` | Switch to onSuccess timing mode |
|
|
90
|
+
| `.NO_ROLLBACK()` | Disable automatic rollback on error |
|
|
91
|
+
| `.ON_ERROR(fn)` | Error callback |
|
|
95
92
|
|
|
96
93
|
### Result
|
|
97
94
|
|
|
@@ -101,7 +98,7 @@ trigger({
|
|
|
101
98
|
|
|
102
99
|
### Timing Modes
|
|
103
100
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
101
|
+
| Usage | Description |
|
|
102
|
+
| -------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| `.UPDATE_CACHE(fn)` | **Immediate** - Update cache instantly before request completes. Rollback on error. |
|
|
104
|
+
| `.ON_SUCCESS().UPDATE_CACHE(fn)` | **On Success** - Wait for successful response, then update cache. `fn` receives response as 2nd arg. |
|
package/dist/index.d.mts
CHANGED
|
@@ -1,28 +1,210 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FindMatchingKey, HasParams, ExtractParamNames, ExtractData, SpooshPlugin } from '@spoosh/core';
|
|
2
2
|
|
|
3
|
-
type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
type Simplify<T> = {
|
|
4
|
+
[K in keyof T]: T[K];
|
|
5
|
+
} & {};
|
|
6
|
+
/**
|
|
7
|
+
* Check if query exists in the method config.
|
|
8
|
+
*/
|
|
9
|
+
type HasQuery<T> = "query" extends keyof T ? true : false;
|
|
10
|
+
/**
|
|
11
|
+
* Extract query type from method config.
|
|
12
|
+
*/
|
|
13
|
+
type ExtractQuery<T> = T extends {
|
|
14
|
+
query: infer Q;
|
|
15
|
+
} ? Q : T extends {
|
|
16
|
+
query?: infer Q;
|
|
17
|
+
} ? Q : never;
|
|
18
|
+
/**
|
|
19
|
+
* Internal optimistic target data.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
type OptimisticTarget = {
|
|
23
|
+
path: string;
|
|
24
|
+
method: string;
|
|
25
|
+
where?: (options: unknown) => boolean;
|
|
26
|
+
updater?: (data: unknown, response?: unknown) => unknown;
|
|
27
|
+
timing: "immediate" | "onSuccess";
|
|
28
|
+
rollbackOnError: boolean;
|
|
12
29
|
onError?: (error: unknown) => void;
|
|
13
30
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
/**
|
|
32
|
+
* WHERE options for filtering cache entries.
|
|
33
|
+
*/
|
|
34
|
+
type WhereOptions<TMethodConfig, TUserPath extends string> = HasParams<TUserPath> extends true ? HasQuery<TMethodConfig> extends true ? {
|
|
35
|
+
params: Record<ExtractParamNames<TUserPath>, string | number>;
|
|
36
|
+
query: ExtractQuery<TMethodConfig>;
|
|
37
|
+
} : {
|
|
38
|
+
params: Record<ExtractParamNames<TUserPath>, string | number>;
|
|
39
|
+
} : HasQuery<TMethodConfig> extends true ? {
|
|
40
|
+
query: ExtractQuery<TMethodConfig>;
|
|
41
|
+
} : never;
|
|
42
|
+
/**
|
|
43
|
+
* Conditionally include a method if it hasn't been used yet.
|
|
44
|
+
*/
|
|
45
|
+
type IfNotUsed<TMethod extends string, TUsed extends string, TType> = TMethod extends TUsed ? never : TType;
|
|
46
|
+
/**
|
|
47
|
+
* Brand for completed builders (UPDATE_CACHE was called).
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
declare const COMPLETED_BRAND: unique symbol;
|
|
51
|
+
/**
|
|
52
|
+
* Chainable builder after GET().
|
|
53
|
+
* Methods can be chained in any order, but each method can only be called once.
|
|
54
|
+
* Internal properties are hidden from autocomplete.
|
|
55
|
+
*
|
|
56
|
+
* @typeParam TTiming - Tracks whether ON_SUCCESS was called to determine UPDATE_CACHE signature
|
|
57
|
+
* @typeParam TUsed - Tracks which methods have been called to prevent duplicate calls
|
|
58
|
+
* @typeParam TCompleted - Tracks whether UPDATE_CACHE was called (required for valid builder)
|
|
59
|
+
*/
|
|
60
|
+
type OptimisticBuilder<TData = unknown, TMethodConfig = unknown, TUserPath extends string = string, TResponse = unknown, TTiming extends "immediate" | "onSuccess" = "immediate", TUsed extends string = never, TCompleted extends boolean = false> = (TCompleted extends true ? {
|
|
61
|
+
readonly [COMPLETED_BRAND]: true;
|
|
62
|
+
} : unknown) & {
|
|
63
|
+
/**
|
|
64
|
+
* Filter which cache entries to update based on query/params.
|
|
65
|
+
*
|
|
66
|
+
* @param predicate - Function that receives cache entry info and returns true to match
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
WHERE: IfNotUsed<"WHERE", TUsed, WhereOptions<TMethodConfig, TUserPath> extends never ? never : (predicate: (entry: Simplify<WhereOptions<TMethodConfig, TUserPath>>) => boolean) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "WHERE", TCompleted>>;
|
|
74
|
+
/**
|
|
75
|
+
* Specify how to update the cached data optimistically.
|
|
76
|
+
* This method is required - an optimistic update must have an updater function.
|
|
77
|
+
*
|
|
78
|
+
* For immediate updates (default): receives only the current data.
|
|
79
|
+
* For ON_SUCCESS: receives current data and the mutation response.
|
|
80
|
+
*
|
|
81
|
+
* @param updater - Function that receives current data (and response if ON_SUCCESS), returns updated data
|
|
82
|
+
*/
|
|
83
|
+
UPDATE_CACHE: IfNotUsed<"UPDATE_CACHE", TUsed, TTiming extends "onSuccess" ? (updater: (data: TData, response: TResponse) => TData) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "UPDATE_CACHE", true> : (updater: (data: TData) => TData) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "UPDATE_CACHE", true>>;
|
|
84
|
+
/**
|
|
85
|
+
* Apply optimistic update only after mutation succeeds.
|
|
86
|
+
* By default, updates are applied immediately before mutation completes.
|
|
87
|
+
* When using ON_SUCCESS, UPDATE_CACHE receives the mutation response as second argument.
|
|
88
|
+
*/
|
|
89
|
+
ON_SUCCESS: IfNotUsed<"ON_SUCCESS", TUsed, () => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, "onSuccess", TUsed | "ON_SUCCESS", TCompleted>>;
|
|
90
|
+
/**
|
|
91
|
+
* Disable automatic rollback when mutation fails.
|
|
92
|
+
* By default, optimistic updates are rolled back on error.
|
|
93
|
+
*/
|
|
94
|
+
NO_ROLLBACK: IfNotUsed<"NO_ROLLBACK", TUsed, () => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "NO_ROLLBACK", TCompleted>>;
|
|
95
|
+
/**
|
|
96
|
+
* Callback when mutation fails.
|
|
97
|
+
*/
|
|
98
|
+
ON_ERROR: IfNotUsed<"ON_ERROR", TUsed, (callback: (error: unknown) => void) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "ON_ERROR", TCompleted>>;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Extract paths that have GET methods.
|
|
102
|
+
*/
|
|
103
|
+
type ReadPaths<TSchema> = {
|
|
104
|
+
[K in keyof TSchema & string]: "GET" extends keyof TSchema[K] ? K : never;
|
|
105
|
+
}[keyof TSchema & string];
|
|
106
|
+
/**
|
|
107
|
+
* Path methods proxy for optimistic API - only GET.
|
|
108
|
+
*/
|
|
109
|
+
type OptimisticPathMethods<TSchema, TPath extends string, TResponse> = FindMatchingKey<TSchema, TPath> extends infer TKey ? TKey extends keyof TSchema ? TSchema[TKey] extends infer TRoute ? "GET" extends keyof TRoute ? TRoute["GET"] extends infer TGetConfig ? {
|
|
110
|
+
GET: () => OptimisticBuilder<ExtractData<TGetConfig>, TGetConfig, TPath, TResponse, "immediate", never, false>;
|
|
111
|
+
} : never : never : never : never : never;
|
|
112
|
+
/**
|
|
113
|
+
* Helper type for creating the optimistic API proxy.
|
|
114
|
+
*/
|
|
115
|
+
type OptimisticApiHelper<TSchema, TResponse = unknown> = <TPath extends ReadPaths<TSchema>>(path: TPath) => OptimisticPathMethods<TSchema, TPath, TResponse>;
|
|
116
|
+
/**
|
|
117
|
+
* A generic OptimisticTarget that accepts any data/response types.
|
|
118
|
+
* Used for the return type of the callback.
|
|
119
|
+
*/
|
|
120
|
+
type AnyOptimisticTarget = OptimisticTarget;
|
|
121
|
+
/**
|
|
122
|
+
* A completed builder that has UPDATE_CACHE called.
|
|
123
|
+
* Uses the brand to ensure UPDATE_CACHE was called.
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
type CompletedOptimisticBuilder = {
|
|
127
|
+
readonly [COMPLETED_BRAND]: true;
|
|
21
128
|
};
|
|
22
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Callback function type for the optimistic option.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* // Single target - immediate update (no response)
|
|
135
|
+
* optimistic: (api) => api("posts")
|
|
136
|
+
* .GET()
|
|
137
|
+
* .UPDATE_CACHE(posts => posts.filter(p => p.id !== deletedId))
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* // With WHERE filter and options
|
|
143
|
+
* optimistic: (api) => api("posts")
|
|
144
|
+
* .GET()
|
|
145
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
146
|
+
* .NO_ROLLBACK()
|
|
147
|
+
* .UPDATE_CACHE(posts => [...posts, newPost])
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* // Apply update after mutation succeeds (with typed response)
|
|
153
|
+
* optimistic: (api) => api("posts")
|
|
154
|
+
* .GET()
|
|
155
|
+
* .ON_SUCCESS()
|
|
156
|
+
* .UPDATE_CACHE((posts, response) => [...posts, response])
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* // Multiple targets
|
|
162
|
+
* optimistic: (api) => [
|
|
163
|
+
* api("posts").GET().UPDATE_CACHE(posts => posts.filter(p => p.id !== id)),
|
|
164
|
+
* api("stats").GET().UPDATE_CACHE(stats => ({ ...stats, count: stats.count - 1 })),
|
|
165
|
+
* ]
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
type OptimisticCallbackFn<TSchema = unknown, TResponse = unknown> = (api: OptimisticApiHelper<TSchema, TResponse>) => CompletedOptimisticBuilder | CompletedOptimisticBuilder[];
|
|
23
169
|
type OptimisticPluginConfig = object;
|
|
24
|
-
interface OptimisticWriteOptions<TSchema = unknown> {
|
|
25
|
-
|
|
170
|
+
interface OptimisticWriteOptions<TSchema = unknown, TResponse = unknown> {
|
|
171
|
+
/**
|
|
172
|
+
* Configure optimistic updates for this mutation.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* // Immediate update (default) - no response available
|
|
177
|
+
* trigger({
|
|
178
|
+
* optimistic: (api) => api("posts")
|
|
179
|
+
* .GET()
|
|
180
|
+
* .UPDATE_CACHE(posts => posts.filter(p => p.id !== deletedId)),
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* // With WHERE filter and disable rollback
|
|
187
|
+
* trigger({
|
|
188
|
+
* optimistic: (api) => api("posts")
|
|
189
|
+
* .GET()
|
|
190
|
+
* .NO_ROLLBACK()
|
|
191
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
192
|
+
* .UPDATE_CACHE(posts => [newPost, ...posts]),
|
|
193
|
+
* });
|
|
194
|
+
* ```
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* // Apply after success - response is available
|
|
199
|
+
* trigger({
|
|
200
|
+
* optimistic: (api) => api("posts")
|
|
201
|
+
* .GET()
|
|
202
|
+
* .ON_SUCCESS()
|
|
203
|
+
* .UPDATE_CACHE((posts, newPost) => [...posts, newPost]),
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
optimistic?: OptimisticCallbackFn<TSchema, TResponse>;
|
|
26
208
|
}
|
|
27
209
|
type OptimisticReadOptions = object;
|
|
28
210
|
type OptimisticInfiniteReadOptions = object;
|
|
@@ -32,7 +214,7 @@ interface OptimisticReadResult {
|
|
|
32
214
|
type OptimisticWriteResult = object;
|
|
33
215
|
declare module "@spoosh/core" {
|
|
34
216
|
interface PluginResolvers<TContext> {
|
|
35
|
-
optimistic: OptimisticCallbackFn<TContext["schema"]> | undefined;
|
|
217
|
+
optimistic: OptimisticCallbackFn<TContext["schema"], TContext["data"]> | undefined;
|
|
36
218
|
}
|
|
37
219
|
}
|
|
38
220
|
|
|
@@ -43,9 +225,9 @@ declare const OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
|
|
|
43
225
|
* Immediately updates cached data before the mutation completes,
|
|
44
226
|
* with automatic rollback on error.
|
|
45
227
|
*
|
|
46
|
-
* When using optimistic updates,
|
|
228
|
+
* When using optimistic updates, invalidation mode defaults to `"none"` to prevent
|
|
47
229
|
* unnecessary refetches that would override the optimistic data. You can override
|
|
48
|
-
* this by explicitly setting `
|
|
230
|
+
* this by explicitly setting the `invalidate` option with a mode string or array.
|
|
49
231
|
*
|
|
50
232
|
* @see {@link https://spoosh.dev/docs/plugins/optimistic | Optimistic Plugin Documentation}
|
|
51
233
|
*
|
|
@@ -61,50 +243,34 @@ declare const OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
|
|
|
61
243
|
* optimisticPlugin(),
|
|
62
244
|
* ]);
|
|
63
245
|
*
|
|
64
|
-
* //
|
|
65
|
-
* trigger({
|
|
66
|
-
* optimistic: ($, api) => $({
|
|
67
|
-
* for: api.posts.$get,
|
|
68
|
-
* updater: (posts) => posts.filter(p => p.id !== deletedId),
|
|
69
|
-
* rollbackOnError: true,
|
|
70
|
-
* }),
|
|
71
|
-
* });
|
|
72
|
-
* ```
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```ts
|
|
76
|
-
* // Multiple targets
|
|
246
|
+
* // Methods can be chained in any order
|
|
77
247
|
* trigger({
|
|
78
|
-
* optimistic: (
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* ],
|
|
248
|
+
* optimistic: (api) => api("posts")
|
|
249
|
+
* .GET()
|
|
250
|
+
* .UPDATE_CACHE(posts => posts.filter(p => p.id !== deletedId)),
|
|
82
251
|
* });
|
|
83
252
|
* ```
|
|
84
253
|
*
|
|
85
254
|
* @example
|
|
86
255
|
* ```ts
|
|
87
|
-
* //
|
|
256
|
+
* // With WHERE filter and disable rollback
|
|
88
257
|
* trigger({
|
|
89
|
-
* optimistic: (
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* autoInvalidate: "all", // You can override to enable refetching after mutation
|
|
258
|
+
* optimistic: (api) => api("posts")
|
|
259
|
+
* .GET()
|
|
260
|
+
* .NO_ROLLBACK()
|
|
261
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
262
|
+
* .UPDATE_CACHE(posts => [newPost, ...posts]),
|
|
95
263
|
* });
|
|
96
264
|
* ```
|
|
97
265
|
*
|
|
98
266
|
* @example
|
|
99
267
|
* ```ts
|
|
100
|
-
* //
|
|
268
|
+
* // Apply after success with typed response
|
|
101
269
|
* trigger({
|
|
102
|
-
* optimistic: (
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* updater: (items, newItem) => [newItem!, ...items],
|
|
107
|
-
* }),
|
|
270
|
+
* optimistic: (api) => api("posts")
|
|
271
|
+
* .GET()
|
|
272
|
+
* .ON_SUCCESS()
|
|
273
|
+
* .UPDATE_CACHE((posts, newPost) => [...posts, newPost]),
|
|
108
274
|
* });
|
|
109
275
|
* ```
|
|
110
276
|
*/
|
|
@@ -116,4 +282,4 @@ declare function optimisticPlugin(): SpooshPlugin<{
|
|
|
116
282
|
writeResult: OptimisticWriteResult;
|
|
117
283
|
}>;
|
|
118
284
|
|
|
119
|
-
export { type
|
|
285
|
+
export { type AnyOptimisticTarget, OPTIMISTIC_SNAPSHOTS_KEY, type OptimisticApiHelper, type OptimisticBuilder, type OptimisticCallbackFn, type OptimisticInfiniteReadOptions, type OptimisticPluginConfig, type OptimisticReadOptions, type OptimisticReadResult, type OptimisticTarget, type OptimisticWriteOptions, type OptimisticWriteResult, optimisticPlugin };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,28 +1,210 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FindMatchingKey, HasParams, ExtractParamNames, ExtractData, SpooshPlugin } from '@spoosh/core';
|
|
2
2
|
|
|
3
|
-
type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
type Simplify<T> = {
|
|
4
|
+
[K in keyof T]: T[K];
|
|
5
|
+
} & {};
|
|
6
|
+
/**
|
|
7
|
+
* Check if query exists in the method config.
|
|
8
|
+
*/
|
|
9
|
+
type HasQuery<T> = "query" extends keyof T ? true : false;
|
|
10
|
+
/**
|
|
11
|
+
* Extract query type from method config.
|
|
12
|
+
*/
|
|
13
|
+
type ExtractQuery<T> = T extends {
|
|
14
|
+
query: infer Q;
|
|
15
|
+
} ? Q : T extends {
|
|
16
|
+
query?: infer Q;
|
|
17
|
+
} ? Q : never;
|
|
18
|
+
/**
|
|
19
|
+
* Internal optimistic target data.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
type OptimisticTarget = {
|
|
23
|
+
path: string;
|
|
24
|
+
method: string;
|
|
25
|
+
where?: (options: unknown) => boolean;
|
|
26
|
+
updater?: (data: unknown, response?: unknown) => unknown;
|
|
27
|
+
timing: "immediate" | "onSuccess";
|
|
28
|
+
rollbackOnError: boolean;
|
|
12
29
|
onError?: (error: unknown) => void;
|
|
13
30
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
/**
|
|
32
|
+
* WHERE options for filtering cache entries.
|
|
33
|
+
*/
|
|
34
|
+
type WhereOptions<TMethodConfig, TUserPath extends string> = HasParams<TUserPath> extends true ? HasQuery<TMethodConfig> extends true ? {
|
|
35
|
+
params: Record<ExtractParamNames<TUserPath>, string | number>;
|
|
36
|
+
query: ExtractQuery<TMethodConfig>;
|
|
37
|
+
} : {
|
|
38
|
+
params: Record<ExtractParamNames<TUserPath>, string | number>;
|
|
39
|
+
} : HasQuery<TMethodConfig> extends true ? {
|
|
40
|
+
query: ExtractQuery<TMethodConfig>;
|
|
41
|
+
} : never;
|
|
42
|
+
/**
|
|
43
|
+
* Conditionally include a method if it hasn't been used yet.
|
|
44
|
+
*/
|
|
45
|
+
type IfNotUsed<TMethod extends string, TUsed extends string, TType> = TMethod extends TUsed ? never : TType;
|
|
46
|
+
/**
|
|
47
|
+
* Brand for completed builders (UPDATE_CACHE was called).
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
declare const COMPLETED_BRAND: unique symbol;
|
|
51
|
+
/**
|
|
52
|
+
* Chainable builder after GET().
|
|
53
|
+
* Methods can be chained in any order, but each method can only be called once.
|
|
54
|
+
* Internal properties are hidden from autocomplete.
|
|
55
|
+
*
|
|
56
|
+
* @typeParam TTiming - Tracks whether ON_SUCCESS was called to determine UPDATE_CACHE signature
|
|
57
|
+
* @typeParam TUsed - Tracks which methods have been called to prevent duplicate calls
|
|
58
|
+
* @typeParam TCompleted - Tracks whether UPDATE_CACHE was called (required for valid builder)
|
|
59
|
+
*/
|
|
60
|
+
type OptimisticBuilder<TData = unknown, TMethodConfig = unknown, TUserPath extends string = string, TResponse = unknown, TTiming extends "immediate" | "onSuccess" = "immediate", TUsed extends string = never, TCompleted extends boolean = false> = (TCompleted extends true ? {
|
|
61
|
+
readonly [COMPLETED_BRAND]: true;
|
|
62
|
+
} : unknown) & {
|
|
63
|
+
/**
|
|
64
|
+
* Filter which cache entries to update based on query/params.
|
|
65
|
+
*
|
|
66
|
+
* @param predicate - Function that receives cache entry info and returns true to match
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
WHERE: IfNotUsed<"WHERE", TUsed, WhereOptions<TMethodConfig, TUserPath> extends never ? never : (predicate: (entry: Simplify<WhereOptions<TMethodConfig, TUserPath>>) => boolean) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "WHERE", TCompleted>>;
|
|
74
|
+
/**
|
|
75
|
+
* Specify how to update the cached data optimistically.
|
|
76
|
+
* This method is required - an optimistic update must have an updater function.
|
|
77
|
+
*
|
|
78
|
+
* For immediate updates (default): receives only the current data.
|
|
79
|
+
* For ON_SUCCESS: receives current data and the mutation response.
|
|
80
|
+
*
|
|
81
|
+
* @param updater - Function that receives current data (and response if ON_SUCCESS), returns updated data
|
|
82
|
+
*/
|
|
83
|
+
UPDATE_CACHE: IfNotUsed<"UPDATE_CACHE", TUsed, TTiming extends "onSuccess" ? (updater: (data: TData, response: TResponse) => TData) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "UPDATE_CACHE", true> : (updater: (data: TData) => TData) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "UPDATE_CACHE", true>>;
|
|
84
|
+
/**
|
|
85
|
+
* Apply optimistic update only after mutation succeeds.
|
|
86
|
+
* By default, updates are applied immediately before mutation completes.
|
|
87
|
+
* When using ON_SUCCESS, UPDATE_CACHE receives the mutation response as second argument.
|
|
88
|
+
*/
|
|
89
|
+
ON_SUCCESS: IfNotUsed<"ON_SUCCESS", TUsed, () => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, "onSuccess", TUsed | "ON_SUCCESS", TCompleted>>;
|
|
90
|
+
/**
|
|
91
|
+
* Disable automatic rollback when mutation fails.
|
|
92
|
+
* By default, optimistic updates are rolled back on error.
|
|
93
|
+
*/
|
|
94
|
+
NO_ROLLBACK: IfNotUsed<"NO_ROLLBACK", TUsed, () => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "NO_ROLLBACK", TCompleted>>;
|
|
95
|
+
/**
|
|
96
|
+
* Callback when mutation fails.
|
|
97
|
+
*/
|
|
98
|
+
ON_ERROR: IfNotUsed<"ON_ERROR", TUsed, (callback: (error: unknown) => void) => OptimisticBuilder<TData, TMethodConfig, TUserPath, TResponse, TTiming, TUsed | "ON_ERROR", TCompleted>>;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Extract paths that have GET methods.
|
|
102
|
+
*/
|
|
103
|
+
type ReadPaths<TSchema> = {
|
|
104
|
+
[K in keyof TSchema & string]: "GET" extends keyof TSchema[K] ? K : never;
|
|
105
|
+
}[keyof TSchema & string];
|
|
106
|
+
/**
|
|
107
|
+
* Path methods proxy for optimistic API - only GET.
|
|
108
|
+
*/
|
|
109
|
+
type OptimisticPathMethods<TSchema, TPath extends string, TResponse> = FindMatchingKey<TSchema, TPath> extends infer TKey ? TKey extends keyof TSchema ? TSchema[TKey] extends infer TRoute ? "GET" extends keyof TRoute ? TRoute["GET"] extends infer TGetConfig ? {
|
|
110
|
+
GET: () => OptimisticBuilder<ExtractData<TGetConfig>, TGetConfig, TPath, TResponse, "immediate", never, false>;
|
|
111
|
+
} : never : never : never : never : never;
|
|
112
|
+
/**
|
|
113
|
+
* Helper type for creating the optimistic API proxy.
|
|
114
|
+
*/
|
|
115
|
+
type OptimisticApiHelper<TSchema, TResponse = unknown> = <TPath extends ReadPaths<TSchema>>(path: TPath) => OptimisticPathMethods<TSchema, TPath, TResponse>;
|
|
116
|
+
/**
|
|
117
|
+
* A generic OptimisticTarget that accepts any data/response types.
|
|
118
|
+
* Used for the return type of the callback.
|
|
119
|
+
*/
|
|
120
|
+
type AnyOptimisticTarget = OptimisticTarget;
|
|
121
|
+
/**
|
|
122
|
+
* A completed builder that has UPDATE_CACHE called.
|
|
123
|
+
* Uses the brand to ensure UPDATE_CACHE was called.
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
type CompletedOptimisticBuilder = {
|
|
127
|
+
readonly [COMPLETED_BRAND]: true;
|
|
21
128
|
};
|
|
22
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Callback function type for the optimistic option.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* // Single target - immediate update (no response)
|
|
135
|
+
* optimistic: (api) => api("posts")
|
|
136
|
+
* .GET()
|
|
137
|
+
* .UPDATE_CACHE(posts => posts.filter(p => p.id !== deletedId))
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* // With WHERE filter and options
|
|
143
|
+
* optimistic: (api) => api("posts")
|
|
144
|
+
* .GET()
|
|
145
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
146
|
+
* .NO_ROLLBACK()
|
|
147
|
+
* .UPDATE_CACHE(posts => [...posts, newPost])
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* // Apply update after mutation succeeds (with typed response)
|
|
153
|
+
* optimistic: (api) => api("posts")
|
|
154
|
+
* .GET()
|
|
155
|
+
* .ON_SUCCESS()
|
|
156
|
+
* .UPDATE_CACHE((posts, response) => [...posts, response])
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* // Multiple targets
|
|
162
|
+
* optimistic: (api) => [
|
|
163
|
+
* api("posts").GET().UPDATE_CACHE(posts => posts.filter(p => p.id !== id)),
|
|
164
|
+
* api("stats").GET().UPDATE_CACHE(stats => ({ ...stats, count: stats.count - 1 })),
|
|
165
|
+
* ]
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
type OptimisticCallbackFn<TSchema = unknown, TResponse = unknown> = (api: OptimisticApiHelper<TSchema, TResponse>) => CompletedOptimisticBuilder | CompletedOptimisticBuilder[];
|
|
23
169
|
type OptimisticPluginConfig = object;
|
|
24
|
-
interface OptimisticWriteOptions<TSchema = unknown> {
|
|
25
|
-
|
|
170
|
+
interface OptimisticWriteOptions<TSchema = unknown, TResponse = unknown> {
|
|
171
|
+
/**
|
|
172
|
+
* Configure optimistic updates for this mutation.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* // Immediate update (default) - no response available
|
|
177
|
+
* trigger({
|
|
178
|
+
* optimistic: (api) => api("posts")
|
|
179
|
+
* .GET()
|
|
180
|
+
* .UPDATE_CACHE(posts => posts.filter(p => p.id !== deletedId)),
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* // With WHERE filter and disable rollback
|
|
187
|
+
* trigger({
|
|
188
|
+
* optimistic: (api) => api("posts")
|
|
189
|
+
* .GET()
|
|
190
|
+
* .NO_ROLLBACK()
|
|
191
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
192
|
+
* .UPDATE_CACHE(posts => [newPost, ...posts]),
|
|
193
|
+
* });
|
|
194
|
+
* ```
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* // Apply after success - response is available
|
|
199
|
+
* trigger({
|
|
200
|
+
* optimistic: (api) => api("posts")
|
|
201
|
+
* .GET()
|
|
202
|
+
* .ON_SUCCESS()
|
|
203
|
+
* .UPDATE_CACHE((posts, newPost) => [...posts, newPost]),
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
optimistic?: OptimisticCallbackFn<TSchema, TResponse>;
|
|
26
208
|
}
|
|
27
209
|
type OptimisticReadOptions = object;
|
|
28
210
|
type OptimisticInfiniteReadOptions = object;
|
|
@@ -32,7 +214,7 @@ interface OptimisticReadResult {
|
|
|
32
214
|
type OptimisticWriteResult = object;
|
|
33
215
|
declare module "@spoosh/core" {
|
|
34
216
|
interface PluginResolvers<TContext> {
|
|
35
|
-
optimistic: OptimisticCallbackFn<TContext["schema"]> | undefined;
|
|
217
|
+
optimistic: OptimisticCallbackFn<TContext["schema"], TContext["data"]> | undefined;
|
|
36
218
|
}
|
|
37
219
|
}
|
|
38
220
|
|
|
@@ -43,9 +225,9 @@ declare const OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
|
|
|
43
225
|
* Immediately updates cached data before the mutation completes,
|
|
44
226
|
* with automatic rollback on error.
|
|
45
227
|
*
|
|
46
|
-
* When using optimistic updates,
|
|
228
|
+
* When using optimistic updates, invalidation mode defaults to `"none"` to prevent
|
|
47
229
|
* unnecessary refetches that would override the optimistic data. You can override
|
|
48
|
-
* this by explicitly setting `
|
|
230
|
+
* this by explicitly setting the `invalidate` option with a mode string or array.
|
|
49
231
|
*
|
|
50
232
|
* @see {@link https://spoosh.dev/docs/plugins/optimistic | Optimistic Plugin Documentation}
|
|
51
233
|
*
|
|
@@ -61,50 +243,34 @@ declare const OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
|
|
|
61
243
|
* optimisticPlugin(),
|
|
62
244
|
* ]);
|
|
63
245
|
*
|
|
64
|
-
* //
|
|
65
|
-
* trigger({
|
|
66
|
-
* optimistic: ($, api) => $({
|
|
67
|
-
* for: api.posts.$get,
|
|
68
|
-
* updater: (posts) => posts.filter(p => p.id !== deletedId),
|
|
69
|
-
* rollbackOnError: true,
|
|
70
|
-
* }),
|
|
71
|
-
* });
|
|
72
|
-
* ```
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```ts
|
|
76
|
-
* // Multiple targets
|
|
246
|
+
* // Methods can be chained in any order
|
|
77
247
|
* trigger({
|
|
78
|
-
* optimistic: (
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* ],
|
|
248
|
+
* optimistic: (api) => api("posts")
|
|
249
|
+
* .GET()
|
|
250
|
+
* .UPDATE_CACHE(posts => posts.filter(p => p.id !== deletedId)),
|
|
82
251
|
* });
|
|
83
252
|
* ```
|
|
84
253
|
*
|
|
85
254
|
* @example
|
|
86
255
|
* ```ts
|
|
87
|
-
* //
|
|
256
|
+
* // With WHERE filter and disable rollback
|
|
88
257
|
* trigger({
|
|
89
|
-
* optimistic: (
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* autoInvalidate: "all", // You can override to enable refetching after mutation
|
|
258
|
+
* optimistic: (api) => api("posts")
|
|
259
|
+
* .GET()
|
|
260
|
+
* .NO_ROLLBACK()
|
|
261
|
+
* .WHERE(entry => entry.query.page === 1)
|
|
262
|
+
* .UPDATE_CACHE(posts => [newPost, ...posts]),
|
|
95
263
|
* });
|
|
96
264
|
* ```
|
|
97
265
|
*
|
|
98
266
|
* @example
|
|
99
267
|
* ```ts
|
|
100
|
-
* //
|
|
268
|
+
* // Apply after success with typed response
|
|
101
269
|
* trigger({
|
|
102
|
-
* optimistic: (
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* updater: (items, newItem) => [newItem!, ...items],
|
|
107
|
-
* }),
|
|
270
|
+
* optimistic: (api) => api("posts")
|
|
271
|
+
* .GET()
|
|
272
|
+
* .ON_SUCCESS()
|
|
273
|
+
* .UPDATE_CACHE((posts, newPost) => [...posts, newPost]),
|
|
108
274
|
* });
|
|
109
275
|
* ```
|
|
110
276
|
*/
|
|
@@ -116,4 +282,4 @@ declare function optimisticPlugin(): SpooshPlugin<{
|
|
|
116
282
|
writeResult: OptimisticWriteResult;
|
|
117
283
|
}>;
|
|
118
284
|
|
|
119
|
-
export { type
|
|
285
|
+
export { type AnyOptimisticTarget, OPTIMISTIC_SNAPSHOTS_KEY, type OptimisticApiHelper, type OptimisticBuilder, type OptimisticCallbackFn, type OptimisticInfiniteReadOptions, type OptimisticPluginConfig, type OptimisticReadOptions, type OptimisticReadResult, type OptimisticTarget, type OptimisticWriteOptions, type OptimisticWriteResult, optimisticPlugin };
|
package/dist/index.js
CHANGED
|
@@ -29,76 +29,98 @@ module.exports = __toCommonJS(src_exports);
|
|
|
29
29
|
var import_core = require("@spoosh/core");
|
|
30
30
|
var import_plugin_invalidation = require("@spoosh/plugin-invalidation");
|
|
31
31
|
var OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
|
|
32
|
-
function
|
|
33
|
-
return
|
|
32
|
+
function createBuilder(state) {
|
|
33
|
+
return {
|
|
34
|
+
...state,
|
|
35
|
+
WHERE(predicate) {
|
|
36
|
+
return createBuilder({ ...state, where: predicate });
|
|
37
|
+
},
|
|
38
|
+
UPDATE_CACHE(updater) {
|
|
39
|
+
return createBuilder({ ...state, updater });
|
|
40
|
+
},
|
|
41
|
+
ON_SUCCESS() {
|
|
42
|
+
return createBuilder({ ...state, timing: "onSuccess" });
|
|
43
|
+
},
|
|
44
|
+
NO_ROLLBACK() {
|
|
45
|
+
return createBuilder({ ...state, rollbackOnError: false });
|
|
46
|
+
},
|
|
47
|
+
ON_ERROR(callback) {
|
|
48
|
+
return createBuilder({ ...state, onError: callback });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function createOptimisticProxy() {
|
|
53
|
+
const createMethodsProxy = (path) => ({
|
|
54
|
+
GET: () => createBuilder({
|
|
55
|
+
path,
|
|
56
|
+
method: "GET",
|
|
57
|
+
timing: "immediate",
|
|
58
|
+
rollbackOnError: true
|
|
59
|
+
})
|
|
60
|
+
});
|
|
61
|
+
return ((path) => createMethodsProxy(path));
|
|
62
|
+
}
|
|
63
|
+
function extractTagsFromPath(path) {
|
|
64
|
+
const pathSegments = path.split("/").filter(Boolean);
|
|
65
|
+
return (0, import_core.generateTags)(pathSegments);
|
|
34
66
|
}
|
|
35
67
|
function getExactMatchPath(tags) {
|
|
36
68
|
return tags.length > 0 ? tags[tags.length - 1] : void 0;
|
|
37
69
|
}
|
|
38
|
-
function
|
|
39
|
-
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
40
|
-
return void 0;
|
|
41
|
-
}
|
|
42
|
-
const record = obj;
|
|
43
|
-
if (key in record) {
|
|
44
|
-
return record[key];
|
|
45
|
-
}
|
|
46
|
-
for (const value of Object.values(record)) {
|
|
47
|
-
const found = findInObject(value, key);
|
|
48
|
-
if (found !== void 0) return found;
|
|
49
|
-
}
|
|
50
|
-
return void 0;
|
|
51
|
-
}
|
|
52
|
-
function parseRequestFromKey(key) {
|
|
70
|
+
function extractOptionsFromKey(key) {
|
|
53
71
|
try {
|
|
54
72
|
const parsed = JSON.parse(key);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
const result = {};
|
|
74
|
+
const opts = parsed.options ?? parsed.pageRequest;
|
|
75
|
+
if (!opts) return null;
|
|
76
|
+
if (opts.query) {
|
|
77
|
+
result.query = opts.query;
|
|
78
|
+
}
|
|
79
|
+
if (opts.params) {
|
|
80
|
+
result.params = opts.params;
|
|
81
|
+
}
|
|
82
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
60
83
|
} catch {
|
|
61
|
-
return
|
|
84
|
+
return null;
|
|
62
85
|
}
|
|
63
86
|
}
|
|
64
|
-
function
|
|
87
|
+
function resolveOptimisticTargets(context) {
|
|
65
88
|
const pluginOptions = context.pluginOptions;
|
|
66
89
|
if (!pluginOptions?.optimistic) return [];
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
updater: config.updater,
|
|
72
|
-
rollbackOnError: config.rollbackOnError,
|
|
73
|
-
onError: config.onError
|
|
74
|
-
});
|
|
75
|
-
const apiProxy = (0, import_core.createSelectorProxy)();
|
|
76
|
-
const optimisticConfigs = pluginOptions.optimistic(
|
|
77
|
-
$,
|
|
78
|
-
apiProxy
|
|
79
|
-
);
|
|
80
|
-
return Array.isArray(optimisticConfigs) ? optimisticConfigs : [optimisticConfigs];
|
|
90
|
+
const apiProxy = createOptimisticProxy();
|
|
91
|
+
const result = pluginOptions.optimistic(apiProxy);
|
|
92
|
+
const targets = Array.isArray(result) ? result : [result];
|
|
93
|
+
return targets;
|
|
81
94
|
}
|
|
82
|
-
function applyOptimisticUpdate(stateManager,
|
|
83
|
-
|
|
95
|
+
function applyOptimisticUpdate(stateManager, target) {
|
|
96
|
+
if (!target.updater) return [];
|
|
97
|
+
const tags = extractTagsFromPath(target.path);
|
|
84
98
|
const targetSelfTag = getExactMatchPath(tags);
|
|
85
99
|
if (!targetSelfTag) return [];
|
|
86
100
|
const snapshots = [];
|
|
87
101
|
const entries = stateManager.getCacheEntriesBySelfTag(targetSelfTag);
|
|
88
102
|
for (const { key, entry } of entries) {
|
|
89
|
-
if (key.includes('"type":"infinite-tracker"'))
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
if (key.includes('"type":"infinite-tracker"')) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!key.includes(`"method":"${target.method}"`)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (target.where) {
|
|
110
|
+
const options = extractOptionsFromKey(key);
|
|
111
|
+
if (!options || !target.where(options)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (entry.state.data === void 0) {
|
|
116
|
+
continue;
|
|
94
117
|
}
|
|
95
|
-
if (entry.state.data === void 0) continue;
|
|
96
118
|
snapshots.push({ key, previousData: entry.state.data });
|
|
97
119
|
stateManager.setCache(key, {
|
|
98
120
|
previousData: entry.state.data,
|
|
99
121
|
state: {
|
|
100
122
|
...entry.state,
|
|
101
|
-
data:
|
|
123
|
+
data: target.updater(entry.state.data, void 0)
|
|
102
124
|
}
|
|
103
125
|
});
|
|
104
126
|
stateManager.setMeta(key, { isOptimistic: true });
|
|
@@ -138,14 +160,14 @@ function optimisticPlugin() {
|
|
|
138
160
|
dependencies: ["spoosh:invalidation"],
|
|
139
161
|
middleware: async (context, next) => {
|
|
140
162
|
const { stateManager } = context;
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
context.plugins.get("spoosh:invalidation")?.
|
|
163
|
+
const targets = resolveOptimisticTargets(context);
|
|
164
|
+
if (targets.length > 0) {
|
|
165
|
+
context.plugins.get("spoosh:invalidation")?.setDefaultMode("none");
|
|
144
166
|
}
|
|
145
|
-
const
|
|
167
|
+
const immediateTargets = targets.filter((t) => t.timing !== "onSuccess");
|
|
146
168
|
const allSnapshots = [];
|
|
147
|
-
for (const
|
|
148
|
-
const snapshots2 = applyOptimisticUpdate(stateManager,
|
|
169
|
+
for (const target of immediateTargets) {
|
|
170
|
+
const snapshots2 = applyOptimisticUpdate(stateManager, target);
|
|
149
171
|
allSnapshots.push(...snapshots2);
|
|
150
172
|
}
|
|
151
173
|
if (allSnapshots.length > 0) {
|
|
@@ -156,39 +178,50 @@ function optimisticPlugin() {
|
|
|
156
178
|
OPTIMISTIC_SNAPSHOTS_KEY
|
|
157
179
|
) ?? [];
|
|
158
180
|
if (response.error) {
|
|
159
|
-
const shouldRollback =
|
|
160
|
-
(
|
|
181
|
+
const shouldRollback = targets.some(
|
|
182
|
+
(t) => t.rollbackOnError && t.timing !== "onSuccess"
|
|
161
183
|
);
|
|
162
184
|
if (shouldRollback && snapshots.length > 0) {
|
|
163
185
|
rollbackOptimistic(stateManager, snapshots);
|
|
164
186
|
}
|
|
165
|
-
for (const
|
|
166
|
-
if (
|
|
167
|
-
|
|
187
|
+
for (const target of targets) {
|
|
188
|
+
if (target.onError) {
|
|
189
|
+
target.onError(response.error);
|
|
168
190
|
}
|
|
169
191
|
}
|
|
170
192
|
} else {
|
|
171
193
|
if (snapshots.length > 0) {
|
|
172
194
|
confirmOptimistic(stateManager, snapshots);
|
|
173
195
|
}
|
|
174
|
-
const
|
|
175
|
-
(
|
|
196
|
+
const onSuccessTargets = targets.filter(
|
|
197
|
+
(t) => t.timing === "onSuccess"
|
|
176
198
|
);
|
|
177
|
-
for (const
|
|
178
|
-
|
|
199
|
+
for (const target of onSuccessTargets) {
|
|
200
|
+
if (!target.updater) continue;
|
|
201
|
+
const tags = extractTagsFromPath(target.path);
|
|
179
202
|
const targetSelfTag = getExactMatchPath(tags);
|
|
180
203
|
if (!targetSelfTag) continue;
|
|
181
204
|
const entries = stateManager.getCacheEntriesBySelfTag(targetSelfTag);
|
|
182
205
|
for (const { key, entry } of entries) {
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
206
|
+
if (key.includes('"type":"infinite-tracker"')) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!key.includes(`"method":"${target.method}"`)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (target.where) {
|
|
213
|
+
const options = extractOptionsFromKey(key);
|
|
214
|
+
if (!options || !target.where(options)) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (entry.state.data === void 0) {
|
|
219
|
+
continue;
|
|
187
220
|
}
|
|
188
221
|
stateManager.setCache(key, {
|
|
189
222
|
state: {
|
|
190
223
|
...entry.state,
|
|
191
|
-
data:
|
|
224
|
+
data: target.updater(entry.state.data, response.data)
|
|
192
225
|
}
|
|
193
226
|
});
|
|
194
227
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -1,81 +1,99 @@
|
|
|
1
1
|
// src/plugin.ts
|
|
2
|
-
import {
|
|
3
|
-
createSelectorProxy,
|
|
4
|
-
extractPathFromSelector,
|
|
5
|
-
generateTags
|
|
6
|
-
} from "@spoosh/core";
|
|
2
|
+
import { generateTags } from "@spoosh/core";
|
|
7
3
|
import "@spoosh/plugin-invalidation";
|
|
8
4
|
var OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
|
|
9
|
-
function
|
|
10
|
-
return
|
|
5
|
+
function createBuilder(state) {
|
|
6
|
+
return {
|
|
7
|
+
...state,
|
|
8
|
+
WHERE(predicate) {
|
|
9
|
+
return createBuilder({ ...state, where: predicate });
|
|
10
|
+
},
|
|
11
|
+
UPDATE_CACHE(updater) {
|
|
12
|
+
return createBuilder({ ...state, updater });
|
|
13
|
+
},
|
|
14
|
+
ON_SUCCESS() {
|
|
15
|
+
return createBuilder({ ...state, timing: "onSuccess" });
|
|
16
|
+
},
|
|
17
|
+
NO_ROLLBACK() {
|
|
18
|
+
return createBuilder({ ...state, rollbackOnError: false });
|
|
19
|
+
},
|
|
20
|
+
ON_ERROR(callback) {
|
|
21
|
+
return createBuilder({ ...state, onError: callback });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function createOptimisticProxy() {
|
|
26
|
+
const createMethodsProxy = (path) => ({
|
|
27
|
+
GET: () => createBuilder({
|
|
28
|
+
path,
|
|
29
|
+
method: "GET",
|
|
30
|
+
timing: "immediate",
|
|
31
|
+
rollbackOnError: true
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
return ((path) => createMethodsProxy(path));
|
|
35
|
+
}
|
|
36
|
+
function extractTagsFromPath(path) {
|
|
37
|
+
const pathSegments = path.split("/").filter(Boolean);
|
|
38
|
+
return generateTags(pathSegments);
|
|
11
39
|
}
|
|
12
40
|
function getExactMatchPath(tags) {
|
|
13
41
|
return tags.length > 0 ? tags[tags.length - 1] : void 0;
|
|
14
42
|
}
|
|
15
|
-
function
|
|
16
|
-
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
17
|
-
return void 0;
|
|
18
|
-
}
|
|
19
|
-
const record = obj;
|
|
20
|
-
if (key in record) {
|
|
21
|
-
return record[key];
|
|
22
|
-
}
|
|
23
|
-
for (const value of Object.values(record)) {
|
|
24
|
-
const found = findInObject(value, key);
|
|
25
|
-
if (found !== void 0) return found;
|
|
26
|
-
}
|
|
27
|
-
return void 0;
|
|
28
|
-
}
|
|
29
|
-
function parseRequestFromKey(key) {
|
|
43
|
+
function extractOptionsFromKey(key) {
|
|
30
44
|
try {
|
|
31
45
|
const parsed = JSON.parse(key);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
const result = {};
|
|
47
|
+
const opts = parsed.options ?? parsed.pageRequest;
|
|
48
|
+
if (!opts) return null;
|
|
49
|
+
if (opts.query) {
|
|
50
|
+
result.query = opts.query;
|
|
51
|
+
}
|
|
52
|
+
if (opts.params) {
|
|
53
|
+
result.params = opts.params;
|
|
54
|
+
}
|
|
55
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
37
56
|
} catch {
|
|
38
|
-
return
|
|
57
|
+
return null;
|
|
39
58
|
}
|
|
40
59
|
}
|
|
41
|
-
function
|
|
60
|
+
function resolveOptimisticTargets(context) {
|
|
42
61
|
const pluginOptions = context.pluginOptions;
|
|
43
62
|
if (!pluginOptions?.optimistic) return [];
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
updater: config.updater,
|
|
49
|
-
rollbackOnError: config.rollbackOnError,
|
|
50
|
-
onError: config.onError
|
|
51
|
-
});
|
|
52
|
-
const apiProxy = createSelectorProxy();
|
|
53
|
-
const optimisticConfigs = pluginOptions.optimistic(
|
|
54
|
-
$,
|
|
55
|
-
apiProxy
|
|
56
|
-
);
|
|
57
|
-
return Array.isArray(optimisticConfigs) ? optimisticConfigs : [optimisticConfigs];
|
|
63
|
+
const apiProxy = createOptimisticProxy();
|
|
64
|
+
const result = pluginOptions.optimistic(apiProxy);
|
|
65
|
+
const targets = Array.isArray(result) ? result : [result];
|
|
66
|
+
return targets;
|
|
58
67
|
}
|
|
59
|
-
function applyOptimisticUpdate(stateManager,
|
|
60
|
-
|
|
68
|
+
function applyOptimisticUpdate(stateManager, target) {
|
|
69
|
+
if (!target.updater) return [];
|
|
70
|
+
const tags = extractTagsFromPath(target.path);
|
|
61
71
|
const targetSelfTag = getExactMatchPath(tags);
|
|
62
72
|
if (!targetSelfTag) return [];
|
|
63
73
|
const snapshots = [];
|
|
64
74
|
const entries = stateManager.getCacheEntriesBySelfTag(targetSelfTag);
|
|
65
75
|
for (const { key, entry } of entries) {
|
|
66
|
-
if (key.includes('"type":"infinite-tracker"'))
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
if (key.includes('"type":"infinite-tracker"')) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!key.includes(`"method":"${target.method}"`)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (target.where) {
|
|
83
|
+
const options = extractOptionsFromKey(key);
|
|
84
|
+
if (!options || !target.where(options)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (entry.state.data === void 0) {
|
|
89
|
+
continue;
|
|
71
90
|
}
|
|
72
|
-
if (entry.state.data === void 0) continue;
|
|
73
91
|
snapshots.push({ key, previousData: entry.state.data });
|
|
74
92
|
stateManager.setCache(key, {
|
|
75
93
|
previousData: entry.state.data,
|
|
76
94
|
state: {
|
|
77
95
|
...entry.state,
|
|
78
|
-
data:
|
|
96
|
+
data: target.updater(entry.state.data, void 0)
|
|
79
97
|
}
|
|
80
98
|
});
|
|
81
99
|
stateManager.setMeta(key, { isOptimistic: true });
|
|
@@ -115,14 +133,14 @@ function optimisticPlugin() {
|
|
|
115
133
|
dependencies: ["spoosh:invalidation"],
|
|
116
134
|
middleware: async (context, next) => {
|
|
117
135
|
const { stateManager } = context;
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
context.plugins.get("spoosh:invalidation")?.
|
|
136
|
+
const targets = resolveOptimisticTargets(context);
|
|
137
|
+
if (targets.length > 0) {
|
|
138
|
+
context.plugins.get("spoosh:invalidation")?.setDefaultMode("none");
|
|
121
139
|
}
|
|
122
|
-
const
|
|
140
|
+
const immediateTargets = targets.filter((t) => t.timing !== "onSuccess");
|
|
123
141
|
const allSnapshots = [];
|
|
124
|
-
for (const
|
|
125
|
-
const snapshots2 = applyOptimisticUpdate(stateManager,
|
|
142
|
+
for (const target of immediateTargets) {
|
|
143
|
+
const snapshots2 = applyOptimisticUpdate(stateManager, target);
|
|
126
144
|
allSnapshots.push(...snapshots2);
|
|
127
145
|
}
|
|
128
146
|
if (allSnapshots.length > 0) {
|
|
@@ -133,39 +151,50 @@ function optimisticPlugin() {
|
|
|
133
151
|
OPTIMISTIC_SNAPSHOTS_KEY
|
|
134
152
|
) ?? [];
|
|
135
153
|
if (response.error) {
|
|
136
|
-
const shouldRollback =
|
|
137
|
-
(
|
|
154
|
+
const shouldRollback = targets.some(
|
|
155
|
+
(t) => t.rollbackOnError && t.timing !== "onSuccess"
|
|
138
156
|
);
|
|
139
157
|
if (shouldRollback && snapshots.length > 0) {
|
|
140
158
|
rollbackOptimistic(stateManager, snapshots);
|
|
141
159
|
}
|
|
142
|
-
for (const
|
|
143
|
-
if (
|
|
144
|
-
|
|
160
|
+
for (const target of targets) {
|
|
161
|
+
if (target.onError) {
|
|
162
|
+
target.onError(response.error);
|
|
145
163
|
}
|
|
146
164
|
}
|
|
147
165
|
} else {
|
|
148
166
|
if (snapshots.length > 0) {
|
|
149
167
|
confirmOptimistic(stateManager, snapshots);
|
|
150
168
|
}
|
|
151
|
-
const
|
|
152
|
-
(
|
|
169
|
+
const onSuccessTargets = targets.filter(
|
|
170
|
+
(t) => t.timing === "onSuccess"
|
|
153
171
|
);
|
|
154
|
-
for (const
|
|
155
|
-
|
|
172
|
+
for (const target of onSuccessTargets) {
|
|
173
|
+
if (!target.updater) continue;
|
|
174
|
+
const tags = extractTagsFromPath(target.path);
|
|
156
175
|
const targetSelfTag = getExactMatchPath(tags);
|
|
157
176
|
if (!targetSelfTag) continue;
|
|
158
177
|
const entries = stateManager.getCacheEntriesBySelfTag(targetSelfTag);
|
|
159
178
|
for (const { key, entry } of entries) {
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
if (key.includes('"type":"infinite-tracker"')) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!key.includes(`"method":"${target.method}"`)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (target.where) {
|
|
186
|
+
const options = extractOptionsFromKey(key);
|
|
187
|
+
if (!options || !target.where(options)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (entry.state.data === void 0) {
|
|
192
|
+
continue;
|
|
164
193
|
}
|
|
165
194
|
stateManager.setCache(key, {
|
|
166
195
|
state: {
|
|
167
196
|
...entry.state,
|
|
168
|
-
data:
|
|
197
|
+
data: target.updater(entry.state.data, response.data)
|
|
169
198
|
}
|
|
170
199
|
});
|
|
171
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spoosh/plugin-optimistic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Optimistic updates plugin for Spoosh - instant UI updates with automatic rollback",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@spoosh/core": ">=0.
|
|
37
|
-
"@spoosh/plugin-invalidation": ">=0.
|
|
36
|
+
"@spoosh/core": ">=0.6.0",
|
|
37
|
+
"@spoosh/plugin-invalidation": ">=0.4.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@spoosh/core": "0.
|
|
41
|
-
"@spoosh/plugin-invalidation": "0.
|
|
40
|
+
"@spoosh/core": "0.6.0",
|
|
41
|
+
"@spoosh/plugin-invalidation": "0.4.0",
|
|
42
42
|
"@spoosh/test-utils": "0.1.5"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|