@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/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +37 -13
- package/dist/audit.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +77 -76
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +184 -189
- package/dist/memory.js.map +1 -1
- package/dist/validation.d.ts +8 -2
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +7 -6
- package/dist/validation.js.map +1 -1
- package/package.json +2 -2
- package/src/audit.ts +34 -14
- package/src/index.ts +5 -0
- package/src/memory.ts +240 -199
- package/src/validation.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portel/photon-core",
|
|
3
|
-
"version": "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
|
-
//
|
|
298
|
-
|
|
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
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
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(
|
|
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
|
|
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
|
|
449
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
44
|
-
* Uses
|
|
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
|
-
*
|
|
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
|
|
91
|
-
|
|
92
|
-
return path.join(dir, `${safeKey}.json`);
|
|
277
|
+
export function setDefaultMemoryBackend(backend: MemoryBackend): void {
|
|
278
|
+
defaultBackend = backend;
|
|
93
279
|
}
|
|
94
280
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
*
|
|
111
|
-
*
|
|
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
|
|
296
|
+
private _backend: MemoryBackend;
|
|
119
297
|
|
|
120
|
-
constructor(
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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.
|
|
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
|
}
|