Puter 键值存储之puter.kv.list()完全指南:键枚举、前缀匹配模式与游标分页
【免费下载链接】puter🌐 The Internet Computer! Free, Open-Source, and Self-Hostable.项目地址: https://gitcode.com/GitHub_Trending/pu/puter
puter.kv.list()是 Puter 键值存储(KV Store)的核心读取方法,用于以字典序(lexicographic order)枚举当前用户在当前应用命名空间下的全部键(key),支持前缀模式过滤、值(value)带回、游标分页与流式迭代。本指南以官方 API 文档 src/docs/src/KV/list.md 为骨架,结合 SDK 源码与测试,完整讲解其语法、全部可选参数、五种可运行示例,并深入其底层实现与计量模型,帮助你安全、高效地在大型存储上做键扫描与分页查询。
puter.kv.list()是什么
在 Puter 中,每个应用在每个用户账号下拥有自己独立的键值存储命名空间,应用之间默认互不可见(除非用户通过puter.perms.request('appData', …)显式授权,参见 KV/set.md 中的说明)。puter.kv.list()就是针对这个命名空间的全量键枚举接口:
- 返回当前应用在该用户键值存储中的全部键(数组形式);
- 如果用户没有任何键,返回空数组;
- 返回结果按键的**字典序(字符串顺序)**排序。
它与其他 KV 方法共同构成完整的读写闭环:set(写入)、get(单键读取)、del(删除)、incr/decr(计数)、expire/expireAt(过期)、update/add/remove(对象路径操作)、flush(清空)。在 SDK 中,这些方法统一挂载在puter.kv模块上,list的实现位于 src/puter-js/src/modules/kv/list.js,模块定义见 src/puter-js/src/modules/kv/index.js。该 API 支持websites、apps、nodejs、workers四类平台(文档 frontmatter 的platforms字段)。
语法与参数
语法形式
puter.kv.list()支持五种调用形式,覆盖了从最简单到最复杂的全部场景:
puter.kv.list() puter.kv.list(pattern) puter.kv.list(returnValues = false) puter.kv.list(pattern, returnValues = false) puter.kv.list(options)其中options对象可以携带pattern、returnValues、limit、cursor、offset、includeTotal、fetchUntilFull、stream等属性。此外 SDK 还允许在任何位置参数形式后追加一个可选的optConfig配置对象(见下文源码分析)。
pattern(String,可选)
如果设置,则只返回匹配该模式的键。模式是基于前缀的,且*通配符只能出现在末尾:
abc与abc*都会匹配所有以abc开头的键,例如abc、abc123、abc123xyz;- 如果前缀本身需要匹配字面量
*,则把*放在末尾,例如key**匹配所有以key*开头的键;同理k*y*匹配k*y前缀; - 默认值是
*,即匹配所有键。
需要注意:这里的模式永远是前缀匹配,无论是否带末尾的
*。这一点与 Events 主题(subject)恰好相反——Events 中kv:cart只监听这一个键,你需要追加*才能扩大为前缀监听(相关文档位于 src/docs/src/Events/ 目录)。
SDK 在发送请求前会对模式做一次规范化(normalizeListPattern,见 list.js):非字符串或空串/纯空白被当作"不传 pattern";末尾的*被剥离后作为裸前缀发送;*单独出现时等价于"匹配一切",因此根本不发送 pattern 字段。这一点有对应的单元测试覆盖(见 kv.test.js 中list("*") matches everything, so no pattern is sent等用例)。
returnValues(Boolean,可选)
- 设为
true时,返回数组中的元素是同时含key与value两个属性的对象(即KVPair对象); - 设为
false(默认)时,返回数组只包含键名字符串。
options(Object,可选)
一个包含以下可选属性的对象:
| 属性 | 类型 | 说明 |
|---|---|---|
pattern | String | 与位置参数pattern相同的前缀模式。 |
returnValues | Boolean | 与位置参数returnValues相同;true时返回KVPair对象数组。 |
limit | Number | 单次调用最多返回的条目数。 |
cursor | String | 上一次调用返回的分页游标。把上一页返回的cursor原样传入即可获取下一页。 |
offset | Number | 在本页开始前跳过的条目数。不推荐使用——offset 越大,请求越慢、越贵;优先用cursor。最大值为5000,且不能与cursor同时使用。 |
includeTotal | Boolean | 为true时,结果会附带一个total字段,表示匹配该查询的全部条目数(跨所有页)。该计数是计量(metered)的,成本随存储规模增长——只在第一页请求一次,避免在热点路径中使用。如果你只需要知道是否还有更多页,检查cursor是否存在即可,不要用计数。 |
fetchUntilFull | Boolean | 一页返回的条目数可能少于limit,即使后面还有更多数据(例如过期键被排除时)。设为true时,会在可能的情况下把本页补满到limit条。要求必须同时指定limit。 |
stream | Boolean | 为true时,方法不再返回 Promise,而是返回一个KVListPage对象的异步迭代器,配合for await ... of使用。可与limit组合控制页大小,或用cursor从上一位置恢复;不能与offset组合。配合includeTotal时,只有第一页携带total。 |
需要特别注意的是:只要在options中使用了limit、cursor、offset、includeTotal、fetchUntilFull中的任意一个,返回值就会从普通数组变为KVListPage分页对象;而stream: true则进一步把返回值变成异步迭代器。这一点在 SDK 源码中体现为paginated标志位的置位逻辑(见 list.js),并被测试用例list(options) copies every pagination option逐字段验证。
返回值
puter.kv.list()返回一个Promise,解析结果取决于调用方式:
- 键数组:
string[],即当前应用在当前用户下的全部键(returnValues为false,且未使用任何分页选项时); KVPair对象数组:每个对象含key与value两个属性(returnValues: true时);KVListPage对象:在options中使用limit、cursor、offset、includeTotal、fetchUntilFull中任意一项时返回。
如果用户没有任何键,返回空数组。
KVListPage对象
KVListPage是分页结果的标准信封,其完整定义见官方文档 src/docs/src/Objects/kvlistpage.md 与 SDK 类型声明 src/puter-js/src/modules/kv/types.js:
| 属性 | 类型 | 说明 |
|---|---|---|
items | Array | 本页条目。returnValues为false时是键名字符串数组;为true时是KVPair对象数组。 |
cursor | String(可选) | 用于获取下一页的游标。只有当还有更多结果时才存在。把该值传给下一次puter.kv.list()调用即可拿到下一页。 |
total | Number(可选) | 匹配该查询的跨页总条目数。仅当请求设置了includeTotal: true时才存在。计算它是计量操作,成本随存储规模增长——只在第一页请求一次,避免热点路径;只需判断是否有更多页时,检查cursor即可。 |
分页遍历规则
- 进行分页遍历时,一直迭代到结果中不再有
cursor为止; - 一页的条目数可能少于
limit,但后面仍有更多页——永远不要用items.length < limit作为列表结束的信号(cursor存在才代表还有下一页)。这一约定同样写进了全仓库的分页规范 doc/pagination.md:"Pages may be short. Post-query filtering (TTL expiry, permission checks) can shrink a page belowlimit— or even to zero — while acursoris still returned."
无分页全量列表的兼容性
全量(非分页)列表仍然解析为普通数组,因此既有代码不受影响——SDK 底层会改为逐页获取,再拼接返回:
- 底层每次请求携带 SDK 默认页大小
SDK_PAGE_LIMIT = 1000,并自动开启fetchUntilFull(见 list.js 与 list.js); - 但全量列表仍然会读取整个存储:每一页都是计量操作,所以在大型存储上裸调
list()会变慢、变贵。当一次全量列表跨越多个页面时,SDK 会通过console.warn输出一次性警告(每个 SDK 实例只提示一次,见nudgeOnce机制,list.js); - 官方建议:优先使用
stream: true或显式的limit/cursor分页,并用pattern收窄扫描范围。
流式迭代(stream: true)
stream: true时,方法返回KVListPage对象的异步迭代器,可以配合for await ... of逐页消费:
for await (const page of puter.kv.list({ pattern: 'log:*', stream: true })) { for (const key of page.items) { console.log(key); } }完整示例
以下五个示例直接取自官方文档 src/docs/src/KV/list.md,覆盖了从入门到进阶的全部用法,均可直接复制到带<script src="https://js.puter.com/v2/"></script>的 HTML 页面中运行。
示例一:获取当前应用键值存储中的全部键
<html> <body> <script src="https://js.puter.com/v2/"></script> <script> (async () => { // (1) Create a number of key-value pairs await puter.kv.set('name', 'Puter Smith'); await puter.kv.set('age', 21); await puter.kv.set('isCool', true); puter.print("Key-value pairs created/updated<br><br>"); // (2) Retrieve all keys const keys = await puter.kv.list(); puter.print(`Keys are: ${keys}<br><br>`); // (3) Retrieve all keys and values const key_vals = await puter.kv.list(true); puter.print(`Keys and values are: ${(key_vals).map((key_val) => key_val.key + ' => ' + key_val.value)}<br><br>`); // (4) Match keys with a pattern const keys_matching_pattern = await puter.kv.list('is*'); puter.print(`Keys matching pattern are: ${keys_matching_pattern}<br>`); // (5) Delete all keys (cleanup) await puter.kv.del('name'); await puter.kv.del('age'); await puter.kv.del('isCool'); })(); </script> </body> </html>示例二:用游标分页(每页 2 条)
<html> <body> <script src="https://js.puter.com/v2/"></script> <script> (async () => { // Create sample data for (let i = 1; i <= 6; i++) { await puter.kv.set(`item-${i}`, `value-${i}`); } puter.print('Created 6 key-value pairs<br><br>'); // Paginate with cursor (2 items per page) let currentCursor = undefined; let page = 1; do { const result = await puter.kv.list({ limit: 2, returnValues: true, cursor: currentCursor, }); const items = result.items; puter.print(`<b>Page ${page}:</b><br>`); for (const item of items) { puter.print(` ${item.key} => ${item.value}<br>`); } puter.print('<br>'); currentCursor = result.cursor; page++; } while (currentCursor); puter.print('Done paginating.<br><br>'); // Cleanup for (let i = 1; i <= 6; i++) { await puter.kv.del(`item-${i}`); } puter.print('Cleaned up sample data.'); })(); </script> </body> </html>注意do ... while (currentCursor)的终止条件:只要上一页返回的cursor非空就继续取下一页,这与"迭代到没有cursor为止"的约定完全一致。
示例三:利用字典序做时间序日志排序
因为结果按字典序排序,使用 ISO 8601 时间戳(例如2025-03-15T10:00:00Z)作为键前缀的一部分,天然就实现了按时间顺序的输出,非常适合日志、事件流水等场景:
<html> <body> <script src="https://js.puter.com/v2/"></script> <script> (async () => { await puter.kv.set('log:2025-03-15T10:00:00Z', { msg: 'third' }); await puter.kv.set('log:2025-01-01T00:00:00Z', { msg: 'first' }); await puter.kv.set('log:2025-02-14T08:00:00Z', { msg: 'second' }); const logs = await puter.kv.list('log:*'); puter.print('Sorted keys: <br/>'); puter.print(logs.join('<br/>')); // Cleanup await puter.kv.del('log:2025-03-15T10:00:00Z'); await puter.kv.del('log:2025-01-01T00:00:00Z'); await puter.kv.del('log:2025-02-14T08:00:00Z'); })(); </script> </body> </html>示例四:数字键用零填充保证排序正确
字典序下数字会按"字符"排序:1, 10, 100, 2, 20,而不是按数值排序。把数字补零到固定宽度(001, 002, 010, 100)即可得到正确的数值顺序:
<html> <body> <script src="https://js.puter.com/v2/"></script> <script> (async () => { // Wrong — will sort as 1, 10, 100, 2, 20 await puter.kv.set('item:1', '...'); await puter.kv.set('item:10', '...'); await puter.kv.set('item:2', '...'); // Correct — zero-pad to a fixed width await puter.kv.set('item:001', '...'); await puter.kv.set('item:002', '...'); await puter.kv.set('item:010', '...'); await puter.kv.set('item:100', '...'); const items = await puter.kv.list('item:*'); puter.print('Items with zero-padding: <br/>'); puter.print(items.join('<br/>')); // Cleanup await puter.kv.del('item:1'); await puter.kv.del('item:10'); await puter.kv.del('item:2'); await puter.kv.del('item:001'); await puter.kv.del('item:002'); await puter.kv.del('item:010'); await puter.kv.del('item:100'); })(); </script> </body> </html>示例五:用前缀模式设计"类查询"过滤(键设计即查询计划)
KV 没有通用查询语言——键设计就是你的查询计划。通过把同一条数据冗余写入多个前缀友好的键,每个读路径就变成了一次简单的前缀查询:
<html> <body> <script src="https://js.puter.com/v2/"></script> <script> (async () => { const orders = [ { id: '0001', status: 'pending', customer: 'alice', total: 48 }, { id: '0002', status: 'shipped', customer: 'alice', total: 72 }, { id: '0003', status: 'pending', customer: 'bob', total: 15 }, ]; // In KV, key design is your query plan. // We store the same order under multiple prefixes so each read path // becomes a simple prefix query with puter.kv.list(). for (const order of orders) { await puter.kv.set(`demo:order:by-id:${order.id}`, order); await puter.kv.set(`demo:order:by-status:${order.status}:${order.id}`, order); await puter.kv.set(`demo:order:by-customer:${order.customer}:${order.id}`, order); await puter.kv.set(`demo:order:by-status-customer:${order.status}:${order.customer}:${order.id}`, order); } puter.print('<b>Stored read paths</b><br>'); puter.print('demo:order:by-status:pending:*<br>'); puter.print('demo:order:by-customer:alice:*<br>'); puter.print('demo:order:by-status-customer:pending:alice:*<br><br>'); const pendingOrders = await puter.kv.list('demo:order:by-status:pending:*', true); puter.print('<b>Query: status = pending</b><br>'); pendingOrders.forEach(({ key, value }) => { puter.print(`${key} => ${value.customer} ($${value.total})<br>`); }); puter.print('<br>'); const aliceOrders = await puter.kv.list('demo:order:by-customer:alice:*', true); puter.print('<b>Query: customer = alice</b><br>'); aliceOrders.forEach(({ key, value }) => { puter.print(`${key} => ${value.status} ($${value.total})<br>`); }); puter.print('<br>'); const alicePendingOrders = await puter.kv.list('demo:order:by-status-customer:pending:alice:*', true); puter.print('<b>Query: status = pending AND customer = alice</b><br>'); alicePendingOrders.forEach(({ key, value }) => { puter.print(`${key} => order ${value.id} ($${value.total})<br>`); }); puter.print('<br>'); puter.print('<b>Takeaway</b><br>'); puter.print('With puter.kv.list(), filtering comes from key prefixes.<br>'); puter.print('If you need another query path, add another prefix-friendly key.<br><br>'); // Cleanup for (const order of orders) { await puter.kv.del(`demo:order:by-id:${order.id}`); await puter.kv.del(`demo:order:by-status:${order.status}:${order.id}`); await puter.kv.del(`demo:order:by-customer:${order.customer}:${order.id}`); await puter.kv.del(`demo:order:by-status-customer:${order.status}:${order.customer}:${order.id}`); } })(); </script> </body> </html>这个示例演示了三种典型"查询":单条件(status = pending)、单条件(customer = alice)以及组合条件(status = pending AND customer = alice),全部通过不同的键前缀完成,且都配合returnValues: true直接拿到完整对象。
底层实现与调用链:源码级剖析
参数解析与模式规范化
puter.kv.list()的实现(src/puter-js/src/modules/kv/list.js)支持位置参数、options 对象以及可选的尾随optConfig三种形态,内部通过大量@overloadJSDoc 声明公开签名(这些签名是types/自动生成的唯一事实来源,见 src/puter-js/src/modules/kv/index.js 中的注释):
- 当第一个参数是纯对象且没有第二、三参数时,按
options对象解析:读取pattern、returnValues、stream及分页字段; - 否则按位置参数解析:字符串视为
pattern,true视为returnValues,对象视为optConfig; options.optConfig还支持"简写"形式:直接传入{ appUuid: 'u' }这类对象即被识别为optConfig(见 src/puter-js/src/modules/kv/lib/args.js 的isOptConfigShorthand);stream: true时会先把stream从简写对象中剔除再作为optConfig传递。
optConfig是每个puter.kv操作都接受的按次配置(见 types.js 的KVOptConfig):
appUuid:指向另一个应用的命名空间,需要app-data:<appUuid>:kv:<op>权限,通常由puter.perms.requestAppData()向用户申请;disableSharing:标记条目对当前应用私有(对list无直接影响,主要由set使用)。
returnValues为false时,SDK 会在请求参数中加入as: 'keys';pattern经过normalizeListPattern规范化后以裸前缀形式发送。最终请求通过utils.makeDriverMethod({ iface: 'puter-kvstore', method: 'list', puter: this.puter, readonly: true })发出——接口名是puter-kvstore,方法名是list,且标记为只读操作(见 list.js)。
三种执行路径
从 list.js 可以清晰看到list()的三种执行路径:
- 流式路径(
stream: true):客户端直接拒绝与offset组合(抛出{ code: 'invalid_request' });未显式指定limit时自动使用SDK_PAGE_LIMIT = 1000并开启fetchUntilFull;通过iteratePages返回异步生成器,逐页跟随cursor。 - 显式分页路径:只要
limit/cursor/offset/includeTotal/fetchUntilFull中任意一项存在,就发单次请求,原样返回后端给出的单个KVListPage。 - 无界全量路径(兼容旧行为):SDK 替调用方逐页请求(每页
limit: 1000+fetchUntilFull: true),再用fetchAllPages拼接成普通数组返回。跨页时会触发一次性的"全量扫描"控制台警告。
客户端分页引擎
全仓库的列表类 API 共享同一套客户端分页引擎 src/puter-js/src/lib/pagination.js:
iteratePages(fetchPage, opts):异步生成器,沿cursor迭代直到其消失;includeTotal只在首个请求上发送(总数不随页变化,且成本随条目数增长);若后端忽略分页参数而返回裸数组,则该数组被当作唯一的"一页",保证旧后端兼容;fetchAllPages(fetchPage):消费全部页面并把items拼接成一个大数组返回。
这也解释了全量list()为何仍然可用但代价高昂:它是在客户端完成的"逐页拉全量",每一页网络请求都是计量操作。
网络层的传输契约
后端统一遵循 doc/pagination.md 定义的分页网络契约:请求可携带limit、cursor(不透明续传令牌,null表示请求第一页)、offset(遗留/不推荐,不能与cursor组合)、includeTotal;分页响应是标准信封:
{ "items": [...], "cursor": "…", "total": 123 }其中cursor仅在还有更多页时存在,total仅在设置了includeTotal时存在。游标是不透明的 base64 编码 JSON,由后端 src/backend/util/pagination.ts 生成与消费:DynamoDB 后端包装LastEvaluatedKey,SQL 后端包装键集(keyset)位置——(sortValue, id)形式的最后一行,并通过WHERE (col, id) > (?, ?) ORDER BY col, id向后寻位。这正是"offset 越大越慢、游标分页才是正道"的技术根源。
测试如何锁定行为
SDK 的单元测试 src/puter-js/src/modules/kv/kv.test.js 通过伪造XMLHttpRequest精确锁定每一次/drivers/call的网络载荷,其中与list相关的用例(第 515–702 行)验证了:
list()发送{ as: 'keys' }(仅要键);list(true)不带as: 'keys'(要键值对);list('abc*')剥离末尾通配符发送裸前缀abc;list('*')干脆不发送pattern;list('k**')保留前缀中的字面量*;list()会跟随游标并拼接出完整列表(两页:['a','b']+['c'],恰好两次请求,第二次携带cursor: 'c2');- 后端返回裸数组时被当作完整列表(单次请求);
- 全量列表跨多页时只警告一次(
warns once when a full listing spans multiple pages),单页内不警告; includeTotal只警告一次,且stream模式下includeTotal只随首个请求发送;stream: true与offset组合在客户端即被拒绝(code: 'invalid_request');list(options)会把全部六个分页/返回选项逐字段透传(pattern 被规范化为p)。
性能与计量(metering)注意事项
puter.kv.list()的一切"昂贵"特性都与计量模型相关,官方文档与源码注释反复强调以下几点,务必在设计中遵守:
- 每个分页请求都是计量操作。裸调
list()会读取整个存储:页数越多,总成本越高,大存储上会显著变慢。 - 优先
stream: true或显式limit/cursor分页,并用pattern把扫描范围收窄到必要前缀。 includeTotal是计量计数,成本随匹配条目数增长。只在第一页请求一次(配合stream时 SDK 也会自动这样做),避免放在热点路径;判断"是否还有下一页"应检查cursor而不是数总数。offset越大越贵(后端需要跳过越来越多行),最大5000,且不能与cursor组合;一律用cursor做深分页。- 警惕过期键导致的"短页":TTL 过期过滤会让一页少于
limit条却仍有下一页,所以绝不能以items.length < limit判断结束。
这些警告在 SDK 中以"每个实例一次"的方式通过console.warn输出(nudgeOnce机制,list.js),不会刷屏,但值得重视。
相关键值存储能力与限制
围绕list(),Puter KV 还有一组配套能力与硬性限制(详见 src/docs/src/KV/ 目录下的各方法文档):
- 写入/读取:
puter.kv.set(key, value, expireAt?)、puter.kv.get(key)(键不存在时解析为undefined)、puter.kv.del(key);set还支持批量与disableSharing私有标记; - 计数:
puter.kv.incr(key, amount?)/puter.kv.decr(key, amount?),限定 64 位有符号整数,且计数只能精确到±9,007,199,254,740,991(Number.MAX_SAFE_INTEGER); - 过期:
puter.kv.expire(key, ttlSeconds)、puter.kv.expireAt(key, timestamp); - 对象路径操作:
puter.kv.update(key, pathMap)、puter.kv.add(key, value)、puter.kv.remove(key, ...paths); - 清空:
puter.kv.flush()(别名puter.kv.clear); - 尺寸限制:键最大1 KB、值最大400 KB,对应 SDK 公开常量
puter.kv.MAX_KEY_SIZE(1024 字节)与puter.kv.MAX_VALUE_SIZE(399 * 1024字节,见 src/puter-js/src/modules/kv/lib/validate.js 与官方文档 MAX_KEY_SIZE.md、MAX_VALUE_SIZE.md)。超限的写入会在客户端直接抛出稳定的{ message, code }错误对象。
在 CLI 中调试键值存储
仓库自带的 Puter CLI(src/cli)提供了交互式 KV 调试工具:puter kv connect <identifier>会打开一个针对指定应用键值存储的 REPL,其中list方法以list([pattern], [values])形式暴露(等价于puter.kv.list,实现见 src/cli/src/commands/kv.js 与 src/cli/src/lib/kvbind.js)。连接时 CLI 会通过一次kv.list({ limit: 101, fetchUntilFull: true })探测存储规模并显示在横幅中(超过 100 个键显示100+ keys),并且 REPL 会自动await结果再回显,方便直接验证前缀模式与分页行为。
小结
puter.kv.list()是 Puter KV 存储的"查询入口",但它只有一种查询维度——字典序 + 前缀匹配。掌握以下要点即可在生产中安全使用:
- 用
pattern收窄扫描范围,用零填充或 ISO 时间戳设计键以获得正确的排序语义; - 大型存储上优先
stream: true或显式limit/cursor分页,用cursor判续页,避免裸list()全量扫描; includeTotal与offset都是计量/低效操作,前者只在第一页用一次,后者尽量不用;- 把"查询计划"做进键前缀里,让每个业务查询都变成一次廉价的前缀枚举。
结合本文给出的官方示例、SDK 源码路径(list.js、pagination.js)与测试用例(kv.test.js),你可以放心地把puter.kv.list()用于日志扫描、订单过滤、时间线枚举等各类实际场景。
【免费下载链接】puter🌐 The Internet Computer! Free, Open-Source, and Self-Hostable.项目地址: https://gitcode.com/GitHub_Trending/pu/puter
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考