This is an automated email from the ASF dual-hosted git repository.

xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new b8455654a fix(binding/nodejs): add missing lister methods (#6769)
b8455654a is described below

commit b8455654a45746cd3491656a3c9375d26b0f78c0
Author: Kilerd Chan <[email protected]>
AuthorDate: Mon Nov 10 23:27:41 2025 +0800

    fix(binding/nodejs): add missing lister methods (#6769)
    
    fix(nodejs): add missing lister methods
---
 bindings/nodejs/generated.d.ts                     |  62 +++++++
 bindings/nodejs/src/lib.rs                         |  87 +++++++++
 bindings/nodejs/tests/suites/asyncLister.suite.mjs | 203 +++++++++++++++++++++
 bindings/nodejs/tests/suites/index.mjs             |   4 +
 bindings/nodejs/tests/suites/syncLister.suite.mjs  | 203 +++++++++++++++++++++
 5 files changed, 559 insertions(+)

diff --git a/bindings/nodejs/generated.d.ts b/bindings/nodejs/generated.d.ts
index c2219d529..3473095c4 100644
--- a/bindings/nodejs/generated.d.ts
+++ b/bindings/nodejs/generated.d.ts
@@ -676,6 +676,68 @@ export declare class Operator {
    * ```
    */
   listSync(path: string, options?: ListOptions | undefined | null): 
Array<Entry>
+  /**
+   * Create a lister to list entries at given path.
+   *
+   * This function returns a Lister that can be used to iterate over entries
+   * in a streaming manner, which is more memory-efficient for large 
directories.
+   *
+   * An error will be returned if given path doesn't end with `/`.
+   *
+   * ### Example
+   *
+   * ```javascript
+   * const lister = await op.lister("path/to/dir/");
+   * let entry;
+   * while ((entry = await lister.next()) !== null) {
+   *   console.log(entry.path());
+   * }
+   * ```
+   *
+   * #### List recursively
+   *
+   * With `recursive` option, you can list recursively.
+   *
+   * ```javascript
+   * const lister = await op.lister("path/to/dir/", { recursive: true });
+   * let entry;
+   * while ((entry = await lister.next()) !== null) {
+   *   console.log(entry.path());
+   * }
+   * ```
+   */
+  lister(path: string, options?: ListOptions | undefined | null): 
Promise<Lister>
+  /**
+   * Create a lister to list entries at given path synchronously.
+   *
+   * This function returns a BlockingLister that can be used to iterate over 
entries
+   * in a streaming manner, which is more memory-efficient for large 
directories.
+   *
+   * An error will be returned if given path doesn't end with `/`.
+   *
+   * ### Example
+   *
+   * ```javascript
+   * const lister = op.listerSync("path/to/dir/");
+   * let entry;
+   * while ((entry = lister.next()) !== null) {
+   *   console.log(entry.path());
+   * }
+   * ```
+   *
+   * #### List recursively
+   *
+   * With `recursive` option, you can list recursively.
+   *
+   * ```javascript
+   * const lister = op.listerSync("path/to/dir/", { recursive: true });
+   * let entry;
+   * while ((entry = lister.next()) !== null) {
+   *   console.log(entry.path());
+   * }
+   * ```
+   */
+  listerSync(path: string, options?: ListOptions | undefined | null): 
BlockingLister
   /**
    * Get a presigned request for read.
    *
diff --git a/bindings/nodejs/src/lib.rs b/bindings/nodejs/src/lib.rs
index 932539ea6..de7ca38bc 100644
--- a/bindings/nodejs/src/lib.rs
+++ b/bindings/nodejs/src/lib.rs
@@ -638,6 +638,93 @@ impl Operator {
         Ok(l.into_iter().map(Entry).collect())
     }
 
+    /// Create a lister to list entries at given path.
+    ///
+    /// This function returns a Lister that can be used to iterate over entries
+    /// in a streaming manner, which is more memory-efficient for large 
directories.
+    ///
+    /// An error will be returned if given path doesn't end with `/`.
+    ///
+    /// ### Example
+    ///
+    /// ```javascript
+    /// const lister = await op.lister("path/to/dir/");
+    /// let entry;
+    /// while ((entry = await lister.next()) !== null) {
+    ///   console.log(entry.path());
+    /// }
+    /// ```
+    ///
+    /// #### List recursively
+    ///
+    /// With `recursive` option, you can list recursively.
+    ///
+    /// ```javascript
+    /// const lister = await op.lister("path/to/dir/", { recursive: true });
+    /// let entry;
+    /// while ((entry = await lister.next()) !== null) {
+    ///   console.log(entry.path());
+    /// }
+    /// ```
+    #[napi]
+    pub async fn lister(
+        &self,
+        path: String,
+        options: Option<options::ListOptions>,
+    ) -> Result<Lister> {
+        let options = options.map_or(ListOptions::default(), 
ListOptions::from);
+        let l = self
+            .async_op
+            .lister_options(&path, options)
+            .await
+            .map_err(format_napi_error)?;
+
+        Ok(Lister(l))
+    }
+
+    /// Create a lister to list entries at given path synchronously.
+    ///
+    /// This function returns a BlockingLister that can be used to iterate 
over entries
+    /// in a streaming manner, which is more memory-efficient for large 
directories.
+    ///
+    /// An error will be returned if given path doesn't end with `/`.
+    ///
+    /// ### Example
+    ///
+    /// ```javascript
+    /// const lister = op.listerSync("path/to/dir/");
+    /// let entry;
+    /// while ((entry = lister.next()) !== null) {
+    ///   console.log(entry.path());
+    /// }
+    /// ```
+    ///
+    /// #### List recursively
+    ///
+    /// With `recursive` option, you can list recursively.
+    ///
+    /// ```javascript
+    /// const lister = op.listerSync("path/to/dir/", { recursive: true });
+    /// let entry;
+    /// while ((entry = lister.next()) !== null) {
+    ///   console.log(entry.path());
+    /// }
+    /// ```
+    #[napi]
+    pub fn lister_sync(
+        &self,
+        path: String,
+        options: Option<options::ListOptions>,
+    ) -> Result<BlockingLister> {
+        let options = options.map_or(ListOptions::default(), 
ListOptions::from);
+        let l = self
+            .blocking_op
+            .lister_options(&path, options)
+            .map_err(format_napi_error)?;
+
+        Ok(BlockingLister(l))
+    }
+
     /// Get a presigned request for read.
     ///
     /// Unit of `expires` is seconds.
diff --git a/bindings/nodejs/tests/suites/asyncLister.suite.mjs 
b/bindings/nodejs/tests/suites/asyncLister.suite.mjs
new file mode 100644
index 000000000..1b5ab1129
--- /dev/null
+++ b/bindings/nodejs/tests/suites/asyncLister.suite.mjs
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { randomUUID } from 'node:crypto'
+import path from 'node:path'
+import { test, describe, expect } from 'vitest'
+
+/**
+ * @param {import("../../index").Operator} op
+ */
+export function run(op) {
+  const capability = op.capability()
+
+  describe.runIf(capability.write && capability.read && 
capability.list)('async lister tests', () => {
+    test('test basic lister', async () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const expected = ['file1', 'file2', 'file3']
+      for (const entry of expected) {
+        await op.write(path.join(dirname, entry), 'test_content')
+      }
+
+      const lister = await op.lister(dirname)
+      const actual = []
+      let entry
+      while ((entry = await lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          actual.push(entry.path().slice(dirname.length))
+        }
+      }
+
+      expect(actual.sort()).toEqual(expected.sort())
+
+      await op.removeAll(dirname)
+    })
+
+    test('test lister returns same results as list', async () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const expected = Array.from({ length: 5 }, (_, i) => 
`file_${i}_${randomUUID()}`)
+      for (const entry of expected) {
+        await op.write(path.join(dirname, entry), 'content')
+      }
+
+      // Get results using list()
+      const listResults = await op.list(dirname)
+      const listPaths = listResults
+        .filter((item) => item.path() !== dirname)
+        .map((item) => item.path())
+        .sort()
+
+      // Get results using lister()
+      const lister = await op.lister(dirname)
+      const listerPaths = []
+      let entry
+      while ((entry = await lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          listerPaths.push(entry.path())
+        }
+      }
+      listerPaths.sort()
+
+      expect(listerPaths).toEqual(listPaths)
+
+      await op.removeAll(dirname)
+    })
+
+    test.runIf(capability.listWithRecursive)('lister with recursive', async () 
=> {
+      const dirname = `random_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const expected = ['x/', 'x/y', 'x/x/', 'x/x/y', 'x/x/x/', 'x/x/x/y', 
'x/x/x/x/']
+      for (const entry of expected) {
+        if (entry.endsWith('/')) {
+          await op.createDir(path.join(dirname, entry))
+        } else {
+          await op.write(path.join(dirname, entry), 'test_scan')
+        }
+      }
+
+      const lister = await op.lister(dirname, { recursive: true })
+      const actual = []
+      let entry
+      while ((entry = await lister.next()) !== null) {
+        if (entry.metadata().isFile()) {
+          actual.push(entry.path().slice(dirname.length))
+        }
+      }
+
+      expect(actual.length).toEqual(3)
+      expect(actual.sort()).toEqual(['x/x/x/y', 'x/x/y', 'x/y'])
+
+      await op.removeAll(dirname)
+    })
+
+    test.runIf(capability.listWithStartAfter)('lister with start after', async 
() => {
+      const dirname = `random_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const given = Array.from({ length: 6 }, (_, i) => path.join(dirname, 
`file_${i}_${randomUUID()}`))
+      for (const entry of given) {
+        await op.write(entry, 'content')
+      }
+
+      const lister = await op.lister(dirname, { startAfter: given[2] })
+      const actual = []
+      let entry
+      while ((entry = await lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          actual.push(entry.path())
+        }
+      }
+
+      const expected = given.slice(3)
+      expect(actual).toEqual(expected)
+
+      await op.removeAll(dirname)
+    })
+
+    test.runIf(capability.listWithLimit)('lister with limit', async () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const given = Array.from({ length: 10 }, (_, i) => path.join(dirname, 
`file_${i}_${randomUUID()}`))
+      for (const entry of given) {
+        await op.write(entry, 'data')
+      }
+
+      const lister = await op.lister(dirname, { limit: 5 })
+      const actual = []
+      let entry
+      while ((entry = await lister.next()) !== null) {
+        actual.push(entry.path())
+      }
+
+      // With limit, we should get the paginated results
+      expect(actual.length).toBeGreaterThan(0)
+
+      await op.removeAll(dirname)
+    })
+
+    test('test empty directory lister', async () => {
+      const dirname = `empty_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const lister = await op.lister(dirname)
+      let entry
+      let count = 0
+      while ((entry = await lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          count++
+        }
+      }
+
+      expect(count).toEqual(0)
+
+      await op.removeAll(dirname)
+    })
+
+    test('test lister metadata', async () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      await op.createDir(dirname)
+
+      const filename = `file_${randomUUID()}`
+      const content = 'test content for metadata'
+      await op.write(path.join(dirname, filename), content)
+
+      const lister = await op.lister(dirname)
+      let entry
+      let found = false
+      while ((entry = await lister.next()) !== null) {
+        if (entry.path() === path.join(dirname, filename)) {
+          const meta = entry.metadata()
+          expect(meta.isFile()).toBe(true)
+          expect(meta.isDirectory()).toBe(false)
+          found = true
+        }
+      }
+
+      expect(found).toBe(true)
+
+      await op.removeAll(dirname)
+    })
+  })
+}
diff --git a/bindings/nodejs/tests/suites/index.mjs 
b/bindings/nodejs/tests/suites/index.mjs
index 791b15c48..36d7de093 100644
--- a/bindings/nodejs/tests/suites/index.mjs
+++ b/bindings/nodejs/tests/suites/index.mjs
@@ -31,6 +31,8 @@ import { run as AsyncReadOptionsTestRun } from 
'./asyncReadOptions.suite.mjs'
 import { run as SyncReadOptionsTestRun } from './syncReadOptions.suite.mjs'
 import { run as AsyncListOptionsTestRun } from './asyncListOptions.suite.mjs'
 import { run as SyncListOptionsTestRun } from './syncListOptions.suite.mjs'
+import { run as AsyncListerTestRun } from './asyncLister.suite.mjs'
+import { run as SyncListerTestRun } from './syncLister.suite.mjs'
 import { run as AsyncDeleteOptionsTestRun } from 
'./asyncDeleteOptions.suite.mjs'
 import { run as SyncDeleteOptionsTestRun } from './syncDeleteOptions.suite.mjs'
 import { run as AsyncWriteOptionsTestRun } from './asyncWriteOptions.suite.mjs'
@@ -71,6 +73,8 @@ export function runner(testName, scheme) {
     SyncReadOptionsTestRun(operator)
     AsyncListOptionsTestRun(operator)
     SyncListOptionsTestRun(operator)
+    AsyncListerTestRun(operator)
+    SyncListerTestRun(operator)
     AsyncDeleteOptionsTestRun(operator)
     SyncDeleteOptionsTestRun(operator)
     AsyncWriteOptionsTestRun(operator)
diff --git a/bindings/nodejs/tests/suites/syncLister.suite.mjs 
b/bindings/nodejs/tests/suites/syncLister.suite.mjs
new file mode 100644
index 000000000..44ee9e6aa
--- /dev/null
+++ b/bindings/nodejs/tests/suites/syncLister.suite.mjs
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { randomUUID } from 'node:crypto'
+import path from 'node:path'
+import { test, describe, expect } from 'vitest'
+
+/**
+ * @param {import("../../index").Operator} op
+ */
+export function run(op) {
+  const capability = op.capability()
+
+  describe.runIf(capability.write && capability.read && capability.list)('sync 
lister tests', () => {
+    test('test basic lister', () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const expected = ['file1', 'file2', 'file3']
+      for (const entry of expected) {
+        op.writeSync(path.join(dirname, entry), 'test_content')
+      }
+
+      const lister = op.listerSync(dirname)
+      const actual = []
+      let entry
+      while ((entry = lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          actual.push(entry.path().slice(dirname.length))
+        }
+      }
+
+      expect(actual.sort()).toEqual(expected.sort())
+
+      op.removeAllSync(dirname)
+    })
+
+    test('test lister returns same results as list', () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const expected = Array.from({ length: 5 }, (_, i) => 
`file_${i}_${randomUUID()}`)
+      for (const entry of expected) {
+        op.writeSync(path.join(dirname, entry), 'content')
+      }
+
+      // Get results using listSync()
+      const listResults = op.listSync(dirname)
+      const listPaths = listResults
+        .filter((item) => item.path() !== dirname)
+        .map((item) => item.path())
+        .sort()
+
+      // Get results using listerSync()
+      const lister = op.listerSync(dirname)
+      const listerPaths = []
+      let entry
+      while ((entry = lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          listerPaths.push(entry.path())
+        }
+      }
+      listerPaths.sort()
+
+      expect(listerPaths).toEqual(listPaths)
+
+      op.removeAllSync(dirname)
+    })
+
+    test.runIf(capability.listWithRecursive)('lister with recursive', () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const expected = ['x/', 'x/y', 'x/x/', 'x/x/y', 'x/x/x/', 'x/x/x/y', 
'x/x/x/x/']
+      for (const entry of expected) {
+        if (entry.endsWith('/')) {
+          op.createDirSync(path.join(dirname, entry))
+        } else {
+          op.writeSync(path.join(dirname, entry), 'test_scan')
+        }
+      }
+
+      const lister = op.listerSync(dirname, { recursive: true })
+      const actual = []
+      let entry
+      while ((entry = lister.next()) !== null) {
+        if (entry.metadata().isFile()) {
+          actual.push(entry.path().slice(dirname.length))
+        }
+      }
+
+      expect(actual.length).toEqual(3)
+      expect(actual.sort()).toEqual(['x/x/x/y', 'x/x/y', 'x/y'])
+
+      op.removeAllSync(dirname)
+    })
+
+    test.runIf(capability.listWithStartAfter)('lister with start after', () => 
{
+      const dirname = `random_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const given = Array.from({ length: 6 }, (_, i) => path.join(dirname, 
`file_${i}_${randomUUID()}`))
+      for (const entry of given) {
+        op.writeSync(entry, 'content')
+      }
+
+      const lister = op.listerSync(dirname, { startAfter: given[2] })
+      const actual = []
+      let entry
+      while ((entry = lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          actual.push(entry.path())
+        }
+      }
+
+      const expected = given.slice(3)
+      expect(actual).toEqual(expected)
+
+      op.removeAllSync(dirname)
+    })
+
+    test.runIf(capability.listWithLimit)('lister with limit', () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const given = Array.from({ length: 10 }, (_, i) => path.join(dirname, 
`file_${i}_${randomUUID()}`))
+      for (const entry of given) {
+        op.writeSync(entry, 'data')
+      }
+
+      const lister = op.listerSync(dirname, { limit: 5 })
+      const actual = []
+      let entry
+      while ((entry = lister.next()) !== null) {
+        actual.push(entry.path())
+      }
+
+      // With limit, we should get the paginated results
+      expect(actual.length).toBeGreaterThan(0)
+
+      op.removeAllSync(dirname)
+    })
+
+    test('test empty directory lister', () => {
+      const dirname = `empty_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const lister = op.listerSync(dirname)
+      let entry
+      let count = 0
+      while ((entry = lister.next()) !== null) {
+        if (entry.path() !== dirname) {
+          count++
+        }
+      }
+
+      expect(count).toEqual(0)
+
+      op.removeAllSync(dirname)
+    })
+
+    test('test lister metadata', () => {
+      const dirname = `random_dir_${randomUUID()}/`
+      op.createDirSync(dirname)
+
+      const filename = `file_${randomUUID()}`
+      const content = 'test content for metadata'
+      op.writeSync(path.join(dirname, filename), content)
+
+      const lister = op.listerSync(dirname)
+      let entry
+      let found = false
+      while ((entry = lister.next()) !== null) {
+        if (entry.path() === path.join(dirname, filename)) {
+          const meta = entry.metadata()
+          expect(meta.isFile()).toBe(true)
+          expect(meta.isDirectory()).toBe(false)
+          found = true
+        }
+      }
+
+      expect(found).toBe(true)
+
+      op.removeAllSync(dirname)
+    })
+  })
+}

Reply via email to