@liquidmetal-ai/precip 1.0.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 (78) hide show
  1. package/.prettierrc +9 -0
  2. package/CHANGELOG.md +8 -0
  3. package/eslint.config.mjs +28 -0
  4. package/package.json +53 -0
  5. package/src/engine/agent.ts +478 -0
  6. package/src/engine/llm-provider.test.ts +275 -0
  7. package/src/engine/llm-provider.ts +330 -0
  8. package/src/engine/stream-parser.ts +170 -0
  9. package/src/index.ts +142 -0
  10. package/src/mounts/mount-manager.test.ts +516 -0
  11. package/src/mounts/mount-manager.ts +327 -0
  12. package/src/mounts/mount-registry.ts +196 -0
  13. package/src/mounts/zod-to-string.test.ts +154 -0
  14. package/src/mounts/zod-to-string.ts +213 -0
  15. package/src/presets/agent-tools.ts +57 -0
  16. package/src/presets/index.ts +5 -0
  17. package/src/sandbox/README.md +1321 -0
  18. package/src/sandbox/bridges/README.md +571 -0
  19. package/src/sandbox/bridges/actor.test.ts +229 -0
  20. package/src/sandbox/bridges/actor.ts +195 -0
  21. package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
  22. package/src/sandbox/bridges/bucket.test.ts +300 -0
  23. package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
  24. package/src/sandbox/bridges/console-multiple.test.ts +187 -0
  25. package/src/sandbox/bridges/console.test.ts +157 -0
  26. package/src/sandbox/bridges/console.ts +122 -0
  27. package/src/sandbox/bridges/fetch.ts +93 -0
  28. package/src/sandbox/bridges/index.ts +78 -0
  29. package/src/sandbox/bridges/readable-stream.ts +323 -0
  30. package/src/sandbox/bridges/response.test.ts +154 -0
  31. package/src/sandbox/bridges/response.ts +123 -0
  32. package/src/sandbox/bridges/review-fixes.test.ts +331 -0
  33. package/src/sandbox/bridges/search.test.ts +475 -0
  34. package/src/sandbox/bridges/search.ts +264 -0
  35. package/src/sandbox/bridges/shared/body-methods.ts +93 -0
  36. package/src/sandbox/bridges/shared/cleanup.ts +112 -0
  37. package/src/sandbox/bridges/shared/convert.ts +76 -0
  38. package/src/sandbox/bridges/shared/headers.ts +181 -0
  39. package/src/sandbox/bridges/shared/index.ts +36 -0
  40. package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
  41. package/src/sandbox/bridges/shared/path-parser.ts +109 -0
  42. package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
  43. package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
  44. package/src/sandbox/bridges/shared/response-object.ts +280 -0
  45. package/src/sandbox/bridges/shared/result-builder.ts +130 -0
  46. package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
  47. package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
  48. package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
  49. package/src/sandbox/bridges/storage.ts +421 -0
  50. package/src/sandbox/bridges/text-decoder.ts +190 -0
  51. package/src/sandbox/bridges/text-encoder.ts +102 -0
  52. package/src/sandbox/bridges/types.ts +39 -0
  53. package/src/sandbox/bridges/utils.ts +123 -0
  54. package/src/sandbox/index.ts +6 -0
  55. package/src/sandbox/quickjs-wasm.d.ts +9 -0
  56. package/src/sandbox/sandbox.test.ts +191 -0
  57. package/src/sandbox/sandbox.ts +831 -0
  58. package/src/sandbox/test-helper.ts +43 -0
  59. package/src/sandbox/test-mocks.ts +154 -0
  60. package/src/sandbox/user-stream.test.ts +77 -0
  61. package/src/skills/frontmatter.test.ts +305 -0
  62. package/src/skills/frontmatter.ts +200 -0
  63. package/src/skills/index.ts +9 -0
  64. package/src/skills/skills-loader.test.ts +237 -0
  65. package/src/skills/skills-loader.ts +200 -0
  66. package/src/tools/actor-storage-tools.ts +250 -0
  67. package/src/tools/code-tools.test.ts +199 -0
  68. package/src/tools/code-tools.ts +444 -0
  69. package/src/tools/file-tools.ts +206 -0
  70. package/src/tools/registry.ts +125 -0
  71. package/src/tools/script-tools.ts +145 -0
  72. package/src/tools/smartbucket-tools.ts +203 -0
  73. package/src/tools/sql-tools.ts +213 -0
  74. package/src/tools/tool-factory.ts +119 -0
  75. package/src/types.ts +512 -0
  76. package/tsconfig.eslint.json +5 -0
  77. package/tsconfig.json +15 -0
  78. package/vitest.config.ts +33 -0
@@ -0,0 +1,893 @@
1
+ /**
2
+ * Tests for the unified storage bridge with actor-storage, write options, and list options
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { executeWithAsyncHost } from '../test-helper.js';
7
+ import { installStorage, type StorageMountInfo } from './storage.js';
8
+
9
+ // ============================================================================
10
+ // Mock Factories
11
+ // ============================================================================
12
+
13
+ function createMockBucket(
14
+ files: Record<
15
+ string,
16
+ { content: string; contentType?: string; customMetadata?: Record<string, string> }
17
+ >
18
+ ) {
19
+ return {
20
+ async get(key: string) {
21
+ const file = files[key];
22
+ if (!file) return null;
23
+
24
+ const encoder = new TextEncoder();
25
+ const data = encoder.encode(file.content);
26
+ let offset = 0;
27
+ const chunkSize = 100;
28
+
29
+ const stream = new ReadableStream<Uint8Array>({
30
+ pull(controller) {
31
+ if (offset >= data.length) {
32
+ controller.close();
33
+ return;
34
+ }
35
+ const chunk = data.slice(offset, offset + chunkSize);
36
+ controller.enqueue(chunk);
37
+ offset += chunkSize;
38
+ }
39
+ });
40
+
41
+ return {
42
+ key,
43
+ body: stream,
44
+ size: data.length,
45
+ uploaded: new Date('2024-01-15T00:00:00Z'),
46
+ httpMetadata: { contentType: file.contentType || 'application/octet-stream' },
47
+ customMetadata: file.customMetadata || {}
48
+ };
49
+ },
50
+
51
+ async put(key: string, content: string, options?: any) {
52
+ files[key] = {
53
+ content,
54
+ contentType: options?.httpMetadata?.contentType,
55
+ customMetadata: options?.customMetadata
56
+ };
57
+ return { key, size: content.length, uploaded: new Date(), etag: 'test', version: '1' };
58
+ },
59
+
60
+ async list(options?: any) {
61
+ const prefix = options?.prefix || '';
62
+ let entries = Object.keys(files).filter(k => k.startsWith(prefix));
63
+
64
+ if (options?.startAfter) {
65
+ entries = entries.filter(k => k > options.startAfter);
66
+ }
67
+ if (options?.limit) {
68
+ entries = entries.slice(0, options.limit);
69
+ }
70
+
71
+ const objects = entries.map(key => ({
72
+ key,
73
+ size: files[key].content.length,
74
+ uploaded: new Date('2024-01-15T00:00:00Z')
75
+ }));
76
+ return { objects, truncated: false, delimitedPrefixes: [] };
77
+ },
78
+
79
+ async delete(key: string) {
80
+ delete files[key];
81
+ }
82
+ };
83
+ }
84
+
85
+ function createMockKvCache(
86
+ data: Record<string, { value: string; metadata?: any; expiration?: number }>
87
+ ) {
88
+ return {
89
+ async get(key: string) {
90
+ return data[key]?.value ?? null;
91
+ },
92
+ async getWithMetadata(key: string) {
93
+ const entry = data[key];
94
+ return {
95
+ value: entry?.value ?? null,
96
+ metadata: entry?.metadata ?? null,
97
+ cacheStatus: null
98
+ };
99
+ },
100
+ async put(key: string, value: string, options?: any) {
101
+ data[key] = {
102
+ value,
103
+ metadata: options?.metadata,
104
+ expiration:
105
+ options?.expiration ||
106
+ (options?.expirationTtl ? Date.now() / 1000 + options.expirationTtl : undefined)
107
+ };
108
+ },
109
+ async list(options?: any) {
110
+ const prefix = options?.prefix || '';
111
+ const keys = Object.keys(data)
112
+ .filter(k => k.startsWith(prefix))
113
+ .map(name => ({
114
+ name,
115
+ expiration: data[name].expiration,
116
+ metadata: data[name].metadata
117
+ }));
118
+ return { keys, list_complete: true, cacheStatus: null };
119
+ },
120
+ async delete(key: string) {
121
+ delete data[key];
122
+ },
123
+ async clear() {
124
+ const total = Object.keys(data).length;
125
+ for (const key of Object.keys(data)) delete data[key];
126
+ return { deleted: total, total };
127
+ }
128
+ };
129
+ }
130
+
131
+ function createMockActorStorage(data: Map<string, unknown> = new Map()) {
132
+ return {
133
+ async get(keyOrKeys: string | string[]) {
134
+ if (Array.isArray(keyOrKeys)) {
135
+ const result = new Map();
136
+ for (const key of keyOrKeys) {
137
+ const value = data.get(key);
138
+ if (value !== undefined) result.set(key, value);
139
+ }
140
+ return result;
141
+ }
142
+ return data.get(keyOrKeys);
143
+ },
144
+ async put(keyOrEntries: string | Record<string, any>, value?: any) {
145
+ if (typeof keyOrEntries === 'string') {
146
+ data.set(keyOrEntries, value);
147
+ } else {
148
+ for (const [k, v] of Object.entries(keyOrEntries)) {
149
+ data.set(k, v);
150
+ }
151
+ }
152
+ },
153
+ async delete(keyOrKeys: string | string[]) {
154
+ if (Array.isArray(keyOrKeys)) {
155
+ let count = 0;
156
+ for (const key of keyOrKeys) {
157
+ if (data.delete(key)) count++;
158
+ }
159
+ return count;
160
+ }
161
+ return data.delete(keyOrKeys);
162
+ },
163
+ async deleteAll() {
164
+ data.clear();
165
+ },
166
+ async list(options?: any) {
167
+ const result = new Map();
168
+ let entries = Array.from(data.entries()).sort(([a], [b]) => a.localeCompare(b));
169
+
170
+ if (options?.prefix) {
171
+ entries = entries.filter(([key]) => key.startsWith(options.prefix));
172
+ }
173
+ if (options?.start) {
174
+ entries = entries.filter(([key]) => key >= options.start);
175
+ }
176
+ if (options?.startAfter) {
177
+ entries = entries.filter(([key]) => key > options.startAfter);
178
+ }
179
+ if (options?.end) {
180
+ entries = entries.filter(([key]) => key <= options.end);
181
+ }
182
+ if (options?.reverse) {
183
+ entries.reverse();
184
+ }
185
+ if (options?.limit) {
186
+ entries = entries.slice(0, options.limit);
187
+ }
188
+
189
+ for (const [key, value] of entries) {
190
+ result.set(key, value);
191
+ }
192
+ return result;
193
+ },
194
+ async getAlarm() {
195
+ return null;
196
+ },
197
+ async setAlarm() {},
198
+ async deleteAlarm() {}
199
+ };
200
+ }
201
+
202
+ // ============================================================================
203
+ // Actor Storage Tests
204
+ // ============================================================================
205
+
206
+ describe('Storage Bridge - Actor Storage', () => {
207
+ it('should read string values from actor storage', async () => {
208
+ const encoder = new TextEncoder();
209
+ const data = new Map<string, unknown>([
210
+ ['counter', encoder.encode('42')],
211
+ ['prefs', encoder.encode(JSON.stringify({ theme: 'dark', lang: 'en' }))]
212
+ ]);
213
+ const mockStorage = createMockActorStorage(data);
214
+ const storageMounts = new Map<string, StorageMountInfo>([
215
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
216
+ ]);
217
+
218
+ const result = await executeWithAsyncHost(
219
+ `
220
+ const counterRes = await read("/state/counter");
221
+ const prefsRes = await read("/state/prefs");
222
+
223
+ // Verify consistent Response-like interface
224
+ const hasBody = counterRes.body !== null;
225
+ const hasText = typeof counterRes.text === 'function';
226
+ const hasJson = typeof counterRes.json === 'function';
227
+ const ok = counterRes.ok;
228
+ const status = counterRes.status;
229
+
230
+ const counter = await counterRes.text();
231
+ const prefs = await prefsRes.json();
232
+ return { counter, prefs, hasBody, hasText, hasJson, ok, status };
233
+ `,
234
+ {},
235
+ {
236
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
237
+ }
238
+ );
239
+
240
+ expect(result.success).toBe(true);
241
+ expect(result.result.counter).toBe('42');
242
+ expect(result.result.prefs).toEqual({ theme: 'dark', lang: 'en' });
243
+ // Same Response-like shape as bucket reads
244
+ expect(result.result.hasBody).toBe(true);
245
+ expect(result.result.hasText).toBe(true);
246
+ expect(result.result.hasJson).toBe(true);
247
+ expect(result.result.ok).toBe(true);
248
+ expect(result.result.status).toBe(200);
249
+ });
250
+
251
+ it('should write values to actor storage as bytes', async () => {
252
+ const data = new Map<string, unknown>();
253
+ const mockStorage = createMockActorStorage(data);
254
+ const storageMounts = new Map<string, StorageMountInfo>([
255
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
256
+ ]);
257
+
258
+ const result = await executeWithAsyncHost(
259
+ `
260
+ await write("/state/counter", "42");
261
+ await write("/state/prefs", JSON.stringify({ theme: "dark", lang: "en" }));
262
+ await write("/state/name", "Alice");
263
+ return { success: true };
264
+ `,
265
+ {},
266
+ {
267
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
268
+ }
269
+ );
270
+
271
+ expect(result.success).toBe(true);
272
+ // Values are stored as Uint8Array
273
+ const decoder = new TextDecoder();
274
+ expect(decoder.decode(data.get('counter') as Uint8Array)).toBe('42');
275
+ expect(decoder.decode(data.get('prefs') as Uint8Array)).toBe('{"theme":"dark","lang":"en"}');
276
+ expect(decoder.decode(data.get('name') as Uint8Array)).toBe('Alice');
277
+ });
278
+
279
+ it('should list keys from actor storage', async () => {
280
+ const encoder = new TextEncoder();
281
+ const data = new Map<string, unknown>([
282
+ ['user:alice', encoder.encode('{"name":"Alice"}')],
283
+ ['user:bob', encoder.encode('{"name":"Bob"}')],
284
+ ['config:theme', encoder.encode('dark')]
285
+ ]);
286
+ const mockStorage = createMockActorStorage(data);
287
+ const storageMounts = new Map<string, StorageMountInfo>([
288
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
289
+ ]);
290
+
291
+ const result = await executeWithAsyncHost(
292
+ `
293
+ const all = await list("/state/");
294
+ const users = await list("/state/user:");
295
+ return { allCount: all.count, userCount: users.count, userKeys: users.files.map(f => f.key) };
296
+ `,
297
+ {},
298
+ {
299
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
300
+ }
301
+ );
302
+
303
+ expect(result.success).toBe(true);
304
+ expect(result.result.allCount).toBe(3);
305
+ expect(result.result.userCount).toBe(2);
306
+ expect(result.result.userKeys).toEqual(['/state/user:alice', '/state/user:bob']);
307
+ });
308
+
309
+ it('should support range queries on actor storage list', async () => {
310
+ const data = new Map<string, unknown>([
311
+ ['a', new Uint8Array([1])],
312
+ ['b', new Uint8Array([2])],
313
+ ['c', new Uint8Array([3])],
314
+ ['d', new Uint8Array([4])],
315
+ ['e', new Uint8Array([5])]
316
+ ]);
317
+ const mockStorage = createMockActorStorage(data);
318
+ const storageMounts = new Map<string, StorageMountInfo>([
319
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
320
+ ]);
321
+
322
+ const result = await executeWithAsyncHost(
323
+ `
324
+ const reversed = await list("/state/", { reverse: true, limit: 3 });
325
+ const range = await list("/state/", { start: "b", end: "d" });
326
+ return {
327
+ reversedKeys: reversed.files.map(f => f.key),
328
+ rangeKeys: range.files.map(f => f.key),
329
+ };
330
+ `,
331
+ {},
332
+ {
333
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
334
+ }
335
+ );
336
+
337
+ expect(result.success).toBe(true);
338
+ expect(result.result.reversedKeys).toEqual(['/state/e', '/state/d', '/state/c']);
339
+ expect(result.result.rangeKeys).toEqual(['/state/b', '/state/c', '/state/d']);
340
+ });
341
+
342
+ it('should delete from actor storage', async () => {
343
+ const encoder = new TextEncoder();
344
+ const data = new Map<string, unknown>([
345
+ ['key1', encoder.encode('value1')],
346
+ ['key2', encoder.encode('value2')]
347
+ ]);
348
+ const mockStorage = createMockActorStorage(data);
349
+ const storageMounts = new Map<string, StorageMountInfo>([
350
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
351
+ ]);
352
+
353
+ const result = await executeWithAsyncHost(
354
+ `
355
+ await remove("/state/key1");
356
+ const remaining = await list("/state/");
357
+ return { count: remaining.count, keys: remaining.files.map(f => f.key) };
358
+ `,
359
+ {},
360
+ {
361
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
362
+ }
363
+ );
364
+
365
+ expect(result.success).toBe(true);
366
+ expect(result.result.count).toBe(1);
367
+ expect(result.result.keys).toEqual(['/state/key2']);
368
+ });
369
+
370
+ it('should throw when reading non-existent key from actor storage', async () => {
371
+ const data = new Map<string, unknown>();
372
+ const mockStorage = createMockActorStorage(data);
373
+ const storageMounts = new Map<string, StorageMountInfo>([
374
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
375
+ ]);
376
+
377
+ const result = await executeWithAsyncHost(
378
+ `
379
+ try {
380
+ await read("/state/nonexistent");
381
+ return { error: false };
382
+ } catch (e) {
383
+ return { error: true, message: String(e) };
384
+ }
385
+ `,
386
+ {},
387
+ {
388
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
389
+ }
390
+ );
391
+
392
+ expect(result.success).toBe(true);
393
+ expect(result.result.error).toBe(true);
394
+ expect(result.result.message).toContain('not found');
395
+ });
396
+ });
397
+
398
+ // ============================================================================
399
+ // Write Options Tests
400
+ // ============================================================================
401
+
402
+ describe('Storage Bridge - Write Options', () => {
403
+ it('should pass ttl option to KV cache', async () => {
404
+ const data: Record<string, any> = {};
405
+ const mockKv = createMockKvCache(data);
406
+ const putSpy = vi.spyOn(mockKv, 'put');
407
+ const storageMounts = new Map<string, StorageMountInfo>([
408
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
409
+ ]);
410
+
411
+ const result = await executeWithAsyncHost(
412
+ `
413
+ await write("/cache/session:abc", "data", { ttl: 300 });
414
+ return { success: true };
415
+ `,
416
+ {},
417
+ {
418
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
419
+ }
420
+ );
421
+
422
+ expect(result.success).toBe(true);
423
+ expect(putSpy).toHaveBeenCalledWith('session:abc', 'data', { expirationTtl: 300 });
424
+ });
425
+
426
+ it('should pass expiration option to KV cache', async () => {
427
+ const data: Record<string, any> = {};
428
+ const mockKv = createMockKvCache(data);
429
+ const putSpy = vi.spyOn(mockKv, 'put');
430
+ const storageMounts = new Map<string, StorageMountInfo>([
431
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
432
+ ]);
433
+
434
+ const result = await executeWithAsyncHost(
435
+ `
436
+ await write("/cache/temp", "value", { expiration: 1720000000 });
437
+ return { success: true };
438
+ `,
439
+ {},
440
+ {
441
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
442
+ }
443
+ );
444
+
445
+ expect(result.success).toBe(true);
446
+ expect(putSpy).toHaveBeenCalledWith('temp', 'value', { expiration: 1720000000 });
447
+ });
448
+
449
+ it('should pass metadata option to KV cache', async () => {
450
+ const data: Record<string, any> = {};
451
+ const mockKv = createMockKvCache(data);
452
+ const putSpy = vi.spyOn(mockKv, 'put');
453
+ const storageMounts = new Map<string, StorageMountInfo>([
454
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
455
+ ]);
456
+
457
+ const result = await executeWithAsyncHost(
458
+ `
459
+ await write("/cache/user:123", "data", { metadata: { source: "api" } });
460
+ return { success: true };
461
+ `,
462
+ {},
463
+ {
464
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
465
+ }
466
+ );
467
+
468
+ expect(result.success).toBe(true);
469
+ expect(putSpy).toHaveBeenCalledWith('user:123', 'data', { metadata: { source: 'api' } });
470
+ });
471
+
472
+ it('should pass contentType option to bucket', async () => {
473
+ const files: Record<string, any> = {};
474
+ const mockBucket = createMockBucket(files);
475
+ const putSpy = vi.spyOn(mockBucket, 'put');
476
+ const storageMounts = new Map<string, StorageMountInfo>([
477
+ ['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any, mode: 'rw' }]
478
+ ]);
479
+
480
+ const result = await executeWithAsyncHost(
481
+ `
482
+ await write("/uploads/report.json", '{"key":"value"}', { contentType: "application/json" });
483
+ return { success: true };
484
+ `,
485
+ {},
486
+ {
487
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
488
+ }
489
+ );
490
+
491
+ expect(result.success).toBe(true);
492
+ expect(putSpy).toHaveBeenCalledWith('report.json', '{"key":"value"}', {
493
+ httpMetadata: { contentType: 'application/json' }
494
+ });
495
+ });
496
+
497
+ it('should pass customMetadata option to bucket', async () => {
498
+ const files: Record<string, any> = {};
499
+ const mockBucket = createMockBucket(files);
500
+ const putSpy = vi.spyOn(mockBucket, 'put');
501
+ const storageMounts = new Map<string, StorageMountInfo>([
502
+ ['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any, mode: 'rw' }]
503
+ ]);
504
+
505
+ const result = await executeWithAsyncHost(
506
+ `
507
+ await write("/uploads/file.txt", "content", { customMetadata: { author: "alice", version: "2" } });
508
+ return { success: true };
509
+ `,
510
+ {},
511
+ {
512
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
513
+ }
514
+ );
515
+
516
+ expect(result.success).toBe(true);
517
+ expect(putSpy).toHaveBeenCalledWith('file.txt', 'content', {
518
+ customMetadata: { author: 'alice', version: '2' }
519
+ });
520
+ });
521
+
522
+ it('should write without options (backward compatible)', async () => {
523
+ const data: Record<string, any> = {};
524
+ const mockKv = createMockKvCache(data);
525
+ const storageMounts = new Map<string, StorageMountInfo>([
526
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
527
+ ]);
528
+
529
+ const result = await executeWithAsyncHost(
530
+ `
531
+ await write("/cache/simple", "value");
532
+ return { success: true };
533
+ `,
534
+ {},
535
+ {
536
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
537
+ }
538
+ );
539
+
540
+ expect(result.success).toBe(true);
541
+ expect(data['simple']?.value).toBe('value');
542
+ });
543
+ });
544
+
545
+ // ============================================================================
546
+ // List Options Tests
547
+ // ============================================================================
548
+
549
+ describe('Storage Bridge - List Options', () => {
550
+ it('should pass limit to bucket list', async () => {
551
+ const files: Record<string, any> = {
552
+ 'a.txt': { content: 'a' },
553
+ 'b.txt': { content: 'b' },
554
+ 'c.txt': { content: 'c' }
555
+ };
556
+ const mockBucket = createMockBucket(files);
557
+ const storageMounts = new Map<string, StorageMountInfo>([
558
+ ['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any }]
559
+ ]);
560
+
561
+ const result = await executeWithAsyncHost(
562
+ `
563
+ const limited = await list("/uploads/", { limit: 2 });
564
+ return { count: limited.count };
565
+ `,
566
+ {},
567
+ {
568
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
569
+ }
570
+ );
571
+
572
+ expect(result.success).toBe(true);
573
+ expect(result.result.count).toBe(2);
574
+ });
575
+
576
+ it('should pass startAfter to bucket list', async () => {
577
+ const files: Record<string, any> = {
578
+ 'a.txt': { content: 'a' },
579
+ 'b.txt': { content: 'b' },
580
+ 'c.txt': { content: 'c' }
581
+ };
582
+ const mockBucket = createMockBucket(files);
583
+ const storageMounts = new Map<string, StorageMountInfo>([
584
+ ['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any }]
585
+ ]);
586
+
587
+ const result = await executeWithAsyncHost(
588
+ `
589
+ const after = await list("/uploads/", { startAfter: "a.txt" });
590
+ return { keys: after.files.map(f => f.key) };
591
+ `,
592
+ {},
593
+ {
594
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
595
+ }
596
+ );
597
+
598
+ expect(result.success).toBe(true);
599
+ expect(result.result.keys).toEqual(['/uploads/b.txt', '/uploads/c.txt']);
600
+ });
601
+
602
+ it('should include expiration in KV list results', async () => {
603
+ const data: Record<string, any> = {
604
+ key1: { value: 'val1', expiration: 1720000000 },
605
+ key2: { value: 'val2' }
606
+ };
607
+ const mockKv = createMockKvCache(data);
608
+ const storageMounts = new Map<string, StorageMountInfo>([
609
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
610
+ ]);
611
+
612
+ const result = await executeWithAsyncHost(
613
+ `
614
+ const listed = await list("/cache/");
615
+ return { files: listed.files };
616
+ `,
617
+ {},
618
+ {
619
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
620
+ }
621
+ );
622
+
623
+ expect(result.success).toBe(true);
624
+ const fileWithExpiration = result.result.files.find((f: any) => f.key === '/cache/key1');
625
+ expect(fileWithExpiration?.expiration).toBe(1720000000);
626
+ });
627
+ });
628
+
629
+ // ============================================================================
630
+ // KV Read with Metadata Tests
631
+ // ============================================================================
632
+
633
+ describe('Storage Bridge - KV Read with Metadata', () => {
634
+ it('should include metadata in KV read response', async () => {
635
+ const data: Record<string, any> = {
636
+ 'user:123': {
637
+ value: JSON.stringify({ name: 'Alice' }),
638
+ metadata: { source: 'api', version: '2' }
639
+ }
640
+ };
641
+ const mockKv = createMockKvCache(data);
642
+ const storageMounts = new Map<string, StorageMountInfo>([
643
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
644
+ ]);
645
+
646
+ const result = await executeWithAsyncHost(
647
+ `
648
+ const res = await read("/cache/user:123");
649
+ const value = await res.json();
650
+ return { value, metadata: res.metadata, ok: res.ok, hasBody: res.body !== null, hasText: typeof res.text === 'function' };
651
+ `,
652
+ {},
653
+ {
654
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
655
+ }
656
+ );
657
+
658
+ expect(result.success).toBe(true);
659
+ expect(result.result.value).toEqual({ name: 'Alice' });
660
+ // metadata is serialized as JSON string via createResponseObject metadata
661
+ expect(result.result.metadata).toBe('{"source":"api","version":"2"}');
662
+ expect(result.result.ok).toBe(true);
663
+ expect(result.result.hasBody).toBe(true);
664
+ expect(result.result.hasText).toBe(true);
665
+ });
666
+
667
+ it('should throw when reading non-existent KV key', async () => {
668
+ const data: Record<string, any> = {};
669
+ const mockKv = createMockKvCache(data);
670
+ const storageMounts = new Map<string, StorageMountInfo>([
671
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
672
+ ]);
673
+
674
+ const result = await executeWithAsyncHost(
675
+ `
676
+ try {
677
+ await read("/cache/nonexistent");
678
+ return { error: false };
679
+ } catch (e) {
680
+ return { error: true, message: String(e) };
681
+ }
682
+ `,
683
+ {},
684
+ {
685
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
686
+ }
687
+ );
688
+
689
+ expect(result.success).toBe(true);
690
+ expect(result.result.error).toBe(true);
691
+ expect(result.result.message).toContain('not found');
692
+ });
693
+ });
694
+
695
+ // ============================================================================
696
+ // Mixed Mount Tests (ensure unified API works across types)
697
+ // ============================================================================
698
+
699
+ describe('Storage Bridge - Mixed Mounts', () => {
700
+ it('should read/write across bucket, KV, and actor-storage using same API', async () => {
701
+ const bucketFiles: Record<string, any> = {
702
+ 'readme.txt': { content: 'Hello from bucket' }
703
+ };
704
+ const kvData: Record<string, any> = {
705
+ 'session:abc': { value: JSON.stringify({ user: 'alice' }) }
706
+ };
707
+ const actorData = new Map<string, unknown>([['counter', new TextEncoder().encode('42')]]);
708
+
709
+ const mockBucket = createMockBucket(bucketFiles);
710
+ const mockKv = createMockKvCache(kvData);
711
+ const mockStorage = createMockActorStorage(actorData);
712
+
713
+ const storageMounts = new Map<string, StorageMountInfo>([
714
+ ['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any, mode: 'rw' }],
715
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }],
716
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
717
+ ]);
718
+
719
+ const result = await executeWithAsyncHost(
720
+ `
721
+ // Read from each
722
+ const fileText = await (await read("/uploads/readme.txt")).text();
723
+ const kvData = await (await read("/cache/session:abc")).json();
724
+ const stateVal = await (await read("/state/counter")).text();
725
+
726
+ // Write to each
727
+ await write("/uploads/new.txt", "new file");
728
+ await write("/cache/temp", "cached", { ttl: 60 });
729
+ await write("/state/counter", "43");
730
+
731
+ // Verify writes
732
+ const newFile = await (await read("/uploads/new.txt")).text();
733
+ const newState = await (await read("/state/counter")).text();
734
+
735
+ return { fileText, kvData, stateVal, newFile, newState };
736
+ `,
737
+ {},
738
+ {
739
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)],
740
+ timeoutMs: 10000
741
+ }
742
+ );
743
+
744
+ expect(result.success).toBe(true);
745
+ expect(result.result.fileText).toBe('Hello from bucket');
746
+ expect(result.result.kvData).toEqual({ user: 'alice' });
747
+ expect(result.result.stateVal).toBe('42');
748
+ expect(result.result.newFile).toBe('new file');
749
+ expect(result.result.newState).toBe('43');
750
+ });
751
+ });
752
+
753
+ // ============================================================================
754
+ // Read-Only Mode Enforcement Tests
755
+ // ============================================================================
756
+
757
+ describe('Storage Bridge - Read-Only Mode Enforcement', () => {
758
+ it('should reject writes to read-only KV mount', async () => {
759
+ const data: Record<string, any> = {};
760
+ const mockKv = createMockKvCache(data);
761
+ const storageMounts = new Map<string, StorageMountInfo>([
762
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any }] // no mode = read-only
763
+ ]);
764
+
765
+ const result = await executeWithAsyncHost(
766
+ `
767
+ try {
768
+ await write("/cache/key", "value");
769
+ return { error: false };
770
+ } catch (e) {
771
+ return { error: true, message: String(e) };
772
+ }
773
+ `,
774
+ {},
775
+ {
776
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
777
+ }
778
+ );
779
+
780
+ expect(result.success).toBe(true);
781
+ expect(result.result.error).toBe(true);
782
+ expect(result.result.message).toContain('read-only');
783
+ });
784
+
785
+ it('should reject deletes from read-only KV mount', async () => {
786
+ const data: Record<string, any> = {
787
+ key1: { value: 'val1' }
788
+ };
789
+ const mockKv = createMockKvCache(data);
790
+ const storageMounts = new Map<string, StorageMountInfo>([
791
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
792
+ ]);
793
+
794
+ const result = await executeWithAsyncHost(
795
+ `
796
+ try {
797
+ await remove("/cache/key1");
798
+ return { error: false };
799
+ } catch (e) {
800
+ return { error: true, message: String(e) };
801
+ }
802
+ `,
803
+ {},
804
+ {
805
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
806
+ }
807
+ );
808
+
809
+ expect(result.success).toBe(true);
810
+ expect(result.result.error).toBe(true);
811
+ expect(result.result.message).toContain('read-only');
812
+ });
813
+
814
+ it('should reject writes to read-only actor-storage mount', async () => {
815
+ const data = new Map<string, unknown>();
816
+ const mockStorage = createMockActorStorage(data);
817
+ const storageMounts = new Map<string, StorageMountInfo>([
818
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any }] // no mode = read-only
819
+ ]);
820
+
821
+ const result = await executeWithAsyncHost(
822
+ `
823
+ try {
824
+ await write("/state/key", "value");
825
+ return { error: false };
826
+ } catch (e) {
827
+ return { error: true, message: String(e) };
828
+ }
829
+ `,
830
+ {},
831
+ {
832
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
833
+ }
834
+ );
835
+
836
+ expect(result.success).toBe(true);
837
+ expect(result.result.error).toBe(true);
838
+ expect(result.result.message).toContain('read-only');
839
+ });
840
+
841
+ it('should reject deletes from read-only actor-storage mount', async () => {
842
+ const encoder = new TextEncoder();
843
+ const data = new Map<string, unknown>([['key1', encoder.encode('value1')]]);
844
+ const mockStorage = createMockActorStorage(data);
845
+ const storageMounts = new Map<string, StorageMountInfo>([
846
+ ['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any }]
847
+ ]);
848
+
849
+ const result = await executeWithAsyncHost(
850
+ `
851
+ try {
852
+ await remove("/state/key1");
853
+ return { error: false };
854
+ } catch (e) {
855
+ return { error: true, message: String(e) };
856
+ }
857
+ `,
858
+ {},
859
+ {
860
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
861
+ }
862
+ );
863
+
864
+ expect(result.success).toBe(true);
865
+ expect(result.result.error).toBe(true);
866
+ expect(result.result.message).toContain('read-only');
867
+ });
868
+
869
+ it('should allow reads from read-only mounts', async () => {
870
+ const data: Record<string, any> = {
871
+ greeting: { value: 'hello' }
872
+ };
873
+ const mockKv = createMockKvCache(data);
874
+ const storageMounts = new Map<string, StorageMountInfo>([
875
+ ['cache', { name: 'cache', type: 'kv', resource: mockKv as any }] // no mode = read-only
876
+ ]);
877
+
878
+ const result = await executeWithAsyncHost(
879
+ `
880
+ const res = await read("/cache/greeting");
881
+ const value = await res.text();
882
+ return { value };
883
+ `,
884
+ {},
885
+ {
886
+ bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
887
+ }
888
+ );
889
+
890
+ expect(result.success).toBe(true);
891
+ expect(result.result.value).toBe('hello');
892
+ });
893
+ });