@lokvis/runtime 0.1.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 (61) hide show
  1. package/dist/asset-store.d.ts +72 -0
  2. package/dist/asset-store.d.ts.map +1 -0
  3. package/dist/asset-store.js +167 -0
  4. package/dist/asset-store.js.map +1 -0
  5. package/dist/capability-registry.d.ts +38 -0
  6. package/dist/capability-registry.d.ts.map +1 -0
  7. package/dist/capability-registry.js +100 -0
  8. package/dist/capability-registry.js.map +1 -0
  9. package/dist/event-bus.d.ts +9 -0
  10. package/dist/event-bus.d.ts.map +1 -0
  11. package/dist/event-bus.js +33 -0
  12. package/dist/event-bus.js.map +1 -0
  13. package/dist/executor.d.ts +42 -0
  14. package/dist/executor.d.ts.map +1 -0
  15. package/dist/executor.js +237 -0
  16. package/dist/executor.js.map +1 -0
  17. package/dist/history.d.ts +92 -0
  18. package/dist/history.d.ts.map +1 -0
  19. package/dist/history.js +171 -0
  20. package/dist/history.js.map +1 -0
  21. package/dist/idb-asset-store.d.ts +46 -0
  22. package/dist/idb-asset-store.d.ts.map +1 -0
  23. package/dist/idb-asset-store.js +76 -0
  24. package/dist/idb-asset-store.js.map +1 -0
  25. package/dist/index.d.ts +19 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +19 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/opfs-asset-store.d.ts +38 -0
  30. package/dist/opfs-asset-store.d.ts.map +1 -0
  31. package/dist/opfs-asset-store.js +122 -0
  32. package/dist/opfs-asset-store.js.map +1 -0
  33. package/dist/runtime.d.ts +87 -0
  34. package/dist/runtime.d.ts.map +1 -0
  35. package/dist/runtime.js +475 -0
  36. package/dist/runtime.js.map +1 -0
  37. package/dist/types.d.ts +99 -0
  38. package/dist/types.d.ts.map +1 -0
  39. package/dist/types.js +8 -0
  40. package/dist/types.js.map +1 -0
  41. package/dist/worker-host.d.ts +154 -0
  42. package/dist/worker-host.d.ts.map +1 -0
  43. package/dist/worker-host.js +394 -0
  44. package/dist/worker-host.js.map +1 -0
  45. package/dist/worker-protocol.d.ts +103 -0
  46. package/dist/worker-protocol.d.ts.map +1 -0
  47. package/dist/worker-protocol.js +87 -0
  48. package/dist/worker-protocol.js.map +1 -0
  49. package/package.json +49 -0
  50. package/src/asset-store.ts +222 -0
  51. package/src/capability-registry.ts +135 -0
  52. package/src/event-bus.ts +41 -0
  53. package/src/executor.ts +303 -0
  54. package/src/history.ts +229 -0
  55. package/src/idb-asset-store.ts +121 -0
  56. package/src/index.ts +20 -0
  57. package/src/opfs-asset-store.ts +167 -0
  58. package/src/runtime.ts +564 -0
  59. package/src/types.ts +121 -0
  60. package/src/worker-host.ts +531 -0
  61. package/src/worker-protocol.ts +187 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Lokvis Workflow Executor
3
+ *
4
+ * 第一年只支持 Linear Workflow(线性链)。
5
+ * 不支持:Branch / Loop / Condition / Parallel。
6
+ */
7
+
8
+ import type {
9
+ Asset,
10
+ AssetId,
11
+ ExecutionContext,
12
+ Workflow,
13
+ WorkflowEdge,
14
+ WorkflowNode,
15
+ WorkflowResult,
16
+ } from '@lokvis/schema';
17
+ import type { EventBus } from '@lokvis/schema';
18
+ import type { AssetStore } from './asset-store.js';
19
+ import type { CapabilityRegistry } from './capability-registry.js';
20
+
21
+ /** 执行中的工作流状态 */
22
+ interface RunningWorkflow {
23
+ id: string;
24
+ workflow: Workflow;
25
+ status: 'running' | 'paused' | 'cancelled';
26
+ abortController: AbortController;
27
+ currentNodeId?: string;
28
+ }
29
+
30
+ /** Workflow 执行器配置 */
31
+ export interface ExecutorConfig {
32
+ assetStore: AssetStore;
33
+ capabilityRegistry: CapabilityRegistry;
34
+ eventBus: EventBus;
35
+ enableLog: boolean;
36
+ }
37
+
38
+ /** 创建执行上下文 */
39
+ function createExecutionContext(
40
+ workflowId: string,
41
+ nodeId: string,
42
+ signal: AbortSignal,
43
+ onProgress?: (progress: number, message?: string) => void
44
+ ): ExecutionContext {
45
+ return {
46
+ workflowId,
47
+ nodeId,
48
+ signal,
49
+ onProgress,
50
+ log: (level, message) => {
51
+ // 日志输出,可被 Analytics 监听
52
+ if (level === 'error') {
53
+ console.error(`[lokvis:${workflowId}:${nodeId}] ${message}`);
54
+ } else if (level === 'warn') {
55
+ console.warn(`[lokvis:${workflowId}:${nodeId}] ${message}`);
56
+ }
57
+ },
58
+ };
59
+ }
60
+
61
+ /** 拓扑排序:将 edges 转换为线性节点序列 */
62
+ function topologicalSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] {
63
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
64
+ const inDegree = new Map<string, number>();
65
+ const adjacency = new Map<string, string[]>();
66
+
67
+ for (const node of nodes) {
68
+ inDegree.set(node.id, 0);
69
+ adjacency.set(node.id, []);
70
+ }
71
+
72
+ for (const edge of edges) {
73
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
74
+ adjacency.get(edge.from)?.push(edge.to);
75
+ }
76
+
77
+ const queue: string[] = [];
78
+ for (const [id, deg] of inDegree) {
79
+ if (deg === 0) queue.push(id);
80
+ }
81
+
82
+ const sorted: WorkflowNode[] = [];
83
+ while (queue.length > 0) {
84
+ const id = queue.shift()!;
85
+ const node = nodeMap.get(id);
86
+ if (node) sorted.push(node);
87
+
88
+ for (const next of adjacency.get(id) ?? []) {
89
+ const newDeg = (inDegree.get(next) ?? 0) - 1;
90
+ inDegree.set(next, newDeg);
91
+ if (newDeg === 0) queue.push(next);
92
+ }
93
+ }
94
+
95
+ if (sorted.length !== nodes.length) {
96
+ throw new Error('Workflow contains a cycle, cannot execute');
97
+ }
98
+
99
+ return sorted;
100
+ }
101
+
102
+ /** Workflow 执行器 */
103
+ export class WorkflowExecutor {
104
+ private config: ExecutorConfig;
105
+ private running = new Map<string, RunningWorkflow>();
106
+ /** 暂停时挂起的 resolve:resume/cancel 唤醒之,避免轮询 */
107
+ private resumeResolvers = new Map<string, () => void>();
108
+
109
+ constructor(config: ExecutorConfig) {
110
+ this.config = config;
111
+ }
112
+
113
+ /** 执行工作流 */
114
+ async execute(
115
+ workflow: Workflow,
116
+ inputs: AssetId[] | Asset[]
117
+ ): Promise<WorkflowResult> {
118
+ const workflowId = workflow.id;
119
+ const abortController = new AbortController();
120
+ const startTime = Date.now();
121
+
122
+ // 解析输入 Asset
123
+ let currentAssets: Asset[];
124
+ if (inputs.length === 0 || typeof inputs[0] === 'string') {
125
+ // AssetId[] - 从 store 加载
126
+ const ids = inputs as AssetId[];
127
+ currentAssets = await Promise.all(
128
+ ids.map(async (id) => {
129
+ const asset = await this.config.assetStore.get(id);
130
+ if (!asset) throw new Error(`Asset not found: ${id}`);
131
+ return asset;
132
+ })
133
+ );
134
+ } else {
135
+ // Asset[]
136
+ currentAssets = inputs as Asset[];
137
+ }
138
+
139
+ // 注册运行状态
140
+ const state: RunningWorkflow = {
141
+ id: workflowId,
142
+ workflow,
143
+ status: 'running',
144
+ abortController,
145
+ };
146
+ this.running.set(workflowId, state);
147
+
148
+ this.config.eventBus.emit({
149
+ type: 'workflow:started',
150
+ workflowId,
151
+ workflow,
152
+ });
153
+
154
+ try {
155
+ // 拓扑排序节点
156
+ const sortedNodes = topologicalSort(workflow.nodes, workflow.edges);
157
+
158
+ // 过滤掉 load 和 export 节点(load 由输入处理,export 由结果处理)
159
+ const transformNodes = sortedNodes.filter((n) => n.type === 'transform');
160
+
161
+ // 依次执行每个 transform 节点
162
+ for (const node of transformNodes) {
163
+ // 暂停则挂起,等待 resume/cancel 唤醒(Promise resolver,无轮询)
164
+ if (state.status === 'paused') {
165
+ await this.waitForResume(workflowId);
166
+ }
167
+ // 唤醒后或每轮起始,若已被取消立即跳出(避免执行多余节点)
168
+ if (state.status === 'cancelled') {
169
+ break;
170
+ }
171
+
172
+ state.currentNodeId = node.id;
173
+ const nodeStart = Date.now();
174
+
175
+ this.config.eventBus.emit({
176
+ type: 'node:started',
177
+ workflowId,
178
+ nodeId: node.id,
179
+ inputs: currentAssets,
180
+ });
181
+
182
+ // 解析能力实现(transform 节点必须有 capability)
183
+ const capability = node.capability;
184
+ if (!capability) {
185
+ throw new Error(`Transform node "${node.id}" has no capability`);
186
+ }
187
+ const impl = this.config.capabilityRegistry.resolve(capability);
188
+ if (!impl) {
189
+ throw new Error(`No implementation registered for capability "${capability}"`);
190
+ }
191
+
192
+ // 执行能力
193
+ const ctx = createExecutionContext(
194
+ workflowId,
195
+ node.id,
196
+ abortController.signal
197
+ );
198
+ const outputs = await impl.execute(currentAssets, node.params ?? {}, ctx);
199
+ currentAssets = outputs;
200
+
201
+ this.config.eventBus.emit({
202
+ type: 'node:finished',
203
+ workflowId,
204
+ nodeId: node.id,
205
+ capability,
206
+ params: node.params ?? {},
207
+ outputs: currentAssets,
208
+ duration: Date.now() - nodeStart,
209
+ });
210
+ }
211
+
212
+ const result: WorkflowResult = {
213
+ workflowId,
214
+ outputs: currentAssets.map((a) => a.id),
215
+ duration: Date.now() - startTime,
216
+ status: state.status === 'cancelled' ? 'cancelled' : 'completed',
217
+ };
218
+
219
+ this.config.eventBus.emit({
220
+ type: 'workflow:completed',
221
+ workflowId,
222
+ result,
223
+ });
224
+
225
+ return result;
226
+ } catch (error) {
227
+ const result: WorkflowResult = {
228
+ workflowId,
229
+ outputs: [],
230
+ duration: Date.now() - startTime,
231
+ status: 'failed',
232
+ error: error instanceof Error ? error.message : String(error),
233
+ };
234
+
235
+ this.config.eventBus.emit({
236
+ type: 'node:failed',
237
+ workflowId,
238
+ nodeId: state.currentNodeId ?? 'unknown',
239
+ error: error instanceof Error ? error : new Error(String(error)),
240
+ });
241
+
242
+ this.config.eventBus.emit({
243
+ type: 'workflow:completed',
244
+ workflowId,
245
+ result,
246
+ });
247
+
248
+ return result;
249
+ } finally {
250
+ this.running.delete(workflowId);
251
+ this.resumeResolvers.delete(workflowId);
252
+ }
253
+ }
254
+
255
+ /** 取消执行 */
256
+ async cancel(workflowId: string): Promise<void> {
257
+ const state = this.running.get(workflowId);
258
+ if (!state) return;
259
+ state.status = 'cancelled';
260
+ state.abortController.abort();
261
+ // 唤醒可能暂停中的执行循环,使其跳出
262
+ this.resolveResume(workflowId);
263
+ this.config.eventBus.emit({ type: 'workflow:cancelled', workflowId });
264
+ }
265
+
266
+ /** 暂停执行 */
267
+ async pause(workflowId: string): Promise<void> {
268
+ const state = this.running.get(workflowId);
269
+ if (!state || state.status !== 'running') return;
270
+ state.status = 'paused';
271
+ this.config.eventBus.emit({ type: 'workflow:paused', workflowId });
272
+ }
273
+
274
+ /** 恢复执行 */
275
+ async resume(workflowId: string): Promise<void> {
276
+ const state = this.running.get(workflowId);
277
+ if (!state || state.status !== 'paused') return;
278
+ state.status = 'running';
279
+ // 唤醒挂起的 waitForResume,无需轮询
280
+ this.resolveResume(workflowId);
281
+ this.config.eventBus.emit({ type: 'workflow:resumed', workflowId });
282
+ }
283
+
284
+ /**
285
+ * 挂起当前执行直到 resume/cancel 唤醒(Promise resolver 模式)。
286
+ * 相比轮询(setTimeout)无延迟、无 CPU 占用;resume/cancel 通过
287
+ * resolveResume 触发 resolver。
288
+ */
289
+ private waitForResume(workflowId: string): Promise<void> {
290
+ return new Promise<void>((resolve) => {
291
+ this.resumeResolvers.set(workflowId, resolve);
292
+ });
293
+ }
294
+
295
+ /** 唤醒挂起的 waitForResume(若存在) */
296
+ private resolveResume(workflowId: string): void {
297
+ const resolve = this.resumeResolvers.get(workflowId);
298
+ if (resolve) {
299
+ this.resumeResolvers.delete(workflowId);
300
+ resolve();
301
+ }
302
+ }
303
+ }
package/src/history.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Lokvis History Stack
3
+ *
4
+ * 每个工作流拥有独立的 HistoryStack,记录每一步 transform 的 HistoryEntry。
5
+ * undo/redo 通过 cursor(游标)实现:
6
+ * - cursor 指向最后一条已应用的 entry
7
+ * - append 新操作时截断 cursor 之后的 redo 分支
8
+ * - 超出上限(默认 10 步)时 LRU 淘汰最旧条目并回调 onEvict 通知清理
9
+ *
10
+ * 每次变更通过回调通知调用方(由 Runtime 转发为 eventBus 的 history:changed)。
11
+ *
12
+ * 对应 PROJECT_PLAN W2 任务 2.1 / 2.2 / 2.4。
13
+ */
14
+ import type { HistoryEntry } from '@lokvis/schema';
15
+
16
+ /** HistoryStack 配置 */
17
+ export interface HistoryStackConfig {
18
+ /** 历史记录上限(默认 10 步) */
19
+ maxEntries: number;
20
+ /** 淘汰条目时的回调,用于清理 OPFS 资产 */
21
+ onEvict?: (entry: HistoryEntry) => void;
22
+ /** 历史变更时的回调,用于转发为 eventBus 事件 */
23
+ onChanged?: (workflowId: string, entries: HistoryEntry[]) => void;
24
+ }
25
+
26
+ /** HistoryStack 状态(供序列化/恢复使用) */
27
+ export interface HistoryStackSnapshot {
28
+ entries: HistoryEntry[];
29
+ cursor: number;
30
+ }
31
+
32
+ export class HistoryStack {
33
+ private entries: HistoryEntry[] = [];
34
+ /** 游标:指向最后一条已应用的 entry;-1 表示尚未应用任何条目 */
35
+ private cursor = -1;
36
+ private readonly config: Required<Omit<HistoryStackConfig, 'onEvict' | 'onChanged'>> &
37
+ Pick<HistoryStackConfig, 'onEvict' | 'onChanged'>;
38
+
39
+ constructor(
40
+ private readonly workflowId: string,
41
+ config: Partial<HistoryStackConfig> = {}
42
+ ) {
43
+ this.config = {
44
+ maxEntries: config.maxEntries ?? 10,
45
+ onEvict: config.onEvict,
46
+ onChanged: config.onChanged,
47
+ };
48
+ }
49
+
50
+ /** 当前可应用的条目数(cursor 之后已截断) */
51
+ get length(): number {
52
+ return this.entries.length;
53
+ }
54
+
55
+ /** cursor 位置(指向最后已应用条目,-1 表示空) */
56
+ get currentIndex(): number {
57
+ return this.cursor;
58
+ }
59
+
60
+ /** 是否可以 undo */
61
+ get canUndo(): boolean {
62
+ return this.cursor >= 0;
63
+ }
64
+
65
+ /** 是否可以 redo */
66
+ get canRedo(): boolean {
67
+ return this.cursor < this.entries.length - 1;
68
+ }
69
+
70
+ /** 所有历史条目(只读视图) */
71
+ list(): HistoryEntry[] {
72
+ return [...this.entries];
73
+ }
74
+
75
+ /** 仅返回已应用的条目(cursor 及之前) */
76
+ applied(): HistoryEntry[] {
77
+ return this.entries.slice(0, this.cursor + 1);
78
+ }
79
+
80
+ /** 仅返回 redo 分支中尚未应用的条目 */
81
+ redoBranch(): HistoryEntry[] {
82
+ return this.entries.slice(this.cursor + 1);
83
+ }
84
+
85
+ /**
86
+ * 追加一条历史记录。
87
+ * 若当前存在 redo 分支(cursor 之后的条目),则截断之,
88
+ * 并对被丢弃的条目触发 onEvict(清理其 outputs 资产,避免 OPFS/IDB 泄漏)。
89
+ * 若超过上限,从最旧端 LRU 淘汰,并触发 onEvict 回调。
90
+ */
91
+ append(entry: HistoryEntry): void {
92
+ if (entry.workflowId !== this.workflowId) {
93
+ throw new Error(
94
+ `HistoryEntry.workflowId mismatch: expected "${this.workflowId}", got "${entry.workflowId}"`
95
+ );
96
+ }
97
+
98
+ // 截断 redo 分支:cursor 之后的条目全部丢弃,并通知清理其 outputs 资产
99
+ if (this.cursor < this.entries.length - 1) {
100
+ const dropped = this.entries.slice(this.cursor + 1);
101
+ this.entries = this.entries.slice(0, this.cursor + 1);
102
+ this.notifyEvict(dropped);
103
+ }
104
+
105
+ this.entries.push(entry);
106
+ this.cursor = this.entries.length - 1;
107
+
108
+ // LRU 淘汰最旧条目
109
+ this.evictIfNeeded();
110
+
111
+ this.notifyChanged();
112
+ }
113
+
114
+ /**
115
+ * 撤销一步:cursor 前移,返回当前应应用的 entry(即 undo 后的"当前"输出)。
116
+ * 返回值含义:
117
+ * - 如果 undo 后 cursor >= 0:返回该 entry(调用方应将其 outputs 作为当前状态)
118
+ * - 如果 undo 后 cursor < 0(回到初始):返回 null(调用方应恢复初始 inputs)
119
+ * 如果不能 undo(cursor < 0),返回 undefined 表示无操作。
120
+ */
121
+ undo(): HistoryEntry | null | undefined {
122
+ if (!this.canUndo) return undefined;
123
+ this.cursor -= 1;
124
+ this.notifyChanged();
125
+ return this.cursor >= 0 ? this.entries[this.cursor]! : null;
126
+ }
127
+
128
+ /**
129
+ * 重做一步:cursor 后移,返回重做后应应用的 entry。
130
+ * 如果不能 redo,返回 undefined 表示无操作。
131
+ */
132
+ redo(): HistoryEntry | undefined {
133
+ if (!this.canRedo) return undefined;
134
+ this.cursor += 1;
135
+ this.notifyChanged();
136
+ return this.entries[this.cursor]!;
137
+ }
138
+
139
+ /** 跳转到指定条目(按 timestamp 顺序的索引) */
140
+ jumpTo(index: number): HistoryEntry | null | undefined {
141
+ if (index < -1 || index >= this.entries.length) return undefined;
142
+ this.cursor = index;
143
+ this.notifyChanged();
144
+ return this.cursor >= 0 ? this.entries[this.cursor]! : null;
145
+ }
146
+
147
+ /** 清空所有历史(不触发 onEvict,用于工作流销毁) */
148
+ clear(): void {
149
+ this.entries = [];
150
+ this.cursor = -1;
151
+ this.notifyChanged();
152
+ }
153
+
154
+ /**
155
+ * 重置历史:清空所有条目并对每条触发 onEvict(清理其 outputs 资产)。
156
+ * 用于工作流重新执行(run)时丢弃上一次(可能失败的)历史,避免资产泄漏。
157
+ * 与 clear() 的区别:clear() 仅重置内存状态,reset() 同时回收资产。
158
+ */
159
+ reset(): void {
160
+ if (this.entries.length > 0) {
161
+ this.notifyEvict(this.entries);
162
+ }
163
+ this.entries = [];
164
+ this.cursor = -1;
165
+ this.notifyChanged();
166
+ }
167
+
168
+ /** 导出快照(用于持久化到 IndexedDB) */
169
+ snapshot(): HistoryStackSnapshot {
170
+ return {
171
+ entries: [...this.entries],
172
+ cursor: this.cursor,
173
+ };
174
+ }
175
+
176
+ /** 从快照恢复 */
177
+ restore(snapshot: HistoryStackSnapshot): void {
178
+ this.entries = [...snapshot.entries];
179
+ this.cursor = Math.max(-1, Math.min(snapshot.cursor, this.entries.length - 1));
180
+ this.notifyChanged();
181
+ }
182
+
183
+ // ─── 内部方法 ──────────────────────────────────────
184
+
185
+ /** 对一批被丢弃的条目触发 onEvict(忽略回调抛错) */
186
+ private notifyEvict(entries: HistoryEntry[]): void {
187
+ const onEvict = this.config.onEvict;
188
+ if (!onEvict || entries.length === 0) return;
189
+ for (const entry of entries) {
190
+ try {
191
+ onEvict(entry);
192
+ } catch {
193
+ // onEvict 失败不应阻断历史操作,由调用方日志记录
194
+ }
195
+ }
196
+ }
197
+
198
+ /** 超过 maxEntries 时从最旧端淘汰,并触发 onEvict 回调 */
199
+ private evictIfNeeded(): void {
200
+ // 无 onEvict 回调时仍需维护上限(静默丢弃,调用方无法清理 OPFS,但内存不爆)
201
+ if (!this.config.onEvict) {
202
+ while (this.entries.length > this.config.maxEntries) {
203
+ this.entries.shift();
204
+ // cursor 跟随左移,但不低于 -1
205
+ this.cursor = Math.max(-1, this.cursor - 1);
206
+ }
207
+ return;
208
+ }
209
+
210
+ const evicted: HistoryEntry[] = [];
211
+ while (this.entries.length > this.config.maxEntries) {
212
+ evicted.push(this.entries.shift()!);
213
+ this.cursor = Math.max(-1, this.cursor - 1);
214
+ }
215
+ this.notifyEvict(evicted);
216
+ }
217
+
218
+ private notifyChanged(): void {
219
+ this.config.onChanged?.(this.workflowId, this.list());
220
+ }
221
+ }
222
+
223
+ /** 工厂:创建 HistoryStack */
224
+ export function createHistoryStack(
225
+ workflowId: string,
226
+ config?: Partial<HistoryStackConfig>
227
+ ): HistoryStack {
228
+ return new HistoryStack(workflowId, config);
229
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * IndexedDB Asset Store
3
+ *
4
+ * 元数据 + Blob 全部存 IndexedDB(通过 Dexie)。
5
+ * 当 OPFS 不可用时作为降级方案,数据持久化且支持跨刷新恢复。
6
+ *
7
+ * 降级链(W2.8 工厂):OPFS → IndexedDB(本实现) → Memory
8
+ */
9
+
10
+ import Dexie, { type Table } from 'dexie';
11
+ import type { Asset, AssetId } from '@lokvis/schema';
12
+ import type { AssetStore } from './asset-store.js';
13
+ import {
14
+ buildAsset,
15
+ generateId,
16
+ parseBlobPath,
17
+ prepareImport,
18
+ } from './asset-store.js';
19
+
20
+ /** IndexedDB 不可用时抛出 */
21
+ export class IdbUnavailableError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = 'IdbUnavailableError';
25
+ }
26
+ }
27
+
28
+ /** IDB 文件存储路径前缀(出现在 BlobHandle.path 中) */
29
+ export const IDB_PATH_PREFIX = 'idb';
30
+
31
+ /** IDB AssetStore 配置 */
32
+ export interface IdbAssetStoreOptions {
33
+ /** 数据库名(默认 'lokvis-assets') */
34
+ dbName?: string;
35
+ /**
36
+ * 测试注入:自定义 Dexie 实例。
37
+ * 默认创建新实例。
38
+ */
39
+ dbInstance?: AssetDatabase;
40
+ }
41
+
42
+ /** IndexedDB 中的资产记录(Asset 元数据 + Blob 数据) */
43
+ export interface AssetRecord {
44
+ /** 主键 = Asset.id */
45
+ id: AssetId;
46
+ /** 资产元数据 */
47
+ asset: Asset;
48
+ /** 原始 Blob 数据(结构化克隆存储) */
49
+ blob: Blob;
50
+ }
51
+
52
+ /** Dexie 数据库封装 */
53
+ export class AssetDatabase extends Dexie {
54
+ assets!: Table<AssetRecord, string>;
55
+
56
+ constructor(name = 'lokvis-assets') {
57
+ super(name);
58
+ this.version(1).stores({
59
+ // 仅主键 id;按 type 查询由上层过滤 list() 实现,无需二级索引
60
+ assets: 'id',
61
+ });
62
+ }
63
+ }
64
+
65
+ /** 检测当前环境是否支持 IndexedDB */
66
+ export function isIdbSupported(): boolean {
67
+ // Dexie 始终可 import,但实际使用需要全局 indexedDB
68
+ return typeof indexedDB !== 'undefined';
69
+ }
70
+
71
+ /** 创建 IndexedDB 版 AssetStore */
72
+ export async function createIdbAssetStore(
73
+ options: IdbAssetStoreOptions = {}
74
+ ): Promise<AssetStore> {
75
+ if (!isIdbSupported() && !options.dbInstance) {
76
+ throw new IdbUnavailableError(
77
+ 'IndexedDB is not available in this environment'
78
+ );
79
+ }
80
+
81
+ const db = options.dbInstance ?? new AssetDatabase(options.dbName);
82
+
83
+ return {
84
+ async import(source) {
85
+ const { id, blob, metadata, type } = prepareImport(source);
86
+ const asset = buildAsset(id, blob, metadata, type, IDB_PATH_PREFIX);
87
+ await db.assets.put({ id, asset, blob });
88
+ return asset;
89
+ },
90
+
91
+ async get(id) {
92
+ const record = await db.assets.get(id);
93
+ return record?.asset;
94
+ },
95
+
96
+ async getBlob(handle) {
97
+ const id = parseBlobPath(handle.path, IDB_PATH_PREFIX);
98
+ const record = await db.assets.get(id);
99
+ if (!record) {
100
+ throw new Error(`Blob not found in IndexedDB for path: ${handle.path}`);
101
+ }
102
+ return record.blob;
103
+ },
104
+
105
+ async remove(id) {
106
+ await db.assets.delete(id);
107
+ },
108
+
109
+ async list() {
110
+ const records = await db.assets.toArray();
111
+ return records.map((r) => r.asset);
112
+ },
113
+
114
+ async create(blob, metadata, type) {
115
+ const id = generateId();
116
+ const asset = buildAsset(id, blob, metadata, type, IDB_PATH_PREFIX);
117
+ await db.assets.put({ id, asset, blob });
118
+ return asset;
119
+ },
120
+ };
121
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @lokvis/runtime
3
+ *
4
+ * Lokvis Runtime - Browser-local workflow execution engine.
5
+ * 所有核心工作流在浏览器执行,Cloudflare 只承担边缘服务。
6
+ */
7
+
8
+ export * from './types.js';
9
+ export * from './event-bus.js';
10
+ export * from './asset-store.js';
11
+ export * from './opfs-asset-store.js';
12
+ export * from './idb-asset-store.js';
13
+ export * from './capability-registry.js';
14
+ export * from './executor.js';
15
+ export * from './worker-protocol.js';
16
+ export * from './worker-host.js';
17
+ export * from './history.js';
18
+ export * from './runtime.js';
19
+
20
+ export { RUNTIME_VERSION } from './runtime.js';