diff --git a/.changeset/tall-laws-tickle.md b/.changeset/tall-laws-tickle.md new file mode 100644 index 0000000..5095054 --- /dev/null +++ b/.changeset/tall-laws-tickle.md @@ -0,0 +1,5 @@ +--- +"@lens-chain/storage-client": patch +--- + +**feat**: support for new `available` status diff --git a/biome.json b/biome.json index 0933042..55911ef 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -7,19 +7,29 @@ }, "files": { "ignoreUnknown": false, - "ignore": [] + "includes": ["**"] }, "formatter": { "enabled": true, "useEditorconfig": true }, - "organizeImports": { - "enabled": true - }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "style": { + "noParameterAssign": "error", + "useAsConstAssertion": "error", + "useDefaultParameterLast": "error", + "useEnumInitializers": "error", + "useSelfClosingElements": "error", + "useSingleVarDeclarator": "error", + "noUnusedTemplateLiteral": "error", + "useNumberNamespace": "error", + "noInferrableTypes": "error", + "noUselessElse": "error" + } } }, "javascript": { diff --git a/package.json b/package.json index 2b42ffe..0edbda9 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,16 @@ "module": "dist/index.js", "types": "dist/index.d.ts", "sideEffects": false, - "files": ["dist"], - "keywords": ["lens", "grove", "chain", "storage", "node"], + "files": [ + "dist" + ], + "keywords": [ + "lens", + "grove", + "chain", + "storage", + "node" + ], "exports": { ".": { "import": "./dist/index.js", @@ -17,8 +25,12 @@ }, "typesVersions": { "*": { - "import": ["./dist/index.d.ts"], - "require": ["./dist/index.d.ts"] + "import": [ + "./dist/index.d.ts" + ], + "require": [ + "./dist/index.d.ts" + ] } }, "scripts": { @@ -30,7 +42,7 @@ "test:browser": "vitest --project browser" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", + "@biomejs/biome": "^2.0.6", "@changesets/cli": "^2.27.9", "@types/node": "^22.9.0", "@vitest/browser": "^3.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f4e231..10683aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^1.9.4 - version: 1.9.4 + specifier: ^2.0.6 + version: 2.0.6 '@changesets/cli': specifier: ^2.27.9 version: 2.27.9 @@ -62,55 +62,55 @@ packages: resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@1.9.4': - resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + '@biomejs/biome@2.0.6': + resolution: {integrity: sha512-RRP+9cdh5qwe2t0gORwXaa27oTOiQRQvrFf49x2PA1tnpsyU7FIHX4ZOFMtBC4QNtyWsN7Dqkf5EDbg4X+9iqA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@1.9.4': - resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + '@biomejs/cli-darwin-arm64@2.0.6': + resolution: {integrity: sha512-AzdiNNjNzsE6LfqWyBvcL29uWoIuZUkndu+wwlXW13EKcBHbbKjNQEZIJKYDc6IL+p7bmWGx3v9ZtcRyIoIz5A==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@1.9.4': - resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + '@biomejs/cli-darwin-x64@2.0.6': + resolution: {integrity: sha512-wJjjP4E7bO4WJmiQaLnsdXMa516dbtC6542qeRkyJg0MqMXP0fvs4gdsHhZ7p9XWTAmGIjZHFKXdsjBvKGIJJQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@1.9.4': - resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + '@biomejs/cli-linux-arm64-musl@2.0.6': + resolution: {integrity: sha512-CVPEMlin3bW49sBqLBg2x016Pws7eUXA27XYDFlEtponD0luYjg2zQaMJ2nOqlkKG9fqzzkamdYxHdMDc2gZFw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@1.9.4': - resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + '@biomejs/cli-linux-arm64@2.0.6': + resolution: {integrity: sha512-ZSVf6TYo5rNMUHIW1tww+rs/krol7U5A1Is/yzWyHVZguuB0lBnIodqyFuwCNqG9aJGyk7xIMS8HG0qGUPz0SA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@1.9.4': - resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + '@biomejs/cli-linux-x64-musl@2.0.6': + resolution: {integrity: sha512-mKHE/e954hR/hSnAcJSjkf4xGqZc/53Kh39HVW1EgO5iFi0JutTN07TSjEMg616julRtfSNJi0KNyxvc30Y4rQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@1.9.4': - resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + '@biomejs/cli-linux-x64@2.0.6': + resolution: {integrity: sha512-geM1MkHTV1Kh2Cs/Xzot9BOF3WBacihw6bkEmxkz4nSga8B9/hWy5BDiOG3gHDGIBa8WxT0nzsJs2f/hPqQIQw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@1.9.4': - resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + '@biomejs/cli-win32-arm64@2.0.6': + resolution: {integrity: sha512-290V4oSFoKaprKE1zkYVsDfAdn0An5DowZ+GIABgjoq1ndhvNxkJcpxPsiYtT7slbVe3xmlT0ncdfOsN7KruzA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@1.9.4': - resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + '@biomejs/cli-win32-x64@2.0.6': + resolution: {integrity: sha512-bfM1Bce0d69Ao7pjTjUS+AWSZ02+5UHdiAP85Th8e9yV5xzw6JrHXbL5YWlcEKQ84FIZMdDc7ncuti1wd2sdbw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -2598,39 +2598,39 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@biomejs/biome@1.9.4': + '@biomejs/biome@2.0.6': optionalDependencies: - '@biomejs/cli-darwin-arm64': 1.9.4 - '@biomejs/cli-darwin-x64': 1.9.4 - '@biomejs/cli-linux-arm64': 1.9.4 - '@biomejs/cli-linux-arm64-musl': 1.9.4 - '@biomejs/cli-linux-x64': 1.9.4 - '@biomejs/cli-linux-x64-musl': 1.9.4 - '@biomejs/cli-win32-arm64': 1.9.4 - '@biomejs/cli-win32-x64': 1.9.4 - - '@biomejs/cli-darwin-arm64@1.9.4': + '@biomejs/cli-darwin-arm64': 2.0.6 + '@biomejs/cli-darwin-x64': 2.0.6 + '@biomejs/cli-linux-arm64': 2.0.6 + '@biomejs/cli-linux-arm64-musl': 2.0.6 + '@biomejs/cli-linux-x64': 2.0.6 + '@biomejs/cli-linux-x64-musl': 2.0.6 + '@biomejs/cli-win32-arm64': 2.0.6 + '@biomejs/cli-win32-x64': 2.0.6 + + '@biomejs/cli-darwin-arm64@2.0.6': optional: true - '@biomejs/cli-darwin-x64@1.9.4': + '@biomejs/cli-darwin-x64@2.0.6': optional: true - '@biomejs/cli-linux-arm64-musl@1.9.4': + '@biomejs/cli-linux-arm64-musl@2.0.6': optional: true - '@biomejs/cli-linux-arm64@1.9.4': + '@biomejs/cli-linux-arm64@2.0.6': optional: true - '@biomejs/cli-linux-x64-musl@1.9.4': + '@biomejs/cli-linux-x64-musl@2.0.6': optional: true - '@biomejs/cli-linux-x64@1.9.4': + '@biomejs/cli-linux-x64@2.0.6': optional: true - '@biomejs/cli-win32-arm64@1.9.4': + '@biomejs/cli-win32-arm64@2.0.6': optional: true - '@biomejs/cli-win32-x64@1.9.4': + '@biomejs/cli-win32-x64@2.0.6': optional: true '@changesets/apply-release-plan@7.0.5': diff --git a/src/StorageClient.test.ts b/src/StorageClient.test.ts index 99fbc04..47923d8 100644 --- a/src/StorageClient.test.ts +++ b/src/StorageClient.test.ts @@ -1,9 +1,8 @@ import { privateKeyToAccount } from 'viem/accounts'; import { describe, expect, it } from 'vitest'; - -import { StorageClient } from './StorageClient'; import { immutable, lensAccountOnly, walletOnly } from './builders'; import { staging } from './environments'; +import { StorageClient } from './StorageClient'; import { FileUploadResponse, type Resource } from './types'; import { never } from './utils'; @@ -73,7 +72,9 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const files = [file1, file2]; it('Then it should create the expected resources', async () => { - const result = await client.uploadFolder(files, { acl: immutable(37111) }); + const result = await client.uploadFolder(files, { + acl: immutable(37111), + }); await assertFileExist(client.resolve(result.files[0]?.uri ?? never())); }); @@ -88,14 +89,21 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const res = await fetch(url); const json = await res.json(); expect(json).toMatchObject({ - files: expect.arrayContaining([expect.any(String), expect.any(String), expect.any(String)]), + files: expect.arrayContaining([ + expect.any(String), + expect.any(String), + expect.any(String), + ]), }); }); it('Then it should support using an arbitrary index file', async () => { const index = new File(['[]'], 'index.json', { type: 'text/plain' }); - const response = await client.uploadFolder(files, { index, acl: immutable(37111) }); + const response = await client.uploadFolder(files, { + index, + acl: immutable(37111), + }); const url = client.resolve(response.folder.uri); const res = await fetch(url); @@ -104,7 +112,8 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { }); it('Then it should support an index file factory', async () => { - const indexFactory = (resources: Resource[]) => resources.map((r) => r.uri); + const indexFactory = (resources: Resource[]) => + resources.map((r) => r.uri); const response = await client.uploadFolder(files, { index: indexFactory, acl: immutable(37111), @@ -113,7 +122,9 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const url = client.resolve(response.folder.uri); const res = await fetch(url); const json = await res.json(); - expect(json).toMatchObject(expect.arrayContaining([expect.stringContaining('lens://')])); + expect(json).toMatchObject( + expect.arrayContaining([expect.stringContaining('lens://')]), + ); }); it('Then it should allow to specify you own index file', async () => { @@ -121,7 +132,10 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const index = new File([JSON.stringify(content)], 'index.json', { type: 'application/json', }); - const response = await client.uploadFolder(files, { index, acl: immutable(37111) }); + const response = await client.uploadFolder(files, { + index, + acl: immutable(37111), + }); const url = client.resolve(response.folder.uri); const res = await fetch(url); @@ -139,9 +153,9 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const response = await client.uploadFile(file1, { acl }); await response.waitForPropagation(); - await expect(client.editFile(response.uri, file2, signer, { acl })).resolves.toBeInstanceOf( - FileUploadResponse, - ); + await expect( + client.editFile(response.uri, file2, signer, { acl }), + ).resolves.toBeInstanceOf(FileUploadResponse); }, ); }); @@ -155,7 +169,9 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const response = await client.uploadFile(file1, { acl }); await response.waitForPropagation(); - await expect(client.delete(response.uri, signer)).resolves.toHaveProperty('success', true); + await expect( + client.delete(response.uri, signer), + ).resolves.toHaveProperty('success', true); }, ); }); @@ -169,9 +185,9 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const response = await client.uploadFile(file1, { acl }); await response.waitForPropagation(); - await expect(client.editFile(response.uri, file2, signer, { acl })).resolves.toBeInstanceOf( - FileUploadResponse, - ); + await expect( + client.editFile(response.uri, file2, signer, { acl }), + ).resolves.toBeInstanceOf(FileUploadResponse); }, ); }); @@ -182,7 +198,10 @@ describe(`Given an instance of the '${StorageClient.name}'`, () => { const response = await client.uploadFile(file1, { acl }); await response.waitForPropagation(); - await expect(client.delete(response.uri, signer)).resolves.toHaveProperty('success', true); + await expect(client.delete(response.uri, signer)).resolves.toHaveProperty( + 'success', + true, + ); }); }); }); diff --git a/src/StorageClient.ts b/src/StorageClient.ts index 42af3a6..f8e094b 100644 --- a/src/StorageClient.ts +++ b/src/StorageClient.ts @@ -1,4 +1,7 @@ -import { type Authorization, AuthorizationService } from './AuthorizationService'; +import { + type Authorization, + AuthorizationService, +} from './AuthorizationService'; import { immutable } from './builders'; import { type EnvironmentConfig, production } from './environments'; import { AuthorizationError, StorageClientError } from './errors'; @@ -11,20 +14,22 @@ import { type Resource, type Signer, type Status, + type StatusResponse, type UploadFileOptions, type UploadFolderOptions, type UploadFolderResponse, type UploadJsonOptions, } from './types'; import { - MultipartEntriesBuilder, - type MultipartEntry, createMultipartRequestInit, + delay, extractStorageKey, invariant, + MultipartEntriesBuilder, + type MultipartEntry, never, resourceFrom, - statusFrom, + statusResponseFrom, } from './utils'; export class StorageClient { @@ -52,12 +57,18 @@ export class StorageClient { * @param options - Any additional options for the upload * @returns The {@link FileUploadResponse} to the uploaded file */ - async uploadFile(file: File, options: UploadFileOptions): Promise; + async uploadFile( + file: File, + options: UploadFileOptions, + ): Promise; /** * * @deprecated use `uploadFile(file: File, options: UploadFileOptions): Promise` instead */ - async uploadFile(file: File, options?: UploadFileOptions): Promise; + async uploadFile( + file: File, + options?: UploadFileOptions, + ): Promise; async uploadFile( file: File, { acl }: UploadFileOptions = { acl: immutable(this.env.defaultChainId) }, @@ -67,6 +78,12 @@ export class StorageClient { ? await this.uploadImmutableFile(file, acl) : await this.uploadMutableFile(file, acl); + await this.waitUntilStatus( + resource.storageKey, + ['done', 'available'], + this.env.cachingTimeout, + ); + return new FileUploadResponse(resource, this); } @@ -85,11 +102,17 @@ export class StorageClient { * @param options - Upload options including the ACL configuration * @returns The {@link FileUploadResponse} to the uploaded JSON */ - async uploadAsJson(json: unknown, options: UploadJsonOptions): Promise; + async uploadAsJson( + json: unknown, + options: UploadJsonOptions, + ): Promise; /** * @deprecated use `uploadAsJson(json: unknown, options: UploadJsonOptions): Promise` instead */ - async uploadAsJson(json: unknown, options?: UploadJsonOptions): Promise; + async uploadAsJson( + json: unknown, + options?: UploadJsonOptions, + ): Promise; async uploadAsJson( json: unknown, options: UploadJsonOptions = { acl: immutable(this.env.defaultChainId) }, @@ -123,12 +146,14 @@ export class StorageClient { files: FileList | File[], options: UploadFolderOptions = { acl: immutable(this.env.defaultChainId) }, ): Promise { - const needsIndex = 'index' in options && !!options.index; + const withIndexFile = 'index' in options && !!options.index; const [folderResource, ...fileResources] = await this.allocateStorage( - files.length + (needsIndex ? 2 : 1), + files.length + (withIndexFile ? 2 : 1), ); - const builder = MultipartEntriesBuilder.from(fileResources).withFiles(Array.from(files)); + const builder = MultipartEntriesBuilder.from(fileResources).withFiles( + Array.from(files), + ); if (options.index) { builder.withIndexFile(options.index); @@ -137,12 +162,19 @@ export class StorageClient { builder.withAclTemplate(options.acl); const entries = builder.build(); - const response = await this.create(folderResource.storageKey, entries); + const response = await this.upload(folderResource.storageKey, entries); if (!response.ok) { throw await StorageClientError.fromResponse(response); } + await this.waitUntilStatus( + // biome-ignore lint/style/noNonNullAssertion: we know the folder has at least one file + withIndexFile ? folderResource.storageKey : fileResources[0]!.storageKey, + ['done', 'available'], + this.env.cachingTimeout, + ); + return { folder: folderResource, files: fileResources, @@ -169,9 +201,16 @@ export class StorageClient { * @param signer - The signer to use for the deletion * @returns The deletion result. */ - async delete(storageKeyOrUri: string, signer: Signer): Promise { + async delete( + storageKeyOrUri: string, + signer: Signer, + ): Promise { const storageKey = extractStorageKey(storageKeyOrUri); - const authorization = await this.authorization.authorize('delete', storageKey, signer); + const authorization = await this.authorization.authorize( + 'delete', + storageKey, + signer, + ); const response = await fetch( `${this.env.backend}/${storageKey}?challenge_cid=${authorization.challengeId}&secret_random=${authorization.secret}`, @@ -239,7 +278,11 @@ export class StorageClient { options: EditFileOptions = { acl: immutable(this.env.defaultChainId) }, ): Promise { const storageKey = extractStorageKey(storageKeyOrUri); - const authorization = await this.authorization.authorize('edit', storageKey, signer); + const authorization = await this.authorization.authorize( + 'edit', + storageKey, + signer, + ); const resource = resourceFrom(storageKey, this.env); const builder = MultipartEntriesBuilder.from([resource]) @@ -259,7 +302,7 @@ export class StorageClient { /** * @internal */ - async status(storageKeyOrUri: string): Promise { + async status(storageKeyOrUri: string): Promise { const storageKey = extractStorageKey(storageKeyOrUri); const response = await fetch(`${this.env.backend}/status/${storageKey}`); @@ -268,17 +311,57 @@ export class StorageClient { } try { const data = await response.json(); - return statusFrom(data); - } catch (error) { + return statusResponseFrom(data); + } catch (_) { throw await StorageClientError.fromResponse(response); } } - private async allocateStorage(amount: number): Promise<[Resource, ...Resource[]]> { + /** + * @internal + */ + async waitUntilStatus( + storageKeyOrUri: string, + expectedStatuses: Status[], + timeout: number, + ): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeout) { + const { status } = await this.status(storageKeyOrUri); + + // Handle common error states + switch (status) { + case 'error_upload': + case 'error_edit': + case 'error_delete': + case 'unauthorized': + throw StorageClientError.from( + `The resource ${storageKeyOrUri} has returned a '${status}' status.`, + ); + } + + if (expectedStatuses.includes(status)) { + return; + } + + await delay(this.env.statusPollingInterval); + } + throw StorageClientError.from( + `Timeout waiting for resource ${storageKeyOrUri} to reach status: ${expectedStatuses.join(' or ')}.`, + ); + } + + private async allocateStorage( + amount: number, + ): Promise<[Resource, ...Resource[]]> { invariant(amount > 0, 'Amount must be greater than 0'); - const response = await fetch(`${this.env.backend}/link/new?amount=${amount}`, { - method: 'POST', - }); + const response = await fetch( + `${this.env.backend}/link/new?amount=${amount}`, + { + method: 'POST', + }, + ); if (!response.ok) { throw await StorageClientError.fromResponse(response); @@ -286,13 +369,18 @@ export class StorageClient { return this.parseResourceFrom(response); } - private async uploadMutableFile(file: File, acl: AclConfig): Promise { + private async uploadMutableFile( + file: File, + acl: AclConfig, + ): Promise { const [resource] = await this.allocateStorage(1); - const builder = MultipartEntriesBuilder.from([resource]).withFile(file).withAclTemplate(acl); + const builder = MultipartEntriesBuilder.from([resource]) + .withFile(file) + .withAclTemplate(acl); const entries = builder.build(); - const response = await this.create(resource.storageKey, entries); + const response = await this.upload(resource.storageKey, entries); if (!response.ok) { throw await StorageClientError.fromResponse(response); @@ -300,7 +388,10 @@ export class StorageClient { return resource; } - private async uploadImmutableFile(file: File, { chainId }: ImmutableAcl): Promise { + private async uploadImmutableFile( + file: File, + { chainId }: ImmutableAcl, + ): Promise { const response = await fetch(`${this.env.backend}?chain_id=${chainId}`, { method: 'POST', headers: { @@ -317,8 +408,15 @@ export class StorageClient { return resource; } - private async create(storageKey: string, entries: readonly MultipartEntry[]): Promise { - return this.multipartRequest('POST', `${this.env.backend}/${storageKey}`, entries); + private async upload( + storageKey: string, + entries: readonly MultipartEntry[], + ): Promise { + return this.multipartRequest( + 'POST', + `${this.env.backend}/${storageKey}`, + entries, + ); } private async update( @@ -326,11 +424,19 @@ export class StorageClient { authorization: Authorization, entries: readonly MultipartEntry[], ): Promise { - return this.multipartRequest( + const response = await this.multipartRequest( 'PUT', `${this.env.backend}/${storageKey}?challenge_cid=${authorization.challengeId}&secret_random=${authorization.secret}`, entries, ); + + await this.waitUntilStatus( + storageKey, + ['done'], + this.env.propagationTimeout, + ); + + return response; } private async multipartRequest( @@ -341,18 +447,23 @@ export class StorageClient { return fetch(url, await createMultipartRequestInit(method, entries)); } - private parseResourceFrom = async (response: Response): Promise<[Resource, ...Resource[]]> => { + private parseResourceFrom = async ( + response: Response, + ): Promise<[Resource, ...Resource[]]> => { const list = await response.json(); return list.map((data: Record) => { const storageKey = - data.storage_key ?? never(`Missing 'storage_key' in response: ${JSON.stringify(data)}`); + data.storage_key ?? + never(`Missing 'storage_key' in response: ${JSON.stringify(data)}`); return { storageKey, // TODO use data.gateway_url once fixed by the API // gatewayUrl: data.gateway_url ?? never('Missing gateway URL'), gatewayUrl: this.resolve(storageKey), - uri: data.uri ?? never(`Missing 'uri' in response: ${JSON.stringify(data)}`), + uri: + data.uri ?? + never(`Missing 'uri' in response: ${JSON.stringify(data)}`), }; }) as [Resource, ...Resource[]]; }; diff --git a/src/builders.ts b/src/builders.ts index e0e8480..0fd7397 100644 --- a/src/builders.ts +++ b/src/builders.ts @@ -12,7 +12,10 @@ import type { * @param address - The Wallet Address that can edit/delete the resource. * @param chainId - The Chain ID that the resource is bound to. See supported chains. */ -export function walletOnly(address: EvmAddress, chainId: number): WalletAddressAcl { +export function walletOnly( + address: EvmAddress, + chainId: number, +): WalletAddressAcl { return { template: 'wallet_address', walletAddress: address, chainId }; } @@ -22,7 +25,10 @@ export function walletOnly(address: EvmAddress, chainId: number): WalletAddressA * @param account - The Lens Account that can edit/delete the resource. * @param chainId - The Lens Chain ID that the resource is bound to. */ -export function lensAccountOnly(account: EvmAddress, chainId: number): LensAccountAcl { +export function lensAccountOnly( + account: EvmAddress, + chainId: number, +): LensAccountAcl { return { template: 'lens_account', chainId, lensAccount: account }; } diff --git a/src/environments.ts b/src/environments.ts index 15c1aa1..75fad16 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -5,8 +5,9 @@ export type EnvironmentConfig = { name: string; backend: string; defaultChainId: number; + cachingTimeout: number; propagationTimeout: number; - propagationPollingInterval: number; + statusPollingInterval: number; }; /** @@ -16,8 +17,9 @@ export const production: EnvironmentConfig = { name: 'production', backend: 'https://api.grove.storage', defaultChainId: 232, + cachingTimeout: 5000, propagationTimeout: 10000, - propagationPollingInterval: 500, + statusPollingInterval: 500, }; /** @@ -27,8 +29,9 @@ export const staging: EnvironmentConfig = { name: 'staging', backend: 'https://api.staging.grove.storage', defaultChainId: 37111, + cachingTimeout: 10000, propagationTimeout: 20000, - propagationPollingInterval: 500, + statusPollingInterval: 500, }; /** @@ -38,6 +41,7 @@ export const local: EnvironmentConfig = { name: 'local', backend: 'http://localhost:30371110', defaultChainId: 37111, + cachingTimeout: 0, // no caching propagationTimeout: 30000, - propagationPollingInterval: 500, + statusPollingInterval: 500, }; diff --git a/src/errors.ts b/src/errors.ts index c92656c..7e5754e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -5,7 +5,7 @@ class BaseError extends Error { // biome-ignore lint/complexity/noThisInStatic: need this to create the correct error type return new this(message); - } catch (error) { + } catch (_) { // biome-ignore lint/complexity/noThisInStatic: need this to create the correct error type return new this(await response.text()); } diff --git a/src/types.ts b/src/types.ts index 0c74e4c..fc0071a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,4 @@ import type { StorageClient } from './StorageClient'; -import { StorageClientError } from './errors'; -import { delay } from './utils'; export type EvmAddress = `0x${string}`; @@ -30,7 +28,11 @@ export type WalletAddressAcl = { chainId: number; }; -export type AclConfig = GenericAcl | ImmutableAcl | LensAccountAcl | WalletAddressAcl; +export type AclConfig = + | GenericAcl + | ImmutableAcl + | LensAccountAcl + | WalletAddressAcl; /** * The marker used to identify the address of the signer attempting @@ -115,34 +117,11 @@ abstract class UploadResponse { * @throws a {@link StorageClientError} if the operation fails or times out. */ async waitForPropagation(): Promise { - const startedAt = Date.now(); - - while (Date.now() - startedAt < this.client.env.propagationTimeout) { - try { - const { status } = await this.client.status(this.resource.storageKey); - - switch (status) { - case 'done': - return; - - case 'error_upload': - case 'error_edit': - case 'error_delete': - case 'unauthorized': - throw StorageClientError.from( - `The resource ${this.resource.storageKey} has returned a '${status}' status.`, - ); - - default: - await delay(this.client.env.propagationPollingInterval); - break; - } - } catch (error) { - console.log(error); - throw StorageClientError.from(error); - } - } - throw StorageClientError.from(`Timeout waiting for ${this.resource.uri} to be persisted.`); + return this.client.waitUntilStatus( + this.resource.storageKey, + ['done'], + this.client.env.propagationTimeout, + ); } } @@ -180,7 +159,22 @@ export type DeleteResponse = { /** * @internal */ -export type Status = { +export type Status = + | 'available' + | 'dirty' + | 'done' + | 'error_delete' + | 'error_edit' + | 'error_upload' + | 'idle' + | 'new' + | 'pending' + | 'unauthorized'; + +/** + * @internal + */ +export type StatusResponse = { /** * The storage key of the resource. */ @@ -188,15 +182,7 @@ export type Status = { /** * The current status. */ - status: - | 'done' - | 'error_delete' - | 'error_edit' - | 'error_upload' - | 'idle' - | 'new' - | 'pending' - | 'unauthorized'; + status: Status; /** * A percentage value between 0-100 indicating the progress of the resource's persistence. * diff --git a/src/utils.ts b/src/utils.ts index dfc01fb..3eb48c9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,12 @@ import type { EnvironmentConfig } from './environments'; import { InvariantError } from './errors'; -import type { AclConfig, CreateIndexContent, Resource, Status } from './types'; +import type { + AclConfig, + CreateIndexContent, + Resource, + Status, + StatusResponse, +} from './types'; /** * Asserts that the given condition is truthy @@ -9,7 +15,10 @@ import type { AclConfig, CreateIndexContent, Resource, Status } from './types'; * @param condition - Either truthy or falsy value * @param message - An error message */ -export function invariant(condition: unknown, message: string): asserts condition { +export function invariant( + condition: unknown, + message: string, +): asserts condition { if (!condition) { throw new InvariantError(message); } @@ -45,7 +54,10 @@ export type MultipartFormDataStream = { stream: ReadableStream; }; -async function* multipartStream(entries: readonly MultipartEntry[], boundary: string) { +async function* multipartStream( + entries: readonly MultipartEntry[], + boundary: string, +) { for (const { name, file } of entries) { yield `--${boundary}\r\n`; @@ -65,7 +77,9 @@ async function* multipartStream(entries: readonly MultipartEntry[], boundary: st yield `--${boundary}--\r\n`; } -function createMultipartStream(entries: readonly MultipartEntry[]): MultipartFormDataStream { +function createMultipartStream( + entries: readonly MultipartEntry[], +): MultipartFormDataStream { const boundary = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`; return { @@ -114,7 +128,7 @@ async function detectStreamSupport(): Promise { const body = await request.text(); return duplexAccessed && !hasContentType && body === '\x00'; // 0 byte as character - } catch (error) { + } catch (_) { return false; } } @@ -129,7 +143,10 @@ function createFormData(entries: readonly MultipartEntry[]): FormData { return formData; } -function computeMultipartSize(entries: readonly MultipartEntry[], boundary: string): number { +function computeMultipartSize( + entries: readonly MultipartEntry[], + boundary: string, +): number { let size = 0; const encoder = new TextEncoder(); @@ -211,7 +228,10 @@ export function extractStorageKey(storageKeyOrUri: string): string { /** * @internal */ -export function resourceFrom(storageKey: string, env: EnvironmentConfig): Resource { +export function resourceFrom( + storageKey: string, + env: EnvironmentConfig, +): Resource { return { storageKey, gatewayUrl: `${env.backend}/${storageKey}`, @@ -222,9 +242,11 @@ export function resourceFrom(storageKey: string, env: EnvironmentConfig): Resour /** * @internal */ -export function statusFrom(data: Record): Status { +export function statusResponseFrom( + data: Record, +): StatusResponse { return { - status: data.status as Status['status'], + status: data.status as Status, storageKey: data.storage_key as string, progress: data.progress as number, }; @@ -276,7 +298,9 @@ function createAclEntry(template: AclConfig): MultipartEntry { }; } -function createDefaultIndexContent(files: readonly Resource[]): Record { +function createDefaultIndexContent( + files: readonly Resource[], +): Record { return { files: files.map((file) => file.storageKey), }; @@ -323,7 +347,9 @@ export class MultipartEntriesBuilder { return this; } - withIndexFile(index: CreateIndexContent | File | true): MultipartEntriesBuilder { + withIndexFile( + index: CreateIndexContent | File | true, + ): MultipartEntriesBuilder { const file = index instanceof File ? index @@ -333,7 +359,10 @@ export class MultipartEntriesBuilder { : index.call(null, this.allocations.slice()), // shallow copy ); - invariant(file.name === 'index.json', "Index file must be named 'index.json'"); + invariant( + file.name === 'index.json', + "Index file must be named 'index.json'", + ); return this.withFile(file); } diff --git a/vitest.config.ts b/vitest.config.ts index 930bf3c..98a93a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { setupFiles: ['./vitest.setup.ts'], env: loadEnv('', process.cwd(), ''), - testTimeout: 10000, + testTimeout: 15000, projects: [ {