@sequenceholdings/storage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Atlas storage client
2
+
3
+ Typed Workspaces V2 metadata and direct-to-GCS sequential resumable transfers.
4
+ Initial release is pending publication and public-contract review.
5
+
6
+ ```ts
7
+ import { createStorageClient } from '@sequenceholdings/storage'
8
+
9
+ const storage = createStorageClient({ baseUrl: atlasUrl, getToken: authenticatedToken })
10
+ const workspace = storage.workspace(workspaceId)
11
+ const result = await workspace.uploads.put({
12
+ path: 'reports/quarterly.pdf', parentId: null,
13
+ contentType: 'application/pdf',
14
+ }, fileBlob, { onProgress: uploadedBytes => console.log(uploadedBytes) })
15
+ // Store result.fileId (and optionally result.versionId) in your application's data model.
16
+ ```
17
+
18
+ `uploads.start` returns an upload ID before transfer. Keep that ID for
19
+ `uploads.resume(id, originalSource)`. Source can be a Blob, Web ReadableStream or
20
+ Node async iterable. The client retains fixed-size chunks, probes GCS offsets and
21
+ retries transient failures. Supply `size` when known (up to 50 GiB); a stream without
22
+ one reserves 1 GiB and fails once it exceeds that. Pass `signal` to stop transfer, then
23
+ either resume or `uploads.abort(id)` to release the reservation. When `put` fails after
24
+ starting a session it aborts the session for you; pass `{ keepSessionOnFailure: true }`
25
+ to keep it and `resume` later. `put` generates an `idempotencyKey` unless you pass
26
+ one and reuses it whenever it resends `start`, which `start` itself does after a
27
+ timeout, a lost reply or a 5xx: the server replays a start that committed, so it
28
+ never leaves an unnamed session holding one of your 20 pending uploads per
29
+ workspace. Small Blobs can use
30
+ `{ method: 'simple' }`; sequential resumable is the default. Parallel multipart is
31
+ not included. Do not log the session URL: it is a bearer credential.
32
+
33
+ `resume` and `put` compute a CRC32C over the whole source as they read it (from
34
+ byte zero, including the prefix a resume skips) and send it on completion; Atlas
35
+ aborts the session with `409` if the stored object differs, which is how a resume
36
+ with a source that yields different bytes is caught. If you transfer bytes yourself
37
+ with `transferStorageUpload`, pass its result: `uploads.complete(id, { crc32c })`.
38
+ `Crc32c` (incremental `update(bytes)` / `digest()`) and `crc32c(bytes)` are exported
39
+ for producers that want to declare `checksum` at start. The completion field is
40
+ optional today and becomes required once in-repo consumers ship this SDK.
41
+
42
+ Replace a file by supplying `fileId` and its `expectedRevision`. Concurrent stale
43
+ replacement fails with `409`; reload before retrying. Completion is idempotent.
44
+ While a just-committed permission change is still being applied, Atlas answers
45
+ `503 permissions_pending`; while other changes hold the workspace, `503
46
+ storage_busy`. The client retries both on reads, upload `start`/`complete`/`abort`,
47
+ `createFolder` and manifest `apply` (replay-safe) for ~10 seconds; other mutations are not retried,
48
+ because the change may already have been applied, so refresh before repeating one.
49
+ `createFolder` sends an `idempotencyKey` (generated per call, or pass
50
+ `{ idempotencyKey }` to cover your own retries), so a resend returns the folder
51
+ it already created instead of `409`. `createStorageClient` bounds each metadata request with `requestTimeoutMs`
52
+ (default 30 s, `0` disables); a timeout surfaces as `request_timeout`.
53
+ `versions`, `restoreVersion` and `deleteVersion` manage immutable history. Restore
54
+ creates a new revision without copying bytes. `update` renames/moves metadata while
55
+ preserving file IDs. `trash`/`restore` are reversible; `purge` and workspace `delete`
56
+ return durable receipts from `operations` (yours by default; `{ scope: 'workspace' }`
57
+ lists everyone's for workspace Admins). Poll these until `succeeded`; failed runs
58
+ requeue automatically, and `retryOperation` restarts one marked `failed` after
59
+ correcting its cause.
60
+
61
+ In an Artifact, use the trusted host's Blob transfer bridge:
62
+
63
+ ```ts
64
+ import { createArtifactStorageClient } from '@sequenceholdings/artifact-studio'
65
+ const workspace = createArtifactStorageClient().workspace(workspaceId)
66
+ await workspace.uploads.put(input, file, {
67
+ onProgress: ({ uploadedBytes }) => updateProgress(uploadedBytes),
68
+ })
69
+ ```
70
+
71
+ Declare `capabilities.storage` as a map from workspace UUID to `viewer`, `editor`
72
+ or `admin`. The declaration only limits reach; the caller also needs resource
73
+ permissions. A folder-only grant is sufficient for that folder's resources.
74
+
75
+ In a Managed Function, use invocation headers, never a caller-supplied token or URL:
76
+
77
+ ```ts
78
+ import { createFunctionStorageClient } from '@sequenceholdings/managed-functions'
79
+ const storage = createFunctionStorageClient({ baseUrl: configuredAtlasUrl, headers: req.headers })
80
+ const file = await storage.workspace(workspaceId).getNode(fileId)
81
+ ```
82
+
83
+ User mode is the default. `{ mode: 'service' }` explicitly selects the manifest's
84
+ attached platform service account, which needs its own storage grants. Declare
85
+ `service_account`, `capabilities.storage` and hosted network egress to your Atlas
86
+ host and `storage.googleapis.com`. Delegation expires after 15 minutes; resume in a
87
+ fresh invocation when necessary. Signed download leases expire after 60 seconds.
88
+
89
+ For local integration use the normal local Atlas environment, `seq-studio storage`
90
+ and `functions deploy -e local`. File references in ORM remain application-owned;
91
+ there is no automatic row-to-file authorization or deletion cascade.
@@ -0,0 +1,453 @@
1
+ import { z } from 'zod';
2
+ import { type CompleteStorageUpload, type StartStorageUpload, type StorageOperationScope, type WorkspaceManifest, type updateNodeSchema, type updateStorageAccessSchema } from './contracts.js';
3
+ import { type StorageUploadSource, type StorageTransferOptions } from './transfer.js';
4
+ export declare const STORAGE_TOKEN_HEADER = "x-sequence-storage-token";
5
+ export declare const STORAGE_SERVICE_TOKEN_HEADER = "x-sequence-storage-token-service";
6
+ export interface StorageTransport {
7
+ request(method: string, path: string, body?: unknown): Promise<unknown>;
8
+ }
9
+ export interface StorageListOptions {
10
+ cursor?: string;
11
+ limit?: number;
12
+ q?: string;
13
+ sort?: 'name' | 'newest';
14
+ }
15
+ export declare class StorageApiError extends Error {
16
+ readonly status: number;
17
+ readonly code: string;
18
+ constructor(status: number, code: string, message: string);
19
+ }
20
+ /** Backoff for replay-safe requests (see `replayable`). */
21
+ export interface StoragePermissionsRetry {
22
+ /** Total attempts including the first; 1 disables retrying. */
23
+ attempts?: number;
24
+ /** Ceiling of the first backoff; each later retry doubles it. */
25
+ baseDelayMs?: number;
26
+ }
27
+ export declare const storageDownloadSchema: z.ZodObject<{
28
+ url: z.ZodURL;
29
+ expiresAt: z.ZodString;
30
+ preview: z.ZodBoolean;
31
+ }, z.core.$strip>;
32
+ /** Supply a transport for an Artifact bridge, or use createStorageClient for HTTP. */
33
+ export declare function storageClient(transport: StorageTransport, { permissionsRetry, }?: {
34
+ permissionsRetry?: StoragePermissionsRetry;
35
+ }): {
36
+ capabilities: () => Promise<{
37
+ canProvision: boolean;
38
+ uploadMethods: string[];
39
+ }>;
40
+ list: (options?: StorageListOptions) => Promise<{
41
+ items: {
42
+ id: string;
43
+ name: string;
44
+ description: string;
45
+ environment: string;
46
+ state: "active" | "trashed" | "purging";
47
+ revision: number;
48
+ createdAt: string;
49
+ role: "viewer" | "editor" | "admin" | "owner";
50
+ quotaBytes: number;
51
+ usedBytes: number;
52
+ reservedBytes: number;
53
+ }[];
54
+ nextCursor: string | null;
55
+ }>;
56
+ shared: (options?: StorageListOptions & {
57
+ trash?: boolean;
58
+ }) => Promise<{
59
+ items: {
60
+ id: string;
61
+ workspaceId: string;
62
+ parentId: string | null;
63
+ name: string;
64
+ kind: "file" | "folder";
65
+ state: "active" | "trashed" | "purging";
66
+ restricted: boolean;
67
+ revision: number;
68
+ currentVersionId: string | null;
69
+ metadata: Record<string, string>;
70
+ createdAt: string;
71
+ updatedAt: string;
72
+ role: "viewer" | "editor" | "admin";
73
+ size: number | null;
74
+ contentType: string | null;
75
+ }[];
76
+ nextCursor: string | null;
77
+ }>;
78
+ apply: (manifest: WorkspaceManifest) => Promise<{
79
+ id: string;
80
+ name: string;
81
+ description: string;
82
+ environment: string;
83
+ state: "active" | "trashed" | "purging";
84
+ revision: number;
85
+ createdAt: string;
86
+ role: "viewer" | "editor" | "admin" | "owner";
87
+ quotaBytes: number;
88
+ usedBytes: number;
89
+ reservedBytes: number;
90
+ }>;
91
+ workspace(id: string): {
92
+ get: () => Promise<{
93
+ id: string;
94
+ name: string;
95
+ description: string;
96
+ environment: string;
97
+ state: "active" | "trashed" | "purging";
98
+ revision: number;
99
+ createdAt: string;
100
+ role: "viewer" | "editor" | "admin" | "owner";
101
+ quotaBytes: number;
102
+ usedBytes: number;
103
+ reservedBytes: number;
104
+ }>;
105
+ delete: (expectedRevision: number) => Promise<{
106
+ id: string;
107
+ workspaceId: string;
108
+ targetId: string | null;
109
+ kind: "purge" | "delete_version";
110
+ status: "queued" | "running" | "succeeded" | "failed";
111
+ completed: number;
112
+ total: number;
113
+ error: string | null;
114
+ createdAt: string;
115
+ updatedAt: string;
116
+ canRetry: boolean;
117
+ }>;
118
+ list: (options?: StorageListOptions & {
119
+ parentId?: string | null;
120
+ trash?: boolean;
121
+ }) => Promise<{
122
+ items: {
123
+ id: string;
124
+ workspaceId: string;
125
+ parentId: string | null;
126
+ name: string;
127
+ kind: "file" | "folder";
128
+ state: "active" | "trashed" | "purging";
129
+ restricted: boolean;
130
+ revision: number;
131
+ currentVersionId: string | null;
132
+ metadata: Record<string, string>;
133
+ createdAt: string;
134
+ updatedAt: string;
135
+ role: "viewer" | "editor" | "admin";
136
+ size: number | null;
137
+ contentType: string | null;
138
+ }[];
139
+ nextCursor: string | null;
140
+ }>;
141
+ getNode: (id: string) => Promise<{
142
+ id: string;
143
+ workspaceId: string;
144
+ parentId: string | null;
145
+ name: string;
146
+ kind: "file" | "folder";
147
+ state: "active" | "trashed" | "purging";
148
+ restricted: boolean;
149
+ revision: number;
150
+ currentVersionId: string | null;
151
+ metadata: Record<string, string>;
152
+ createdAt: string;
153
+ updatedAt: string;
154
+ role: "viewer" | "editor" | "admin";
155
+ size: number | null;
156
+ contentType: string | null;
157
+ }>;
158
+ /**
159
+ * Keyed like upload start, so every resend (storage_busy,
160
+ * permissions_pending, a lost reply) returns the one folder it made.
161
+ * Pass `idempotencyKey` to extend that across your own retries.
162
+ */
163
+ createFolder: (name: string, parentId?: string | null, { idempotencyKey, }?: {
164
+ idempotencyKey?: string;
165
+ }) => Promise<{
166
+ id: string;
167
+ workspaceId: string;
168
+ parentId: string | null;
169
+ name: string;
170
+ kind: "file" | "folder";
171
+ state: "active" | "trashed" | "purging";
172
+ restricted: boolean;
173
+ revision: number;
174
+ currentVersionId: string | null;
175
+ metadata: Record<string, string>;
176
+ createdAt: string;
177
+ updatedAt: string;
178
+ role: "viewer" | "editor" | "admin";
179
+ size: number | null;
180
+ contentType: string | null;
181
+ }>;
182
+ update: (id: string, input: z.input<typeof updateNodeSchema>) => Promise<{
183
+ id: string;
184
+ workspaceId: string;
185
+ parentId: string | null;
186
+ name: string;
187
+ kind: "file" | "folder";
188
+ state: "active" | "trashed" | "purging";
189
+ restricted: boolean;
190
+ revision: number;
191
+ currentVersionId: string | null;
192
+ metadata: Record<string, string>;
193
+ createdAt: string;
194
+ updatedAt: string;
195
+ role: "viewer" | "editor" | "admin";
196
+ size: number | null;
197
+ contentType: string | null;
198
+ }>;
199
+ trash: (id: string, expectedRevision: number) => Promise<{
200
+ id: string;
201
+ workspaceId: string;
202
+ parentId: string | null;
203
+ name: string;
204
+ kind: "file" | "folder";
205
+ state: "active" | "trashed" | "purging";
206
+ restricted: boolean;
207
+ revision: number;
208
+ currentVersionId: string | null;
209
+ metadata: Record<string, string>;
210
+ createdAt: string;
211
+ updatedAt: string;
212
+ role: "viewer" | "editor" | "admin";
213
+ size: number | null;
214
+ contentType: string | null;
215
+ }>;
216
+ restore: (id: string, expectedRevision: number) => Promise<{
217
+ id: string;
218
+ workspaceId: string;
219
+ parentId: string | null;
220
+ name: string;
221
+ kind: "file" | "folder";
222
+ state: "active" | "trashed" | "purging";
223
+ restricted: boolean;
224
+ revision: number;
225
+ currentVersionId: string | null;
226
+ metadata: Record<string, string>;
227
+ createdAt: string;
228
+ updatedAt: string;
229
+ role: "viewer" | "editor" | "admin";
230
+ size: number | null;
231
+ contentType: string | null;
232
+ }>;
233
+ purge: (id: string, expectedRevision: number) => Promise<{
234
+ id: string;
235
+ workspaceId: string;
236
+ targetId: string | null;
237
+ kind: "purge" | "delete_version";
238
+ status: "queued" | "running" | "succeeded" | "failed";
239
+ completed: number;
240
+ total: number;
241
+ error: string | null;
242
+ createdAt: string;
243
+ updatedAt: string;
244
+ canRetry: boolean;
245
+ }>;
246
+ download: (id: string, options?: {
247
+ versionId?: string;
248
+ preview?: boolean;
249
+ }) => Promise<{
250
+ url: string;
251
+ expiresAt: string;
252
+ preview: boolean;
253
+ }>;
254
+ versions: (id: string, options?: {
255
+ before?: number;
256
+ limit?: number;
257
+ }) => Promise<{
258
+ items: {
259
+ id: string;
260
+ fileId: string;
261
+ revision: number;
262
+ size: number;
263
+ checksum: string;
264
+ contentType: string;
265
+ createdAt: string;
266
+ createdBy: string;
267
+ state: "active" | "purging";
268
+ }[];
269
+ nextRevision: number | null;
270
+ }>;
271
+ restoreVersion: (id: string, versionId: string, expectedRevision: number) => Promise<{
272
+ id: string;
273
+ fileId: string;
274
+ revision: number;
275
+ size: number;
276
+ checksum: string;
277
+ contentType: string;
278
+ createdAt: string;
279
+ createdBy: string;
280
+ state: "active" | "purging";
281
+ }>;
282
+ deleteVersion: (id: string, versionId: string, expectedRevision: number) => Promise<{
283
+ id: string;
284
+ workspaceId: string;
285
+ targetId: string | null;
286
+ kind: "purge" | "delete_version";
287
+ status: "queued" | "running" | "succeeded" | "failed";
288
+ completed: number;
289
+ total: number;
290
+ error: string | null;
291
+ createdAt: string;
292
+ updatedAt: string;
293
+ canRetry: boolean;
294
+ }>;
295
+ access: (id?: string) => Promise<{
296
+ revision: number;
297
+ restricted: boolean;
298
+ grants: {
299
+ type: "user" | "group" | "organization" | "service_account";
300
+ id: string;
301
+ role: "viewer" | "editor" | "admin";
302
+ }[];
303
+ owner: string | null;
304
+ inherited: boolean;
305
+ workspaceAdminOverride: true;
306
+ displayNames: Record<string, string>;
307
+ }>;
308
+ setAccess: (input: z.input<typeof updateStorageAccessSchema>, id?: string) => Promise<{
309
+ revision: number;
310
+ restricted: boolean;
311
+ grants: {
312
+ type: "user" | "group" | "organization" | "service_account";
313
+ id: string;
314
+ role: "viewer" | "editor" | "admin";
315
+ }[];
316
+ owner: string | null;
317
+ inherited: boolean;
318
+ workspaceAdminOverride: true;
319
+ displayNames: Record<string, string>;
320
+ }>;
321
+ transferOwner: (principal: {
322
+ type: "user" | "service_account";
323
+ id: string;
324
+ }, expectedRevision: number) => Promise<{
325
+ revision: number;
326
+ restricted: boolean;
327
+ grants: {
328
+ type: "user" | "group" | "organization" | "service_account";
329
+ id: string;
330
+ role: "viewer" | "editor" | "admin";
331
+ }[];
332
+ owner: string | null;
333
+ inherited: boolean;
334
+ workspaceAdminOverride: true;
335
+ displayNames: Record<string, string>;
336
+ }>;
337
+ operations: (options?: {
338
+ before?: string;
339
+ limit?: number;
340
+ scope?: StorageOperationScope;
341
+ }) => Promise<{
342
+ items: {
343
+ id: string;
344
+ workspaceId: string;
345
+ targetId: string | null;
346
+ kind: "purge" | "delete_version";
347
+ status: "queued" | "running" | "succeeded" | "failed";
348
+ completed: number;
349
+ total: number;
350
+ error: string | null;
351
+ createdAt: string;
352
+ updatedAt: string;
353
+ canRetry: boolean;
354
+ }[];
355
+ nextCursor: string | null;
356
+ }>;
357
+ retryOperation: (id: string) => Promise<{
358
+ id: string;
359
+ workspaceId: string;
360
+ targetId: string | null;
361
+ kind: "purge" | "delete_version";
362
+ status: "queued" | "running" | "succeeded" | "failed";
363
+ completed: number;
364
+ total: number;
365
+ error: string | null;
366
+ createdAt: string;
367
+ updatedAt: string;
368
+ canRetry: boolean;
369
+ }>;
370
+ uploads: {
371
+ start: (input: StartStorageUpload) => Promise<{
372
+ id: string;
373
+ fileId: string;
374
+ uploadUrl: string | null;
375
+ expiresAt: string;
376
+ state: "pending" | "complete" | "aborted";
377
+ versionId: string | null;
378
+ maxBytes: number;
379
+ }>;
380
+ get: (id: string) => Promise<{
381
+ id: string;
382
+ fileId: string;
383
+ uploadUrl: string | null;
384
+ expiresAt: string;
385
+ state: "pending" | "complete" | "aborted";
386
+ versionId: string | null;
387
+ maxBytes: number;
388
+ }>;
389
+ /** `resume` passes the CRC32C it computed; pass your own when transferring bytes yourself. */
390
+ complete: (id: string, input?: CompleteStorageUpload) => Promise<{
391
+ id: string;
392
+ fileId: string;
393
+ uploadUrl: string | null;
394
+ expiresAt: string;
395
+ state: "pending" | "complete" | "aborted";
396
+ versionId: string | null;
397
+ maxBytes: number;
398
+ }>;
399
+ abort: (id: string) => Promise<{
400
+ id: string;
401
+ fileId: string;
402
+ uploadUrl: string | null;
403
+ expiresAt: string;
404
+ state: "pending" | "complete" | "aborted";
405
+ versionId: string | null;
406
+ maxBytes: number;
407
+ }>;
408
+ resume(id: string, source: StorageUploadSource, options?: StorageTransferOptions & {
409
+ size?: number;
410
+ }): Promise<{
411
+ id: string;
412
+ fileId: string;
413
+ uploadUrl: string | null;
414
+ expiresAt: string;
415
+ state: "pending" | "complete" | "aborted";
416
+ versionId: string | null;
417
+ maxBytes: number;
418
+ }>;
419
+ /**
420
+ * Start, transfer and complete in one call. A failure after the session
421
+ * starts aborts it (best effort) to release its reservation; pass
422
+ * `keepSessionOnFailure` to keep it for a manual `resume`.
423
+ */
424
+ put(input: Omit<StartStorageUpload, "idempotencyKey"> & {
425
+ /** Defaults to a fresh key per put, reused across its start retries. */
426
+ idempotencyKey?: string;
427
+ }, source: StorageUploadSource, { keepSessionOnFailure, ...options }?: StorageTransferOptions & {
428
+ keepSessionOnFailure?: boolean;
429
+ }): Promise<{
430
+ id: string;
431
+ fileId: string;
432
+ uploadUrl: string | null;
433
+ expiresAt: string;
434
+ state: "pending" | "complete" | "aborted";
435
+ versionId: string | null;
436
+ maxBytes: number;
437
+ }>;
438
+ };
439
+ };
440
+ };
441
+ export type StorageClient = ReturnType<typeof storageClient>;
442
+ export declare function createStorageClient({ baseUrl, getToken, getHeaders, fetch: fetcher, permissionsRetry, requestTimeoutMs, }: {
443
+ baseUrl: string;
444
+ getToken?: () => string | Promise<string>;
445
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
446
+ fetch?: typeof fetch;
447
+ permissionsRetry?: StoragePermissionsRetry;
448
+ /**
449
+ * Per metadata request, including reading the reply; 0 disables it. Byte
450
+ * transfer goes to the provider directly and is not bounded by this.
451
+ */
452
+ requestTimeoutMs?: number;
453
+ }): StorageClient;