@spoosh/plugin-invalidation 0.9.1 → 0.11.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
@@ -1,6 +1,6 @@
1
1
  # @spoosh/plugin-invalidation
2
2
 
3
- Cache invalidation plugin for Spoosh - auto-invalidates related queries after mutations.
3
+ Cache invalidation plugin for Spoosh - auto-invalidates related queries after mutations using wildcard patterns.
4
4
 
5
5
  **[Documentation](https://spoosh.dev/docs/react/plugins/invalidation)** · **Requirements:** TypeScript >= 5.0 · **Peer Dependencies:** `@spoosh/core`
6
6
 
@@ -12,29 +12,28 @@ npm install @spoosh/plugin-invalidation
12
12
 
13
13
  ## How It Works
14
14
 
15
- Tags are automatically generated from the API path hierarchy:
15
+ Tags are automatically generated from the API path:
16
16
 
17
17
  ```typescript
18
- // Query tags are generated from the path:
19
- useRead((api) => api("users").GET());
20
- // → tags: ["users"]
18
+ useRead((api) => api("posts").GET());
19
+ // tag: "posts"
21
20
 
22
- useRead((api) => api("users/:id").GET({ params: { id: 123 } }));
23
- // → tags: ["users", "users/123"]
21
+ useRead((api) => api("posts/:id").GET({ params: { id: 123 } }));
22
+ // → tag: "posts/123"
24
23
 
25
- useRead((api) => api("users/:id/posts").GET({ params: { id: 123 } }));
26
- // → tags: ["users", "users/123", "users/123/posts"]
24
+ useRead((api) => api("posts/:id/comments").GET({ params: { id: 123 } }));
25
+ // → tag: "posts/123/comments"
27
26
  ```
28
27
 
29
- When a mutation succeeds, related queries are automatically invalidated:
28
+ When a mutation succeeds, related queries are automatically invalidated using wildcard patterns:
30
29
 
31
30
  ```typescript
32
- // Creating a post at users/123/posts invalidates:
33
- const { trigger } = useWrite((api) => api("users/:id/posts").POST());
34
- await trigger({ params: { id: 123 }, body: { title: "New Post" } });
31
+ const { trigger } = useWrite((api) => api("posts/:id/comments").POST());
32
+ await trigger({ params: { id: 123 }, body: { text: "Hello" } });
35
33
 
36
- // Invalidates: "users", "users/123", "users/123/posts"
37
- // All queries matching these tags will refetch automatically
34
+ // Default behavior (autoInvalidate: true):
35
+ // Invalidates: ["posts", "posts/*"]
36
+ // ✓ Matches: "posts", "posts/123", "posts/123/comments", etc.
38
37
  ```
39
38
 
40
39
  ## Usage
@@ -49,172 +48,153 @@ const { trigger } = useWrite((api) => api("posts").POST());
49
48
  await trigger({ body: { title: "New Post" } });
50
49
  ```
51
50
 
52
- ## Default Configuration
51
+ ## Pattern Matching
53
52
 
54
- ```typescript
55
- // Default: invalidate all related tags (full hierarchy)
56
- invalidationPlugin(); // same as { defaultMode: "all" }
57
-
58
- // Only invalidate the exact endpoint by default
59
- invalidationPlugin({ defaultMode: "self" });
60
-
61
- // Disable auto-invalidation by default (manual only)
62
- invalidationPlugin({ defaultMode: "none" });
63
- ```
53
+ | Pattern | Matches | Does NOT Match |
54
+ | ---------------------- | --------------------------------- | ---------------------- |
55
+ | `"posts"` | `"posts"` (exact) | `"posts/1"`, `"users"` |
56
+ | `"posts/*"` | `"posts/1"`, `"posts/1/comments"` | `"posts"` (parent) |
57
+ | `["posts", "posts/*"]` | `"posts"` AND all children | - |
64
58
 
65
59
  ## Per-Request Invalidation
66
60
 
67
61
  ```typescript
68
- // Mode only (string)
62
+ // Exact match only
69
63
  await trigger({
70
64
  body: { title: "New Post" },
71
- invalidate: "all", // Invalidate entire path hierarchy
65
+ invalidate: "posts",
72
66
  });
73
67
 
68
+ // Children only (not the parent)
74
69
  await trigger({
75
70
  body: { title: "New Post" },
76
- invalidate: "self", // Only invalidate the exact endpoint
71
+ invalidate: "posts/*",
77
72
  });
78
73
 
74
+ // Parent AND all children
79
75
  await trigger({
80
76
  body: { title: "New Post" },
81
- invalidate: "none", // No invalidation
77
+ invalidate: ["posts", "posts/*"],
82
78
  });
83
79
 
84
- // Single tag (string)
80
+ // Multiple patterns
85
81
  await trigger({
86
82
  body: { title: "New Post" },
87
- invalidate: "posts", // Invalidate only "posts" tag
83
+ invalidate: ["posts", "users/*", "dashboard"],
88
84
  });
89
85
 
90
- // Multiple tags (array without mode keyword)
86
+ // Disable invalidation for this mutation
91
87
  await trigger({
92
88
  body: { title: "New Post" },
93
- invalidate: ["posts", "users", "custom-tag"],
94
- // → Default mode: 'none' (only explicit tags are invalidated)
89
+ invalidate: false,
95
90
  });
96
91
 
97
- // Mode + Tags (array with mode keyword at any position)
92
+ // Global refetch - triggers ALL queries to refetch
98
93
  await trigger({
99
94
  body: { title: "New Post" },
100
- invalidate: ["all", "dashboard", "stats"],
101
- // → 'all' mode + explicit tags
95
+ invalidate: "*",
102
96
  });
97
+ ```
103
98
 
104
- await trigger({
105
- body: { title: "New Post" },
106
- invalidate: ["posts", "self", "users"],
107
- // → 'self' mode + explicit tags
108
- });
99
+ ## Options
109
100
 
110
- await trigger({
111
- body: { title: "New Post" },
112
- invalidate: ["dashboard", "stats", "all"],
113
- // → 'all' mode + explicit tags (mode can be anywhere)
114
- });
101
+ ### Plugin Config
115
102
 
116
- // Wildcard - global refetch
117
- await trigger({
118
- body: { title: "New Post" },
119
- invalidate: "*", // Triggers ALL queries to refetch
120
- });
103
+ | Option | Type | Default | Description |
104
+ | ---------------- | ---------- | ------- | ---------------------------------------------- |
105
+ | `autoInvalidate` | `boolean` | `true` | Auto-generate invalidation patterns from path |
106
+ | `groups` | `string[]` | `[]` | Path prefixes that use deeper segment matching |
121
107
 
122
- // Combined with clearCache (from @spoosh/plugin-cache)
123
- await trigger({
124
- clearCache: true, // Clear all cached data
125
- invalidate: "*", // Then refetch all queries
108
+ ```typescript
109
+ // Default: auto-invalidate using [firstSegment, firstSegment/*]
110
+ invalidationPlugin(); // same as { autoInvalidate: true }
111
+
112
+ // Disable auto-invalidation (manual only)
113
+ invalidationPlugin({ autoInvalidate: false });
114
+
115
+ // Groups: use deeper segment matching for grouped endpoints
116
+ invalidationPlugin({
117
+ groups: ["admin", "api/v1"],
126
118
  });
127
119
  ```
128
120
 
129
- ## Options
121
+ ### Groups Configuration
130
122
 
131
- ### Plugin Config
132
-
133
- | Option | Type | Default | Description |
134
- | ------------- | --------------------------- | ------- | --------------------------------------------------- |
135
- | `defaultMode` | `"all" \| "self" \| "none"` | `"all"` | Default invalidation mode when option not specified |
123
+ Use `groups` when you have path prefixes that should be treated as a namespace:
136
124
 
137
- ### Per-Request Options
125
+ ```typescript
126
+ invalidationPlugin({
127
+ groups: ["admin", "api/v1"],
128
+ });
138
129
 
139
- | Option | Type | Description |
140
- | ------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
141
- | `invalidate` | `"all" \| "self" \| "none" \| "*" \| string \| string[]` | Mode (`"all"`, `"self"`, `"none"`), wildcard (`"*"` for global refetch), single tag, or array of tags with optional mode keyword |
130
+ // Without groups:
131
+ // POST admin/posts invalidates ["admin", "admin/*"]
142
132
 
143
- ### Invalidation Modes
133
+ // With groups: ["admin"]:
134
+ // POST admin/posts → invalidates ["admin/posts", "admin/posts/*"]
135
+ // POST admin/users → invalidates ["admin/users", "admin/users/*"]
136
+ // POST admin → invalidates ["admin", "admin/*"]
144
137
 
145
- | Mode | Description | Example |
146
- | -------- | --------------------------------------- | ----------------------------------------------------------- |
147
- | `"all"` | Invalidate all tags from path hierarchy | `users/123/posts` → `users`, `users/123`, `users/123/posts` |
148
- | `"self"` | Only invalidate the exact endpoint tag | `users/123/posts` → `users/123/posts` |
149
- | `"none"` | Disable auto-invalidation (manual only) | No automatic invalidation |
150
- | `"*"` | Global refetch - triggers all queries | All active queries refetch |
138
+ // With groups: ["api/v1"]:
139
+ // POST api/v1/users invalidates ["api/v1/users", "api/v1/users/*"]
140
+ ```
151
141
 
152
- ### Understanding `"all"` vs `"*"`
142
+ ### Per-Request Options
153
143
 
154
- These two options serve different purposes:
144
+ | Option | Type | Description |
145
+ | ------------ | ------------------------------------ | ------------------------------------------------------------------------- |
146
+ | `invalidate` | `string \| string[] \| false \| "*"` | Pattern(s) to invalidate, `false` to disable, or `"*"` for global refetch |
155
147
 
156
- - **`"all"`** - Invalidates all tags **from the current endpoint's path hierarchy**. If you're mutating `users/123/posts`, it invalidates `["users", "users/123", "users/123/posts"]`. It's scoped to the mutation's path.
148
+ ## Default Behavior
157
149
 
158
- - **`"*"`** - Triggers a **global refetch of every active query** in your app, regardless of tags. Use this sparingly for scenarios like "user logged out" or "full data sync from server".
150
+ When `autoInvalidate: true` (default) and no `invalidate` option is provided:
159
151
 
160
152
  ```typescript
161
- // "all" - scoped to this mutation's path hierarchy
162
- await trigger({ invalidate: "all" });
163
- // If path is users/123/posts → invalidates: users, users/123, users/123/posts
153
+ // POST /posts/123/comments
154
+ // Invalidates: ["posts", "posts/*"]
164
155
 
165
- // "*" - refetches ALL queries in the entire app
166
- await trigger({ invalidate: "*" });
167
- // Every active useRead/injectRead will refetch
156
+ // The first path segment is used to generate patterns:
157
+ // - "posts" - exact match for the root
158
+ // - "posts/*" - all children under posts
168
159
  ```
169
160
 
170
161
  ## Instance API
171
162
 
172
- The plugin exposes `invalidate` for manually triggering cache invalidation outside of mutations:
163
+ The plugin exposes `invalidate` for manual cache invalidation:
173
164
 
174
165
  ```typescript
175
166
  import { create } from "@spoosh/react";
176
167
 
177
168
  const { useRead, invalidate } = create(spoosh);
178
169
 
179
- // Invalidate with string array
180
- invalidate(["users", "posts"]);
170
+ // Single pattern
171
+ invalidate("posts");
181
172
 
182
- // Invalidate with single string
183
- invalidate("users");
173
+ // Multiple patterns
174
+ invalidate(["posts", "users/*"]);
184
175
 
185
- // Global refetch - triggers ALL queries to refetch
176
+ // Global refetch
186
177
  invalidate("*");
187
178
 
188
- // Useful for external events like WebSocket messages
189
- socket.on("data-changed", (tags) => {
190
- invalidate(tags);
179
+ // Useful for external events
180
+ socket.on("posts-updated", () => {
181
+ invalidate(["posts", "posts/*"]);
191
182
  });
192
183
 
193
- // WebSocket: trigger global refetch
194
184
  socket.on("full-sync", () => {
195
185
  invalidate("*");
196
186
  });
197
187
  ```
198
188
 
199
- | Method | Description |
200
- | ------------ | ---------------------------------------------------------------------- |
201
- | `invalidate` | Manually invalidate cache entries by tags, or use `"*"` to refetch all |
202
-
203
189
  ## Combining with Cache Plugin
204
190
 
205
- For scenarios like logout or user switching, combine `invalidate: "*"` with `clearCache` from `@spoosh/plugin-cache`:
191
+ For scenarios like logout, combine with `clearCache` from `@spoosh/plugin-cache`:
206
192
 
207
193
  ```typescript
208
194
  const { trigger } = useWrite((api) => api("auth/logout").POST());
209
195
 
210
- // Clear cache + trigger all queries to refetch
211
196
  await trigger({
212
- clearCache: true, // From cache plugin: clear all cached data
213
- invalidate: "*", // From invalidation plugin: trigger all queries to refetch
197
+ clearCache: true, // Clear all cached data
198
+ invalidate: "*", // Trigger all queries to refetch
214
199
  });
215
200
  ```
216
-
217
- This ensures both:
218
-
219
- 1. All cached data is cleared (no stale data from previous session)
220
- 2. All active queries refetch with fresh data
package/dist/index.d.mts CHANGED
@@ -1,31 +1,57 @@
1
1
  import * as _spoosh_core from '@spoosh/core';
2
2
 
3
- type InvalidationMode = "all" | "self" | "none";
4
3
  /**
5
4
  * Extract paths that have GET methods (eligible for invalidation)
5
+ * Excludes "/" root path to avoid "//*" pattern in autocomplete
6
6
  */
7
7
  type ReadPaths<TSchema> = {
8
- [K in keyof TSchema & string]: "GET" extends keyof TSchema[K] ? K : never;
8
+ [K in keyof TSchema & string]: "GET" extends keyof TSchema[K] ? K extends "/" | "" ? never : K : never;
9
9
  }[keyof TSchema & string];
10
10
  /**
11
- * Unified invalidate option
12
- * - String: mode only ('all' | 'self' | 'none')
13
- * - Array: tags only OR [mode keyword mixed with tags]
14
- * - If array contains 'all' or 'self' at ANY position, it's treated as mode + tags
15
- * - Otherwise, it's tags only with mode defaulting to 'none'
16
- * - 'none' keyword should NOT be used in arrays (use string 'none' instead)
11
+ * Support both exact paths and wildcard patterns in autocomplete
17
12
  */
18
- type InvalidateOption<TSchema = unknown> = InvalidationMode | "*" | ReadPaths<TSchema> | (ReadPaths<TSchema> | "all" | "self" | (string & {}))[];
13
+ type InvalidatePattern<TSchema> = ReadPaths<TSchema> | `${ReadPaths<TSchema>}/*`;
14
+ /**
15
+ * Unified invalidate option with wildcard pattern support
16
+ * - `"posts"` - Exact match only
17
+ * - `"posts/*"` - Children only (posts/1, posts/1/comments) - NOT posts itself
18
+ * - `["posts", "posts/*"]` - posts AND all children
19
+ * - `"*"` - Global refetch
20
+ * - `false` - Disable invalidation
21
+ * - Any custom string is also allowed for custom tags
22
+ */
23
+ type InvalidateOption<TSchema = unknown> = "*" | false | InvalidatePattern<TSchema> | (string & {}) | (InvalidatePattern<TSchema> | (string & {}))[];
19
24
  interface InvalidationPluginConfig {
20
25
  /**
21
- * Default invalidation mode when invalidate option is not specified
22
- * @default "all"
26
+ * Enable automatic invalidation for mutations.
27
+ * When true, mutations automatically invalidate `[firstSegment, firstSegment/*]`.
28
+ * @default true
29
+ */
30
+ autoInvalidate?: boolean;
31
+ /**
32
+ * Path groups that should use deeper segment matching for invalidation.
33
+ * Useful for grouped endpoints like `admin/posts`, `api/v1/users`.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * invalidationPlugin({
38
+ * groups: ["admin", "api/v1"]
39
+ * })
40
+ *
41
+ * // Without groups:
42
+ * // POST admin/posts → invalidates ["admin", "admin/*"]
43
+ *
44
+ * // With groups: ["admin"]:
45
+ * // POST admin/posts → invalidates ["admin/posts", "admin/posts/*"]
46
+ * // POST admin/users → invalidates ["admin/users", "admin/users/*"]
47
+ * // POST admin → invalidates ["admin", "admin/*"]
48
+ * ```
23
49
  */
24
- defaultMode?: InvalidationMode;
50
+ groups?: string[];
25
51
  }
26
52
  type InvalidationWriteOptions = object;
27
53
  interface InvalidationWriteTriggerOptions<TSchema = unknown> {
28
- /** Unified invalidation configuration */
54
+ /** Unified invalidation configuration with wildcard pattern support */
29
55
  invalidate?: InvalidateOption<TSchema>;
30
56
  }
31
57
  type InvalidationReadOptions = object;
@@ -33,43 +59,43 @@ type InvalidationPagesOptions = object;
33
59
  type InvalidationReadResult = object;
34
60
  type InvalidationWriteResult = object;
35
61
  interface InvalidationQueueTriggerOptions<TSchema = unknown> {
36
- /** Unified invalidation configuration */
62
+ /** Unified invalidation configuration with wildcard pattern support */
37
63
  invalidate?: InvalidateOption<TSchema>;
38
64
  }
39
65
  type InvalidationQueueResult = object;
40
66
  /**
41
- * Manual invalidation - tags only, or "*" for global refetch
67
+ * Manual invalidation with pattern support
42
68
  */
43
69
  type InvalidateFn<TSchema> = {
44
70
  (tag: "*"): void;
45
- (tag: ReadPaths<TSchema> | (string & {})): void;
46
- (tags: (ReadPaths<TSchema> | (string & {}))[]): void;
71
+ (pattern: InvalidatePattern<TSchema> | (string & {})): void;
72
+ (patterns: (InvalidatePattern<TSchema> | (string & {}))[]): void;
47
73
  };
48
74
  interface InvalidationInstanceApi {
49
- /** Manually invalidate cache entries by tags. Useful for external events like WebSocket messages. */
75
+ /** Manually invalidate cache entries by patterns. Useful for external events like WebSocket messages. */
50
76
  invalidate: InvalidateFn<unknown>;
51
77
  }
52
- interface InvalidationPluginExports {
53
- /** Set the default invalidation mode for this mutation */
54
- setDefaultMode: (value: InvalidationMode) => void;
78
+ interface InvalidationPluginInternal {
79
+ /** Disable auto-invalidation for this request. Used by optimistic plugin. */
80
+ disableAutoInvalidate: () => void;
55
81
  }
56
82
  declare module "@spoosh/core" {
57
- interface PluginExportsRegistry {
58
- "spoosh:invalidation": InvalidationPluginExports;
59
- }
60
83
  interface PluginResolvers<TContext> {
61
84
  invalidate: InvalidateOption<TContext["schema"]> | undefined;
62
85
  }
63
- interface InstanceApiResolvers<TSchema> {
86
+ interface ApiResolvers<TSchema> {
64
87
  invalidate: InvalidateFn<TSchema>;
65
88
  }
89
+ interface PluginInternalRegistry {
90
+ "spoosh:invalidation": InvalidationPluginInternal;
91
+ }
66
92
  }
67
93
 
68
94
  /**
69
95
  * Enables automatic cache invalidation after mutations.
70
96
  *
71
97
  * Marks related cache entries as stale and triggers refetches
72
- * based on tags or explicit invalidation targets.
98
+ * based on wildcard patterns or explicit invalidation targets.
73
99
  *
74
100
  * @param config - Plugin configuration
75
101
  *
@@ -81,24 +107,24 @@ declare module "@spoosh/core" {
81
107
  *
82
108
  * const spoosh = new Spoosh<ApiSchema, Error>("/api")
83
109
  * .use([
84
- * invalidationPlugin({ defaultMode: "all" }),
110
+ * invalidationPlugin({ autoInvalidate: true }),
85
111
  * ]);
86
112
  *
87
113
  * // Per-mutation invalidation
88
114
  * trigger({
89
- * invalidate: "self", // Mode only
115
+ * invalidate: "posts", // Exact match only
90
116
  * });
91
117
  *
92
118
  * trigger({
93
- * invalidate: "posts", // Single tag
119
+ * invalidate: "posts/*", // Children only (posts/1, posts/1/comments)
94
120
  * });
95
121
  *
96
122
  * trigger({
97
- * invalidate: ["posts", "users"], // Multiple tags
123
+ * invalidate: ["posts", "posts/*"], // posts AND all children
98
124
  * });
99
125
  *
100
126
  * trigger({
101
- * invalidate: ["all", "posts", "custom-tag"], // Mode + tags
127
+ * invalidate: false, // Disable invalidation
102
128
  * });
103
129
  *
104
130
  * trigger({
@@ -115,7 +141,8 @@ declare function invalidationPlugin(config?: InvalidationPluginConfig): _spoosh_
115
141
  readResult: InvalidationReadResult;
116
142
  writeResult: InvalidationWriteResult;
117
143
  queueResult: InvalidationQueueResult;
118
- instanceApi: InvalidationInstanceApi;
144
+ api: InvalidationInstanceApi;
145
+ internal: InvalidationPluginInternal;
119
146
  }>;
120
147
 
121
- export { type InvalidateOption, type InvalidationMode, type InvalidationPagesOptions, type InvalidationPluginConfig, type InvalidationPluginExports, type InvalidationQueueResult, type InvalidationQueueTriggerOptions, type InvalidationReadOptions, type InvalidationReadResult, type InvalidationWriteOptions, type InvalidationWriteResult, type InvalidationWriteTriggerOptions, invalidationPlugin };
148
+ export { type InvalidateFn, type InvalidateOption, type InvalidationInstanceApi, type InvalidationPagesOptions, type InvalidationPluginConfig, type InvalidationPluginInternal, type InvalidationQueueResult, type InvalidationQueueTriggerOptions, type InvalidationReadOptions, type InvalidationReadResult, type InvalidationWriteOptions, type InvalidationWriteResult, type InvalidationWriteTriggerOptions, invalidationPlugin };
package/dist/index.d.ts CHANGED
@@ -1,31 +1,57 @@
1
1
  import * as _spoosh_core from '@spoosh/core';
2
2
 
3
- type InvalidationMode = "all" | "self" | "none";
4
3
  /**
5
4
  * Extract paths that have GET methods (eligible for invalidation)
5
+ * Excludes "/" root path to avoid "//*" pattern in autocomplete
6
6
  */
7
7
  type ReadPaths<TSchema> = {
8
- [K in keyof TSchema & string]: "GET" extends keyof TSchema[K] ? K : never;
8
+ [K in keyof TSchema & string]: "GET" extends keyof TSchema[K] ? K extends "/" | "" ? never : K : never;
9
9
  }[keyof TSchema & string];
10
10
  /**
11
- * Unified invalidate option
12
- * - String: mode only ('all' | 'self' | 'none')
13
- * - Array: tags only OR [mode keyword mixed with tags]
14
- * - If array contains 'all' or 'self' at ANY position, it's treated as mode + tags
15
- * - Otherwise, it's tags only with mode defaulting to 'none'
16
- * - 'none' keyword should NOT be used in arrays (use string 'none' instead)
11
+ * Support both exact paths and wildcard patterns in autocomplete
17
12
  */
18
- type InvalidateOption<TSchema = unknown> = InvalidationMode | "*" | ReadPaths<TSchema> | (ReadPaths<TSchema> | "all" | "self" | (string & {}))[];
13
+ type InvalidatePattern<TSchema> = ReadPaths<TSchema> | `${ReadPaths<TSchema>}/*`;
14
+ /**
15
+ * Unified invalidate option with wildcard pattern support
16
+ * - `"posts"` - Exact match only
17
+ * - `"posts/*"` - Children only (posts/1, posts/1/comments) - NOT posts itself
18
+ * - `["posts", "posts/*"]` - posts AND all children
19
+ * - `"*"` - Global refetch
20
+ * - `false` - Disable invalidation
21
+ * - Any custom string is also allowed for custom tags
22
+ */
23
+ type InvalidateOption<TSchema = unknown> = "*" | false | InvalidatePattern<TSchema> | (string & {}) | (InvalidatePattern<TSchema> | (string & {}))[];
19
24
  interface InvalidationPluginConfig {
20
25
  /**
21
- * Default invalidation mode when invalidate option is not specified
22
- * @default "all"
26
+ * Enable automatic invalidation for mutations.
27
+ * When true, mutations automatically invalidate `[firstSegment, firstSegment/*]`.
28
+ * @default true
29
+ */
30
+ autoInvalidate?: boolean;
31
+ /**
32
+ * Path groups that should use deeper segment matching for invalidation.
33
+ * Useful for grouped endpoints like `admin/posts`, `api/v1/users`.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * invalidationPlugin({
38
+ * groups: ["admin", "api/v1"]
39
+ * })
40
+ *
41
+ * // Without groups:
42
+ * // POST admin/posts → invalidates ["admin", "admin/*"]
43
+ *
44
+ * // With groups: ["admin"]:
45
+ * // POST admin/posts → invalidates ["admin/posts", "admin/posts/*"]
46
+ * // POST admin/users → invalidates ["admin/users", "admin/users/*"]
47
+ * // POST admin → invalidates ["admin", "admin/*"]
48
+ * ```
23
49
  */
24
- defaultMode?: InvalidationMode;
50
+ groups?: string[];
25
51
  }
26
52
  type InvalidationWriteOptions = object;
27
53
  interface InvalidationWriteTriggerOptions<TSchema = unknown> {
28
- /** Unified invalidation configuration */
54
+ /** Unified invalidation configuration with wildcard pattern support */
29
55
  invalidate?: InvalidateOption<TSchema>;
30
56
  }
31
57
  type InvalidationReadOptions = object;
@@ -33,43 +59,43 @@ type InvalidationPagesOptions = object;
33
59
  type InvalidationReadResult = object;
34
60
  type InvalidationWriteResult = object;
35
61
  interface InvalidationQueueTriggerOptions<TSchema = unknown> {
36
- /** Unified invalidation configuration */
62
+ /** Unified invalidation configuration with wildcard pattern support */
37
63
  invalidate?: InvalidateOption<TSchema>;
38
64
  }
39
65
  type InvalidationQueueResult = object;
40
66
  /**
41
- * Manual invalidation - tags only, or "*" for global refetch
67
+ * Manual invalidation with pattern support
42
68
  */
43
69
  type InvalidateFn<TSchema> = {
44
70
  (tag: "*"): void;
45
- (tag: ReadPaths<TSchema> | (string & {})): void;
46
- (tags: (ReadPaths<TSchema> | (string & {}))[]): void;
71
+ (pattern: InvalidatePattern<TSchema> | (string & {})): void;
72
+ (patterns: (InvalidatePattern<TSchema> | (string & {}))[]): void;
47
73
  };
48
74
  interface InvalidationInstanceApi {
49
- /** Manually invalidate cache entries by tags. Useful for external events like WebSocket messages. */
75
+ /** Manually invalidate cache entries by patterns. Useful for external events like WebSocket messages. */
50
76
  invalidate: InvalidateFn<unknown>;
51
77
  }
52
- interface InvalidationPluginExports {
53
- /** Set the default invalidation mode for this mutation */
54
- setDefaultMode: (value: InvalidationMode) => void;
78
+ interface InvalidationPluginInternal {
79
+ /** Disable auto-invalidation for this request. Used by optimistic plugin. */
80
+ disableAutoInvalidate: () => void;
55
81
  }
56
82
  declare module "@spoosh/core" {
57
- interface PluginExportsRegistry {
58
- "spoosh:invalidation": InvalidationPluginExports;
59
- }
60
83
  interface PluginResolvers<TContext> {
61
84
  invalidate: InvalidateOption<TContext["schema"]> | undefined;
62
85
  }
63
- interface InstanceApiResolvers<TSchema> {
86
+ interface ApiResolvers<TSchema> {
64
87
  invalidate: InvalidateFn<TSchema>;
65
88
  }
89
+ interface PluginInternalRegistry {
90
+ "spoosh:invalidation": InvalidationPluginInternal;
91
+ }
66
92
  }
67
93
 
68
94
  /**
69
95
  * Enables automatic cache invalidation after mutations.
70
96
  *
71
97
  * Marks related cache entries as stale and triggers refetches
72
- * based on tags or explicit invalidation targets.
98
+ * based on wildcard patterns or explicit invalidation targets.
73
99
  *
74
100
  * @param config - Plugin configuration
75
101
  *
@@ -81,24 +107,24 @@ declare module "@spoosh/core" {
81
107
  *
82
108
  * const spoosh = new Spoosh<ApiSchema, Error>("/api")
83
109
  * .use([
84
- * invalidationPlugin({ defaultMode: "all" }),
110
+ * invalidationPlugin({ autoInvalidate: true }),
85
111
  * ]);
86
112
  *
87
113
  * // Per-mutation invalidation
88
114
  * trigger({
89
- * invalidate: "self", // Mode only
115
+ * invalidate: "posts", // Exact match only
90
116
  * });
91
117
  *
92
118
  * trigger({
93
- * invalidate: "posts", // Single tag
119
+ * invalidate: "posts/*", // Children only (posts/1, posts/1/comments)
94
120
  * });
95
121
  *
96
122
  * trigger({
97
- * invalidate: ["posts", "users"], // Multiple tags
123
+ * invalidate: ["posts", "posts/*"], // posts AND all children
98
124
  * });
99
125
  *
100
126
  * trigger({
101
- * invalidate: ["all", "posts", "custom-tag"], // Mode + tags
127
+ * invalidate: false, // Disable invalidation
102
128
  * });
103
129
  *
104
130
  * trigger({
@@ -115,7 +141,8 @@ declare function invalidationPlugin(config?: InvalidationPluginConfig): _spoosh_
115
141
  readResult: InvalidationReadResult;
116
142
  writeResult: InvalidationWriteResult;
117
143
  queueResult: InvalidationQueueResult;
118
- instanceApi: InvalidationInstanceApi;
144
+ api: InvalidationInstanceApi;
145
+ internal: InvalidationPluginInternal;
119
146
  }>;
120
147
 
121
- export { type InvalidateOption, type InvalidationMode, type InvalidationPagesOptions, type InvalidationPluginConfig, type InvalidationPluginExports, type InvalidationQueueResult, type InvalidationQueueTriggerOptions, type InvalidationReadOptions, type InvalidationReadResult, type InvalidationWriteOptions, type InvalidationWriteResult, type InvalidationWriteTriggerOptions, invalidationPlugin };
148
+ export { type InvalidateFn, type InvalidateOption, type InvalidationInstanceApi, type InvalidationPagesOptions, type InvalidationPluginConfig, type InvalidationPluginInternal, type InvalidationQueueResult, type InvalidationQueueTriggerOptions, type InvalidationReadOptions, type InvalidationReadResult, type InvalidationWriteOptions, type InvalidationWriteResult, type InvalidationWriteTriggerOptions, invalidationPlugin };
package/dist/index.js CHANGED
@@ -27,96 +27,118 @@ module.exports = __toCommonJS(src_exports);
27
27
  // src/plugin.ts
28
28
  var import_core = require("@spoosh/core");
29
29
  var PLUGIN_NAME = "spoosh:invalidation";
30
- var INVALIDATION_DEFAULT_KEY = "invalidation:defaultMode";
31
- function resolveModeTags(context, mode) {
32
- const params = context.request.params;
33
- switch (mode) {
34
- case "all":
35
- return context.tags.map((tag) => (0, import_core.resolvePathString)(tag, params));
36
- case "self":
37
- return [(0, import_core.resolvePathString)(context.path, params)];
38
- case "none":
39
- return [];
30
+ var AUTO_INVALIDATE_DISABLED_KEY = "invalidation:autoDisabled";
31
+ function calculateSegmentDepth(path, groups) {
32
+ if (groups.length === 0) return 1;
33
+ const sortedGroups = [...groups].sort((a, b) => b.length - a.length);
34
+ for (const group of sortedGroups) {
35
+ const normalizedGroup = group.replace(/^\/+|\/+$/g, "");
36
+ if (path === normalizedGroup || path.startsWith(normalizedGroup + "/")) {
37
+ return normalizedGroup.split("/").length + 1;
38
+ }
40
39
  }
40
+ return 1;
41
41
  }
42
- function resolveInvalidateTags(context, defaultMode) {
43
- const invalidateOption = context.pluginOptions?.invalidate;
44
- if (!invalidateOption) {
45
- const overrideDefault = context.temp.get(INVALIDATION_DEFAULT_KEY);
46
- const effectiveDefault = overrideDefault ?? defaultMode;
47
- return resolveModeTags(context, effectiveDefault);
42
+ function resolveInvalidateTags(path, params, invalidateOption, autoInvalidate, groups) {
43
+ if (invalidateOption === false) {
44
+ return false;
45
+ }
46
+ if (invalidateOption === "*" || invalidateOption === "/*") {
47
+ return ["*"];
48
48
  }
49
49
  if (typeof invalidateOption === "string") {
50
- if (invalidateOption === "all" || invalidateOption === "self" || invalidateOption === "none") {
51
- return resolveModeTags(context, invalidateOption);
50
+ const normalized = (0, import_core.normalizeTag)(invalidateOption);
51
+ if (normalized === "*") {
52
+ return ["*"];
52
53
  }
53
- return [invalidateOption];
54
+ return [normalized];
54
55
  }
55
56
  if (Array.isArray(invalidateOption)) {
56
- const tags = [];
57
- let mode = "none";
58
- for (const item of invalidateOption) {
59
- if (item === "all" || item === "self") {
60
- mode = item;
61
- } else if (typeof item === "string") {
62
- tags.push(item);
63
- }
64
- }
65
- tags.push(...resolveModeTags(context, mode));
66
- return [...new Set(tags)];
57
+ return [...new Set(invalidateOption.map(import_core.normalizeTag))];
58
+ }
59
+ if (!autoInvalidate) {
60
+ return false;
61
+ }
62
+ const resolvedPath = (0, import_core.normalizeTag)((0, import_core.resolvePathString)(path, params));
63
+ const segments = resolvedPath.split("/");
64
+ const depth = calculateSegmentDepth(resolvedPath, groups);
65
+ const baseSegments = segments.slice(0, depth);
66
+ if (baseSegments.length === 0 || !baseSegments[0]) {
67
+ return false;
67
68
  }
68
- return [];
69
+ const base = baseSegments.join("/");
70
+ return [base, `${base}/*`];
69
71
  }
70
72
  function invalidationPlugin(config = {}) {
71
- const { defaultMode = "all" } = config;
73
+ const { autoInvalidate = true, groups = [] } = config;
72
74
  return (0, import_core.createSpooshPlugin)({
73
75
  name: PLUGIN_NAME,
74
76
  operations: ["write", "queue"],
75
- exports(context) {
76
- return {
77
- setDefaultMode(value) {
78
- context.temp.set(INVALIDATION_DEFAULT_KEY, value);
79
- }
80
- };
81
- },
82
77
  afterResponse(context, response) {
83
78
  const t = context.tracer?.(PLUGIN_NAME);
84
79
  if (!response.error) {
85
- const tags = resolveInvalidateTags(context, defaultMode);
80
+ const params = context.request.params;
81
+ const invalidateOption = context.pluginOptions?.invalidate;
82
+ const isAutoDisabled = context.temp.get(AUTO_INVALIDATE_DISABLED_KEY);
83
+ const effectiveAutoInvalidate = isAutoDisabled ? false : autoInvalidate;
84
+ const tags = resolveInvalidateTags(
85
+ context.path,
86
+ params,
87
+ invalidateOption,
88
+ effectiveAutoInvalidate,
89
+ groups
90
+ );
91
+ if (tags === false) {
92
+ t?.skip("Invalidation disabled", { color: "muted" });
93
+ return;
94
+ }
86
95
  if (tags.includes("*")) {
87
96
  t?.log("Refetch all", { color: "warning" });
88
97
  context.eventEmitter.emit("refetchAll", void 0);
89
98
  return;
90
99
  }
91
100
  if (tags.length > 0) {
92
- t?.log("Invalidated tags", {
101
+ t?.log("Invalidated patterns", {
93
102
  color: "info",
94
- info: [{ label: "Tags", value: tags }]
103
+ info: [{ label: "Patterns", value: tags }]
95
104
  });
96
105
  context.stateManager.markStale(tags);
97
106
  context.eventEmitter.emit("invalidate", tags);
98
107
  } else {
99
- t?.skip("No tags to invalidate", { color: "muted" });
108
+ t?.skip("No patterns to invalidate", { color: "muted" });
100
109
  }
101
110
  }
102
111
  },
103
- instanceApi(context) {
112
+ api(context) {
104
113
  const { stateManager, eventEmitter } = context;
105
114
  const et = context.eventTracer?.(PLUGIN_NAME);
106
115
  const invalidate = (input) => {
107
- const tags = Array.isArray(input) ? input : [input];
108
- if (tags.includes("*")) {
116
+ const rawPatterns = Array.isArray(input) ? input : [input];
117
+ if (rawPatterns.includes("*") || rawPatterns.includes("/*")) {
109
118
  et?.emit("Refetch all (manual)", { color: "warning" });
110
119
  eventEmitter.emit("refetchAll", void 0);
111
120
  return;
112
121
  }
113
- if (tags.length > 0) {
114
- et?.emit(`Invalidated: ${tags.join(", ")}`, { color: "info" });
115
- stateManager.markStale(tags);
116
- eventEmitter.emit("invalidate", tags);
122
+ const patterns = rawPatterns.map(import_core.normalizeTag);
123
+ if (patterns.includes("*")) {
124
+ et?.emit("Refetch all (manual)", { color: "warning" });
125
+ eventEmitter.emit("refetchAll", void 0);
126
+ return;
127
+ }
128
+ if (patterns.length > 0) {
129
+ et?.emit(`Invalidated: ${patterns.join(", ")}`, { color: "info" });
130
+ stateManager.markStale(patterns);
131
+ eventEmitter.emit("invalidate", patterns);
117
132
  }
118
133
  };
119
134
  return { invalidate };
135
+ },
136
+ internal(context) {
137
+ return {
138
+ disableAutoInvalidate() {
139
+ context.temp.set(AUTO_INVALIDATE_DISABLED_KEY, true);
140
+ }
141
+ };
120
142
  }
121
143
  });
122
144
  }
package/dist/index.mjs CHANGED
@@ -1,99 +1,122 @@
1
1
  // src/plugin.ts
2
2
  import {
3
3
  resolvePathString,
4
- createSpooshPlugin
4
+ createSpooshPlugin,
5
+ normalizeTag
5
6
  } from "@spoosh/core";
6
7
  var PLUGIN_NAME = "spoosh:invalidation";
7
- var INVALIDATION_DEFAULT_KEY = "invalidation:defaultMode";
8
- function resolveModeTags(context, mode) {
9
- const params = context.request.params;
10
- switch (mode) {
11
- case "all":
12
- return context.tags.map((tag) => resolvePathString(tag, params));
13
- case "self":
14
- return [resolvePathString(context.path, params)];
15
- case "none":
16
- return [];
8
+ var AUTO_INVALIDATE_DISABLED_KEY = "invalidation:autoDisabled";
9
+ function calculateSegmentDepth(path, groups) {
10
+ if (groups.length === 0) return 1;
11
+ const sortedGroups = [...groups].sort((a, b) => b.length - a.length);
12
+ for (const group of sortedGroups) {
13
+ const normalizedGroup = group.replace(/^\/+|\/+$/g, "");
14
+ if (path === normalizedGroup || path.startsWith(normalizedGroup + "/")) {
15
+ return normalizedGroup.split("/").length + 1;
16
+ }
17
17
  }
18
+ return 1;
18
19
  }
19
- function resolveInvalidateTags(context, defaultMode) {
20
- const invalidateOption = context.pluginOptions?.invalidate;
21
- if (!invalidateOption) {
22
- const overrideDefault = context.temp.get(INVALIDATION_DEFAULT_KEY);
23
- const effectiveDefault = overrideDefault ?? defaultMode;
24
- return resolveModeTags(context, effectiveDefault);
20
+ function resolveInvalidateTags(path, params, invalidateOption, autoInvalidate, groups) {
21
+ if (invalidateOption === false) {
22
+ return false;
23
+ }
24
+ if (invalidateOption === "*" || invalidateOption === "/*") {
25
+ return ["*"];
25
26
  }
26
27
  if (typeof invalidateOption === "string") {
27
- if (invalidateOption === "all" || invalidateOption === "self" || invalidateOption === "none") {
28
- return resolveModeTags(context, invalidateOption);
28
+ const normalized = normalizeTag(invalidateOption);
29
+ if (normalized === "*") {
30
+ return ["*"];
29
31
  }
30
- return [invalidateOption];
32
+ return [normalized];
31
33
  }
32
34
  if (Array.isArray(invalidateOption)) {
33
- const tags = [];
34
- let mode = "none";
35
- for (const item of invalidateOption) {
36
- if (item === "all" || item === "self") {
37
- mode = item;
38
- } else if (typeof item === "string") {
39
- tags.push(item);
40
- }
41
- }
42
- tags.push(...resolveModeTags(context, mode));
43
- return [...new Set(tags)];
35
+ return [...new Set(invalidateOption.map(normalizeTag))];
36
+ }
37
+ if (!autoInvalidate) {
38
+ return false;
39
+ }
40
+ const resolvedPath = normalizeTag(resolvePathString(path, params));
41
+ const segments = resolvedPath.split("/");
42
+ const depth = calculateSegmentDepth(resolvedPath, groups);
43
+ const baseSegments = segments.slice(0, depth);
44
+ if (baseSegments.length === 0 || !baseSegments[0]) {
45
+ return false;
44
46
  }
45
- return [];
47
+ const base = baseSegments.join("/");
48
+ return [base, `${base}/*`];
46
49
  }
47
50
  function invalidationPlugin(config = {}) {
48
- const { defaultMode = "all" } = config;
51
+ const { autoInvalidate = true, groups = [] } = config;
49
52
  return createSpooshPlugin({
50
53
  name: PLUGIN_NAME,
51
54
  operations: ["write", "queue"],
52
- exports(context) {
53
- return {
54
- setDefaultMode(value) {
55
- context.temp.set(INVALIDATION_DEFAULT_KEY, value);
56
- }
57
- };
58
- },
59
55
  afterResponse(context, response) {
60
56
  const t = context.tracer?.(PLUGIN_NAME);
61
57
  if (!response.error) {
62
- const tags = resolveInvalidateTags(context, defaultMode);
58
+ const params = context.request.params;
59
+ const invalidateOption = context.pluginOptions?.invalidate;
60
+ const isAutoDisabled = context.temp.get(AUTO_INVALIDATE_DISABLED_KEY);
61
+ const effectiveAutoInvalidate = isAutoDisabled ? false : autoInvalidate;
62
+ const tags = resolveInvalidateTags(
63
+ context.path,
64
+ params,
65
+ invalidateOption,
66
+ effectiveAutoInvalidate,
67
+ groups
68
+ );
69
+ if (tags === false) {
70
+ t?.skip("Invalidation disabled", { color: "muted" });
71
+ return;
72
+ }
63
73
  if (tags.includes("*")) {
64
74
  t?.log("Refetch all", { color: "warning" });
65
75
  context.eventEmitter.emit("refetchAll", void 0);
66
76
  return;
67
77
  }
68
78
  if (tags.length > 0) {
69
- t?.log("Invalidated tags", {
79
+ t?.log("Invalidated patterns", {
70
80
  color: "info",
71
- info: [{ label: "Tags", value: tags }]
81
+ info: [{ label: "Patterns", value: tags }]
72
82
  });
73
83
  context.stateManager.markStale(tags);
74
84
  context.eventEmitter.emit("invalidate", tags);
75
85
  } else {
76
- t?.skip("No tags to invalidate", { color: "muted" });
86
+ t?.skip("No patterns to invalidate", { color: "muted" });
77
87
  }
78
88
  }
79
89
  },
80
- instanceApi(context) {
90
+ api(context) {
81
91
  const { stateManager, eventEmitter } = context;
82
92
  const et = context.eventTracer?.(PLUGIN_NAME);
83
93
  const invalidate = (input) => {
84
- const tags = Array.isArray(input) ? input : [input];
85
- if (tags.includes("*")) {
94
+ const rawPatterns = Array.isArray(input) ? input : [input];
95
+ if (rawPatterns.includes("*") || rawPatterns.includes("/*")) {
86
96
  et?.emit("Refetch all (manual)", { color: "warning" });
87
97
  eventEmitter.emit("refetchAll", void 0);
88
98
  return;
89
99
  }
90
- if (tags.length > 0) {
91
- et?.emit(`Invalidated: ${tags.join(", ")}`, { color: "info" });
92
- stateManager.markStale(tags);
93
- eventEmitter.emit("invalidate", tags);
100
+ const patterns = rawPatterns.map(normalizeTag);
101
+ if (patterns.includes("*")) {
102
+ et?.emit("Refetch all (manual)", { color: "warning" });
103
+ eventEmitter.emit("refetchAll", void 0);
104
+ return;
105
+ }
106
+ if (patterns.length > 0) {
107
+ et?.emit(`Invalidated: ${patterns.join(", ")}`, { color: "info" });
108
+ stateManager.markStale(patterns);
109
+ eventEmitter.emit("invalidate", patterns);
94
110
  }
95
111
  };
96
112
  return { invalidate };
113
+ },
114
+ internal(context) {
115
+ return {
116
+ disableAutoInvalidate() {
117
+ context.temp.set(AUTO_INVALIDATE_DISABLED_KEY, true);
118
+ }
119
+ };
97
120
  }
98
121
  });
99
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spoosh/plugin-invalidation",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Cache invalidation plugin for Spoosh - auto-invalidates after mutations",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,10 +33,10 @@
33
33
  }
34
34
  },
35
35
  "peerDependencies": {
36
- "@spoosh/core": ">=0.15.0"
36
+ "@spoosh/core": ">=0.18.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@spoosh/core": "0.15.1",
39
+ "@spoosh/core": "0.18.0",
40
40
  "@spoosh/test-utils": "0.3.0"
41
41
  },
42
42
  "scripts": {
@@ -44,6 +44,6 @@
44
44
  "build": "tsup",
45
45
  "typecheck": "tsc --noEmit",
46
46
  "lint": "eslint src --max-warnings 0",
47
- "format": "prettier --write 'src/**/*.ts'"
47
+ "format": "prettier --write 'src/**/*.ts' '*.md'"
48
48
  }
49
49
  }