schemantic 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +196 -174
  2. package/LICENSE +21 -21
  3. package/README.md +88 -830
  4. package/dist/cli/index.d.ts +4 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +185 -61
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/configuration.d.ts +6 -0
  9. package/dist/core/configuration.d.ts.map +1 -0
  10. package/dist/core/configuration.js +64 -0
  11. package/dist/core/configuration.js.map +1 -0
  12. package/dist/core/index.d.ts +1 -0
  13. package/dist/core/index.d.ts.map +1 -1
  14. package/dist/core/index.js +1 -0
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/core/output-writer.d.ts +7 -0
  17. package/dist/core/output-writer.d.ts.map +1 -0
  18. package/dist/core/output-writer.js +255 -0
  19. package/dist/core/output-writer.js.map +1 -0
  20. package/dist/core/schemantic.d.ts +8 -2
  21. package/dist/core/schemantic.d.ts.map +1 -1
  22. package/dist/core/schemantic.js +196 -71
  23. package/dist/core/schemantic.js.map +1 -1
  24. package/dist/generators/api-client-generator.d.ts +9 -20
  25. package/dist/generators/api-client-generator.d.ts.map +1 -1
  26. package/dist/generators/api-client-generator.js +364 -424
  27. package/dist/generators/api-client-generator.js.map +1 -1
  28. package/dist/generators/base.d.ts.map +1 -1
  29. package/dist/generators/base.js +7 -25
  30. package/dist/generators/base.js.map +1 -1
  31. package/dist/generators/enum-generator.d.ts.map +1 -1
  32. package/dist/generators/enum-generator.js +30 -3
  33. package/dist/generators/enum-generator.js.map +1 -1
  34. package/dist/generators/hook-generator.d.ts +3 -17
  35. package/dist/generators/hook-generator.d.ts.map +1 -1
  36. package/dist/generators/hook-generator.js +51 -445
  37. package/dist/generators/hook-generator.js.map +1 -1
  38. package/dist/generators/naming.d.ts +10 -0
  39. package/dist/generators/naming.d.ts.map +1 -0
  40. package/dist/generators/naming.js +331 -0
  41. package/dist/generators/naming.js.map +1 -0
  42. package/dist/generators/object-generator.d.ts +0 -4
  43. package/dist/generators/object-generator.d.ts.map +1 -1
  44. package/dist/generators/object-generator.js +13 -101
  45. package/dist/generators/object-generator.js.map +1 -1
  46. package/dist/generators/primitive-generator.d.ts +1 -13
  47. package/dist/generators/primitive-generator.d.ts.map +1 -1
  48. package/dist/generators/primitive-generator.js +11 -76
  49. package/dist/generators/primitive-generator.js.map +1 -1
  50. package/dist/generators/projections.d.ts +10 -0
  51. package/dist/generators/projections.d.ts.map +1 -0
  52. package/dist/generators/projections.js +65 -0
  53. package/dist/generators/projections.js.map +1 -0
  54. package/dist/generators/schema-type.d.ts +23 -0
  55. package/dist/generators/schema-type.d.ts.map +1 -0
  56. package/dist/generators/schema-type.js +233 -0
  57. package/dist/generators/schema-type.js.map +1 -0
  58. package/dist/parsers/base.d.ts +2 -0
  59. package/dist/parsers/base.d.ts.map +1 -1
  60. package/dist/parsers/base.js +12 -2
  61. package/dist/parsers/base.js.map +1 -1
  62. package/dist/parsers/factory.d.ts.map +1 -1
  63. package/dist/parsers/factory.js +2 -1
  64. package/dist/parsers/factory.js.map +1 -1
  65. package/dist/parsers/openapi.d.ts +4 -0
  66. package/dist/parsers/openapi.d.ts.map +1 -1
  67. package/dist/parsers/openapi.js +107 -20
  68. package/dist/parsers/openapi.js.map +1 -1
  69. package/dist/plugins/branded-types.d.ts.map +1 -1
  70. package/dist/plugins/branded-types.js +231 -365
  71. package/dist/plugins/branded-types.js.map +1 -1
  72. package/dist/plugins/builtin.d.ts.map +1 -1
  73. package/dist/plugins/builtin.js +6 -141
  74. package/dist/plugins/builtin.js.map +1 -1
  75. package/dist/plugins/index.d.ts +4 -0
  76. package/dist/plugins/index.d.ts.map +1 -1
  77. package/dist/plugins/index.js +17 -0
  78. package/dist/plugins/index.js.map +1 -1
  79. package/dist/plugins/loader.d.ts +5 -1
  80. package/dist/plugins/loader.d.ts.map +1 -1
  81. package/dist/plugins/loader.js +42 -5
  82. package/dist/plugins/loader.js.map +1 -1
  83. package/dist/plugins/manager.d.ts.map +1 -1
  84. package/dist/plugins/manager.js +7 -7
  85. package/dist/plugins/manager.js.map +1 -1
  86. package/dist/plugins/performance-monitoring.d.ts +31 -1
  87. package/dist/plugins/performance-monitoring.d.ts.map +1 -1
  88. package/dist/plugins/performance-monitoring.js +146 -207
  89. package/dist/plugins/performance-monitoring.js.map +1 -1
  90. package/dist/plugins/request-deduplication.d.ts.map +1 -1
  91. package/dist/plugins/request-deduplication.js +151 -586
  92. package/dist/plugins/request-deduplication.js.map +1 -1
  93. package/dist/plugins/zod-validation.d.ts +23 -15
  94. package/dist/plugins/zod-validation.d.ts.map +1 -1
  95. package/dist/plugins/zod-validation.js +390 -268
  96. package/dist/plugins/zod-validation.js.map +1 -1
  97. package/dist/types/core.d.ts +20 -3
  98. package/dist/types/core.d.ts.map +1 -1
  99. package/dist/types/core.js +2 -0
  100. package/dist/types/core.js.map +1 -1
  101. package/dist/types/openapi.d.ts +14 -1
  102. package/dist/types/openapi.d.ts.map +1 -1
  103. package/docs/ADVANCED_PLUGINS_IMPLEMENTATION.md +24 -355
  104. package/docs/ADVANCED_PLUGIN_DEVELOPMENT.md +33 -309
  105. package/docs/API.md +80 -601
  106. package/docs/AUDIT.md +49 -0
  107. package/docs/CONFIGURATION.md +81 -715
  108. package/docs/FASTAPI_TUTORIAL.md +109 -1497
  109. package/docs/NAMING_MIGRATION.md +59 -0
  110. package/docs/PLUGINS.md +49 -687
  111. package/docs/PLUGIN_DEVELOPMENT.md +53 -720
  112. package/docs/REAUDIT.md +399 -0
  113. package/docs/RELEASING.md +88 -0
  114. package/docs/REMEDIATION.md +58 -0
  115. package/docs/superpowers/plans/2026-10-10-audit-remediation.md +113 -0
  116. package/package.json +114 -95
  117. package/dist/__tests__/setup.d.ts +0 -6
  118. package/dist/__tests__/setup.d.ts.map +0 -1
  119. package/dist/__tests__/setup.js +0 -30
  120. package/dist/__tests__/setup.js.map +0 -1
  121. package/dist/__tests__/test-config.d.ts +0 -36
  122. package/dist/__tests__/test-config.d.ts.map +0 -1
  123. package/dist/__tests__/test-config.js +0 -562
  124. package/dist/__tests__/test-config.js.map +0 -1
@@ -36,18 +36,12 @@ exports.requestDeduplicationPlugin = {
36
36
  if (!options.enabled) {
37
37
  return;
38
38
  }
39
- try {
40
- // Add deduplication middleware to client
41
- const deduplicationCode = generateDeduplicationCode(generatedClient, options);
42
- generatedClient.content += deduplicationCode;
43
- // Update dependencies
44
- generatedClient.dependencies.push("crypto-js"); // For hashing
45
- // Add deduplication exports
46
- generatedClient.exports.push("RequestDeduplicator", "CacheManager", "RequestCoalescer", "clearRequestCache", "getCacheStats");
47
- }
48
- catch (error) {
49
- console.warn("Request deduplication plugin afterClientGeneration failed:", error);
50
- }
39
+ // Add deduplication middleware to client
40
+ const deduplicationCode = generateDeduplicationCode(generatedClient, options);
41
+ generatedClient.content += deduplicationCode;
42
+ // Update dependencies
43
+ // Add deduplication exports
44
+ generatedClient.exports.push("RequestDeduplicator", "CacheManager", "RequestCoalescer", "clearRequestCache", "getCacheStats", "invalidateCache", "withDeduplication", "DeduplicationRequest", "RequestKeyGenerator");
51
45
  },
52
46
  };
53
47
  /**
@@ -71,580 +65,151 @@ function getPluginOptions(context) {
71
65
  * Generate request deduplication code for API client
72
66
  */
73
67
  function generateDeduplicationCode(_generatedClient, options) {
74
- return `
75
-
76
- /**
77
- * Advanced Request Deduplication System
78
- */
79
-
80
- /**
81
- * LRU Cache implementation with TTL support
82
- */
83
- class LRUCacheWithTTL<K, V> {
84
- private readonly maxSize: number;
85
- private readonly cache = new Map<K, { value: V; timestamp: number; ttl: number }>();
86
- private readonly accessOrder = new Map<K, number>();
87
- private accessCounter = 0;
88
-
89
- constructor(maxSize: number = ${options.maxCacheSize || 1000}) {
90
- this.maxSize = maxSize;
91
- }
92
-
93
- set(key: K, value: V, ttl: number = ${options.defaultTTL || 5 * 60 * 1000}): void {
94
- const now = Date.now();
95
-
96
- // Remove expired entries before adding new one
97
- this.cleanup();
98
-
99
- // If at capacity, remove LRU item
100
- if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
101
- this.evictLRU();
102
- }
103
-
104
- this.cache.set(key, {
105
- value,
106
- timestamp: now,
107
- ttl
108
- });
109
-
110
- this.accessOrder.set(key, ++this.accessCounter);
111
- }
112
-
113
- get(key: K): V | undefined {
114
- const entry = this.cache.get(key);
115
- if (!entry) {
116
- return undefined;
117
- }
118
-
119
- const now = Date.now();
120
- if (now - entry.timestamp > entry.ttl) {
121
- // Entry is expired
122
- this.cache.delete(key);
123
- this.accessOrder.delete(key);
124
- return undefined;
125
- }
126
-
127
- // Update access order
128
- this.accessOrder.set(key, ++this.accessCounter);
129
- return entry.value;
130
- }
131
-
132
- has(key: K): boolean {
133
- return this.get(key) !== undefined;
134
- }
135
-
136
- delete(key: K): boolean {
137
- const result = this.cache.delete(key);
138
- this.accessOrder.delete(key);
139
- return result;
140
- }
141
-
142
- clear(): void {
143
- this.cache.clear();
144
- this.accessOrder.clear();
145
- this.accessCounter = 0;
146
- }
147
-
148
- size(): number {
149
- this.cleanup();
150
- return this.cache.size;
151
- }
152
-
153
- private cleanup(): void {
154
- const now = Date.now();
155
- const expiredKeys: K[] = [];
156
-
157
- for (const [key, entry] of this.cache) {
158
- if (now - entry.timestamp > entry.ttl) {
159
- expiredKeys.push(key);
160
- }
161
- }
162
-
163
- for (const key of expiredKeys) {
164
- this.cache.delete(key);
165
- this.accessOrder.delete(key);
166
- }
167
- }
168
-
169
- private evictLRU(): void {
170
- let oldestKey: K | undefined;
171
- let oldestAccess = Infinity;
172
-
173
- for (const [key, accessTime] of this.accessOrder) {
174
- if (accessTime < oldestAccess) {
175
- oldestAccess = accessTime;
176
- oldestKey = key;
177
- }
178
- }
179
-
180
- if (oldestKey !== undefined) {
181
- this.cache.delete(oldestKey);
182
- this.accessOrder.delete(oldestKey);
183
- }
184
- }
185
-
186
- getStats(): { size: number; hitRate: number; evictions: number } {
187
- return {
188
- size: this.cache.size,
189
- hitRate: 0, // Would need to track hits/misses for this
190
- evictions: 0 // Would need to track evictions for this
191
- };
192
- }
193
- }
194
-
195
- /**
196
- * Request fingerprint generator
197
- */
198
- class RequestFingerprintGenerator {
199
- /**
200
- * Generate a unique fingerprint for a request
201
- */
202
- static generate(
203
- method: string,
204
- url: string,
205
- headers: Record<string, string> = {},
206
- body?: unknown,
207
- params?: Record<string, unknown>
208
- ): string {
209
- const fingerprint = {
210
- method: method.toUpperCase(),
211
- url: this.normalizeURL(url),
212
- headers: this.normalizeHeaders(headers),
213
- body: body ? JSON.stringify(body) : undefined,
214
- params: params ? JSON.stringify(params) : undefined
215
- };
216
-
217
- // Simple hash generation (in production, use crypto-js or similar)
218
- return this.simpleHash(JSON.stringify(fingerprint));
219
- }
220
-
221
- private static normalizeURL(url: string): string {
222
- try {
223
- const urlObj = new URL(url);
224
- // Sort query parameters for consistent fingerprinting
225
- const params = new URLSearchParams(urlObj.search);
226
- const sortedParams = new URLSearchParams();
227
- Array.from(params.keys()).sort().forEach(key => {
228
- params.getAll(key).forEach(value => sortedParams.append(key, value));
229
- });
230
- urlObj.search = sortedParams.toString();
231
- return urlObj.toString();
232
- } catch {
233
- return url;
234
- }
235
- }
236
-
237
- private static normalizeHeaders(headers: Record<string, string>): Record<string, string> {
238
- const normalized: Record<string, string> = {};
239
-
240
- // Only include cache-relevant headers and normalize case
241
- const relevantHeaders = ['authorization', 'content-type', 'accept', 'cache-control'];
242
-
243
- for (const [key, value] of Object.entries(headers)) {
244
- const lowerKey = key.toLowerCase();
245
- if (relevantHeaders.includes(lowerKey)) {
246
- normalized[lowerKey] = value;
247
- }
248
- }
249
-
250
- return normalized;
251
- }
252
-
253
- private static simpleHash(str: string): string {
254
- let hash = 0;
255
- for (let i = 0; i < str.length; i++) {
256
- const char = str.charCodeAt(i);
257
- hash = ((hash << 5) - hash) + char;
258
- hash = hash & hash; // Convert to 32-bit integer
259
- }
260
- return hash.toString(36);
261
- }
262
- }
263
-
264
- /**
265
- * Request coalescing manager
266
- */
267
- class RequestCoalescer {
268
- private readonly pendingRequests = new Map<string, Promise<unknown>>();
269
- private readonly maxPending: number;
270
-
271
- constructor(maxPending = ${options.maxPendingRequests || 100}) {
272
- this.maxPending = maxPending;
273
- }
274
-
275
- /**
276
- * Coalesce identical requests
277
- */
278
- async coalesce<T>(
279
- fingerprint: string,
280
- requestFactory: () => Promise<T>
281
- ): Promise<T> {
282
- // Check if identical request is already pending
283
- const existingRequest = this.pendingRequests.get(fingerprint);
284
- if (existingRequest) {
285
- ${options.enableDebugLogging
286
- ? "console.debug(`Request coalesced: ${fingerprint}`);"
287
- : ""}
288
- return existingRequest as Promise<T>;
289
- }
290
-
291
- // Check if we're at capacity
292
- if (this.pendingRequests.size >= this.maxPending) {
293
- ${options.enableDebugLogging
294
- ? "console.warn(`Request coalescing at capacity, executing directly`);"
295
- : ""}
296
- return requestFactory();
297
- }
298
-
299
- // Create new request and track it
300
- const requestPromise = requestFactory()
301
- .finally(() => {
302
- // Clean up when request completes
303
- this.pendingRequests.delete(fingerprint);
304
- });
305
-
306
- this.pendingRequests.set(fingerprint, requestPromise);
307
- return requestPromise;
308
- }
309
-
310
- /**
311
- * Clear all pending requests
312
- */
313
- clear(): void {
314
- this.pendingRequests.clear();
315
- }
316
-
317
- /**
318
- * Get current pending request count
319
- */
320
- getPendingCount(): number {
321
- return this.pendingRequests.size;
322
- }
323
- }
324
-
325
- /**
326
- * Cache manager with stale-while-revalidate support
327
- */
328
- class CacheManager<T = unknown> {
329
- private readonly cache = new LRUCacheWithTTL<string, CacheEntry<T>>();
330
- private readonly staleRevalidatePromises = new Map<string, Promise<T>>();
331
-
332
- constructor(private readonly options: {
333
- defaultTTL: number;
334
- enableStaleWhileRevalidate: boolean;
335
- debugLogging: boolean;
336
- }) {}
337
-
338
- /**
339
- * Get cached response or undefined if not cached/expired
340
- */
341
- get(fingerprint: string): T | undefined {
342
- const entry = this.cache.get(fingerprint);
343
-
344
- if (!entry) {
345
- return undefined;
346
- }
347
-
348
- const now = Date.now();
349
- const age = now - entry.timestamp;
350
-
351
- if (age > entry.ttl) {
352
- // Entry is expired
353
- if (this.options.enableStaleWhileRevalidate && !entry.stale) {
354
- // Mark as stale but return it
355
- entry.stale = true;
356
- this.cache.set(fingerprint, entry, entry.ttl);
357
- ${options.enableDebugLogging
358
- ? "this.options.debugLogging && console.debug(`Serving stale data: ${fingerprint}`);"
359
- : ""}
360
- return entry.data;
361
- }
362
-
363
- // Entry is too old even for stale-while-revalidate
364
- this.cache.delete(fingerprint);
365
- return undefined;
366
- }
367
-
368
- ${options.enableDebugLogging
369
- ? "this.options.debugLogging && console.debug(`Cache hit: ${fingerprint}`);"
370
- : ""}
371
- return entry.data;
372
- }
373
-
374
- /**
375
- * Set cached response
376
- */
377
- set(fingerprint: string, data: T, ttl?: number): void {
378
- const entry: CacheEntry<T> = {
379
- data,
380
- timestamp: Date.now(),
381
- ttl: ttl || this.options.defaultTTL,
382
- stale: false
383
- };
384
-
385
- this.cache.set(fingerprint, entry, entry.ttl);
386
- ${options.enableDebugLogging
387
- ? "this.options.debugLogging && console.debug(`Cache set: ${fingerprint}`);"
388
- : ""}
389
- }
390
-
391
- /**
392
- * Check if entry exists and is fresh
393
- */
394
- hasFresh(fingerprint: string): boolean {
395
- const entry = this.cache.get(fingerprint);
396
- if (!entry) return false;
397
-
398
- const age = Date.now() - entry.timestamp;
399
- return age <= entry.ttl && !entry.stale;
400
- }
401
-
402
- /**
403
- * Check if entry exists (including stale)
404
- */
405
- has(fingerprint: string): boolean {
406
- return this.cache.has(fingerprint);
407
- }
408
-
409
- /**
410
- * Invalidate cache entry
411
- */
412
- invalidate(fingerprint: string): void {
413
- this.cache.delete(fingerprint);
414
- this.staleRevalidatePromises.delete(fingerprint);
415
- ${options.enableDebugLogging
416
- ? "this.options.debugLogging && console.debug(`Cache invalidated: ${fingerprint}`);"
417
- : ""}
418
- }
419
-
420
- /**
421
- * Clear entire cache
422
- */
423
- clear(): void {
424
- this.cache.clear();
425
- this.staleRevalidatePromises.clear();
426
- }
427
-
428
- /**
429
- * Get cache statistics
430
- */
431
- getStats(): { size: number; hitRate: number } {
432
- return {
433
- size: this.cache.size(),
434
- hitRate: 0 // Would need hit/miss tracking
435
- };
436
- }
437
-
438
- /**
439
- * Revalidate stale entry in background
440
- */
441
- revalidateInBackground<U extends T>(
442
- fingerprint: string,
443
- revalidator: () => Promise<U>
444
- ): void {
445
- if (this.staleRevalidatePromises.has(fingerprint)) {
446
- return; // Already revalidating
447
- }
448
-
449
- const revalidationPromise = revalidator()
450
- .then(freshData => {
451
- this.set(fingerprint, freshData);
452
- return freshData;
453
- })
454
- .catch(error => {
455
- ${options.enableDebugLogging
456
- ? "this.options.debugLogging && console.warn(`Background revalidation failed: ${fingerprint}`, error);"
457
- : ""}
458
- throw error;
459
- })
460
- .finally(() => {
461
- this.staleRevalidatePromises.delete(fingerprint);
462
- });
463
-
464
- this.staleRevalidatePromises.set(fingerprint, revalidationPromise);
465
- }
466
- }
467
-
468
- /**
469
- * Main request deduplicator
470
- */
471
- export class RequestDeduplicator {
472
- private readonly cacheManager: CacheManager;
473
- private readonly coalescer: RequestCoalescer;
474
- private readonly endpointPolicies: Record<string, any>;
475
-
476
- constructor() {
477
- this.cacheManager = new CacheManager({
478
- defaultTTL: ${options.defaultTTL || 5 * 60 * 1000},
479
- enableStaleWhileRevalidate: ${options.enableStaleWhileRevalidate || true},
480
- debugLogging: ${options.enableDebugLogging || false}
481
- });
482
-
483
- this.coalescer = new RequestCoalescer(${options.maxPendingRequests || 100});
484
- this.endpointPolicies = ${JSON.stringify(options.endpointPolicies || {})};
485
- }
486
-
487
- /**
488
- * Execute request with deduplication
489
- */
490
- async execute<T>(
491
- method: string,
492
- url: string,
493
- requestFactory: () => Promise<T>,
494
- options: {
495
- headers?: Record<string, string>;
496
- body?: unknown;
497
- params?: Record<string, unknown>;
498
- skipCache?: boolean;
499
- customTTL?: number;
500
- } = {}
501
- ): Promise<T> {
502
- if (options.skipCache) {
503
- return requestFactory();
504
- }
505
-
506
- // Generate request fingerprint
507
- const fingerprint = RequestFingerprintGenerator.generate(
508
- method,
509
- url,
510
- options.headers,
511
- options.body,
512
- options.params
513
- );
514
-
515
- // Check endpoint-specific policy
516
- const policy = this.getEndpointPolicy(url);
517
- if (!policy.enabled) {
518
- return requestFactory();
519
- }
520
-
521
- // Try cache first
522
- const cachedResult = this.cacheManager.get(fingerprint);
523
- if (cachedResult !== undefined) {
524
- // If stale and stale-while-revalidate is enabled, revalidate in background
525
- if (policy.staleWhileRevalidate && !this.cacheManager.hasFresh(fingerprint)) {
526
- this.cacheManager.revalidateInBackground(fingerprint, requestFactory);
527
- }
528
- return cachedResult as T;
529
- }
530
-
531
- // No cache hit, execute request with coalescing
532
- const result = await this.coalescer.coalesce(fingerprint, requestFactory);
533
-
534
- // Cache the result
535
- const ttl = options.customTTL || policy.ttl || ${options.defaultTTL || 5 * 60 * 1000};
536
- this.cacheManager.set(fingerprint, result, ttl);
537
-
538
- return result;
539
- }
540
-
541
- /**
542
- * Invalidate cache for specific pattern
543
- */
544
- invalidate(pattern: string | RegExp): void {
545
- // In a real implementation, this would iterate through cache keys
546
- // and invalidate matching entries
547
- if (typeof pattern === 'string') {
548
- const fingerprint = RequestFingerprintGenerator.generate('GET', pattern);
549
- this.cacheManager.invalidate(fingerprint);
550
- }
551
- }
552
-
553
- /**
554
- * Clear all caches
555
- */
556
- clearCache(): void {
557
- this.cacheManager.clear();
558
- this.coalescer.clear();
559
- }
560
-
561
- /**
562
- * Get cache statistics
563
- */
564
- getStats(): {
565
- cacheSize: number;
566
- pendingRequests: number;
567
- cacheHitRate: number;
568
- } {
569
- return {
570
- cacheSize: this.cacheManager.getStats().size,
571
- pendingRequests: this.coalescer.getPendingCount(),
572
- cacheHitRate: this.cacheManager.getStats().hitRate
573
- };
574
- }
575
-
576
- private getEndpointPolicy(url: string): {
577
- enabled: boolean;
578
- ttl: number;
579
- staleWhileRevalidate: boolean;
580
- } {
581
- // Simple URL matching - in production, use more sophisticated matching
582
- for (const [pattern, policy] of Object.entries(this.endpointPolicies)) {
583
- if (url.includes(pattern)) {
584
- return {
585
- enabled: policy.enabled ?? true,
586
- ttl: policy.ttl ?? ${options.defaultTTL || 5 * 60 * 1000},
587
- staleWhileRevalidate: policy.staleWhileRevalidate ?? ${options.enableStaleWhileRevalidate || true}
588
- };
589
- }
590
- }
591
-
592
- return {
593
- enabled: true,
594
- ttl: ${options.defaultTTL || 5 * 60 * 1000},
595
- staleWhileRevalidate: ${options.enableStaleWhileRevalidate || true}
596
- };
597
- }
598
- }
599
-
600
- /**
601
- * Global deduplicator instance
602
- */
603
- const globalDeduplicator = new RequestDeduplicator();
604
-
605
- /**
606
- * Utility functions
607
- */
608
- export function clearRequestCache(): void {
609
- globalDeduplicator.clearCache();
610
- }
611
-
612
- export function getCacheStats(): ReturnType<RequestDeduplicator['getStats']> {
613
- return globalDeduplicator.getStats();
614
- }
615
-
616
- export function invalidateCache(pattern: string | RegExp): void {
617
- globalDeduplicator.invalidate(pattern);
618
- }
619
-
620
- /**
621
- * Request wrapper with deduplication
622
- */
623
- export function withDeduplication<T extends (...args: any[]) => Promise<any>>(
624
- requestFn: T,
625
- options: {
626
- method: string;
627
- urlExtractor: (...args: Parameters<T>) => string;
628
- headersExtractor?: (...args: Parameters<T>) => Record<string, string>;
629
- bodyExtractor?: (...args: Parameters<T>) => unknown;
630
- paramsExtractor?: (...args: Parameters<T>) => Record<string, unknown>;
631
- }
632
- ): T {
633
- return ((...args: Parameters<T>) => {
634
- const url = options.urlExtractor(...args);
635
- const headers = options.headersExtractor?.(...args);
636
- const body = options.bodyExtractor?.(...args);
637
- const params = options.paramsExtractor?.(...args);
638
-
639
- return globalDeduplicator.execute(
640
- options.method,
641
- url,
642
- () => requestFn(...args),
643
- { headers, body, params }
644
- );
645
- }) as T;
646
- }`;
68
+ if (options.customKeyGenerator !== undefined) {
69
+ throw new Error("Generation-time customKeyGenerator names are unsupported; pass a callback to the runtime RequestDeduplicator constructor");
70
+ }
71
+ return `
72
+ /** Request identity includes all supplied headers. For implicit cookie/session credentials,
73
+ * supply cacheScope, create one deduplicator per session, or use skipCache. Keys are never logged. */
74
+ export interface DeduplicationRequest {
75
+ method: string; url: string; headers: Record<string, string>; body?: unknown;
76
+ params?: Record<string, unknown>; cacheScope?: string;
77
+ }
78
+ export type RequestKeyGenerator = (request: DeduplicationRequest) => string;
79
+ function canonicalRequestValue(value: unknown, seen = new Set<unknown>()): string {
80
+ if (value === null) return 'null';
81
+ if (typeof value !== 'object') {
82
+ if (typeof value === 'function' || typeof value === 'symbol') throw new Error('Unsupported cache identity');
83
+ return typeof value + ':' + JSON.stringify(typeof value === 'number' && Object.is(value, -0) ? '-0' : String(value));
84
+ }
85
+ if (seen.has(value)) throw new Error('Cyclic cache identity');
86
+ seen.add(value);
87
+ try {
88
+ if (value instanceof Date) return 'date:' + value.toISOString();
89
+ if (Array.isArray(value)) return '[' + Array.from(value, item => canonicalRequestValue(item, seen)).join(',') + ']';
90
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error('Unsupported cache identity');
91
+ return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonicalRequestValue((value as Record<string, unknown>)[key], seen)).join(',') + '}';
92
+ } finally { seen.delete(value); }
93
+ }
94
+ function requestIdentity(request: DeduplicationRequest): string {
95
+ const headers: Record<string, string> = {};
96
+ new Headers(request.headers).forEach((value, key) => { headers[key] = value; });
97
+ // Preserve URL query ordering: repeated/order-sensitive query values can affect server responses.
98
+ return canonicalRequestValue({ ...request, method: request.method.toUpperCase(), headers });
99
+ }
100
+ export class RequestCoalescer {
101
+ private readonly pending = new Map<string, Promise<unknown>>();
102
+ constructor(private readonly maxPending = ${options.maxPendingRequests ?? 100}) {}
103
+ async coalesce<T>(key: string, factory: () => Promise<T>): Promise<T> {
104
+ const existing = this.pending.get(key);
105
+ if (existing) return existing as Promise<T>;
106
+ if (this.pending.size >= this.maxPending) return factory();
107
+ const promise = Promise.resolve().then(factory).finally(() => {
108
+ if (this.pending.get(key) === promise) this.pending.delete(key);
109
+ });
110
+ this.pending.set(key, promise); return promise;
111
+ }
112
+ clear(): void { this.pending.clear(); }
113
+ getPendingCount(): number { return this.pending.size; }
114
+ }
115
+ interface CacheEntry<T> { data: T; timestamp: number; ttl: number; staleWhileRevalidate: boolean; }
116
+ export class CacheManager<T = unknown> {
117
+ private readonly cache = new Map<string, CacheEntry<T>>();
118
+ private readonly revalidations = new Map<string, Promise<unknown>>();
119
+ private hits = 0; private misses = 0; private evictions = 0;
120
+ constructor(private readonly options: { defaultTTL: number; enableStaleWhileRevalidate: boolean; debugLogging: boolean; maxSize?: number }) {}
121
+ get(key: string, allowStale = this.options.enableStaleWhileRevalidate): T | undefined {
122
+ const entry = this.cache.get(key);
123
+ if (!entry || (Date.now() - entry.timestamp >= entry.ttl && !(allowStale && entry.staleWhileRevalidate))) {
124
+ this.misses++; if (entry) this.cache.delete(key); return undefined;
125
+ }
126
+ this.hits++; this.cache.delete(key); this.cache.set(key, entry); return entry.data;
127
+ }
128
+ set(key: string, data: T, ttl = this.options.defaultTTL, staleWhileRevalidate = this.options.enableStaleWhileRevalidate): void {
129
+ if ((this.options.maxSize ?? ${options.maxCacheSize ?? 1000}) <= 0) return;
130
+ this.cache.delete(key);
131
+ if (this.cache.size >= (this.options.maxSize ?? ${options.maxCacheSize ?? 1000})) {
132
+ const oldest = this.cache.keys().next().value;
133
+ if (oldest !== undefined) { this.cache.delete(oldest); this.evictions++; }
134
+ }
135
+ this.cache.set(key, { data, ttl, timestamp: Date.now(), staleWhileRevalidate });
136
+ }
137
+ hasFresh(key: string): boolean { const e = this.cache.get(key); return !!e && Date.now() - e.timestamp < e.ttl; }
138
+ has(key: string): boolean { return this.cache.has(key); }
139
+ invalidate(key: string): void { this.cache.delete(key); this.revalidations.delete(key); }
140
+ clear(): void { this.cache.clear(); this.revalidations.clear(); this.hits = 0; this.misses = 0; this.evictions = 0; }
141
+ keys(): IterableIterator<string> { return this.cache.keys(); }
142
+ getStats(): { size: number; hitRate: number; hits: number; misses: number; evictions: number } {
143
+ for (const [key, entry] of this.cache) if (!entry.staleWhileRevalidate && Date.now() - entry.timestamp >= entry.ttl) this.cache.delete(key);
144
+ return { size: this.cache.size, hitRate: this.hits + this.misses ? this.hits / (this.hits + this.misses) : 0, hits: this.hits, misses: this.misses, evictions: this.evictions };
145
+ }
146
+ revalidateInBackground<U extends T>(key: string, factory: () => Promise<U>, ttl = this.options.defaultTTL, staleWhileRevalidate = this.options.enableStaleWhileRevalidate): void {
147
+ if (this.revalidations.has(key)) return;
148
+ let refresh: Promise<U>; try { refresh = factory(); } catch { return; }
149
+ const promise = refresh.then(data => {
150
+ // Invalidation or clear revokes pending writes.
151
+ if (this.revalidations.get(key) === promise) this.set(key, data, ttl, staleWhileRevalidate);
152
+ }).catch(() => undefined).finally(() => { if (this.revalidations.get(key) === promise) this.revalidations.delete(key); });
153
+ this.revalidations.set(key, promise);
154
+ }
155
+ }
156
+ export class RequestDeduplicator {
157
+ private readonly cacheManager = new CacheManager({ defaultTTL: ${options.defaultTTL ?? 300000}, enableStaleWhileRevalidate: ${options.enableStaleWhileRevalidate ?? true}, debugLogging: ${options.enableDebugLogging ?? false}, maxSize: ${options.maxCacheSize ?? 1000} });
158
+ private readonly coalescer = new RequestCoalescer();
159
+ private readonly urls = new Map<string, Set<string>>();
160
+ private epoch = 0;
161
+ private readonly endpointPolicies: Record<string, { enabled?: boolean; ttl?: number; staleWhileRevalidate?: boolean }> = ${JSON.stringify(options.endpointPolicies ?? {})};
162
+ constructor(private readonly runtimeOptions: { customKeyGenerator?: RequestKeyGenerator } = {}) {}
163
+ async execute<T>(method: string, url: string, factory: () => Promise<T>, options: { headers?: Record<string, string>; body?: unknown; params?: Record<string, unknown>; cacheScope?: string; skipCache?: boolean; customTTL?: number } = {}): Promise<T> {
164
+ if (options.skipCache || !['GET', 'HEAD'].includes(method.toUpperCase())) return factory();
165
+ const policy = this.getEndpointPolicy(url); if (!policy.enabled) return factory();
166
+ const request: DeduplicationRequest = { method: method.toUpperCase(), url, headers: options.headers ?? {}, ...(options.body !== undefined ? { body: options.body } : {}), ...(options.params !== undefined ? { params: options.params } : {}), ...(options.cacheScope !== undefined ? { cacheScope: options.cacheScope } : {}) };
167
+ const keyGenerator = this.runtimeOptions.customKeyGenerator;
168
+ let key: string;
169
+ if (keyGenerator) key = keyGenerator(request);
170
+ else { try { key = requestIdentity(request); } catch { return factory(); } }
171
+ const ttl = options.customTTL ?? policy.ttl;
172
+ const cached = this.cacheManager.get(key, policy.staleWhileRevalidate);
173
+ if (cached !== undefined || this.cacheManager.has(key)) {
174
+ if (policy.staleWhileRevalidate && !this.cacheManager.hasFresh(key)) this.cacheManager.revalidateInBackground(key, factory, ttl, policy.staleWhileRevalidate);
175
+ return cached as T;
176
+ }
177
+ const epoch = this.epoch;
178
+ const result = ${options.enableCoalescing ?? true} ? await this.coalescer.coalesce(key, factory) : await factory();
179
+ if (epoch === this.epoch) {
180
+ this.cacheManager.set(key, result, ttl, policy.staleWhileRevalidate);
181
+ const urls = this.urls.get(key) ?? new Set<string>(); urls.add(url); this.urls.set(key, urls);
182
+ for (const known of this.urls.keys()) if (!this.cacheManager.has(known)) this.urls.delete(known);
183
+ }
184
+ return result;
185
+ }
186
+ invalidate(pattern: string | RegExp): void {
187
+ this.epoch++; this.coalescer.clear();
188
+ for (const [key, urls] of this.urls) {
189
+ const matches = Array.from(urls).some(url => { if (typeof pattern === 'string') return url.includes(pattern); pattern.lastIndex = 0; return pattern.test(url); });
190
+ if (matches) { this.cacheManager.invalidate(key); this.urls.delete(key); }
191
+ }
192
+ }
193
+ clearCache(): void { this.epoch++; this.cacheManager.clear(); this.coalescer.clear(); this.urls.clear(); }
194
+ getStats(): { cacheSize: number; pendingRequests: number; cacheHitRate: number; hits: number; misses: number; evictions: number } {
195
+ const stats = this.cacheManager.getStats(); return { cacheSize: stats.size, pendingRequests: this.coalescer.getPendingCount(), cacheHitRate: stats.hitRate, hits: stats.hits, misses: stats.misses, evictions: stats.evictions };
196
+ }
197
+ private getEndpointPolicy(url: string): { enabled: boolean; ttl: number; staleWhileRevalidate: boolean } {
198
+ const match = Object.entries(this.endpointPolicies).find(([pattern]) => url.includes(pattern));
199
+ const policy = match?.[1]; return { enabled: policy?.enabled ?? true, ttl: policy?.ttl ?? ${options.defaultTTL ?? 300000}, staleWhileRevalidate: policy?.staleWhileRevalidate ?? ${options.enableStaleWhileRevalidate ?? true} };
200
+ }
201
+ }
202
+ const globalDeduplicator = new RequestDeduplicator();
203
+ export function clearRequestCache(): void { globalDeduplicator.clearCache(); }
204
+ export function getCacheStats(): ReturnType<RequestDeduplicator['getStats']> { return globalDeduplicator.getStats(); }
205
+ export function invalidateCache(pattern: string | RegExp): void { globalDeduplicator.invalidate(pattern); }
206
+ export function withDeduplication<T extends (...args: any[]) => Promise<any>>(requestFn: T, options: { method: string; urlExtractor: (...args: Parameters<T>) => string; headersExtractor?: (...args: Parameters<T>) => Record<string, string>; bodyExtractor?: (...args: Parameters<T>) => unknown; paramsExtractor?: (...args: Parameters<T>) => Record<string, unknown>; cacheScopeExtractor?: (...args: Parameters<T>) => string }): T {
207
+ return ((...args: Parameters<T>) => {
208
+ const headers = options.headersExtractor?.(...args); const body = options.bodyExtractor?.(...args); const params = options.paramsExtractor?.(...args); const cacheScope = options.cacheScopeExtractor?.(...args);
209
+ return globalDeduplicator.execute(options.method, options.urlExtractor(...args), () => requestFn(...args), { ...(headers !== undefined ? { headers } : {}), ...(body !== undefined ? { body } : {}), ...(params !== undefined ? { params } : {}), ...(cacheScope !== undefined ? { cacheScope } : {}) });
210
+ }) as T;
211
+ }
212
+ `;
647
213
  }
648
- // Export plugin for registration
649
214
  exports.default = exports.requestDeduplicationPlugin;
650
215
  //# sourceMappingURL=request-deduplication.js.map