@nestm/storage 0.1.0-alpha.7 → 0.1.0-alpha.9

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 (74) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +302 -18
  3. package/SECURITY.md +34 -5
  4. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts +21 -3
  5. package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts.map +1 -1
  6. package/dist/ai-sdk/ai-sdk-workspace-tools.js +163 -73
  7. package/dist/ai-sdk/ai-sdk-workspace-tools.js.map +1 -1
  8. package/dist/ai-sdk/index.d.ts +1 -1
  9. package/dist/ai-sdk/index.d.ts.map +1 -1
  10. package/dist/ai-sdk/index.js.map +1 -1
  11. package/dist/files-sdk/files-sdk.driver.d.ts +54 -10
  12. package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
  13. package/dist/files-sdk/files-sdk.driver.js +831 -63
  14. package/dist/files-sdk/files-sdk.driver.js.map +1 -1
  15. package/dist/files-sdk/fs/index.d.ts +4 -4
  16. package/dist/files-sdk/fs/index.d.ts.map +1 -1
  17. package/dist/files-sdk/fs/index.js +242 -9
  18. package/dist/files-sdk/fs/index.js.map +1 -1
  19. package/dist/files-sdk/index.d.ts +1 -1
  20. package/dist/files-sdk/index.d.ts.map +1 -1
  21. package/dist/files-sdk/index.js.map +1 -1
  22. package/dist/files-sdk/provider/index.d.ts +9 -5
  23. package/dist/files-sdk/provider/index.d.ts.map +1 -1
  24. package/dist/files-sdk/provider/index.js +53 -15
  25. package/dist/files-sdk/provider/index.js.map +1 -1
  26. package/dist/files-sdk/s3/construction-metadata.d.ts +7 -0
  27. package/dist/files-sdk/s3/construction-metadata.d.ts.map +1 -0
  28. package/dist/files-sdk/s3/construction-metadata.js +13 -0
  29. package/dist/files-sdk/s3/construction-metadata.js.map +1 -0
  30. package/dist/files-sdk/s3/index.d.ts +39 -19
  31. package/dist/files-sdk/s3/index.d.ts.map +1 -1
  32. package/dist/files-sdk/s3/index.js +936 -113
  33. package/dist/files-sdk/s3/index.js.map +1 -1
  34. package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
  35. package/dist/gateway/storage-gateway.controller.js +25 -5
  36. package/dist/gateway/storage-gateway.controller.js.map +1 -1
  37. package/dist/storage-etag.d.ts +13 -0
  38. package/dist/storage-etag.d.ts.map +1 -0
  39. package/dist/storage-etag.js +31 -0
  40. package/dist/storage-etag.js.map +1 -0
  41. package/dist/storage.client.d.ts +3 -1
  42. package/dist/storage.client.d.ts.map +1 -1
  43. package/dist/storage.client.js +87 -22
  44. package/dist/storage.client.js.map +1 -1
  45. package/dist/storage.driver.d.ts +21 -1
  46. package/dist/storage.driver.d.ts.map +1 -1
  47. package/dist/storage.driver.js.map +1 -1
  48. package/dist/storage.types.d.ts +90 -19
  49. package/dist/storage.types.d.ts.map +1 -1
  50. package/dist/storage.types.js.map +1 -1
  51. package/dist/testing/index.d.ts +1 -0
  52. package/dist/testing/index.d.ts.map +1 -1
  53. package/dist/testing/index.js +1 -0
  54. package/dist/testing/index.js.map +1 -1
  55. package/dist/testing/provider-conformance.d.ts +65 -0
  56. package/dist/testing/provider-conformance.d.ts.map +1 -0
  57. package/dist/testing/provider-conformance.js +853 -0
  58. package/dist/testing/provider-conformance.js.map +1 -0
  59. package/dist/workspace/index.d.ts +2 -1
  60. package/dist/workspace/index.d.ts.map +1 -1
  61. package/dist/workspace/index.js +1 -0
  62. package/dist/workspace/index.js.map +1 -1
  63. package/dist/workspace/storage-workspace.cursor.d.ts +68 -3
  64. package/dist/workspace/storage-workspace.cursor.d.ts.map +1 -1
  65. package/dist/workspace/storage-workspace.cursor.js +402 -39
  66. package/dist/workspace/storage-workspace.cursor.js.map +1 -1
  67. package/dist/workspace/storage-workspace.d.ts.map +1 -1
  68. package/dist/workspace/storage-workspace.js +162 -62
  69. package/dist/workspace/storage-workspace.js.map +1 -1
  70. package/dist/workspace/storage-workspace.types.d.ts +31 -5
  71. package/dist/workspace/storage-workspace.types.d.ts.map +1 -1
  72. package/dist/workspace/storage-workspace.types.js +2 -0
  73. package/dist/workspace/storage-workspace.types.js.map +1 -1
  74. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
1
  import { StorageClient } from '../storage.client.js';
2
+ import { isCanonicalStorageEtag } from '../storage-etag.js';
2
3
  import { StorageErrorCode } from '../storage.error.js';
3
- import { StorageWorkspaceCursorStore } from './storage-workspace.cursor.js';
4
+ import { STORAGE_WORKSPACE_MAX_CURSOR_BYTES, decodeStorageWorkspaceCursor, issueStorageWorkspaceCursor, resolveStorageWorkspaceCursorConfiguration, } from './storage-workspace.cursor.js';
4
5
  import { sanitizeWorkspaceError, workspaceError, } from './storage-workspace.error.js';
5
6
  import { assertWorkspacePath, containsControlCharacter, isPathInside, joinWorkspacePath, workspaceBasename, } from './storage-workspace.path.js';
6
7
  import { DEFAULT_STORAGE_WORKSPACE_LIMITS, STORAGE_WORKSPACE_PERMISSIONS, } from './storage-workspace.types.js';
@@ -29,18 +30,42 @@ function positiveSafeInteger(value, label) {
29
30
  }
30
31
  return value;
31
32
  }
32
- function assertEtag(etag, maxBytes, operation) {
33
- if (typeof etag !== 'string' ||
34
- etag.length === 0 ||
35
- containsControlCharacter(etag) ||
36
- encoder.encode(etag).byteLength > maxBytes) {
37
- throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `${operation} requires a valid non-empty etag.`, { permanent: true });
33
+ function assertEtag(etag, operation) {
34
+ if (!isCanonicalStorageEtag(etag)) {
35
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `${operation} requires a canonical bare etag of at most 1,024 bytes.`, { permanent: true });
38
36
  }
39
37
  }
38
+ function writeModeOf(options) {
39
+ const candidate = options;
40
+ const mode = candidate?.mode;
41
+ if (mode === 'create' || mode === 'overwrite' || mode === 'replace') {
42
+ if (mode !== 'replace' && candidate !== undefined && 'etag' in candidate) {
43
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, 'Workspace write options may include an etag only in replace mode.', { permanent: true });
44
+ }
45
+ return mode;
46
+ }
47
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, 'Workspace write mode must be create, overwrite, or replace.', { permanent: true });
48
+ }
49
+ function hasExplicitMode(options, expected, operation) {
50
+ if (typeof options !== 'object' || options === null) {
51
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `${operation} options must be an object.`, { permanent: true });
52
+ }
53
+ if (!('mode' in options)) {
54
+ return false;
55
+ }
56
+ if (options.mode !== expected) {
57
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `${operation} mode must be ${expected}.`, { permanent: true });
58
+ }
59
+ if ('etag' in options) {
60
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `${operation} ${expected} options must not include an etag.`, { permanent: true });
61
+ }
62
+ return true;
63
+ }
40
64
  function resolveLimits(requested, parent) {
41
65
  const baseline = parent ?? DEFAULT_STORAGE_WORKSPACE_LIMITS;
42
66
  const next = {
43
67
  cursorTtlMs: requested?.cursorTtlMs ?? baseline.cursorTtlMs,
68
+ maxCursorBytes: requested?.maxCursorBytes ?? baseline.maxCursorBytes,
44
69
  maxPageSize: requested?.maxPageSize ?? baseline.maxPageSize,
45
70
  maxPathBytes: requested?.maxPathBytes ?? baseline.maxPathBytes,
46
71
  maxReadBytes: requested?.maxReadBytes ?? baseline.maxReadBytes,
@@ -55,6 +80,9 @@ function resolveLimits(requested, parent) {
55
80
  throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `Child workspace limit "${name}" cannot exceed its parent limit.`, { permanent: true });
56
81
  }
57
82
  }
83
+ if (next.maxCursorBytes > STORAGE_WORKSPACE_MAX_CURSOR_BYTES) {
84
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, `limits.maxCursorBytes cannot exceed ${STORAGE_WORKSPACE_MAX_CURSOR_BYTES}.`, { permanent: true });
85
+ }
58
86
  return Object.freeze(next);
59
87
  }
60
88
  function resolvePermissions(requested, parent) {
@@ -77,8 +105,7 @@ function logicalFile(metadata, path) {
77
105
  metadata.size < 0 ||
78
106
  typeof metadata.contentType !== 'string' ||
79
107
  metadata.contentType.length === 0 ||
80
- (metadata.etag !== undefined &&
81
- (typeof metadata.etag !== 'string' || metadata.etag.length === 0)) ||
108
+ (metadata.etag !== undefined && !isCanonicalStorageEtag(metadata.etag)) ||
82
109
  (metadata.lastModified !== undefined &&
83
110
  !Number.isFinite(new Date(metadata.lastModified).getTime()))) {
84
111
  throw workspaceError(StorageErrorCode.PROVIDER, 'Storage provider returned malformed file metadata.', { permanent: true });
@@ -224,7 +251,7 @@ async function collectBoundedText(stream, announcedSize, maxBytes, path) {
224
251
  async function collectBoundedBytes(stream, announcedSize, maxBytes, path) {
225
252
  if (announcedSize > maxBytes) {
226
253
  await stream.cancel().catch(() => undefined);
227
- throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `Workspace file "${path}" exceeds the ${maxBytes}-byte copy limit.`, { path, permanent: true });
254
+ throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `Workspace file "${path}" exceeds the ${maxBytes}-byte limit.`, { path, permanent: true });
228
255
  }
229
256
  const reader = stream.getReader();
230
257
  const chunks = [];
@@ -241,7 +268,7 @@ async function collectBoundedBytes(stream, announcedSize, maxBytes, path) {
241
268
  size += result.value.byteLength;
242
269
  if (size > maxBytes) {
243
270
  await reader.cancel().catch(() => undefined);
244
- throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `Workspace file "${path}" exceeded the ${maxBytes}-byte copy limit.`, { path, permanent: true });
271
+ throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `Workspace file "${path}" exceeded the ${maxBytes}-byte limit.`, { path, permanent: true });
245
272
  }
246
273
  chunks.push(result.value);
247
274
  }
@@ -315,10 +342,31 @@ class StorageWorkspaceImplementation {
315
342
  });
316
343
  }
317
344
  }
345
+ async readBytes(path, options) {
346
+ this.#require('read');
347
+ const logicalPath = this.#filePath(path);
348
+ const maxBytes = options?.maxBytes ?? this.#limits.maxReadBytes;
349
+ positiveSafeInteger(maxBytes, 'maxBytes');
350
+ if (maxBytes > this.#limits.maxReadBytes) {
351
+ throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `maxBytes cannot exceed the ${this.#limits.maxReadBytes}-byte workspace read limit.`, { path: logicalPath, permanent: true });
352
+ }
353
+ try {
354
+ const object = await this.#state.client.downloadStream(this.#scope(logicalPath), operationOptions(options));
355
+ this.#assertResultPath(object.key, logicalPath);
356
+ const bytes = await collectBoundedBytes(object.body, object.size, maxBytes, logicalPath);
357
+ return { ...logicalFile(object, logicalPath), bytes };
358
+ }
359
+ catch (error) {
360
+ throw sanitizeWorkspaceError(error, {
361
+ operation: 'read',
362
+ path: logicalPath,
363
+ });
364
+ }
365
+ }
318
366
  async list(options = {}) {
319
367
  this.#require('list');
320
- const continuation = options.cursor
321
- ? this.#state.cursorStore.consume(options.cursor, this.#binding('list'))
368
+ const continuation = options.cursor !== undefined
369
+ ? await decodeStorageWorkspaceCursor(this.#state.cursor, this.#cursorBinding(), 'list', options.cursor)
322
370
  : undefined;
323
371
  if (continuation !== undefined &&
324
372
  ((options.directory !== undefined &&
@@ -331,7 +379,6 @@ class StorageWorkspaceImplementation {
331
379
  const directory = this.#directoryPath(continuation?.directory ?? options.directory ?? '');
332
380
  const limit = this.#pageLimit(continuation?.limit ?? options.limit, this.#limits.maxPageSize);
333
381
  const recursive = continuation?.recursive ?? options.recursive === true;
334
- const binding = this.#binding('list');
335
382
  const scopedDirectory = this.#scopeDirectory(directory);
336
383
  try {
337
384
  const page = await this.#state.client.list({
@@ -383,12 +430,13 @@ class StorageWorkspaceImplementation {
383
430
  }
384
431
  return {
385
432
  ...(page.cursor !== undefined && {
386
- cursor: this.#state.cursorStore.issue(binding, {
433
+ cursor: await issueStorageWorkspaceCursor(this.#state.cursor, this.#cursorBinding(), {
387
434
  backendCursor: page.cursor,
388
435
  directory,
389
436
  limit,
437
+ operation: 'list',
390
438
  recursive,
391
- }, this.#limits.cursorTtlMs),
439
+ }),
392
440
  }),
393
441
  entries,
394
442
  };
@@ -402,8 +450,8 @@ class StorageWorkspaceImplementation {
402
450
  }
403
451
  async search(query, options = {}) {
404
452
  this.#require('search');
405
- const continuation = options.cursor
406
- ? this.#state.cursorStore.consume(options.cursor, this.#binding('search'))
453
+ const continuation = options.cursor !== undefined
454
+ ? await decodeStorageWorkspaceCursor(this.#state.cursor, this.#cursorBinding(), 'search', options.cursor)
407
455
  : undefined;
408
456
  if (continuation !== undefined &&
409
457
  ((query.length > 0 && query !== continuation.query) ||
@@ -432,18 +480,11 @@ class StorageWorkspaceImplementation {
432
480
  }
433
481
  const caseInsensitive = continuation?.caseInsensitive ?? options.caseInsensitive === true;
434
482
  const limit = this.#pageLimit(continuation?.limit ?? options.limit, this.#limits.maxSearchResults);
435
- const binding = this.#binding('search');
436
- const prior = continuation ?? {
437
- backendCursor: undefined,
438
- caseInsensitive,
439
- directory,
440
- limit,
441
- match,
442
- query,
443
- scanned: 0,
483
+ const prior = {
484
+ backendCursor: continuation?.backendCursor,
485
+ scanned: continuation?.scanned ?? 0,
444
486
  };
445
487
  return this.#searchPage(query, {
446
- binding,
447
488
  caseInsensitive,
448
489
  directory,
449
490
  limit,
@@ -453,7 +494,8 @@ class StorageWorkspaceImplementation {
453
494
  });
454
495
  }
455
496
  async writeFile(path, body, options) {
456
- this.#require(options.mode);
497
+ const mode = writeModeOf(options);
498
+ this.#require(mode === 'overwrite' ? 'write' : mode);
457
499
  const logicalPath = this.#filePath(path);
458
500
  if (typeof body !== 'string' && !(body instanceof Uint8Array)) {
459
501
  throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, 'Workspace writes accept only strings or Uint8Array bodies.', { path: logicalPath, permanent: true });
@@ -464,9 +506,6 @@ class StorageWorkspaceImplementation {
464
506
  if (size > this.#limits.maxWriteBytes) {
465
507
  throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `Workspace write exceeds the ${this.#limits.maxWriteBytes}-byte limit.`, { path: logicalPath, permanent: true });
466
508
  }
467
- if (options.mode === 'replace') {
468
- assertEtag(options.etag, this.#limits.maxPathBytes, 'Replace');
469
- }
470
509
  try {
471
510
  const common = {
472
511
  ...(options.contentType !== undefined && {
@@ -475,18 +514,27 @@ class StorageWorkspaceImplementation {
475
514
  ...(options.metadata !== undefined && { metadata: options.metadata }),
476
515
  ...operationOptions(options),
477
516
  };
478
- const result = options.mode === 'create'
479
- ? await this.#state.client.uploadConditional(this.#scope(logicalPath), body, { ...common, condition: { type: 'create' } })
480
- : await this.#state.client.uploadConditional(this.#scope(logicalPath), body, {
517
+ let result;
518
+ if (mode === 'overwrite') {
519
+ result = await this.#state.client.upload(this.#scope(logicalPath), body, common);
520
+ }
521
+ else if (mode === 'create') {
522
+ result = await this.#state.client.uploadConditional(this.#scope(logicalPath), body, { ...common, condition: { type: 'create' } });
523
+ }
524
+ else {
525
+ const etag = options.etag;
526
+ assertEtag(etag, 'Replace');
527
+ result = await this.#state.client.uploadConditional(this.#scope(logicalPath), body, {
481
528
  ...common,
482
- condition: { etag: options.etag, type: 'replace' },
529
+ condition: { etag, type: 'replace' },
483
530
  });
531
+ }
484
532
  this.#assertResultPath(result.key, logicalPath);
485
533
  return logicalFile(result, logicalPath);
486
534
  }
487
535
  catch (error) {
488
536
  throw sanitizeWorkspaceError(error, {
489
- operation: options.mode,
537
+ operation: mode,
490
538
  path: logicalPath,
491
539
  });
492
540
  }
@@ -494,34 +542,45 @@ class StorageWorkspaceImplementation {
494
542
  async copyFile(source, destination, options) {
495
543
  this.#require('copy');
496
544
  this.#require('read');
497
- this.#require('create');
545
+ const overwrite = hasExplicitMode(options, 'overwrite', 'Copy');
546
+ this.#require(overwrite ? 'write' : 'create');
498
547
  const sourcePath = this.#filePath(source, 'source path');
499
548
  const destinationPath = this.#filePath(destination, 'destination path');
500
549
  if (sourcePath === destinationPath) {
501
550
  throw workspaceError(StorageErrorCode.CONFLICT, 'Copy destination must differ from its source.', { path: destinationPath, permanent: true });
502
551
  }
503
- if (this.#state.client.capabilities.conditionalMutation?.create !== true) {
504
- throw workspaceError(StorageErrorCode.NOT_SUPPORTED, 'Safe copy requires create-only conditional upload support.', { operation: 'copy', path: destinationPath, permanent: true });
552
+ if (overwrite) {
553
+ return this.#copyOverwrite(sourcePath, destinationPath, options);
554
+ }
555
+ const conditional = options;
556
+ assertEtag(conditional.etag, 'Copy');
557
+ const capabilities = this.#state.client.capabilities;
558
+ if (capabilities.conditionalCreate?.resultEtag !== true ||
559
+ capabilities.conditionalRead?.etag !== true) {
560
+ throw workspaceError(StorageErrorCode.NOT_SUPPORTED, 'Safe copy requires exact-ETag reads and create-only uploads that return an ETag.', { operation: 'copy', path: destinationPath, permanent: true });
505
561
  }
506
- return this.#copyCreate(sourcePath, destinationPath, options);
562
+ return this.#copyCreate(sourcePath, destinationPath, conditional);
507
563
  }
508
564
  async moveFile(source, destination, options) {
509
565
  this.#require('move');
510
566
  this.#require('read');
511
567
  this.#require('create');
512
568
  this.#require('delete');
513
- const conditional = this.#state.client.capabilities.conditionalMutation;
514
- if (conditional?.create !== true ||
515
- conditional.delete !== true ||
516
- conditional.etag !== true) {
517
- throw workspaceError(StorageErrorCode.NOT_SUPPORTED, 'Safe move requires create-only upload, conditional delete, and upload ETag support.', { operation: 'move', permanent: true });
569
+ if (typeof options !== 'object' || options === null || 'mode' in options) {
570
+ throw workspaceError(StorageErrorCode.INVALID_ARGUMENT, 'Workspace move supports only an ETag-conditional mutation.', { permanent: true });
518
571
  }
519
572
  const sourcePath = this.#filePath(source, 'source path');
520
573
  const destinationPath = this.#filePath(destination, 'destination path');
521
574
  if (sourcePath === destinationPath) {
522
575
  throw workspaceError(StorageErrorCode.CONFLICT, 'Move destination must differ from its source.', { path: destinationPath, permanent: true });
523
576
  }
524
- assertEtag(options.etag, this.#limits.maxPathBytes, 'Move');
577
+ const capabilities = this.#state.client.capabilities;
578
+ if (capabilities.conditionalCreate?.resultEtag !== true ||
579
+ capabilities.conditionalDelete?.etag !== true ||
580
+ capabilities.conditionalRead?.etag !== true) {
581
+ throw workspaceError(StorageErrorCode.NOT_SUPPORTED, 'Safe move requires exact-ETag reads, create-only uploads that return an ETag, and conditional delete.', { operation: 'move', permanent: true });
582
+ }
583
+ assertEtag(options.etag, 'Move');
525
584
  const copied = await this.#copyCreate(sourcePath, destinationPath, options);
526
585
  if (copied.etag === undefined) {
527
586
  throw workspaceError(StorageErrorCode.NOT_SUPPORTED, 'Safe move requires the destination provider to return an etag.', { path: destinationPath, permanent: true });
@@ -544,12 +603,22 @@ class StorageWorkspaceImplementation {
544
603
  async deleteFile(path, options) {
545
604
  this.#require('delete');
546
605
  const logicalPath = this.#filePath(path);
547
- assertEtag(options.etag, this.#limits.maxPathBytes, 'Delete');
606
+ const unconditional = hasExplicitMode(options, 'unconditional', 'Delete');
607
+ if (unconditional) {
608
+ this.#require('write');
609
+ }
548
610
  try {
549
- await this.#state.client.deleteConditional(this.#scope(logicalPath), {
550
- condition: { etag: options.etag },
551
- ...operationOptions(options),
552
- });
611
+ if (unconditional) {
612
+ await this.#state.client.delete(this.#scope(logicalPath), operationOptions(options));
613
+ }
614
+ else {
615
+ const conditional = options;
616
+ assertEtag(conditional.etag, 'Delete');
617
+ await this.#state.client.deleteConditional(this.#scope(logicalPath), {
618
+ condition: { etag: conditional.etag },
619
+ ...operationOptions(conditional),
620
+ });
621
+ }
553
622
  }
554
623
  catch (error) {
555
624
  throw sanitizeWorkspaceError(error, {
@@ -565,14 +634,20 @@ class StorageWorkspaceImplementation {
565
634
  }
566
635
  return new StorageWorkspaceImplementation(WORKSPACE_CONSTRUCTOR, {
567
636
  ...this.#state,
568
- id: `${this.#state.id}/${path}`,
569
637
  prefix: joinWorkspacePath(this.#state.prefix, path),
570
638
  }, resolvePermissions(options.permissions, this.#permissions), resolveLimits(options.limits, this.#limits));
571
639
  }
572
640
  async #copyCreate(sourcePath, destinationPath, options) {
573
641
  try {
574
- const source = await this.#state.client.downloadStream(this.#scope(sourcePath), operationOptions(options));
642
+ const source = await this.#state.client.downloadConditional(this.#scope(sourcePath), {
643
+ condition: { etag: options.etag },
644
+ ...operationOptions(options),
645
+ });
575
646
  this.#assertResultPath(source.key, sourcePath);
647
+ if (source.etag !== options.etag) {
648
+ await source.body.cancel().catch(() => undefined);
649
+ throw workspaceError(StorageErrorCode.PROVIDER, 'Conditional read returned an unexpected source ETag.', { operation: 'copy', path: sourcePath, permanent: true });
650
+ }
576
651
  const bytes = await collectBoundedBytes(source.body, source.size, this.#limits.maxWriteBytes, sourcePath);
577
652
  const result = await this.#state.client.uploadConditional(this.#scope(destinationPath), bytes, {
578
653
  condition: { type: 'create' },
@@ -590,6 +665,26 @@ class StorageWorkspaceImplementation {
590
665
  });
591
666
  }
592
667
  }
668
+ async #copyOverwrite(sourcePath, destinationPath, options) {
669
+ try {
670
+ const source = await this.#state.client.downloadStream(this.#scope(sourcePath), operationOptions(options));
671
+ this.#assertResultPath(source.key, sourcePath);
672
+ const bytes = await collectBoundedBytes(source.body, source.size, this.#limits.maxWriteBytes, sourcePath);
673
+ const result = await this.#state.client.upload(this.#scope(destinationPath), bytes, {
674
+ contentType: source.contentType,
675
+ ...(source.metadata !== undefined && { metadata: source.metadata }),
676
+ ...operationOptions(options),
677
+ });
678
+ this.#assertResultPath(result.key, destinationPath);
679
+ return logicalFile(result, destinationPath);
680
+ }
681
+ catch (error) {
682
+ throw sanitizeWorkspaceError(error, {
683
+ operation: 'copy',
684
+ path: destinationPath,
685
+ });
686
+ }
687
+ }
593
688
  async #searchPage(query, context) {
594
689
  const entries = [];
595
690
  let backendCursor = context.state.backendCursor;
@@ -648,17 +743,19 @@ class StorageWorkspaceImplementation {
648
743
  if (scanned >= this.#limits.maxSearchScan && hasMore) {
649
744
  throw workspaceError(StorageErrorCode.LIMIT_EXCEEDED, `Search exceeded the ${this.#limits.maxSearchScan}-object scan limit.`, { permanent: true });
650
745
  }
746
+ const nextBackendCursor = backendCursor;
651
747
  return {
652
- ...(hasMore && {
653
- cursor: this.#state.cursorStore.issue(context.binding, {
654
- backendCursor,
748
+ ...(nextBackendCursor !== undefined && {
749
+ cursor: await issueStorageWorkspaceCursor(this.#state.cursor, this.#cursorBinding(), {
750
+ backendCursor: nextBackendCursor,
655
751
  caseInsensitive: context.caseInsensitive,
656
752
  directory: context.directory,
657
753
  limit: context.limit,
658
754
  match: context.match,
755
+ operation: 'search',
659
756
  query,
660
757
  scanned,
661
- }, this.#limits.cursorTtlMs),
758
+ }),
662
759
  }),
663
760
  entries,
664
761
  };
@@ -725,8 +822,12 @@ class StorageWorkspaceImplementation {
725
822
  }
726
823
  return limit;
727
824
  }
728
- #binding(operation) {
729
- return `${this.#state.id}:${operation}`;
825
+ #cursorBinding() {
826
+ return {
827
+ limits: this.#limits,
828
+ prefix: this.#state.prefix,
829
+ store: this.#state.client.name,
830
+ };
730
831
  }
731
832
  }
732
833
  export function mountStorageWorkspace(client, options) {
@@ -737,8 +838,7 @@ export function mountStorageWorkspace(client, options) {
737
838
  });
738
839
  return new StorageWorkspaceImplementation(WORKSPACE_CONSTRUCTOR, {
739
840
  client,
740
- cursorStore: new StorageWorkspaceCursorStore(),
741
- id: `workspace:${crypto.randomUUID()}`,
841
+ cursor: resolveStorageWorkspaceCursorConfiguration(options.cursor),
742
842
  prefix,
743
843
  }, resolvePermissions(options.permissions), limits);
744
844
  }