@spoosh/plugin-optimistic 0.7.1 → 0.8.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/dist/index.mjs CHANGED
@@ -3,40 +3,14 @@ import {
3
3
  createSpooshPlugin
4
4
  } from "@spoosh/core";
5
5
  import "@spoosh/plugin-invalidation";
6
- function createBuilder(state) {
7
- return {
8
- ...state,
9
- WHERE(predicate) {
10
- return createBuilder({ ...state, where: predicate });
11
- },
12
- UPDATE_CACHE(updater) {
13
- return createBuilder({ ...state, updater });
14
- },
15
- ON_SUCCESS() {
16
- return createBuilder({ ...state, timing: "onSuccess" });
17
- },
18
- NO_ROLLBACK() {
19
- return createBuilder({ ...state, rollbackOnError: false });
20
- },
21
- ON_ERROR(callback) {
22
- return createBuilder({ ...state, onError: callback });
23
- }
24
- };
25
- }
26
- function createOptimisticProxy() {
27
- const createMethodsProxy = (path) => ({
28
- GET: () => createBuilder({
29
- path,
30
- method: "GET",
31
- timing: "immediate",
32
- rollbackOnError: true
33
- })
34
- });
35
- return ((path) => createMethodsProxy(path));
36
- }
6
+
7
+ // src/utils/path.ts
37
8
  function isParameterSegment(segment) {
38
9
  return segment.startsWith(":");
39
10
  }
11
+ function hasPatternParams(path) {
12
+ return path.split("/").some(isParameterSegment);
13
+ }
40
14
  function pathMatchesPattern(actualPath, pattern) {
41
15
  const actualSegments = actualPath.split("/").filter(Boolean);
42
16
  const patternSegments = pattern.split("/").filter(Boolean);
@@ -64,9 +38,9 @@ function pathMatchesPattern(actualPath, pattern) {
64
38
  }
65
39
  return { matches: true, params, paramMapping };
66
40
  }
67
- function hasPatternParams(path) {
68
- return path.split("/").some(isParameterSegment);
69
- }
41
+
42
+ // src/utils/cache-key.ts
43
+ import { generateSelfTagFromKey } from "@spoosh/core";
70
44
  function extractPathFromKey(key) {
71
45
  try {
72
46
  const parsed = JSON.parse(key);
@@ -74,11 +48,39 @@ function extractPathFromKey(key) {
74
48
  if (typeof path === "string") {
75
49
  return path;
76
50
  }
51
+ if (Array.isArray(path)) {
52
+ return path.join("/");
53
+ }
77
54
  return null;
78
55
  } catch {
79
56
  return null;
80
57
  }
81
58
  }
59
+ function formatCacheKeyForTrace(key) {
60
+ const resolvedPath = generateSelfTagFromKey(key);
61
+ if (!resolvedPath) return "unknown";
62
+ try {
63
+ const parsed = JSON.parse(key);
64
+ const opts = parsed.options ?? parsed.pageRequest;
65
+ const query = opts?.query;
66
+ if (!query || Object.keys(query).length === 0) {
67
+ return resolvedPath;
68
+ }
69
+ const queryString = Object.entries(query).map(
70
+ ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`
71
+ ).join("&");
72
+ return `${resolvedPath}?${queryString}`;
73
+ } catch {
74
+ return resolvedPath;
75
+ }
76
+ }
77
+ function stringifyParams(params) {
78
+ const result = {};
79
+ for (const [key, value] of Object.entries(params)) {
80
+ result[key] = String(value);
81
+ }
82
+ return result;
83
+ }
82
84
  function extractOptionsFromKey(key) {
83
85
  try {
84
86
  const parsed = JSON.parse(key);
@@ -89,7 +91,7 @@ function extractOptionsFromKey(key) {
89
91
  result.query = opts.query;
90
92
  }
91
93
  if (opts.params) {
92
- result.params = opts.params;
94
+ result.params = stringifyParams(opts.params);
93
95
  }
94
96
  return Object.keys(result).length > 0 ? result : null;
95
97
  } catch {
@@ -101,30 +103,75 @@ function mapParamsToTargetNames(actualParams, paramMapping) {
101
103
  const result = {};
102
104
  for (const [targetName, actualName] of Object.entries(paramMapping)) {
103
105
  if (actualName in actualParams) {
104
- result[targetName] = actualParams[actualName];
106
+ result[targetName] = String(actualParams[actualName]);
105
107
  }
106
108
  }
107
109
  for (const [key, value] of Object.entries(actualParams)) {
108
110
  if (!Object.values(paramMapping).includes(key)) {
109
- result[key] = value;
111
+ result[key] = String(value);
110
112
  }
111
113
  }
112
114
  return result;
113
115
  }
116
+
117
+ // src/builder/index.ts
118
+ function createBuilder(state, isConfirmed = false) {
119
+ return {
120
+ ...state,
121
+ filter(predicate) {
122
+ return createBuilder({ ...state, filter: predicate }, isConfirmed);
123
+ },
124
+ set(updater) {
125
+ if (isConfirmed) {
126
+ return createBuilder(
127
+ {
128
+ ...state,
129
+ confirmedUpdater: updater
130
+ },
131
+ isConfirmed
132
+ );
133
+ }
134
+ return createBuilder(
135
+ {
136
+ ...state,
137
+ immediateUpdater: updater
138
+ },
139
+ isConfirmed
140
+ );
141
+ },
142
+ confirmed() {
143
+ return createBuilder(state, true);
144
+ },
145
+ disableRollback() {
146
+ return createBuilder({ ...state, rollbackOnError: false }, isConfirmed);
147
+ },
148
+ onError(callback) {
149
+ return createBuilder({ ...state, onError: callback }, isConfirmed);
150
+ }
151
+ };
152
+ }
153
+ function createCacheProxy() {
154
+ return ((path) => createBuilder({
155
+ path,
156
+ rollbackOnError: true
157
+ }));
158
+ }
159
+
160
+ // src/plugin.ts
114
161
  function resolveOptimisticTargets(context) {
115
162
  const pluginOptions = context.pluginOptions;
116
163
  if (!pluginOptions?.optimistic) return [];
117
- const apiProxy = createOptimisticProxy();
118
- const result = pluginOptions.optimistic(apiProxy);
164
+ const cacheProxy = createCacheProxy();
165
+ const result = pluginOptions.optimistic(cacheProxy);
119
166
  const targets = Array.isArray(result) ? result : [result];
120
167
  return targets;
121
168
  }
122
- function getMatchingEntries(stateManager, targetPath, targetMethod) {
169
+ function getMatchingEntries(stateManager, targetPath) {
123
170
  const results = [];
171
+ const allEntries = stateManager.getAllCacheEntries();
124
172
  if (hasPatternParams(targetPath)) {
125
- const allEntries = stateManager.getAllCacheEntries();
126
173
  for (const { key, entry } of allEntries) {
127
- if (!key.includes(`"method":"${targetMethod}"`)) continue;
174
+ if (!key.includes(`"method":"GET"`)) continue;
128
175
  const actualPath = extractPathFromKey(key);
129
176
  if (!actualPath) continue;
130
177
  const { matches, params, paramMapping } = pathMatchesPattern(
@@ -136,36 +183,26 @@ function getMatchingEntries(stateManager, targetPath, targetMethod) {
136
183
  }
137
184
  }
138
185
  } else {
139
- const allEntries = stateManager.getAllCacheEntries();
140
186
  for (const { key, entry } of allEntries) {
141
- if (!key.includes(`"method":"${targetMethod}"`)) continue;
187
+ if (!key.includes(`"method":"GET"`)) continue;
142
188
  const actualPath = extractPathFromKey(key);
143
189
  if (!actualPath) continue;
144
190
  if (actualPath === targetPath) {
145
191
  results.push({ key, entry, extractedParams: {}, paramMapping: {} });
146
- } else if (hasPatternParams(actualPath)) {
147
- const { matches, params, paramMapping } = pathMatchesPattern(
148
- targetPath,
149
- actualPath
150
- );
151
- if (matches) {
152
- results.push({ key, entry, extractedParams: params, paramMapping });
153
- }
154
192
  }
155
193
  }
156
194
  }
157
195
  return results;
158
196
  }
159
- function applyOptimisticUpdate(stateManager, target, t) {
160
- if (!target.updater) return [];
197
+ function applyUpdate(stateManager, target, updater, response, t) {
161
198
  const snapshots = [];
162
- const matchingEntries = getMatchingEntries(
163
- stateManager,
164
- target.path,
165
- target.method
166
- );
199
+ const matchingEntries = getMatchingEntries(stateManager, target.path);
200
+ if (matchingEntries.length === 0) {
201
+ t?.skip(`Skipped ${target.path} (no cache entry)`);
202
+ return [];
203
+ }
167
204
  for (const { key, entry, extractedParams, paramMapping } of matchingEntries) {
168
- if (target.where) {
205
+ if (target.filter) {
169
206
  const options = extractOptionsFromKey(key) ?? {};
170
207
  const mappedParams = mapParamsToTargetNames(
171
208
  options.params,
@@ -178,14 +215,23 @@ function applyOptimisticUpdate(stateManager, target, t) {
178
215
  ...mappedParams
179
216
  }
180
217
  };
181
- if (!target.where(mergedOptions)) {
218
+ try {
219
+ if (!target.filter(mergedOptions)) {
220
+ t?.skip(
221
+ `Skipped ${formatCacheKeyForTrace(key)} (filter not matched)`
222
+ );
223
+ continue;
224
+ }
225
+ } catch {
226
+ t?.skip(`Skipped ${formatCacheKeyForTrace(key)} (filter error)`);
182
227
  continue;
183
228
  }
184
229
  }
185
230
  if (entry?.state.data === void 0) {
231
+ t?.skip(`Skipped ${formatCacheKeyForTrace(key)} (no cached data)`);
186
232
  continue;
187
233
  }
188
- const afterData = target.updater(entry.state.data, void 0);
234
+ const afterData = updater(entry.state.data, response);
189
235
  snapshots.push({ key, previousData: entry.state.data, afterData });
190
236
  stateManager.setCache(key, {
191
237
  previousData: entry.state.data,
@@ -195,14 +241,10 @@ function applyOptimisticUpdate(stateManager, target, t) {
195
241
  }
196
242
  });
197
243
  stateManager.setMeta(key, { isOptimistic: true });
198
- t?.log("Marked as optimistic", {
199
- color: "info",
200
- info: [{ value: { isOptimistic: true } }]
201
- });
202
244
  }
203
245
  return snapshots;
204
246
  }
205
- function confirmOptimistic(stateManager, snapshots, t) {
247
+ function confirmOptimistic(stateManager, snapshots) {
206
248
  for (const { key } of snapshots) {
207
249
  const entry = stateManager.getCache(key);
208
250
  if (entry) {
@@ -210,14 +252,10 @@ function confirmOptimistic(stateManager, snapshots, t) {
210
252
  previousData: void 0
211
253
  });
212
254
  stateManager.setMeta(key, { isOptimistic: false });
213
- t?.log("Optimistic confirmed", {
214
- color: "success",
215
- info: [{ value: { isOptimistic: false } }]
216
- });
217
255
  }
218
256
  }
219
257
  }
220
- function rollbackOptimistic(stateManager, snapshots, t) {
258
+ function rollbackOptimistic(stateManager, snapshots) {
221
259
  for (const { key, previousData } of snapshots) {
222
260
  const entry = stateManager.getCache(key);
223
261
  if (entry) {
@@ -229,28 +267,12 @@ function rollbackOptimistic(stateManager, snapshots, t) {
229
267
  }
230
268
  });
231
269
  stateManager.setMeta(key, { isOptimistic: false });
232
- t?.log("Optimistic rolled back", {
233
- color: "warning",
234
- info: [{ value: { isOptimistic: false } }]
235
- });
236
270
  }
237
271
  }
238
272
  }
239
- function buildSnapshotDiff(snapshots, mode = "apply") {
240
- const first = snapshots[0];
241
- const label = mode === "apply" ? "Optimistic update to cache" : mode === "rollback" ? "Rollback optimistic changes" : "Apply onSuccess updates";
242
- if (snapshots.length === 1) {
243
- return mode === "apply" || mode === "onSuccess" ? { before: first.previousData, after: first.afterData, label } : { before: first.afterData, after: first.previousData, label };
244
- }
245
- return mode === "apply" || mode === "onSuccess" ? {
246
- before: snapshots.map((s) => ({ key: s.key, data: s.previousData })),
247
- after: snapshots.map((s) => ({ key: s.key, data: s.afterData })),
248
- label
249
- } : {
250
- before: snapshots.map((s) => ({ key: s.key, data: s.afterData })),
251
- after: snapshots.map((s) => ({ key: s.key, data: s.previousData })),
252
- label
253
- };
273
+ function buildSingleDiff(snapshot, mode = "apply") {
274
+ const label = mode === "apply" ? "Optimistic update" : mode === "rollback" ? "Rollback optimistic" : "Confirmed update";
275
+ return mode === "apply" || mode === "confirmed" ? { before: snapshot.previousData, after: snapshot.afterData, label } : { before: snapshot.afterData, after: snapshot.previousData, label };
254
276
  }
255
277
  var PLUGIN_NAME = "spoosh:optimistic";
256
278
  function optimisticPlugin() {
@@ -267,28 +289,37 @@ function optimisticPlugin() {
267
289
  return next();
268
290
  }
269
291
  context.plugins.get("spoosh:invalidation")?.setDefaultMode("none");
270
- const immediateTargets = targets.filter((t2) => t2.timing !== "onSuccess");
271
- const allSnapshots = [];
272
- for (const target of immediateTargets) {
273
- const snapshots = applyOptimisticUpdate(stateManager, target, t);
274
- allSnapshots.push(...snapshots);
275
- }
276
- if (allSnapshots.length > 0) {
277
- t?.log(`Applied ${allSnapshots.length} immediate update(s)`, {
278
- diff: buildSnapshotDiff(allSnapshots)
279
- });
292
+ const allImmediateSnapshots = [];
293
+ for (const target of targets) {
294
+ if (!target.immediateUpdater) continue;
295
+ const snapshots = applyUpdate(
296
+ stateManager,
297
+ target,
298
+ target.immediateUpdater,
299
+ void 0,
300
+ t
301
+ );
302
+ for (const snapshot of snapshots) {
303
+ t?.log(
304
+ `Applied optimistic update to ${formatCacheKeyForTrace(snapshot.key)}`,
305
+ { diff: buildSingleDiff(snapshot) }
306
+ );
307
+ }
308
+ allImmediateSnapshots.push(...snapshots);
280
309
  }
281
310
  const response = await next();
282
311
  if (response.error) {
283
312
  const shouldRollback = targets.some(
284
- (t2) => t2.rollbackOnError && t2.timing !== "onSuccess"
313
+ (target) => target.rollbackOnError && target.immediateUpdater
285
314
  );
286
- if (shouldRollback && allSnapshots.length > 0) {
287
- rollbackOptimistic(stateManager, allSnapshots, t);
288
- t?.log(`Rolled back ${allSnapshots.length} update(s)`, {
289
- color: "warning",
290
- diff: buildSnapshotDiff(allSnapshots, "rollback")
291
- });
315
+ if (shouldRollback && allImmediateSnapshots.length > 0) {
316
+ rollbackOptimistic(stateManager, allImmediateSnapshots);
317
+ for (const snapshot of allImmediateSnapshots) {
318
+ t?.log(`Rolled back ${formatCacheKeyForTrace(snapshot.key)}`, {
319
+ color: "warning",
320
+ diff: buildSingleDiff(snapshot, "rollback")
321
+ });
322
+ }
292
323
  }
293
324
  for (const target of targets) {
294
325
  if (target.onError) {
@@ -296,66 +327,28 @@ function optimisticPlugin() {
296
327
  }
297
328
  }
298
329
  } else {
299
- if (allSnapshots.length > 0) {
300
- confirmOptimistic(stateManager, allSnapshots, t);
330
+ if (allImmediateSnapshots.length > 0) {
331
+ confirmOptimistic(stateManager, allImmediateSnapshots);
301
332
  }
302
- const onSuccessTargets = targets.filter(
303
- (target) => target.timing === "onSuccess"
304
- );
305
- const onSuccessSnapshots = [];
306
- for (const target of onSuccessTargets) {
307
- if (!target.updater) continue;
308
- const matchingEntries = getMatchingEntries(
333
+ for (const target of targets) {
334
+ if (!target.confirmedUpdater) continue;
335
+ const snapshots = applyUpdate(
309
336
  stateManager,
310
- target.path,
311
- target.method
337
+ target,
338
+ target.confirmedUpdater,
339
+ response.data,
340
+ t
312
341
  );
313
- for (const {
314
- key,
315
- entry,
316
- extractedParams,
317
- paramMapping
318
- } of matchingEntries) {
319
- if (target.where) {
320
- const options = extractOptionsFromKey(key) ?? {};
321
- const mappedParams = mapParamsToTargetNames(
322
- options.params,
323
- paramMapping
324
- );
325
- const mergedOptions = {
326
- ...options,
327
- params: {
328
- ...extractedParams,
329
- ...mappedParams
330
- }
331
- };
332
- if (!target.where(mergedOptions)) {
333
- continue;
342
+ for (const snapshot of snapshots) {
343
+ t?.log(
344
+ `Applied confirmed update to ${formatCacheKeyForTrace(snapshot.key)}`,
345
+ {
346
+ color: "success",
347
+ diff: buildSingleDiff(snapshot, "confirmed")
334
348
  }
335
- }
336
- if (entry?.state.data === void 0) {
337
- continue;
338
- }
339
- const afterData = target.updater(entry.state.data, response.data);
340
- onSuccessSnapshots.push({
341
- key,
342
- previousData: entry.state.data,
343
- afterData
344
- });
345
- stateManager.setCache(key, {
346
- state: {
347
- ...entry.state,
348
- data: afterData
349
- }
350
- });
349
+ );
351
350
  }
352
351
  }
353
- if (onSuccessSnapshots.length > 0) {
354
- t?.log(`Applied ${onSuccessSnapshots.length} onSuccess update(s)`, {
355
- color: "success",
356
- diff: buildSnapshotDiff(onSuccessSnapshots, "onSuccess")
357
- });
358
- }
359
352
  }
360
353
  return response;
361
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spoosh/plugin-optimistic",
3
- "version": "0.7.1",
3
+ "version": "0.8.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.15.0",
36
+ "@spoosh/core": ">=0.17.0",
37
37
  "@spoosh/plugin-invalidation": ">=0.7.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@spoosh/core": "0.15.1",
41
- "@spoosh/plugin-invalidation": "0.9.1",
40
+ "@spoosh/core": "0.17.0",
41
+ "@spoosh/plugin-invalidation": "0.10.0",
42
42
  "@spoosh/test-utils": "0.3.0"
43
43
  },
44
44
  "scripts": {
@@ -46,6 +46,6 @@
46
46
  "build": "tsup",
47
47
  "typecheck": "tsc --noEmit",
48
48
  "lint": "eslint src --max-warnings 0",
49
- "format": "prettier --write 'src/**/*.ts'"
49
+ "format": "prettier --write 'src/**/*.ts' '*.md'"
50
50
  }
51
51
  }