@velajs/storage 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -52,5 +52,26 @@ class AppModule {}
52
52
  | R2 (HTTP + hybrid) | `@velajs/storage/drivers/r2-http` | ✅ | ✅ |
53
53
  | storagesdk bridge | `@velajs/storage/storagesdk` | Node/Bun only | depends on adapter |
54
54
 
55
+ ## Testing
56
+
57
+ There's no separate storage fake — the **memory driver IS the fake**. Build a disk in one line and
58
+ assert against it with the helpers from `@velajs/storage/testing`:
59
+
60
+ ```ts
61
+ import { createStorage } from '@velajs/storage';
62
+ import { memoryDriver } from '@velajs/storage/drivers/memory';
63
+ import { assertExists, assertMissing, assertCount } from '@velajs/storage/testing';
64
+
65
+ const storage = createStorage({ driver: memoryDriver() });
66
+ await storage.upload('avatars/1.png', bytes);
67
+
68
+ await assertExists(storage, 'avatars/1.png');
69
+ await assertMissing(storage, 'avatars/2.png');
70
+ await assertCount(storage, 'avatars/', 1); // or assertCount(storage, 1) for the whole disk
71
+ ```
72
+
73
+ The helpers accept anything memory-backed — a `Storage` facade or an injected `StorageService`.
74
+ For DI/integration tests, register the same driver instead: `StorageModule.forRoot({ driver: memoryDriver() })`.
75
+
55
76
  See the docs site for presigned uploads, the HTTP upload controller + browser client, middleware,
56
77
  multi-bucket, and the full capability matrix.
@@ -58,7 +58,10 @@ function toError(status, code, message, key) {
58
58
  const retryable = mapped === 'Provider' || mapped === 'RateLimited' || mapped === 'Timeout' || mapped === 'Network';
59
59
  return new StorageError(mapped, message ?? code ?? `S3 error${key ? ` (${key})` : ''}`, {
60
60
  status: status >= 400 ? status : undefined,
61
- retryable
61
+ retryable,
62
+ // `message`/`code` are the provider's own <Message>/<Code> — never client-safe,
63
+ // even on a 4xx status (e.g. AccessDenied/NoSuchKey text can carry internal detail).
64
+ internal: true
62
65
  });
63
66
  }
64
67
  async function s3Error(res, key) {
@@ -47,10 +47,16 @@ import { clampExpiry, dispositionHeader, mapError, parseRange, serializeStored }
47
47
  fail(c, e) {
48
48
  const err = e instanceof StorageError ? e : StorageError.wrap(e);
49
49
  const { wire, status } = mapError(err.code);
50
+ // Never echo a provider/transport/wrapped message to the client: those are
51
+ // flagged `internal` (driver <Message>/<Code>, StorageError.wrap, fromStatus)
52
+ // and can carry host/bucket/credential detail — including on a 4xx status.
53
+ // Redact internal errors at any status, plus all upstream (5xx) responses;
54
+ // 4xx codes with an author-vouched message echo it.
55
+ const message = err.internal || status >= 500 ? 'storage backend error' : err.message;
50
56
  return c.json({
51
57
  error: {
52
58
  code: wire,
53
- message: err.message
59
+ message
54
60
  }
55
61
  }, status);
56
62
  }
@@ -12,12 +12,22 @@ export interface StorageErrorOptions {
12
12
  status?: number;
13
13
  /** Override the default retryability implied by {@link StorageErrorCode}. */
14
14
  retryable?: boolean;
15
+ /**
16
+ * True when `message` came from a provider/transport/wrapped source rather
17
+ * than being authored as client-safe text. The HTTP controller redacts such
18
+ * messages so raw provider detail never reaches a client. Default false —
19
+ * a bare `new StorageError(code, msg)` is the author's vouch that `msg` is
20
+ * client-safe; driver/wrap code MUST set this when echoing provider text.
21
+ */
22
+ internal?: boolean;
15
23
  }
16
24
  export declare class StorageError extends Error {
17
25
  name: string;
18
26
  readonly code: StorageErrorCode;
19
27
  readonly retryable: boolean;
20
28
  readonly status: number | undefined;
29
+ /** Whether `message` is non-client-safe (provider/transport/wrapped origin). */
30
+ readonly internal: boolean;
21
31
  constructor(code: StorageErrorCode, message: string, options?: StorageErrorOptions);
22
32
  /** Wrap an arbitrary thrown value as a `StorageError` (idempotent). */
23
33
  static wrap(e: unknown): StorageError;
@@ -15,6 +15,7 @@ export class StorageError extends Error {
15
15
  code;
16
16
  retryable;
17
17
  status;
18
+ /** Whether `message` is non-client-safe (provider/transport/wrapped origin). */ internal;
18
19
  constructor(code, message, options){
19
20
  super(message, options?.cause !== undefined ? {
20
21
  cause: options.cause
@@ -22,14 +23,17 @@ export class StorageError extends Error {
22
23
  this.code = code;
23
24
  this.status = options?.status;
24
25
  this.retryable = options?.retryable ?? RETRYABLE.has(code);
26
+ this.internal = options?.internal ?? false;
25
27
  }
26
28
  /** Wrap an arbitrary thrown value as a `StorageError` (idempotent). */ static wrap(e) {
27
29
  if (e instanceof StorageError) return e;
28
30
  if (isAbort(e)) return new StorageError('Aborted', 'operation aborted', {
29
31
  cause: e
30
32
  });
33
+ // The wrapped message is arbitrary provider/host text — never client-safe.
31
34
  return new StorageError('Provider', e instanceof Error ? e.message : String(e), {
32
- cause: e
35
+ cause: e,
36
+ internal: true
33
37
  });
34
38
  }
35
39
  /** Map a transport status code to a `StorageError` (5xx/429 retryable). */ static fromStatus(status, message, cause) {
@@ -37,7 +41,9 @@ export class StorageError extends Error {
37
41
  return new StorageError(code, message, {
38
42
  status,
39
43
  cause,
40
- retryable: status === 429 || status >= 500
44
+ retryable: status === 429 || status >= 500,
45
+ // Message came off a transport response — treat as non-client-safe.
46
+ internal: true
41
47
  });
42
48
  }
43
49
  }
@@ -0,0 +1,17 @@
1
+ import type { ListOptions, ListResult, OperationOptions } from '../storage.types';
2
+ /**
3
+ * The read surface the assertions need. Satisfied by `StorageService`, the
4
+ * `Storage` facade, and a bare `StorageDriver` — anything memory-backed works.
5
+ */
6
+ export interface StorageTarget {
7
+ exists(key: string, opts?: OperationOptions): Promise<boolean>;
8
+ list(opts?: ListOptions): Promise<ListResult>;
9
+ }
10
+ /** Assert an object exists at `key`; throws a diagnostic `Error` otherwise. */
11
+ export declare function assertExists(storage: StorageTarget, key: string): Promise<void>;
12
+ /** Assert nothing is stored at `key`; throws a diagnostic `Error` otherwise. */
13
+ export declare function assertMissing(storage: StorageTarget, key: string): Promise<void>;
14
+ /** Assert the disk holds exactly `expected` objects. */
15
+ export declare function assertCount(storage: StorageTarget, expected: number): Promise<void>;
16
+ /** Assert exactly `expected` objects exist under `prefix`. */
17
+ export declare function assertCount(storage: StorageTarget, prefix: string, expected: number): Promise<void>;
@@ -0,0 +1,57 @@
1
+ // @velajs/storage/testing — assertion helpers for tests.
2
+ //
3
+ // Vela has no separate storage fake: the in-package **memory driver IS the
4
+ // fake**. Build a test disk in one obvious line and assert against it —
5
+ // no bootstrap, no subclass:
6
+ //
7
+ // import { createStorage } from '@velajs/storage';
8
+ // import { memoryDriver } from '@velajs/storage/drivers/memory';
9
+ // import { assertExists, assertCount } from '@velajs/storage/testing';
10
+ //
11
+ // const storage = createStorage({ driver: memoryDriver() });
12
+ // await storage.upload('avatar.png', bytes);
13
+ // await assertExists(storage, 'avatar.png');
14
+ // await assertCount(storage, 'avatars/', 1);
15
+ //
16
+ // For DI/integration tests, register the same driver on the module instead —
17
+ // `StorageModule.forRoot({ driver: memoryDriver() })` — then inject the
18
+ // `StorageService` and pass it to these helpers. Both a `Storage` facade and a
19
+ // `StorageService` satisfy the {@link StorageTarget} surface below.
20
+ //
21
+ // The helpers are thin and framework-free: they throw a plain `Error` with a
22
+ // diagnostic message on failure (no vitest/`expect` dependency), and drive the
23
+ // public `exists`/`list` API so prefix scoping and drivers stay honest.
24
+ /** Assert an object exists at `key`; throws a diagnostic `Error` otherwise. */ export async function assertExists(storage, key) {
25
+ if (!await storage.exists(key)) {
26
+ const keys = await collectKeys(storage);
27
+ throw new Error(`assertExists: expected an object at "${key}" to exist, but it is missing. ` + `Stored: ${keys.length ? keys.join(', ') : '(none)'}`);
28
+ }
29
+ }
30
+ /** Assert nothing is stored at `key`; throws a diagnostic `Error` otherwise. */ export async function assertMissing(storage, key) {
31
+ if (await storage.exists(key)) {
32
+ throw new Error(`assertMissing: expected no object at "${key}", but one exists`);
33
+ }
34
+ }
35
+ export async function assertCount(storage, prefixOrExpected, maybeExpected) {
36
+ const prefix = typeof prefixOrExpected === 'string' ? prefixOrExpected : undefined;
37
+ const expected = typeof prefixOrExpected === 'number' ? prefixOrExpected : maybeExpected;
38
+ const keys = await collectKeys(storage, prefix);
39
+ if (keys.length !== expected) {
40
+ const scope = prefix ? ` under prefix "${prefix}"` : '';
41
+ const found = keys.length ? `: ${keys.join(', ')}` : '';
42
+ throw new Error(`assertCount: expected ${expected} object(s)${scope}, but found ${keys.length}${found}`);
43
+ }
44
+ }
45
+ /** Walk every listing page (following the cursor) and collect the object keys. */ async function collectKeys(storage, prefix) {
46
+ const keys = [];
47
+ let cursor;
48
+ do {
49
+ const page = await storage.list({
50
+ prefix,
51
+ cursor
52
+ });
53
+ for (const item of page.items)keys.push(item.key);
54
+ cursor = page.cursor;
55
+ }while (cursor)
56
+ return keys;
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/storage",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Edge-first, driver-based object/file storage for the Vela framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,6 +37,10 @@
37
37
  "./storagesdk": {
38
38
  "types": "./dist/storagesdk/index.d.ts",
39
39
  "import": "./dist/storagesdk/index.js"
40
+ },
41
+ "./testing": {
42
+ "types": "./dist/testing/index.d.ts",
43
+ "import": "./dist/testing/index.js"
40
44
  }
41
45
  },
42
46
  "files": [
@@ -45,14 +49,6 @@
45
49
  "LICENSE",
46
50
  "CHANGELOG.md"
47
51
  ],
48
- "scripts": {
49
- "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
50
- "test": "vitest run",
51
- "test:workers": "vitest run --config vitest.config.workers.ts",
52
- "typecheck": "tsc --noEmit",
53
- "prepare": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
54
- "prepublishOnly": "pnpm run typecheck && pnpm test"
55
- },
56
52
  "sideEffects": false,
57
53
  "keywords": [
58
54
  "vela",
@@ -105,12 +101,10 @@
105
101
  "unplugin-swc": "^1.5.9",
106
102
  "vitest": "^4.1.9"
107
103
  },
108
- "packageManager": "pnpm@10.20.0",
109
- "pnpm": {
110
- "onlyBuiltDependencies": [
111
- "@swc/core",
112
- "esbuild",
113
- "workerd"
114
- ]
104
+ "scripts": {
105
+ "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
106
+ "test": "vitest run",
107
+ "test:workers": "vitest run --config vitest.config.workers.ts",
108
+ "typecheck": "tsc --noEmit"
115
109
  }
116
- }
110
+ }