@portel/photon-core 2.20.0 → 2.22.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/src/memory.ts CHANGED
@@ -55,6 +55,11 @@ export interface MemoryBackend {
55
55
  * uses a per-key promise chain.
56
56
  */
57
57
  update(namespace: string, key: string, updater: (current: any | null) => any): Promise<any>;
58
+ /**
59
+ * List all key-value pairs in the namespace, optionally filtered by key prefix.
60
+ * Aligns with Deno KV's list() surface for minimal, predictable enumeration.
61
+ */
62
+ list(namespace: string, prefix?: string): Promise<Array<{ key: string; value: any }>>;
58
63
  }
59
64
 
60
65
  // ════════════════════════════════════════════════════════════════════════════════
@@ -185,6 +190,26 @@ export class FileMemoryBackend implements MemoryBackend {
185
190
  return updated;
186
191
  });
187
192
  }
193
+
194
+ async list(namespace: string, prefix?: string): Promise<Array<{ key: string; value: any }>> {
195
+ let allKeys: string[];
196
+ try {
197
+ const files = await fs.readdir(namespace);
198
+ allKeys = files.filter(f => f.endsWith('.json') && !f.endsWith('.tmp')).map(f => f.slice(0, -5));
199
+ } catch (error: any) {
200
+ if (error.code === 'ENOENT') return [];
201
+ throw error;
202
+ }
203
+
204
+ const filtered = prefix ? allKeys.filter(k => k.startsWith(prefix)) : allKeys;
205
+ const entries = await Promise.all(
206
+ filtered.map(async key => {
207
+ const value = await this.get(namespace, key);
208
+ return { key, value };
209
+ })
210
+ );
211
+ return entries.filter(e => e.value !== null);
212
+ }
188
213
  }
189
214
 
190
215
  // ════════════════════════════════════════════════════════════════════════════════
@@ -331,6 +356,10 @@ export class MemoryProvider {
331
356
  return result;
332
357
  }
333
358
 
359
+ async list<T = any>(prefix?: string, scope: MemoryScope = 'photon'): Promise<Array<{ key: string; value: T }>> {
360
+ return this._backend.list(this.ns(scope), prefix) as Promise<Array<{ key: string; value: T }>>;
361
+ }
362
+
334
363
  async update<T = any>(
335
364
  key: string,
336
365
  updater: (current: T | null) => T,
package/src/middleware.ts CHANGED
@@ -626,6 +626,54 @@ const retryableMiddleware = defineMiddleware<{ count: number; delay: number }>({
626
626
  },
627
627
  });
628
628
 
629
+ // --- bulkhead (phase 15) ---
630
+ // Caps concurrent in-flight executions per photon:instance:tool. Unlike
631
+ // @throttled (which rate-limits over a time window), bulkhead protects
632
+ // downstream resources from being overwhelmed by concurrent load.
633
+ // Fast-fails when the cap is hit — callers should back off or queue.
634
+
635
+ interface BulkheadStateEntry {
636
+ inFlight: number;
637
+ }
638
+
639
+ const bulkheadMiddleware = defineMiddleware<{ maxConcurrent: number }>({
640
+ name: 'bulkhead',
641
+ phase: 15,
642
+ parseShorthand(value: string) {
643
+ return { maxConcurrent: Math.max(1, parseInt(value.trim(), 10) || 1) };
644
+ },
645
+ parseConfig(raw) {
646
+ return {
647
+ maxConcurrent: Math.max(1, parseInt(raw.maxConcurrent || raw.max || '1', 10)),
648
+ };
649
+ },
650
+ create(config, state) {
651
+ return async (ctx, next) => {
652
+ const key = `${ctx.photon}:${ctx.instance}:${ctx.tool}`;
653
+ let entry = state.get<BulkheadStateEntry>(key);
654
+ if (!entry) {
655
+ entry = { inFlight: 0 };
656
+ state.set(key, entry);
657
+ }
658
+
659
+ if (entry.inFlight >= config.maxConcurrent) {
660
+ const error = new Error(
661
+ `Bulkhead full: ${ctx.photon}.${ctx.tool} has ${entry.inFlight} concurrent executions (cap: ${config.maxConcurrent})`
662
+ );
663
+ error.name = 'PhotonBulkheadFullError';
664
+ throw error;
665
+ }
666
+
667
+ entry.inFlight++;
668
+ try {
669
+ return await next();
670
+ } finally {
671
+ entry.inFlight = Math.max(0, entry.inFlight - 1);
672
+ }
673
+ };
674
+ },
675
+ });
676
+
629
677
  // ═══════════════════════════════════════════════════════════════════════════════
630
678
  // GLOBAL BUILT-IN REGISTRY
631
679
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -634,6 +682,7 @@ export const builtinRegistry = new MiddlewareRegistry();
634
682
  builtinRegistry.register(fallbackMiddleware);
635
683
  builtinRegistry.register(loggedMiddleware);
636
684
  builtinRegistry.register(circuitBreakerMiddleware);
685
+ builtinRegistry.register(bulkheadMiddleware);
637
686
  builtinRegistry.register(throttledMiddleware);
638
687
  builtinRegistry.register(debouncedMiddleware);
639
688
  builtinRegistry.register(cachedMiddleware);
@@ -1180,6 +1180,18 @@ export class SchemaExtractor {
1180
1180
  declarations.push({ name: 'circuitBreaker', config: circuitBreaker, phase: def?.phase ?? 8 });
1181
1181
  }
1182
1182
 
1183
+ // @bulkhead
1184
+ const bulkheadMatch = jsdocContent.match(/@bulkhead(?:\s+(\d+))?/i);
1185
+ if (bulkheadMatch) {
1186
+ const maxConcurrent = Math.max(1, parseInt(bulkheadMatch[1] || '1', 10));
1187
+ const def = builtinRegistry.get('bulkhead');
1188
+ declarations.push({
1189
+ name: 'bulkhead',
1190
+ config: { maxConcurrent },
1191
+ phase: def?.phase ?? 15,
1192
+ });
1193
+ }
1194
+
1183
1195
  // @cached
1184
1196
  const cached = this.extractCached(jsdocContent);
1185
1197
  if (cached) {
package/src/validation.ts CHANGED
@@ -12,15 +12,27 @@
12
12
  // ERROR BASE CLASSES
13
13
  // ══════════════════════════════════════════════════════════════════════════════
14
14
 
15
+ export interface PhotonErrorOptions {
16
+ /** Root cause per ECMAScript Error `cause` proposal. Preserved on the error
17
+ * so OTel `recordException` can capture the original stack trace. */
18
+ cause?: unknown;
19
+ }
20
+
15
21
  export class PhotonError extends Error {
22
+ public readonly cause?: unknown;
23
+
16
24
  constructor(
17
25
  message: string,
18
26
  public readonly code: string,
19
27
  public readonly details?: Record<string, unknown>,
20
28
  public readonly suggestion?: string,
29
+ options?: PhotonErrorOptions,
21
30
  ) {
22
31
  super(message);
23
32
  this.name = 'PhotonError';
33
+ if (options?.cause !== undefined) {
34
+ this.cause = options.cause;
35
+ }
24
36
  Error.captureStackTrace?.(this, this.constructor);
25
37
  }
26
38
  }
@@ -30,8 +42,9 @@ export class ValidationError extends PhotonError {
30
42
  message: string,
31
43
  details?: Record<string, unknown>,
32
44
  suggestion?: string,
45
+ options?: PhotonErrorOptions,
33
46
  ) {
34
- super(message, 'VALIDATION_ERROR', details, suggestion);
47
+ super(message, 'VALIDATION_ERROR', details, suggestion, options);
35
48
  this.name = 'ValidationError';
36
49
  }
37
50
  }