@actiondock/testing 2.0.4

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/storage.ts ADDED
@@ -0,0 +1,601 @@
1
+ import {
2
+ type Clock,
3
+ createDefaultSqliteDriver,
4
+ getSystemClock,
5
+ type RuntimeStorage,
6
+ type SqliteDriver,
7
+ type StateEntry,
8
+ type TerminalRunStatus,
9
+ } from "@actiondock/core";
10
+ import type { RuntimeError, RunRecord } from "@actiondock/sdk";
11
+
12
+ /**
13
+ * 内存运行时存储初始化选项。
14
+ */
15
+ export interface MemoryStorageOptions {
16
+ /** 绑定的 Package 标识,默认为 test-pkg */
17
+ packageId?: string;
18
+ /** 可选注入的时间提供器,便于与模拟时钟联动 */
19
+ clock?: Clock;
20
+ /** 可选显式注入的底层 SQLite 驱动 */
21
+ driver?: SqliteDriver;
22
+ }
23
+
24
+ /**
25
+ * 统一内存运行时存储实现。
26
+ * 基于 SQLite 内存模式构建,确保与生产环境核心存储具备完全相同的配置优先级、状态过期契约与运行终态行为。
27
+ */
28
+ export class MemoryStorage implements RuntimeStorage {
29
+ private driver: SqliteDriver;
30
+ private packageId: string;
31
+ private clock: Clock;
32
+ private isClosed = false;
33
+
34
+ constructor(options: MemoryStorageOptions = {}) {
35
+ this.packageId = options.packageId || "test-pkg";
36
+ this.clock = options.clock || getSystemClock();
37
+ this.driver = options.driver || createDefaultSqliteDriver(":memory:");
38
+ this.init();
39
+ }
40
+
41
+ /**
42
+ * 初始化内存表结构与索引。
43
+ */
44
+ private init(): void {
45
+ this.driver.exec("PRAGMA journal_mode = MEMORY;");
46
+ this.driver.exec("PRAGMA synchronous = OFF;");
47
+
48
+ this.driver.transaction(() => {
49
+ this.driver.exec(`
50
+ CREATE TABLE IF NOT EXISTS config (
51
+ package_id TEXT NOT NULL,
52
+ key TEXT NOT NULL,
53
+ value_json TEXT,
54
+ updated_at TEXT NOT NULL,
55
+ PRIMARY KEY (package_id, key)
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS state (
59
+ package_id TEXT NOT NULL,
60
+ namespace TEXT NOT NULL,
61
+ key TEXT NOT NULL,
62
+ value_json TEXT,
63
+ updated_at TEXT NOT NULL,
64
+ expires_at TEXT,
65
+ PRIMARY KEY (package_id, namespace, key)
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS runs (
69
+ id TEXT PRIMARY KEY,
70
+ root_run_id TEXT NOT NULL,
71
+ parent_run_id TEXT,
72
+ package_id TEXT NOT NULL,
73
+ package_instance_id TEXT NOT NULL,
74
+ action_id TEXT NOT NULL,
75
+ generation_id TEXT NOT NULL,
76
+ owner_id TEXT NOT NULL,
77
+ status TEXT NOT NULL,
78
+ input_json TEXT,
79
+ output_json TEXT,
80
+ error_json TEXT,
81
+ started_at TEXT NOT NULL,
82
+ finished_at TEXT,
83
+ duration_ms INTEGER
84
+ );
85
+
86
+ CREATE INDEX IF NOT EXISTS idx_runs_action ON runs(package_id, action_id);
87
+ CREATE INDEX IF NOT EXISTS idx_runs_root ON runs(root_run_id);
88
+ CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC);
89
+ CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);
90
+ `);
91
+ });
92
+ }
93
+
94
+ // --- 配置管理 ---
95
+
96
+ getConfig<T = unknown>(key: string): T | undefined {
97
+ const stmt = this.driver.prepare(
98
+ "SELECT value_json FROM config WHERE package_id = ? AND key = ?"
99
+ );
100
+ const row = stmt.get<{ value_json: string }>(this.packageId, key);
101
+ if (!row || row.value_json === undefined || row.value_json === null) {
102
+ return undefined;
103
+ }
104
+ try {
105
+ return JSON.parse(row.value_json) as T;
106
+ } catch {
107
+ return row.value_json as unknown as T;
108
+ }
109
+ }
110
+
111
+ listConfig(): Record<string, unknown> {
112
+ const stmt = this.driver.prepare(
113
+ "SELECT key, value_json FROM config WHERE package_id = ?"
114
+ );
115
+ const rows = stmt.all<{ key: string; value_json: string }>(this.packageId);
116
+ const result: Record<string, unknown> = {};
117
+ for (const row of rows) {
118
+ try {
119
+ result[row.key] = JSON.parse(row.value_json);
120
+ } catch {
121
+ result[row.key] = row.value_json;
122
+ }
123
+ }
124
+ return result;
125
+ }
126
+
127
+ setConfig(key: string, value: unknown): void {
128
+ const stmt = this.driver.prepare(`
129
+ INSERT INTO config (package_id, key, value_json, updated_at)
130
+ VALUES (?, ?, ?, ?)
131
+ ON CONFLICT(package_id, key) DO UPDATE SET
132
+ value_json = excluded.value_json,
133
+ updated_at = excluded.updated_at
134
+ `);
135
+ const valJson = JSON.stringify(value);
136
+ const now = this.clock.now().toISOString();
137
+ stmt.run(this.packageId, key, valJson, now);
138
+ }
139
+
140
+ deleteConfig(key: string): boolean {
141
+ const stmt = this.driver.prepare(
142
+ "DELETE FROM config WHERE package_id = ? AND key = ?"
143
+ );
144
+ const res = stmt.run(this.packageId, key);
145
+ return res.changes > 0;
146
+ }
147
+
148
+ // --- 状态管理 ---
149
+
150
+ async getState<T = unknown>(
151
+ namespace: string,
152
+ key: string
153
+ ): Promise<T | undefined> {
154
+ const stmt = this.driver.prepare(
155
+ "SELECT value_json, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
156
+ );
157
+ const row = stmt.get<{ value_json: string; expires_at?: string }>(
158
+ this.packageId,
159
+ namespace,
160
+ key
161
+ );
162
+ if (!row || row.value_json === undefined || row.value_json === null) {
163
+ return undefined;
164
+ }
165
+
166
+ if (row.expires_at) {
167
+ const expires = new Date(row.expires_at).getTime();
168
+ const current = this.clock.now().getTime();
169
+ if (current >= expires) {
170
+ this.deleteState(namespace, key).catch(() => {});
171
+ return undefined;
172
+ }
173
+ }
174
+
175
+ try {
176
+ return JSON.parse(row.value_json) as T;
177
+ } catch {
178
+ return row.value_json as unknown as T;
179
+ }
180
+ }
181
+
182
+ async findState<T = unknown>(
183
+ targetKey: string,
184
+ namespace?: string
185
+ ): Promise<StateEntry | undefined> {
186
+ let ns = namespace;
187
+ let actualKey = targetKey;
188
+
189
+ if (!ns && targetKey.includes(":")) {
190
+ const parts = targetKey.split(":");
191
+ ns = parts[0];
192
+ actualKey = parts.slice(1).join(":");
193
+ }
194
+
195
+ if (ns) {
196
+ const val = await this.getState<T>(ns, actualKey);
197
+ if (val === undefined) return undefined;
198
+
199
+ const stmt = this.driver.prepare(
200
+ "SELECT updated_at, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
201
+ );
202
+ const row = stmt.get<{ updated_at: string; expires_at?: string }>(
203
+ this.packageId,
204
+ ns,
205
+ actualKey
206
+ );
207
+
208
+ return {
209
+ packageId: this.packageId,
210
+ namespace: ns,
211
+ key: actualKey,
212
+ fullKey: targetKey,
213
+ value: val,
214
+ updatedAt: row?.updated_at || this.clock.now().toISOString(),
215
+ expiresAt: row?.expires_at,
216
+ };
217
+ }
218
+
219
+ const val = await this.getState<T>("", actualKey);
220
+ if (val !== undefined) {
221
+ const stmt = this.driver.prepare(
222
+ "SELECT updated_at, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
223
+ );
224
+ const row = stmt.get<{ updated_at: string; expires_at?: string }>(
225
+ this.packageId,
226
+ "",
227
+ actualKey
228
+ );
229
+ return {
230
+ packageId: this.packageId,
231
+ namespace: "",
232
+ key: actualKey,
233
+ fullKey: actualKey,
234
+ value: val,
235
+ updatedAt: row?.updated_at || this.clock.now().toISOString(),
236
+ expiresAt: row?.expires_at,
237
+ };
238
+ }
239
+
240
+ const stmt = this.driver.prepare(
241
+ "SELECT namespace, key, value_json, updated_at, expires_at FROM state WHERE package_id = ? AND key = ?"
242
+ );
243
+ const rows = stmt.all<{
244
+ namespace: string;
245
+ key: string;
246
+ value_json: string;
247
+ updated_at: string;
248
+ expires_at?: string;
249
+ }>(this.packageId, actualKey);
250
+
251
+ const now = this.clock.now().getTime();
252
+ for (const row of rows) {
253
+ if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
254
+ continue;
255
+ }
256
+ let parsedVal: unknown;
257
+ try {
258
+ parsedVal = JSON.parse(row.value_json);
259
+ } catch {
260
+ parsedVal = row.value_json;
261
+ }
262
+ return {
263
+ packageId: this.packageId,
264
+ namespace: row.namespace,
265
+ key: row.key,
266
+ fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
267
+ value: parsedVal,
268
+ updatedAt: row.updated_at,
269
+ expiresAt: row.expires_at,
270
+ };
271
+ }
272
+
273
+ return undefined;
274
+ }
275
+
276
+ async setState<T = unknown>(
277
+ namespace: string,
278
+ key: string,
279
+ value: T,
280
+ ttl?: number
281
+ ): Promise<void> {
282
+ const stmt = this.driver.prepare(`
283
+ INSERT INTO state (package_id, namespace, key, value_json, updated_at, expires_at)
284
+ VALUES (?, ?, ?, ?, ?, ?)
285
+ ON CONFLICT(package_id, namespace, key) DO UPDATE SET
286
+ value_json = excluded.value_json,
287
+ updated_at = excluded.updated_at,
288
+ expires_at = excluded.expires_at
289
+ `);
290
+ const valJson = JSON.stringify(value);
291
+ const now = this.clock.now();
292
+ const updatedAt = now.toISOString();
293
+
294
+ let expiresAt: string | null = null;
295
+ if (typeof ttl === "number" && ttl > 0) {
296
+ expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
297
+ }
298
+
299
+ stmt.run(this.packageId, namespace, key, valJson, updatedAt, expiresAt);
300
+ }
301
+
302
+ async deleteState(namespace: string, key: string): Promise<boolean> {
303
+ const stmt = this.driver.prepare(
304
+ "DELETE FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
305
+ );
306
+ const res = stmt.run(this.packageId, namespace, key);
307
+ return res.changes > 0;
308
+ }
309
+
310
+ async deleteStateSmart(
311
+ targetKey: string,
312
+ namespace?: string
313
+ ): Promise<boolean> {
314
+ let ns = namespace;
315
+ let actualKey = targetKey;
316
+
317
+ if (!ns && targetKey.includes(":")) {
318
+ const parts = targetKey.split(":");
319
+ ns = parts[0];
320
+ actualKey = parts.slice(1).join(":");
321
+ }
322
+
323
+ if (ns !== undefined) {
324
+ return this.deleteState(ns, actualKey);
325
+ }
326
+
327
+ const deletedRoot = await this.deleteState("", actualKey);
328
+ if (deletedRoot) return true;
329
+
330
+ const stmt = this.driver.prepare(
331
+ "DELETE FROM state WHERE package_id = ? AND key = ?"
332
+ );
333
+ const res = stmt.run(this.packageId, actualKey);
334
+ return res.changes > 0;
335
+ }
336
+
337
+ async clearState(
338
+ options: { namespace?: string; all?: boolean; prefix?: string } = {}
339
+ ): Promise<number> {
340
+ let sql = "DELETE FROM state WHERE package_id = ?";
341
+ const params: unknown[] = [this.packageId];
342
+
343
+ if (options.namespace !== undefined) {
344
+ sql += " AND namespace = ?";
345
+ params.push(options.namespace);
346
+ }
347
+
348
+ if (options.prefix) {
349
+ sql += " AND key LIKE ? ESCAPE '\\'";
350
+ const escapedPrefix = options.prefix.replace(/([%_\\])/g, "\\$1");
351
+ params.push(`${escapedPrefix}%`);
352
+ }
353
+
354
+ const stmt = this.driver.prepare(sql);
355
+ const res = stmt.run(...params);
356
+ return res.changes;
357
+ }
358
+
359
+ async listStateKeys(
360
+ namespace?: string | null,
361
+ prefix?: string
362
+ ): Promise<string[]> {
363
+ let sql = "SELECT namespace, key, expires_at FROM state WHERE package_id = ?";
364
+ const params: unknown[] = [this.packageId];
365
+
366
+ if (namespace !== null && namespace !== undefined) {
367
+ sql += " AND namespace = ?";
368
+ params.push(namespace);
369
+ }
370
+
371
+ if (prefix) {
372
+ sql += " AND key LIKE ? ESCAPE '\\'";
373
+ const escapedPrefix = prefix.replace(/([%_\\])/g, "\\$1");
374
+ params.push(`${escapedPrefix}%`);
375
+ }
376
+
377
+ const stmt = this.driver.prepare(sql);
378
+ const rows = stmt.all<{
379
+ namespace: string;
380
+ key: string;
381
+ expires_at?: string;
382
+ }>(...params);
383
+
384
+ const now = this.clock.now().getTime();
385
+ const result: string[] = [];
386
+
387
+ for (const row of rows) {
388
+ if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
389
+ continue;
390
+ }
391
+ if (namespace !== null && namespace !== undefined) {
392
+ result.push(row.key);
393
+ } else {
394
+ result.push(row.namespace ? `${row.namespace}:${row.key}` : row.key);
395
+ }
396
+ }
397
+
398
+ return result;
399
+ }
400
+
401
+ async listStateEntries(
402
+ options: { namespace?: string; prefix?: string } = {}
403
+ ): Promise<StateEntry[]> {
404
+ let sql = "SELECT namespace, key, value_json, updated_at, expires_at FROM state WHERE package_id = ?";
405
+ const params: unknown[] = [this.packageId];
406
+
407
+ if (options.namespace !== undefined) {
408
+ sql += " AND namespace = ?";
409
+ params.push(options.namespace);
410
+ }
411
+
412
+ if (options.prefix) {
413
+ sql += " AND key LIKE ? ESCAPE '\\'";
414
+ const escapedPrefix = options.prefix.replace(/([%_\\])/g, "\\$1");
415
+ params.push(`${escapedPrefix}%`);
416
+ }
417
+
418
+ const stmt = this.driver.prepare(sql);
419
+ const rows = stmt.all<{
420
+ namespace: string;
421
+ key: string;
422
+ value_json: string;
423
+ updated_at: string;
424
+ expires_at?: string;
425
+ }>(...params);
426
+
427
+ const now = this.clock.now().getTime();
428
+ const results: StateEntry[] = [];
429
+
430
+ for (const row of rows) {
431
+ if (row.expires_at && now >= new Date(row.expires_at).getTime()) {
432
+ continue;
433
+ }
434
+
435
+ let parsedVal: unknown;
436
+ try {
437
+ parsedVal = JSON.parse(row.value_json);
438
+ } catch {
439
+ parsedVal = row.value_json;
440
+ }
441
+
442
+ results.push({
443
+ packageId: this.packageId,
444
+ namespace: row.namespace,
445
+ key: row.key,
446
+ fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
447
+ value: parsedVal,
448
+ updatedAt: row.updated_at,
449
+ expiresAt: row.expires_at,
450
+ });
451
+ }
452
+
453
+ return results;
454
+ }
455
+
456
+ // --- 运行记录管理 ---
457
+
458
+ createRun(record: RunRecord): void {
459
+ const stmt = this.driver.prepare(`
460
+ INSERT INTO runs (
461
+ id, root_run_id, parent_run_id, package_id, package_instance_id,
462
+ action_id, generation_id, owner_id, status, input_json, output_json,
463
+ error_json, started_at, finished_at, duration_ms
464
+ )
465
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
466
+ `);
467
+
468
+ const rootRunId = record.rootRunId || record.id;
469
+ const parentRunId = record.parentRunId || null;
470
+ const packageInstanceId = record.packageInstanceId || record.packageId || this.packageId;
471
+ const generationId = record.generationId || "1";
472
+ const ownerId = record.ownerId || "local";
473
+ const inputJson = record.input !== undefined ? JSON.stringify(record.input) : null;
474
+ const outputJson = record.output !== undefined ? JSON.stringify(record.output) : null;
475
+ const errorJson = record.error ? JSON.stringify(record.error) : null;
476
+ const finishedAt = record.finishedAt || null;
477
+ const durationMs = typeof record.durationMs === "number" ? record.durationMs : null;
478
+
479
+ stmt.run(
480
+ record.id,
481
+ rootRunId,
482
+ parentRunId,
483
+ record.packageId || this.packageId,
484
+ packageInstanceId,
485
+ record.actionId,
486
+ generationId,
487
+ ownerId,
488
+ record.status,
489
+ inputJson,
490
+ outputJson,
491
+ errorJson,
492
+ record.startedAt,
493
+ finishedAt,
494
+ durationMs
495
+ );
496
+ }
497
+
498
+ updateRun(
499
+ id: string,
500
+ status: TerminalRunStatus,
501
+ output?: unknown,
502
+ error?: RuntimeError,
503
+ finishedAt?: string
504
+ ): void {
505
+ if (this.isClosed) return;
506
+ try {
507
+ const stmt = this.driver.prepare(`
508
+ UPDATE runs
509
+ SET status = ?, output_json = ?, error_json = ?, finished_at = ?
510
+ WHERE id = ?
511
+ `);
512
+ stmt.run(
513
+ status,
514
+ output !== undefined ? JSON.stringify(output) : null,
515
+ error ? JSON.stringify(error) : null,
516
+ finishedAt || this.clock.now().toISOString(),
517
+ id
518
+ );
519
+ } catch {
520
+ // 存储连接已释放时忽略
521
+ }
522
+ }
523
+
524
+ getRun(id: string): RunRecord | null {
525
+ const stmt = this.driver.prepare(
526
+ "SELECT * FROM runs WHERE id = ? AND package_id = ?"
527
+ );
528
+ const row = stmt.get<Record<string, unknown>>(id, this.packageId);
529
+ if (!row) return null;
530
+ return this.mapRunRecord(row);
531
+ }
532
+
533
+ listRuns(options: { actionId?: string; limit?: number } = {}): RunRecord[] {
534
+ const limit = options.limit || 50;
535
+ let rows: Record<string, unknown>[];
536
+ if (options.actionId) {
537
+ const stmt = this.driver.prepare(`
538
+ SELECT * FROM runs
539
+ WHERE package_id = ? AND action_id = ?
540
+ ORDER BY started_at DESC
541
+ LIMIT ?
542
+ `);
543
+ rows = stmt.all<Record<string, unknown>>(this.packageId, options.actionId, limit);
544
+ } else {
545
+ const stmt = this.driver.prepare(`
546
+ SELECT * FROM runs
547
+ WHERE package_id = ?
548
+ ORDER BY started_at DESC
549
+ LIMIT ?
550
+ `);
551
+ rows = stmt.all<Record<string, unknown>>(this.packageId, limit);
552
+ }
553
+ return rows.map((r) => this.mapRunRecord(r));
554
+ }
555
+
556
+ clearRuns(options: { actionId?: string; status?: string } = {}): number {
557
+ let sql = "DELETE FROM runs WHERE package_id = ?";
558
+ const params: unknown[] = [this.packageId];
559
+
560
+ if (options.actionId) {
561
+ sql += " AND action_id = ?";
562
+ params.push(options.actionId);
563
+ }
564
+
565
+ if (options.status) {
566
+ sql += " AND status = ?";
567
+ params.push(options.status);
568
+ }
569
+
570
+ const stmt = this.driver.prepare(sql);
571
+ const res = stmt.run(...params);
572
+ return res.changes;
573
+ }
574
+
575
+ close(): void {
576
+ if (!this.isClosed) {
577
+ this.isClosed = true;
578
+ this.driver.close();
579
+ }
580
+ }
581
+
582
+ private mapRunRecord(row: Record<string, unknown>): RunRecord {
583
+ return {
584
+ id: String(row.id),
585
+ rootRunId: String(row.root_run_id || row.id),
586
+ parentRunId: row.parent_run_id ? String(row.parent_run_id) : undefined,
587
+ packageId: String(row.package_id),
588
+ packageInstanceId: String(row.package_instance_id || row.package_id),
589
+ actionId: String(row.action_id),
590
+ generationId: String(row.generation_id || "1"),
591
+ ownerId: String(row.owner_id || "local"),
592
+ status: row.status as RunRecord["status"],
593
+ input: row.input_json ? JSON.parse(String(row.input_json)) : undefined,
594
+ output: row.output_json ? JSON.parse(String(row.output_json)) : undefined,
595
+ error: row.error_json ? (JSON.parse(String(row.error_json)) as RuntimeError) : undefined,
596
+ startedAt: String(row.started_at),
597
+ finishedAt: row.finished_at ? String(row.finished_at) : undefined,
598
+ durationMs: typeof row.duration_ms === "number" ? row.duration_ms : undefined,
599
+ };
600
+ }
601
+ }