@portel/photon-core 2.19.2 → 2.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portel/photon-core",
3
- "version": "2.19.2",
3
+ "version": "2.21.0",
4
4
  "description": "Core library for parsing, loading, and managing .photon.ts files - runtime-agnostic foundation for building custom Photon runtimes",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "build": "tsc",
34
34
  "clean": "rm -rf dist",
35
35
  "prepublishOnly": "npm run clean && npm run build",
36
- "test": "npm run build && npx tsx tests/mixin.test.ts && npx tsx tests/channels.test.ts && npx tsx tests/shared-utils.test.ts && npx tsx tests/collections.test.ts && npx tsx tests/audit.test.ts && npx tsx tests/memory.test.ts && npx tsx tests/instance-store.test.ts && npx tsx tests/watcher.test.ts",
36
+ "test": "npm run build && npx tsx tests/mixin.test.ts && npx tsx tests/channels.test.ts && npx tsx tests/shared-utils.test.ts && npx tsx tests/collections.test.ts && npx tsx tests/audit.test.ts && npx tsx tests/memory.test.ts && npx tsx tests/instance-store.test.ts && npx tsx tests/watcher.test.ts && npx tsx tests/photon-error.test.ts",
37
37
  "test:channels": "npx tsx tests/channels.test.ts",
38
38
  "test:mixin": "npm run build && npx tsx tests/mixin.test.ts"
39
39
  },
package/src/audit.ts CHANGED
@@ -294,18 +294,31 @@ export class AuditTrail {
294
294
  if (!fs.existsSync(dataRoot)) return results;
295
295
 
296
296
  try {
297
- // Scan .data/{ns}/{photon}/logs/executions.jsonl
298
- const nsDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
297
+ // Local-namespace photons are flattened at dataRoot:
298
+ // .data/{photon}/logs/executions.jsonl
299
+ // Marketplace-namespaced photons nest one level:
300
+ // .data/{ns}/{photon}/logs/executions.jsonl
301
+ const topDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
299
302
  .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'));
300
303
 
301
- for (const nsDir of nsDirs) {
302
- const nsPath = path.join(dataRoot, nsDir.name);
303
- const photonDirs = fs.readdirSync(nsPath, { withFileTypes: true })
304
- .filter(e => e.isDirectory());
305
-
304
+ for (const dir of topDirs) {
305
+ const topPath = path.join(dataRoot, dir.name);
306
+ // Flat layout: this entry IS a photon if it has logs/executions.jsonl directly.
307
+ if (fs.existsSync(path.join(topPath, 'logs', 'executions.jsonl'))) {
308
+ if (!results.includes(dir.name)) results.push(dir.name);
309
+ continue;
310
+ }
311
+ // Otherwise treat as namespace and scan one level deeper.
312
+ let photonDirs: fs.Dirent[] = [];
313
+ try {
314
+ photonDirs = fs.readdirSync(topPath, { withFileTypes: true })
315
+ .filter(e => e.isDirectory());
316
+ } catch {
317
+ continue;
318
+ }
306
319
  for (const pDir of photonDirs) {
307
- if (fs.existsSync(path.join(nsPath, pDir.name, 'logs', 'executions.jsonl'))) {
308
- results.push(pDir.name);
320
+ if (fs.existsSync(path.join(topPath, pDir.name, 'logs', 'executions.jsonl'))) {
321
+ if (!results.includes(pDir.name)) results.push(pDir.name);
309
322
  }
310
323
  }
311
324
  }
@@ -442,16 +455,23 @@ export class AuditTrail {
442
455
 
443
456
  try {
444
457
  if (fs.existsSync(dataRoot)) {
445
- const nsDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
458
+ const topDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
446
459
  .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'));
447
460
 
448
- for (const nsDir of nsDirs) {
449
- const nsPath = path.join(dataRoot, nsDir.name);
461
+ for (const dir of topDirs) {
462
+ const topPath = path.join(dataRoot, dir.name);
463
+ // Flat local-namespace layout: .data/{photon}/logs/executions.jsonl
464
+ const flatLog = path.join(topPath, 'logs', 'executions.jsonl');
465
+ if (fs.existsSync(flatLog)) {
466
+ paths.push(flatLog);
467
+ continue;
468
+ }
469
+ // Otherwise treat as namespace and scan one level deeper.
450
470
  try {
451
- const photonDirs = fs.readdirSync(nsPath, { withFileTypes: true })
471
+ const photonDirs = fs.readdirSync(topPath, { withFileTypes: true })
452
472
  .filter(e => e.isDirectory());
453
473
  for (const pDir of photonDirs) {
454
- const logPath = path.join(nsPath, pDir.name, 'logs', 'executions.jsonl');
474
+ const logPath = path.join(topPath, pDir.name, 'logs', 'executions.jsonl');
455
475
  if (fs.existsSync(logPath)) paths.push(logPath);
456
476
  }
457
477
  } catch { /* skip unreadable ns dir */ }
package/src/index.ts CHANGED
@@ -443,8 +443,13 @@ export {
443
443
 
444
444
  // ===== SCOPED MEMORY =====
445
445
  // Framework-level key-value storage (this.memory on Photon base class)
446
+ // MemoryBackend is the pluggable interface; FileMemoryBackend is the default.
446
447
  export {
447
448
  MemoryProvider,
449
+ FileMemoryBackend,
450
+ setDefaultMemoryBackend,
451
+ getDefaultMemoryBackend,
452
+ type MemoryBackend,
448
453
  type MemoryScope,
449
454
  } from './memory.js';
450
455
 
package/src/memory.ts CHANGED
@@ -4,24 +4,16 @@
4
4
  * Framework-level key-value storage for photons that eliminates
5
5
  * boilerplate file I/O. Available as `this.memory` on Photon.
6
6
  *
7
- * Three scopes:
8
- * | Scope | Meaning | Storage |
9
- * |----------|----------------------------------|-----------------------------------------------|
10
- * | photon | Private to this photon (default) | .data/{namespace}/{photonName}/memory/ |
11
- * | session | Per-user session (Beam sessions) | .data/_sessions/{sessionId}/{ns}/{photon}/ |
12
- * | global | Shared across all photons | .data/_global/ |
7
+ * Architecture: MemoryProvider delegates to a pluggable MemoryBackend.
8
+ * The default backend is FileMemoryBackend (JSON files on disk).
9
+ * Enterprise deployments can swap in Redis, Postgres, or SQLite.
13
10
  *
14
- * @example
15
- * ```typescript
16
- * export default class TodoList extends Photon {
17
- * async add({ text }: { text: string }) {
18
- * const items = await this.memory.get<Task[]>('items') ?? [];
19
- * items.push({ id: crypto.randomUUID(), text });
20
- * await this.memory.set('items', items);
21
- * return items;
22
- * }
23
- * }
24
- * ```
11
+ * Three scopes:
12
+ * | Scope | Meaning |
13
+ * |----------|----------------------------------|
14
+ * | photon | Private to this photon (default) |
15
+ * | session | Per-user session (Beam sessions) |
16
+ * | global | Shared across all photons |
25
17
  */
26
18
 
27
19
  import * as fs from 'fs/promises';
@@ -39,10 +31,191 @@ import {
39
31
 
40
32
  export type MemoryScope = 'photon' | 'session' | 'global';
41
33
 
34
+ // ════════════════════════════════════════════════════════════════════════════════
35
+ // BACKEND INTERFACE
36
+ // ════════════════════════════════════════════════════════════════════════════════
37
+
38
+ /**
39
+ * Pluggable storage backend for MemoryProvider.
40
+ *
41
+ * Implementations handle the actual persistence. All methods receive
42
+ * a resolved namespace (scope + photonId + sessionId already baked in)
43
+ * so the backend doesn't need to know about scoping rules.
44
+ */
45
+ export interface MemoryBackend {
46
+ get(namespace: string, key: string): Promise<any | null>;
47
+ set(namespace: string, key: string, value: any): Promise<void>;
48
+ delete(namespace: string, key: string): Promise<boolean>;
49
+ has(namespace: string, key: string): Promise<boolean>;
50
+ keys(namespace: string): Promise<string[]>;
51
+ clear(namespace: string): Promise<void>;
52
+ /**
53
+ * Atomic read-modify-write. Backends with native transactions (Redis WATCH,
54
+ * Postgres FOR UPDATE) should use them here. The default file backend
55
+ * uses a per-key promise chain.
56
+ */
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 }>>;
63
+ }
64
+
65
+ // ════════════════════════════════════════════════════════════════════════════════
66
+ // FILE BACKEND (default)
67
+ // ════════════════════════════════════════════════════════════════════════════════
68
+
69
+ function keyPath(dir: string, key: string): string {
70
+ const safeKey = key.replace(/[^a-zA-Z0-9_.-]/g, '_');
71
+ return path.join(dir, `${safeKey}.json`);
72
+ }
73
+
74
+ async function pathExists(p: string): Promise<boolean> {
75
+ try {
76
+ await fs.access(p);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
42
83
  /**
43
- * Resolve storage directory for a given scope.
44
- * Uses new .data/ paths with fallback to legacy locations.
84
+ * File-based memory backend. Each key is a JSON file on disk.
85
+ * Uses per-key promise chains and temp+rename for safe concurrent access.
45
86
  */
87
+ export class FileMemoryBackend implements MemoryBackend {
88
+ private _locks = new Map<string, Promise<void>>();
89
+
90
+ private async withLock<T>(namespace: string, key: string, fn: () => Promise<T>): Promise<T> {
91
+ const lockKey = `${namespace}:${key}`;
92
+ const prev = this._locks.get(lockKey) ?? Promise.resolve();
93
+ let resolve!: () => void;
94
+ const next = new Promise<void>(r => { resolve = r; });
95
+ this._locks.set(lockKey, next);
96
+ try {
97
+ await prev;
98
+ return await fn();
99
+ } finally {
100
+ resolve();
101
+ if (this._locks.get(lockKey) === next) {
102
+ this._locks.delete(lockKey);
103
+ }
104
+ }
105
+ }
106
+
107
+ async get(namespace: string, key: string): Promise<any | null> {
108
+ return this.withLock(namespace, key, async () => {
109
+ const filePath = keyPath(namespace, key);
110
+ try {
111
+ const content = await fs.readFile(filePath, 'utf-8');
112
+ return JSON.parse(content);
113
+ } catch (error: any) {
114
+ if (error.code === 'ENOENT') return null;
115
+ throw error;
116
+ }
117
+ });
118
+ }
119
+
120
+ async set(namespace: string, key: string, value: any): Promise<void> {
121
+ return this.withLock(namespace, key, async () => {
122
+ if (!await pathExists(namespace)) {
123
+ await fs.mkdir(namespace, { recursive: true });
124
+ }
125
+ const filePath = keyPath(namespace, key);
126
+ const tmpPath = filePath + '.tmp';
127
+ await fs.writeFile(tmpPath, JSON.stringify(value, null, 2));
128
+ await fs.rename(tmpPath, filePath);
129
+ });
130
+ }
131
+
132
+ async delete(namespace: string, key: string): Promise<boolean> {
133
+ return this.withLock(namespace, key, async () => {
134
+ const filePath = keyPath(namespace, key);
135
+ try {
136
+ await fs.unlink(filePath);
137
+ return true;
138
+ } catch (error: any) {
139
+ if (error.code === 'ENOENT') return false;
140
+ throw error;
141
+ }
142
+ });
143
+ }
144
+
145
+ async has(namespace: string, key: string): Promise<boolean> {
146
+ return pathExists(keyPath(namespace, key));
147
+ }
148
+
149
+ async keys(namespace: string): Promise<string[]> {
150
+ try {
151
+ const files = await fs.readdir(namespace);
152
+ return files.filter(f => f.endsWith('.json') && !f.endsWith('.tmp')).map(f => f.slice(0, -5));
153
+ } catch (error: any) {
154
+ if (error.code === 'ENOENT') return [];
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ async clear(namespace: string): Promise<void> {
160
+ try {
161
+ const files = await fs.readdir(namespace);
162
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
163
+ await Promise.all(jsonFiles.map(file => fs.unlink(path.join(namespace, file))));
164
+ } catch (error: any) {
165
+ if (error.code === 'ENOENT') return;
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async update(namespace: string, key: string, updater: (current: any | null) => any): Promise<any> {
171
+ return this.withLock(namespace, key, async () => {
172
+ const filePath = keyPath(namespace, key);
173
+
174
+ let current: any = null;
175
+ try {
176
+ const content = await fs.readFile(filePath, 'utf-8');
177
+ current = JSON.parse(content);
178
+ } catch (error: any) {
179
+ if (error.code !== 'ENOENT') throw error;
180
+ }
181
+
182
+ const updated = updater(current);
183
+
184
+ if (!await pathExists(namespace)) {
185
+ await fs.mkdir(namespace, { recursive: true });
186
+ }
187
+ const tmpPath = filePath + '.tmp';
188
+ await fs.writeFile(tmpPath, JSON.stringify(updated, null, 2));
189
+ await fs.rename(tmpPath, filePath);
190
+ return updated;
191
+ });
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
+ }
213
+ }
214
+
215
+ // ════════════════════════════════════════════════════════════════════════════════
216
+ // SCOPE RESOLUTION
217
+ // ════════════════════════════════════════════════════════════════════════════════
218
+
46
219
  function resolveDir(
47
220
  photonId: string,
48
221
  namespace: string,
@@ -53,7 +226,6 @@ function resolveDir(
53
226
  switch (scope) {
54
227
  case 'photon': {
55
228
  const newDir = getPhotonMemoryDir(namespace, photonId, baseDir);
56
- // Fallback: check legacy path if new path has no data yet
57
229
  if (!fsSync.existsSync(newDir)) {
58
230
  const legacyDir = getLegacyMemoryDir(photonId, baseDir);
59
231
  if (fsSync.existsSync(legacyDir)) return legacyDir;
@@ -84,70 +256,59 @@ function resolveDir(
84
256
  }
85
257
  }
86
258
 
259
+ // ════════════════════════════════════════════════════════════════════════════════
260
+ // MEMORY PROVIDER (public API — delegates to backend)
261
+ // ════════════════════════════════════════════════════════════════════════════════
262
+
263
+ /** Default shared backend instance (file-based) */
264
+ let defaultBackend: MemoryBackend = new FileMemoryBackend();
265
+
87
266
  /**
88
- * Get the file path for a key within a directory
267
+ * Set the global default memory backend.
268
+ * Call before any photons are loaded to switch storage layer.
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * import { setDefaultMemoryBackend } from '@portel/photon-core';
273
+ * import { RedisMemoryBackend } from '@portel/photon-redis';
274
+ * setDefaultMemoryBackend(new RedisMemoryBackend({ url: 'redis://...' }));
275
+ * ```
89
276
  */
90
- function keyPath(dir: string, key: string): string {
91
- const safeKey = key.replace(/[^a-zA-Z0-9_.-]/g, '_');
92
- return path.join(dir, `${safeKey}.json`);
277
+ export function setDefaultMemoryBackend(backend: MemoryBackend): void {
278
+ defaultBackend = backend;
93
279
  }
94
280
 
95
- /**
96
- * Check if a path exists (async)
97
- */
98
- async function pathExists(p: string): Promise<boolean> {
99
- try {
100
- await fs.access(p);
101
- return true;
102
- } catch {
103
- return false;
104
- }
281
+ export function getDefaultMemoryBackend(): MemoryBackend {
282
+ return defaultBackend;
105
283
  }
106
284
 
107
285
  /**
108
286
  * Scoped Memory Provider
109
287
  *
110
- * Provides key-value storage with automatic JSON serialization.
111
- * Each key is stored as a separate file for atomic operations.
288
+ * The public API surface for `this.memory` on photon instances.
289
+ * Delegates all operations to the configured MemoryBackend.
112
290
  */
113
291
  export class MemoryProvider {
114
292
  private _photonId: string;
115
293
  private _namespace: string;
116
294
  private _sessionId?: string;
117
295
  private _baseDir?: string;
118
- private _locks = new Map<string, Promise<void>>();
296
+ private _backend: MemoryBackend;
119
297
 
120
- constructor(photonId: string, sessionId?: string, namespace?: string, baseDir?: string) {
298
+ constructor(
299
+ photonId: string,
300
+ sessionId?: string,
301
+ namespace?: string,
302
+ baseDir?: string,
303
+ backend?: MemoryBackend
304
+ ) {
121
305
  this._photonId = photonId;
122
306
  this._namespace = namespace || 'local';
123
307
  this._sessionId = sessionId;
124
308
  this._baseDir = baseDir;
309
+ this._backend = backend ?? defaultBackend;
125
310
  }
126
311
 
127
- /**
128
- * Serialize file operations per key to prevent concurrent write corruption.
129
- * Reads also go through the lock to avoid reading a partially-written file.
130
- */
131
- private async withKeyLock<T>(key: string, scope: MemoryScope, fn: () => Promise<T>): Promise<T> {
132
- const lockKey = `${scope}:${key}`;
133
- const prev = this._locks.get(lockKey) ?? Promise.resolve();
134
- let resolve!: () => void;
135
- const next = new Promise<void>(r => { resolve = r; });
136
- this._locks.set(lockKey, next);
137
- try {
138
- await prev;
139
- return await fn();
140
- } finally {
141
- resolve();
142
- if (this._locks.get(lockKey) === next) {
143
- this._locks.delete(lockKey);
144
- }
145
- }
146
- }
147
-
148
- /**
149
- * Current session ID (can be updated by the runtime)
150
- */
151
312
  get sessionId(): string | undefined {
152
313
  return this._sessionId;
153
314
  }
@@ -156,174 +317,54 @@ export class MemoryProvider {
156
317
  this._sessionId = id;
157
318
  }
158
319
 
159
- /**
160
- * Get a value from memory
161
- *
162
- * @param key The key to retrieve
163
- * @param scope Storage scope (default: 'photon')
164
- * @returns The stored value, or null if not found
165
- */
166
- async get<T = any>(key: string, scope: MemoryScope = 'photon'): Promise<T | null> {
167
- return this.withKeyLock(key, scope, async () => {
168
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
169
- const filePath = keyPath(dir, key);
320
+ /** Resolve the storage namespace (directory for file backend, prefix for Redis, etc.) */
321
+ private ns(scope: MemoryScope): string {
322
+ return resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
323
+ }
170
324
 
171
- try {
172
- const content = await fs.readFile(filePath, 'utf-8');
173
- return JSON.parse(content) as T;
174
- } catch (error: any) {
175
- if (error.code === 'ENOENT') return null;
176
- throw error;
177
- }
178
- });
325
+ async get<T = any>(key: string, scope: MemoryScope = 'photon'): Promise<T | null> {
326
+ return this._backend.get(this.ns(scope), key);
179
327
  }
180
328
 
181
- /**
182
- * Set a value in memory
183
- *
184
- * @param key The key to store
185
- * @param value The value (must be JSON-serializable)
186
- * @param scope Storage scope (default: 'photon')
187
- */
188
329
  async set<T = any>(key: string, value: T, scope: MemoryScope = 'photon'): Promise<void> {
189
- return this.withKeyLock(key, scope, async () => {
190
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
191
-
192
- if (!await pathExists(dir)) {
193
- await fs.mkdir(dir, { recursive: true });
194
- }
195
-
196
- const filePath = keyPath(dir, key);
197
- // Write to temp file then rename for atomic replacement
198
- const tmpPath = filePath + '.tmp';
199
- await fs.writeFile(tmpPath, JSON.stringify(value, null, 2));
200
- await fs.rename(tmpPath, filePath);
201
- });
330
+ return this._backend.set(this.ns(scope), key, value);
202
331
  }
203
332
 
204
- /**
205
- * Delete a key from memory
206
- *
207
- * @param key The key to delete
208
- * @param scope Storage scope (default: 'photon')
209
- * @returns true if the key existed and was deleted
210
- */
211
333
  async delete(key: string, scope: MemoryScope = 'photon'): Promise<boolean> {
212
- return this.withKeyLock(key, scope, async () => {
213
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
214
- const filePath = keyPath(dir, key);
215
-
216
- try {
217
- await fs.unlink(filePath);
218
- return true;
219
- } catch (error: any) {
220
- if (error.code === 'ENOENT') return false;
221
- throw error;
222
- }
223
- });
334
+ return this._backend.delete(this.ns(scope), key);
224
335
  }
225
336
 
226
- /**
227
- * Check if a key exists in memory
228
- *
229
- * @param key The key to check
230
- * @param scope Storage scope (default: 'photon')
231
- */
232
337
  async has(key: string, scope: MemoryScope = 'photon'): Promise<boolean> {
233
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
234
- return pathExists(keyPath(dir, key));
338
+ return this._backend.has(this.ns(scope), key);
235
339
  }
236
340
 
237
- /**
238
- * List all keys in memory for a scope
239
- *
240
- * @param scope Storage scope (default: 'photon')
241
- */
242
341
  async keys(scope: MemoryScope = 'photon'): Promise<string[]> {
243
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
244
-
245
- try {
246
- const files = await fs.readdir(dir);
247
- return files
248
- .filter(f => f.endsWith('.json'))
249
- .map(f => f.slice(0, -5));
250
- } catch (error: any) {
251
- if (error.code === 'ENOENT') return [];
252
- throw error;
253
- }
342
+ return this._backend.keys(this.ns(scope));
254
343
  }
255
344
 
256
- /**
257
- * Clear all keys in a scope
258
- *
259
- * @param scope Storage scope (default: 'photon')
260
- */
261
345
  async clear(scope: MemoryScope = 'photon'): Promise<void> {
262
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
263
-
264
- try {
265
- const files = await fs.readdir(dir);
266
- const jsonFiles = files.filter(f => f.endsWith('.json'));
267
- await Promise.all(jsonFiles.map(file => fs.unlink(path.join(dir, file))));
268
- } catch (error: any) {
269
- if (error.code === 'ENOENT') return;
270
- throw error;
271
- }
346
+ return this._backend.clear(this.ns(scope));
272
347
  }
273
348
 
274
- /**
275
- * Get all key-value pairs in a scope
276
- *
277
- * @param scope Storage scope (default: 'photon')
278
- */
279
349
  async getAll<T = any>(scope: MemoryScope = 'photon'): Promise<Record<string, T>> {
280
350
  const allKeys = await this.keys(scope);
281
351
  const result: Record<string, T> = {};
282
-
283
352
  for (const key of allKeys) {
284
353
  const value = await this.get<T>(key, scope);
285
- if (value !== null) {
286
- result[key] = value;
287
- }
354
+ if (value !== null) result[key] = value;
288
355
  }
289
-
290
356
  return result;
291
357
  }
292
358
 
293
- /**
294
- * Atomic read-modify-write for a key.
295
- * Serialized per key so concurrent updates don't corrupt data.
296
- *
297
- * @param key The key to update
298
- * @param updater Function that receives current value and returns new value
299
- * @param scope Storage scope (default: 'photon')
300
- */
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
+
301
363
  async update<T = any>(
302
364
  key: string,
303
365
  updater: (current: T | null) => T,
304
366
  scope: MemoryScope = 'photon'
305
367
  ): Promise<T> {
306
- return this.withKeyLock(key, scope, async () => {
307
- const dir = resolveDir(this._photonId, this._namespace, scope, this._sessionId, this._baseDir);
308
- const filePath = keyPath(dir, key);
309
-
310
- let current: T | null = null;
311
- try {
312
- const content = await fs.readFile(filePath, 'utf-8');
313
- current = JSON.parse(content) as T;
314
- } catch (error: any) {
315
- if (error.code !== 'ENOENT') throw error;
316
- }
317
-
318
- const updated = updater(current);
319
-
320
- if (!await pathExists(dir)) {
321
- await fs.mkdir(dir, { recursive: true });
322
- }
323
- const tmpPath = filePath + '.tmp';
324
- await fs.writeFile(tmpPath, JSON.stringify(updated, null, 2));
325
- await fs.rename(tmpPath, filePath);
326
- return updated;
327
- });
368
+ return this._backend.update(this.ns(scope), key, updater);
328
369
  }
329
370
  }
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
  }