@spoosh/plugin-optimistic 0.2.0 → 0.3.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 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.posts(id).$delete);
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: ($, api) =>
35
- $({
36
- for: api.posts.$get,
37
- updater: (posts) => posts.filter((p) => p.id !== id),
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: ($, api) =>
45
- $({
46
- for: api.posts.$get,
47
- timing: "onSuccess",
48
- updater: (posts, newPost) => [newPost!, ...posts],
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: ($, api) => [
55
- $({
56
- for: api.posts.$get,
57
- updater: (posts) => posts.filter((p) => p.id !== id),
58
- }),
59
- $({
60
- for: api.stats.$get,
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: ($, api) =>
69
- $({
70
- for: api.posts.$get,
71
- match: (request) => request.query?.page === 1,
72
- updater: (posts, newPost) => [newPost!, ...posts],
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 | Description |
82
- | ------------ | -------------------------------- | ------------------------------------- |
83
- | `optimistic` | `($, api) => config \| config[]` | Callback to define optimistic updates |
76
+ | Option | Type | Description |
77
+ | ------------ | ------------------------------- | ------------------------------------- |
78
+ | `optimistic` | `(api) => builder \| builder[]` | Callback to define optimistic updates |
84
79
 
85
- ### Config Object
80
+ ### Builder Methods (DSL)
86
81
 
87
- | Property | Type | Default | Description |
88
- | ----------------- | ---------------------------- | ------------- | ------------------------------------ |
89
- | `for` | `api.endpoint.$get` | required | The endpoint to update |
90
- | `updater` | `(data, response?) => data` | required | Function to update cached data |
91
- | `match` | `(request) => boolean` | - | Filter which cache entries to update |
92
- | `timing` | `"immediate" \| "onSuccess"` | `"immediate"` | When to apply the update (see below) |
93
- | `rollbackOnError` | `boolean` | `true` | Whether to rollback on error |
94
- | `onError` | `(error) => void` | - | Error callback |
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
- | Mode | Description |
105
- | ------------- | --------------------------------------------------------------------------- |
106
- | `"immediate"` | Update cache instantly before request completes. Rollback on error. |
107
- | `"onSuccess"` | Wait for successful response, then update cache with response data applied. |
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 { SpooshResponse, QuerySchemaHelper, SpooshPlugin } from '@spoosh/core';
1
+ import { FindMatchingKey, HasParams, ExtractParamNames, ExtractData, SpooshPlugin } from '@spoosh/core';
2
2
 
3
- type ExtractResponseData<T> = T extends SpooshResponse<infer D, unknown, unknown> ? D : unknown;
4
- type ExtractRequestOptions<T> = T extends SpooshResponse<unknown, unknown, infer R> ? R : never;
5
- type CleanRequestOptions<T> = unknown extends T ? never : keyof T extends never ? never : T;
6
- type CacheConfig<TFor extends () => Promise<SpooshResponse<unknown, unknown, unknown>>, TResponse = unknown, TData = ExtractResponseData<Awaited<ReturnType<TFor>>>, TRequest = CleanRequestOptions<ExtractRequestOptions<Awaited<ReturnType<TFor>>>>> = {
7
- for: TFor;
8
- match?: [TRequest] extends [never] ? never : (request: TRequest) => boolean;
9
- timing?: "immediate" | "onSuccess";
10
- updater: (data: TData, response?: TResponse) => TData;
11
- rollbackOnError?: boolean;
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
- type ResolvedCacheConfig = {
15
- for: (...args: any[]) => Promise<SpooshResponse<unknown, unknown>>;
16
- match?: (request: Record<string, unknown>) => boolean;
17
- timing?: "immediate" | "onSuccess";
18
- updater: (data: unknown, response?: unknown) => unknown;
19
- rollbackOnError?: boolean;
20
- onError?: (error: unknown) => void;
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
- type OptimisticCallbackFn<TSchema = unknown, TResponse = unknown> = ($: <TFor extends () => Promise<SpooshResponse<unknown, unknown, unknown>>>(config: CacheConfig<TFor, TResponse>) => ResolvedCacheConfig, api: QuerySchemaHelper<TSchema>) => ResolvedCacheConfig | ResolvedCacheConfig[];
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
- optimistic?: OptimisticCallbackFn<TSchema>;
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
 
@@ -61,50 +243,34 @@ declare const OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
61
243
  * optimisticPlugin(),
62
244
  * ]);
63
245
  *
64
- * // In useWrite - autoInvalidate defaults to "none" when optimistic is used
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: ($, api) => [
79
- * $({ for: api.posts.$get, updater: (posts) => posts.filter(p => p.id !== deletedId) }),
80
- * $({ for: api.stats.$get, updater: (stats) => ({ ...stats, count: stats.count - 1 }) }),
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
- * // Optimistic update with explicit invalidation
256
+ * // With WHERE filter and disable rollback
88
257
  * trigger({
89
- * optimistic: ($, api) => $({
90
- * for: api.posts.$get,
91
- * updater: (posts) => posts.filter(p => p.id !== deletedId),
92
- * }),
93
- * // By default autoInvalidate is "none" when using optimistic updates
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
- * // Filtering by request
268
+ * // Apply after success with typed response
101
269
  * trigger({
102
- * optimistic: ($, api) => $({
103
- * for: api.items.$get,
104
- * timing: "onSuccess",
105
- * match: (request) => request.query?.page === 1,
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 CacheConfig, OPTIMISTIC_SNAPSHOTS_KEY, type OptimisticCallbackFn, type OptimisticInfiniteReadOptions, type OptimisticPluginConfig, type OptimisticReadOptions, type OptimisticReadResult, type OptimisticWriteOptions, type OptimisticWriteResult, type ResolvedCacheConfig, optimisticPlugin };
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 { SpooshResponse, QuerySchemaHelper, SpooshPlugin } from '@spoosh/core';
1
+ import { FindMatchingKey, HasParams, ExtractParamNames, ExtractData, SpooshPlugin } from '@spoosh/core';
2
2
 
3
- type ExtractResponseData<T> = T extends SpooshResponse<infer D, unknown, unknown> ? D : unknown;
4
- type ExtractRequestOptions<T> = T extends SpooshResponse<unknown, unknown, infer R> ? R : never;
5
- type CleanRequestOptions<T> = unknown extends T ? never : keyof T extends never ? never : T;
6
- type CacheConfig<TFor extends () => Promise<SpooshResponse<unknown, unknown, unknown>>, TResponse = unknown, TData = ExtractResponseData<Awaited<ReturnType<TFor>>>, TRequest = CleanRequestOptions<ExtractRequestOptions<Awaited<ReturnType<TFor>>>>> = {
7
- for: TFor;
8
- match?: [TRequest] extends [never] ? never : (request: TRequest) => boolean;
9
- timing?: "immediate" | "onSuccess";
10
- updater: (data: TData, response?: TResponse) => TData;
11
- rollbackOnError?: boolean;
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
- type ResolvedCacheConfig = {
15
- for: (...args: any[]) => Promise<SpooshResponse<unknown, unknown>>;
16
- match?: (request: Record<string, unknown>) => boolean;
17
- timing?: "immediate" | "onSuccess";
18
- updater: (data: unknown, response?: unknown) => unknown;
19
- rollbackOnError?: boolean;
20
- onError?: (error: unknown) => void;
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
- type OptimisticCallbackFn<TSchema = unknown, TResponse = unknown> = ($: <TFor extends () => Promise<SpooshResponse<unknown, unknown, unknown>>>(config: CacheConfig<TFor, TResponse>) => ResolvedCacheConfig, api: QuerySchemaHelper<TSchema>) => ResolvedCacheConfig | ResolvedCacheConfig[];
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
- optimistic?: OptimisticCallbackFn<TSchema>;
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
 
@@ -61,50 +243,34 @@ declare const OPTIMISTIC_SNAPSHOTS_KEY = "optimistic:snapshots";
61
243
  * optimisticPlugin(),
62
244
  * ]);
63
245
  *
64
- * // In useWrite - autoInvalidate defaults to "none" when optimistic is used
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: ($, api) => [
79
- * $({ for: api.posts.$get, updater: (posts) => posts.filter(p => p.id !== deletedId) }),
80
- * $({ for: api.stats.$get, updater: (stats) => ({ ...stats, count: stats.count - 1 }) }),
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
- * // Optimistic update with explicit invalidation
256
+ * // With WHERE filter and disable rollback
88
257
  * trigger({
89
- * optimistic: ($, api) => $({
90
- * for: api.posts.$get,
91
- * updater: (posts) => posts.filter(p => p.id !== deletedId),
92
- * }),
93
- * // By default autoInvalidate is "none" when using optimistic updates
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
- * // Filtering by request
268
+ * // Apply after success with typed response
101
269
  * trigger({
102
- * optimistic: ($, api) => $({
103
- * for: api.items.$get,
104
- * timing: "onSuccess",
105
- * match: (request) => request.query?.page === 1,
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 CacheConfig, OPTIMISTIC_SNAPSHOTS_KEY, type OptimisticCallbackFn, type OptimisticInfiniteReadOptions, type OptimisticPluginConfig, type OptimisticReadOptions, type OptimisticReadResult, type OptimisticWriteOptions, type OptimisticWriteResult, type ResolvedCacheConfig, optimisticPlugin };
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 extractTagsFromFor(forFn) {
33
- return (0, import_core.generateTags)((0, import_core.extractPathFromSelector)(forFn));
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 findInObject(obj, key) {
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
- return {
56
- query: findInObject(parsed, "query"),
57
- params: findInObject(parsed, "params"),
58
- body: findInObject(parsed, "body")
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 void 0;
84
+ return null;
62
85
  }
63
86
  }
64
- function resolveOptimisticConfigs(context) {
87
+ function resolveOptimisticTargets(context) {
65
88
  const pluginOptions = context.pluginOptions;
66
89
  if (!pluginOptions?.optimistic) return [];
67
- const $ = (config) => ({
68
- for: config.for,
69
- match: config.match,
70
- timing: config.timing,
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, config) {
83
- const tags = extractTagsFromFor(config.for);
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"')) continue;
90
- if (!key.includes('"method":"$get"')) continue;
91
- if (config.match) {
92
- const request = parseRequestFromKey(key);
93
- if (!request || !config.match(request)) continue;
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: config.updater(entry.state.data, void 0)
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 configs = resolveOptimisticConfigs(context);
142
- if (configs.length > 0) {
163
+ const targets = resolveOptimisticTargets(context);
164
+ if (targets.length > 0) {
143
165
  context.plugins.get("spoosh:invalidation")?.setAutoInvalidateDefault("none");
144
166
  }
145
- const immediateConfigs = configs.filter((c) => c.timing !== "onSuccess");
167
+ const immediateTargets = targets.filter((t) => t.timing !== "onSuccess");
146
168
  const allSnapshots = [];
147
- for (const config of immediateConfigs) {
148
- const snapshots2 = applyOptimisticUpdate(stateManager, config);
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 = configs.some(
160
- (c) => c.rollbackOnError !== false && c.timing !== "onSuccess"
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 config of configs) {
166
- if (config.onError) {
167
- config.onError(response.error);
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 onSuccessConfigs = configs.filter(
175
- (c) => c.timing === "onSuccess"
196
+ const onSuccessTargets = targets.filter(
197
+ (t) => t.timing === "onSuccess"
176
198
  );
177
- for (const config of onSuccessConfigs) {
178
- const tags = extractTagsFromFor(config.for);
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 (!key.includes('"method":"$get"')) continue;
184
- if (config.match) {
185
- const request = parseRequestFromKey(key);
186
- if (!request || !config.match(request)) continue;
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: config.updater(entry.state.data, response.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 extractTagsFromFor(forFn) {
10
- return generateTags(extractPathFromSelector(forFn));
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 findInObject(obj, key) {
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
- return {
33
- query: findInObject(parsed, "query"),
34
- params: findInObject(parsed, "params"),
35
- body: findInObject(parsed, "body")
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 void 0;
57
+ return null;
39
58
  }
40
59
  }
41
- function resolveOptimisticConfigs(context) {
60
+ function resolveOptimisticTargets(context) {
42
61
  const pluginOptions = context.pluginOptions;
43
62
  if (!pluginOptions?.optimistic) return [];
44
- const $ = (config) => ({
45
- for: config.for,
46
- match: config.match,
47
- timing: config.timing,
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, config) {
60
- const tags = extractTagsFromFor(config.for);
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"')) continue;
67
- if (!key.includes('"method":"$get"')) continue;
68
- if (config.match) {
69
- const request = parseRequestFromKey(key);
70
- if (!request || !config.match(request)) continue;
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: config.updater(entry.state.data, void 0)
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 configs = resolveOptimisticConfigs(context);
119
- if (configs.length > 0) {
136
+ const targets = resolveOptimisticTargets(context);
137
+ if (targets.length > 0) {
120
138
  context.plugins.get("spoosh:invalidation")?.setAutoInvalidateDefault("none");
121
139
  }
122
- const immediateConfigs = configs.filter((c) => c.timing !== "onSuccess");
140
+ const immediateTargets = targets.filter((t) => t.timing !== "onSuccess");
123
141
  const allSnapshots = [];
124
- for (const config of immediateConfigs) {
125
- const snapshots2 = applyOptimisticUpdate(stateManager, config);
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 = configs.some(
137
- (c) => c.rollbackOnError !== false && c.timing !== "onSuccess"
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 config of configs) {
143
- if (config.onError) {
144
- config.onError(response.error);
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 onSuccessConfigs = configs.filter(
152
- (c) => c.timing === "onSuccess"
169
+ const onSuccessTargets = targets.filter(
170
+ (t) => t.timing === "onSuccess"
153
171
  );
154
- for (const config of onSuccessConfigs) {
155
- const tags = extractTagsFromFor(config.for);
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 (!key.includes('"method":"$get"')) continue;
161
- if (config.match) {
162
- const request = parseRequestFromKey(key);
163
- if (!request || !config.match(request)) continue;
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: config.updater(entry.state.data, response.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.2.0",
3
+ "version": "0.3.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.4.0",
36
+ "@spoosh/core": ">=0.6.0",
37
37
  "@spoosh/plugin-invalidation": ">=0.1.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@spoosh/core": "0.5.0",
41
- "@spoosh/plugin-invalidation": "0.1.4",
40
+ "@spoosh/core": "0.6.0",
41
+ "@spoosh/plugin-invalidation": "0.3.0",
42
42
  "@spoosh/test-utils": "0.1.5"
43
43
  },
44
44
  "scripts": {