@qnsp/storage-sdk 0.0.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/src/index.ts ADDED
@@ -0,0 +1,664 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ import type {
4
+ StorageClientTelemetry,
5
+ StorageClientTelemetryConfig,
6
+ StorageClientTelemetryEvent,
7
+ } from "./observability.js";
8
+ import { createStorageClientTelemetry, isStorageClientTelemetry } from "./observability.js";
9
+
10
+ /**
11
+ * @qnsp/storage-sdk
12
+ *
13
+ * TypeScript SDK client for the QNSP storage-service API.
14
+ * Provides a high-level interface for document upload, download, and management operations.
15
+ */
16
+
17
+ export interface StorageClientConfig {
18
+ readonly baseUrl: string;
19
+ readonly apiKey?: string;
20
+ readonly tenantId: string;
21
+ readonly timeoutMs?: number;
22
+ readonly telemetry?: StorageClientTelemetry | StorageClientTelemetryConfig;
23
+ }
24
+
25
+ type InternalStorageClientConfig = {
26
+ readonly baseUrl: string;
27
+ readonly apiKey: string;
28
+ readonly tenantId: string;
29
+ readonly timeoutMs: number;
30
+ };
31
+
32
+ export interface InitiateUploadOptions {
33
+ readonly name: string;
34
+ readonly mimeType: string;
35
+ readonly sizeBytes: number;
36
+ readonly classification?: string;
37
+ readonly metadata?: Record<string, unknown>;
38
+ readonly tags?: readonly string[];
39
+ readonly retentionPolicy?: {
40
+ readonly mode?: "compliance" | "governance" | null;
41
+ readonly retainUntil?: string | null;
42
+ readonly legalHolds?: readonly string[];
43
+ };
44
+ }
45
+
46
+ export interface UploadPartResult {
47
+ readonly uploadId: string;
48
+ readonly partId: number;
49
+ readonly status: string;
50
+ readonly sizeBytes: number;
51
+ readonly checksumSha3: string;
52
+ readonly retries: number;
53
+ readonly totalParts: number;
54
+ readonly completedParts: number;
55
+ readonly bytesReceived: number;
56
+ readonly lastPartNumber: number;
57
+ readonly resumeToken: string | null;
58
+ readonly scan?: {
59
+ readonly status: string;
60
+ readonly signature: string | null;
61
+ readonly engine: string | null;
62
+ };
63
+ }
64
+
65
+ export interface CompleteUploadResult {
66
+ readonly documentId: string;
67
+ readonly tenantId: string;
68
+ readonly version: number;
69
+ readonly sizeBytes: number;
70
+ readonly checksumSha3: string;
71
+ readonly parts: readonly {
72
+ readonly partId: number;
73
+ readonly checksumSha3: string;
74
+ readonly sizeBytes: number;
75
+ }[];
76
+ readonly downloadManifest: unknown;
77
+ readonly cdnDownload?: {
78
+ readonly url: string;
79
+ readonly expiresAt: string;
80
+ readonly token: string;
81
+ } | null;
82
+ }
83
+
84
+ export interface DownloadDescriptor {
85
+ readonly documentId: string;
86
+ readonly tenantId: string;
87
+ readonly version: number;
88
+ readonly sizeBytes: number;
89
+ readonly checksumSha3: string;
90
+ readonly manifest: unknown;
91
+ }
92
+
93
+ export interface UploadStatus {
94
+ readonly uploadId: string;
95
+ readonly documentId: string;
96
+ readonly tenantId: string;
97
+ readonly status: "pending" | "committed" | "aborted" | "quarantined";
98
+ readonly totalParts: number;
99
+ readonly completedParts: number;
100
+ readonly bytesReceived: number;
101
+ readonly lastPartNumber: number;
102
+ readonly chunkSizeBytes: number;
103
+ readonly totalSizeBytes: number;
104
+ readonly expiresAt: string;
105
+ readonly resumeToken: string | null;
106
+ readonly createdAt: string;
107
+ readonly updatedAt: string;
108
+ }
109
+
110
+ export interface DocumentPolicies {
111
+ readonly documentId: string;
112
+ readonly tenantId: string;
113
+ readonly compliance: {
114
+ readonly retentionMode: "compliance" | "governance" | null;
115
+ readonly retainUntil: string | null;
116
+ readonly legalHolds: readonly string[];
117
+ readonly wormLockExpiresAt: string | null;
118
+ };
119
+ readonly lifecycle?: {
120
+ readonly currentTier?: "hot" | "warm" | "cold" | "frozen";
121
+ readonly targetTier?: "hot" | "warm" | "cold" | "frozen" | null;
122
+ readonly transitionAfter?: string | null;
123
+ };
124
+ }
125
+
126
+ export interface UpdatePoliciesRequest {
127
+ readonly retentionMode?: "compliance" | "governance";
128
+ readonly retainUntil?: string;
129
+ readonly legalHolds?: readonly string[];
130
+ readonly wormLockExpiresAt?: string;
131
+ }
132
+
133
+ export interface ApplyLegalHoldRequest {
134
+ readonly holdId: string;
135
+ }
136
+
137
+ export interface ScheduleTransitionRequest {
138
+ readonly targetTier: "hot" | "warm" | "cold" | "frozen";
139
+ readonly transitionAfter: string;
140
+ }
141
+
142
+ interface RequestOptions {
143
+ readonly body?: unknown;
144
+ readonly headers?: Record<string, string>;
145
+ readonly signal?: AbortSignal;
146
+ readonly operation?: string;
147
+ readonly telemetryRoute?: string;
148
+ readonly telemetryTarget?: string;
149
+ }
150
+
151
+ export class StorageClient {
152
+ private readonly config: InternalStorageClientConfig;
153
+ private readonly telemetry: StorageClientTelemetry | null;
154
+ private readonly targetService: string;
155
+
156
+ constructor(config: StorageClientConfig) {
157
+ this.config = {
158
+ baseUrl: config.baseUrl.replace(/\/$/, ""),
159
+ apiKey: config.apiKey ?? "",
160
+ tenantId: config.tenantId,
161
+ timeoutMs: config.timeoutMs ?? 30_000,
162
+ };
163
+
164
+ this.telemetry = config.telemetry
165
+ ? isStorageClientTelemetry(config.telemetry)
166
+ ? config.telemetry
167
+ : createStorageClientTelemetry(config.telemetry)
168
+ : null;
169
+
170
+ try {
171
+ this.targetService = new URL(this.config.baseUrl).host;
172
+ } catch {
173
+ this.targetService = "storage-service";
174
+ }
175
+ }
176
+
177
+ private async request<T>(method: string, path: string, options?: RequestOptions): Promise<T> {
178
+ const url = `${this.config.baseUrl}${path}`;
179
+ const headers: Record<string, string> = {
180
+ "Content-Type": "application/json",
181
+ ...options?.headers,
182
+ };
183
+
184
+ if (this.config.apiKey) {
185
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
186
+ }
187
+
188
+ const controller = new AbortController();
189
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
190
+ const signal = options?.signal ?? controller.signal;
191
+ const route = options?.telemetryRoute ?? new URL(path, this.config.baseUrl).pathname;
192
+ const target = options?.telemetryTarget ?? this.targetService;
193
+ const start = performance.now();
194
+ let status: "ok" | "error" = "ok";
195
+ let httpStatus: number | undefined;
196
+ let errorMessage: string | undefined;
197
+
198
+ try {
199
+ const init: RequestInit = {
200
+ method,
201
+ headers,
202
+ signal,
203
+ };
204
+
205
+ if (options?.body !== undefined) {
206
+ init.body = JSON.stringify(options.body);
207
+ }
208
+
209
+ const response = await fetch(url, init);
210
+
211
+ clearTimeout(timeoutId);
212
+ httpStatus = response.status;
213
+
214
+ if (!response.ok) {
215
+ status = "error";
216
+ const errorText = await response.text().catch(() => "Unknown error");
217
+ errorMessage = errorText;
218
+ throw new Error(
219
+ `Storage API error: ${response.status} ${response.statusText} - ${errorText}`,
220
+ );
221
+ }
222
+
223
+ if (response.status === 204) {
224
+ return undefined as T;
225
+ }
226
+
227
+ return (await response.json()) as T;
228
+ } catch (error) {
229
+ clearTimeout(timeoutId);
230
+ status = "error";
231
+ if (!errorMessage && error instanceof Error) {
232
+ errorMessage = error.message;
233
+ }
234
+ if (error instanceof Error && error.name === "AbortError") {
235
+ errorMessage = `timeout after ${this.config.timeoutMs}ms`;
236
+ throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
237
+ }
238
+ throw error;
239
+ } finally {
240
+ const durationMs = performance.now() - start;
241
+ const event: StorageClientTelemetryEvent = {
242
+ operation: options?.operation ?? `${method} ${route}`,
243
+ method,
244
+ route,
245
+ target,
246
+ status,
247
+ durationMs,
248
+ ...(typeof httpStatus === "number" ? { httpStatus } : {}),
249
+ ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
250
+ };
251
+ this.recordTelemetryEvent(event);
252
+ }
253
+ }
254
+
255
+ async initiateUpload(options: InitiateUploadOptions): Promise<{
256
+ readonly uploadId: string;
257
+ readonly documentId: string;
258
+ readonly tenantId: string;
259
+ readonly chunkSizeBytes: number;
260
+ readonly totalSizeBytes: number;
261
+ readonly totalParts: number;
262
+ readonly expiresAt: string;
263
+ readonly resumeToken: string | null;
264
+ readonly pqc: {
265
+ readonly provider: string;
266
+ readonly algorithm: string;
267
+ readonly keyId: string;
268
+ };
269
+ }> {
270
+ return this.request("POST", "/storage/v1/documents", {
271
+ body: {
272
+ name: options.name,
273
+ mimeType: options.mimeType,
274
+ sizeBytes: options.sizeBytes,
275
+ classification: options.classification ?? "confidential",
276
+ metadata: options.metadata ?? {},
277
+ tags: options.tags ?? [],
278
+ retentionPolicy: options.retentionPolicy,
279
+ },
280
+ operation: "initiateUpload",
281
+ });
282
+ }
283
+
284
+ async uploadPart(
285
+ uploadId: string,
286
+ partId: number,
287
+ data: ReadableStream<Uint8Array> | Buffer | Uint8Array,
288
+ ): Promise<UploadPartResult> {
289
+ const url = `${this.config.baseUrl}/storage/v1/uploads/${uploadId}/parts/${partId}`;
290
+ const headers: Record<string, string> = {
291
+ "Content-Type": "application/octet-stream",
292
+ };
293
+
294
+ if (this.config.apiKey) {
295
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
296
+ }
297
+
298
+ const bytesSent =
299
+ data instanceof Buffer || data instanceof Uint8Array ? data.byteLength : undefined;
300
+ const route = "/storage/v1/uploads/:uploadId/parts/:partId";
301
+ const start = performance.now();
302
+ let status: "ok" | "error" = "ok";
303
+ let httpStatus: number | undefined;
304
+ let errorMessage: string | undefined;
305
+
306
+ const body =
307
+ data instanceof ReadableStream
308
+ ? data
309
+ : data instanceof Buffer
310
+ ? new ReadableStream({
311
+ start(controller) {
312
+ controller.enqueue(new Uint8Array(data));
313
+ controller.close();
314
+ },
315
+ })
316
+ : new ReadableStream({
317
+ start(controller) {
318
+ controller.enqueue(data);
319
+ controller.close();
320
+ },
321
+ });
322
+
323
+ const controller = new AbortController();
324
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
325
+
326
+ try {
327
+ const response = await fetch(url, {
328
+ method: "PUT",
329
+ headers,
330
+ body,
331
+ signal: controller.signal,
332
+ });
333
+
334
+ clearTimeout(timeoutId);
335
+ httpStatus = response.status;
336
+
337
+ if (!response.ok) {
338
+ status = "error";
339
+ const errorText = await response.text().catch(() => "Unknown error");
340
+ errorMessage = errorText;
341
+ throw new Error(
342
+ `Upload part error: ${response.status} ${response.statusText} - ${errorText}`,
343
+ );
344
+ }
345
+
346
+ return (await response.json()) as UploadPartResult;
347
+ } catch (error) {
348
+ clearTimeout(timeoutId);
349
+ status = "error";
350
+ if (!errorMessage && error instanceof Error) {
351
+ errorMessage = error.message;
352
+ }
353
+ if (error instanceof Error && error.name === "AbortError") {
354
+ errorMessage = `timeout after ${this.config.timeoutMs}ms`;
355
+ throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
356
+ }
357
+ throw error;
358
+ } finally {
359
+ const durationMs = performance.now() - start;
360
+ const event: StorageClientTelemetryEvent = {
361
+ operation: "uploadPart",
362
+ method: "PUT",
363
+ route,
364
+ target: this.targetService,
365
+ status,
366
+ durationMs,
367
+ ...(typeof httpStatus === "number" ? { httpStatus } : {}),
368
+ ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
369
+ ...(typeof bytesSent === "number" ? { bytesSent } : {}),
370
+ };
371
+ this.recordTelemetryEvent(event);
372
+ }
373
+ }
374
+
375
+ async getUploadStatus(uploadId: string): Promise<UploadStatus> {
376
+ // Use GET since we need the full status object
377
+ return this.request<UploadStatus>("GET", `/storage/v1/uploads/${uploadId}`, {
378
+ operation: "getUploadStatus",
379
+ telemetryRoute: "/storage/v1/uploads/:uploadId",
380
+ });
381
+ }
382
+
383
+ async completeUpload(uploadId: string): Promise<CompleteUploadResult> {
384
+ return this.request("POST", `/storage/v1/uploads/${uploadId}/complete`, {
385
+ operation: "completeUpload",
386
+ telemetryRoute: "/storage/v1/uploads/:uploadId/complete",
387
+ });
388
+ }
389
+
390
+ async getDownloadDescriptor(
391
+ documentId: string,
392
+ version: number,
393
+ options?: {
394
+ readonly token?: string | null;
395
+ readonly expiresAt?: number | null;
396
+ readonly signature?: string | null;
397
+ },
398
+ ): Promise<DownloadDescriptor> {
399
+ const params = new URLSearchParams({
400
+ tenantId: this.config.tenantId,
401
+ });
402
+
403
+ if (options?.token) {
404
+ params.set("token", options.token);
405
+ }
406
+ if (options?.expiresAt) {
407
+ params.set("expiresAt", String(options.expiresAt));
408
+ }
409
+ if (options?.signature) {
410
+ params.set("signature", options.signature);
411
+ }
412
+
413
+ return this.request(
414
+ "GET",
415
+ `/storage/v1/documents/${documentId}/versions/${version}/download?${params}`,
416
+ {
417
+ operation: "getDownloadDescriptor",
418
+ telemetryRoute: "/storage/v1/documents/:documentId/versions/:version/download",
419
+ },
420
+ );
421
+ }
422
+
423
+ async downloadStream(
424
+ documentId: string,
425
+ version: number,
426
+ options?: {
427
+ readonly token?: string | null;
428
+ readonly expiresAt?: number | null;
429
+ readonly signature?: string | null;
430
+ readonly range?: string | null;
431
+ },
432
+ ): Promise<{
433
+ readonly stream: ReadableStream<Uint8Array>;
434
+ readonly statusCode: 200 | 206;
435
+ readonly totalSize: number;
436
+ readonly contentLength: number;
437
+ readonly range?: { readonly start: number; readonly end: number };
438
+ readonly checksumSha3: string;
439
+ }> {
440
+ const params = new URLSearchParams({
441
+ tenantId: this.config.tenantId,
442
+ });
443
+
444
+ if (options?.token) {
445
+ params.set("token", options.token);
446
+ }
447
+ if (options?.expiresAt) {
448
+ params.set("expiresAt", String(options.expiresAt));
449
+ }
450
+ if (options?.signature) {
451
+ params.set("signature", options.signature);
452
+ }
453
+ if (options?.range) {
454
+ params.set("range", options.range);
455
+ }
456
+
457
+ const url = `${this.config.baseUrl}/storage/v1/documents/${documentId}/versions/${version}/content?${params}`;
458
+ const headers: Record<string, string> = {};
459
+
460
+ if (options?.range) {
461
+ headers["Range"] = options.range;
462
+ }
463
+
464
+ if (this.config.apiKey) {
465
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
466
+ }
467
+
468
+ const controller = new AbortController();
469
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
470
+ const route = "/storage/v1/documents/:documentId/versions/:version/content";
471
+ const start = performance.now();
472
+ let status: "ok" | "error" = "ok";
473
+ let httpStatus: number | undefined;
474
+ let errorMessage: string | undefined;
475
+ let bytesReceived: number | undefined;
476
+
477
+ try {
478
+ const response = await fetch(url, {
479
+ method: "GET",
480
+ headers,
481
+ signal: controller.signal,
482
+ });
483
+
484
+ clearTimeout(timeoutId);
485
+ httpStatus = response.status;
486
+
487
+ if (!response.ok) {
488
+ status = "error";
489
+ const errorText = await response.text().catch(() => "Unknown error");
490
+ errorMessage = errorText;
491
+ throw new Error(`Download error: ${response.status} ${response.statusText} - ${errorText}`);
492
+ }
493
+
494
+ const contentRange = response.headers.get("Content-Range");
495
+ const rangeMatch = contentRange?.startsWith("bytes ")
496
+ ? contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/)
497
+ : null;
498
+
499
+ const parsedRange =
500
+ rangeMatch?.[1] && rangeMatch[2]
501
+ ? {
502
+ start: Number.parseInt(rangeMatch[1], 10),
503
+ end: Number.parseInt(rangeMatch[2], 10),
504
+ }
505
+ : undefined;
506
+
507
+ const totalSize = rangeMatch?.[3]
508
+ ? Number.parseInt(rangeMatch[3], 10)
509
+ : Number.parseInt(response.headers.get("X-Total-Size") ?? "0", 10);
510
+ const contentLength = Number.parseInt(response.headers.get("Content-Length") ?? "0", 10);
511
+ const checksumSha3 = response.headers.get("X-Checksum-Sha3") ?? "";
512
+
513
+ const stream =
514
+ response.body ??
515
+ new ReadableStream<Uint8Array>({
516
+ start(controller) {
517
+ controller.close();
518
+ },
519
+ });
520
+
521
+ bytesReceived = contentLength > 0 ? contentLength : totalSize > 0 ? totalSize : undefined;
522
+
523
+ const statusCode = (response.status === 206 ? 206 : 200) as 200 | 206;
524
+
525
+ return {
526
+ stream,
527
+ statusCode,
528
+ totalSize: totalSize > 0 ? totalSize : contentLength,
529
+ contentLength: contentLength > 0 ? contentLength : totalSize,
530
+ checksumSha3,
531
+ ...(parsedRange ? { range: parsedRange } : {}),
532
+ };
533
+ } catch (error) {
534
+ clearTimeout(timeoutId);
535
+ status = "error";
536
+ if (!errorMessage && error instanceof Error) {
537
+ errorMessage = error.message;
538
+ }
539
+ if (error instanceof Error && error.name === "AbortError") {
540
+ errorMessage = `timeout after ${this.config.timeoutMs}ms`;
541
+ throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
542
+ }
543
+ throw error;
544
+ } finally {
545
+ const durationMs = performance.now() - start;
546
+ const event: StorageClientTelemetryEvent = {
547
+ operation: "downloadStream",
548
+ method: "GET",
549
+ route,
550
+ target: this.targetService,
551
+ status,
552
+ durationMs,
553
+ ...(typeof httpStatus === "number" ? { httpStatus } : {}),
554
+ ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
555
+ ...(typeof bytesReceived === "number" ? { bytesReceived } : {}),
556
+ };
557
+ this.recordTelemetryEvent(event);
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Retrieve document policies (compliance + lifecycle summary).
563
+ * Requires x-tenant-id header.
564
+ */
565
+ async getDocumentPolicies(documentId: string): Promise<DocumentPolicies> {
566
+ return this.request("GET", `/storage/v1/documents/${documentId}/policies`, {
567
+ operation: "getDocumentPolicies",
568
+ telemetryRoute: "/storage/v1/documents/:documentId/policies",
569
+ headers: {
570
+ "x-tenant-id": this.config.tenantId,
571
+ },
572
+ });
573
+ }
574
+
575
+ /**
576
+ * Update document retention/WORM/legal hold list atomically.
577
+ * Requires x-tenant-id header.
578
+ */
579
+ async updateDocumentPolicies(
580
+ documentId: string,
581
+ input: UpdatePoliciesRequest,
582
+ ): Promise<DocumentPolicies> {
583
+ return this.request("PATCH", `/storage/v1/documents/${documentId}/policies`, {
584
+ body: input,
585
+ operation: "updateDocumentPolicies",
586
+ telemetryRoute: "/storage/v1/documents/:documentId/policies",
587
+ headers: {
588
+ "x-tenant-id": this.config.tenantId,
589
+ },
590
+ });
591
+ }
592
+
593
+ /**
594
+ * Apply a legal hold by id. Hold ids are caller-defined.
595
+ * Requires x-tenant-id header.
596
+ */
597
+ async applyLegalHold(
598
+ documentId: string,
599
+ request: ApplyLegalHoldRequest,
600
+ ): Promise<{
601
+ readonly documentId: string;
602
+ readonly tenantId: string;
603
+ readonly legalHolds: readonly string[];
604
+ }> {
605
+ return this.request("POST", `/storage/v1/documents/${documentId}/legal-holds`, {
606
+ body: request,
607
+ operation: "applyLegalHold",
608
+ telemetryRoute: "/storage/v1/documents/:documentId/legal-holds",
609
+ headers: {
610
+ "x-tenant-id": this.config.tenantId,
611
+ },
612
+ });
613
+ }
614
+
615
+ /**
616
+ * Release a legal hold by id.
617
+ * Requires x-tenant-id header.
618
+ */
619
+ async releaseLegalHold(documentId: string, holdId: string): Promise<void> {
620
+ return this.request("DELETE", `/storage/v1/documents/${documentId}/legal-holds/${holdId}`, {
621
+ operation: "releaseLegalHold",
622
+ telemetryRoute: "/storage/v1/documents/:documentId/legal-holds/:holdId",
623
+ headers: {
624
+ "x-tenant-id": this.config.tenantId,
625
+ },
626
+ });
627
+ }
628
+
629
+ /**
630
+ * Schedule a lifecycle tier transition for a document.
631
+ * Requires x-tenant-id header.
632
+ */
633
+ async scheduleLifecycleTransition(
634
+ documentId: string,
635
+ request: ScheduleTransitionRequest,
636
+ ): Promise<{
637
+ readonly documentId: string;
638
+ readonly tenantId: string;
639
+ readonly lifecycle: {
640
+ readonly currentTier?: "hot" | "warm" | "cold" | "frozen";
641
+ readonly targetTier?: "hot" | "warm" | "cold" | "frozen" | null;
642
+ readonly transitionAfter?: string | null;
643
+ };
644
+ }> {
645
+ return this.request("POST", `/storage/v1/documents/${documentId}/lifecycle/transitions`, {
646
+ body: request,
647
+ operation: "scheduleLifecycleTransition",
648
+ telemetryRoute: "/storage/v1/documents/:documentId/lifecycle/transitions",
649
+ headers: {
650
+ "x-tenant-id": this.config.tenantId,
651
+ },
652
+ });
653
+ }
654
+
655
+ private recordTelemetryEvent(event: StorageClientTelemetryEvent): void {
656
+ if (!this.telemetry) {
657
+ return;
658
+ }
659
+ this.telemetry.record(event);
660
+ }
661
+ }
662
+
663
+ export * from "./events.js";
664
+ export * from "./observability.js";