Skip to content

feat(spec,service-storage): 恢复前缀枚举为游标形态 —— list(prefix, { cursor, limit }) + 双适配器一致性用例 (#6781) - #6885

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6781-storage-cursor-enumeration
Aug 9, 2026
Merged

feat(spec,service-storage): 恢复前缀枚举为游标形态 —— list(prefix, { cursor, limit }) + 双适配器一致性用例 (#6781)#6885
os-project-manager merged 1 commit into
mainfrom
claude/issue-6781-storage-cursor-enumeration

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6781

Fixes 而非 Part of 的判断:本卡的验收面(契约 / 两个适配器 / 一致性用例 / SwappableStorageService 透传 / 退役 pin 翻转 / minor changeset)在本 PR 内全部落地,消费侧(cloud#1197、cloud PR #1205)被 issue 正文明确排除在本卡之外。#6781 自身是 cloud#1203 的一部分,那条父线不受本 PR 关闭影响。

为什么

#5540 / #5541 以「仓内无人调用」为测量摘除了 IStorageService.list?(prefix)。该测量对本仓为真,对隔壁 cloud 为假 —— cloud 有两个生产调用方:环境删除时的租户附件回收(cloud#935 即该清扫静默空转、把已删租户的上传永久孤儿化的事故),以及 marketplace 快照 GC。两处摘除注记都逐字预留了唯一一条回归路线,本 PR 就是它(cloud#1203 维护者裁定 option B)。

形状 —— 恢复的是预留的那个,不是旧签名

list?(prefix: string, options?: StorageListOptions): Promise< StorageListPage >;

interface StorageListOptions { cursor?: string; limit?: number }
interface StorageListPage { items: StorageFileInfo[]; nextCursor?: string }

#5266 测得的两个缺陷因此不可表达:

#5266 缺陷 为何不可能复发
S3 在 1000 处静默截断 nextCursor 当且仅当仍有剩余时出现。1000 变成默认 limit,被截断的页会自己说出来。
local 只列一层、S3 递归 只有一套语义(原始 key 字符串前缀、递归匹配),由 storage-adapter-list.conformance.test.ts同一张表同时压在两个后端上。

契约上写死并被用例逐条压住的语义:原始 key 前缀(list('a') 会返回 ab.txt,要限定目录得自己带尾斜杠);只返回文件(文件系统目录与 S3 零字节目录标记都跳过);升序 key 顺序;除末页外页页填满;nextCursor 当且仅当仍有剩余;一次分页跑完不重不漏。

契约级的参数纪律(这是「不再分叉」的结构性保证)

limitcursor 一律拒绝而非纠正 —— VALIDATION_ERROR / 400(ADR-0112)。校验器与游标编解码放在契约上(resolveStorageListLimit / encodeStorageListCursor / decodeStorageListCursor),不在各适配器里:两个后端因此不可能对同一个坏参数给出两种回答,第三方适配器也直接继承同一套默认值与拒绝语义。

附带结果:游标在任何后端都只表示一件事 —— 「从这个 key 之后继续」。两个适配器发出字节相同的游标,一致性用例因此可以逐页比对 nextCursor 本身,而不只是比对 key;SwappableStorageService 中途换适配器也能续跑而非重头来过(已有用例)。

逐后端交代(PD #12:只有一个 driver 遵守的接口是第二套事实契约)

后端 游标形态 原生 / 模拟 证据
S3StorageAdapter 原生ListObjectsV2 本就是原始前缀 + 升序 + MaxKeys/IsTruncated/ContinuationToken/StartAfter 分页原生;单次调用内循环以突破 1000 上限为本 PR 新增 packages/services/service-storage/src/s3-storage-adapter.ts:229-306
LocalStorageAdapter 模拟。文件系统没有 key 空间,本适配器去贴合 S3 而非反过来 剪枝遍历 + 有界有序插入;内存由 limit 而非目录树规模决定 packages/services/service-storage/src/local-storage-adapter.ts:229-360
SwappableStorageService 透传 内层无 list 时以 does not support list() 拒绝,与其余可选能力同款 packages/services/service-storage/src/swappable-storage-service.ts:75-92

仓内实现 IStorageService 的全部对象即以上三个类,加上 swappable-storage-service.test.ts 的两个测试替身(FakeAdapter 已补齐游标形态并复用契约 helper,MinimalAdapter 故意不实现,用来压拒绝分支)。

S3 侧没有真实 bucket,因此一致性用例把 @aws-sdk/client-s3 mock 成一个按文档实现的假 bucket:升序、Prefix 为原始字符串前缀、MaxKeys 上限硬顶 1000、IsTruncated / NextContinuationToken / StartAfter 齐备,且续传令牌是不由 key 派生的不透明句柄ct-3)—— 一旦适配器把它当作对外游标漏出去,契约的 decodeStorageListCursor 会拒绝、跨后端游标比对会红。假 bucket 的保真度是这套用例的承重部分,不是布景。

反向验证(先定方向,再动手)

预判:删掉 S3 的单调用内循环 ⇒ 只有 S3 侧红;删掉 local 的有序插入 ⇒ 顺序类与跨后端类红。两次都如预判,且第二次暴露了本 PR 自己的覆盖洞:

  1. 把 S3 循环改回「一次 ListObjectsV2、不读 IsTruncated」:35 例中 10 例红,含 expected [ …(1000) ] to have a length of 1500 —— service-storage: IStorageService.list(prefix) means two different things on the two shipped adapters (local: one level, directories as files; S3: recursive, silently capped at 1000) #5266 的静默 1000 截断被原样复现;local 侧 25 例全绿,故障被正确定位到单一后端。
  2. 把 local 的有序插入换成「按遍历顺序 push」:只有 4 条跨后端用例红,单后端用例全绿。也就是说单后端套件对真实的顺序缺陷是瞎的,唯一探测器是 local↔s3 对比 —— 正是 PD Add comprehensive test suite for Zod schema validation #12 那句话的实测版。据此给 fixture 补了 a.txtreaddir 先给目录 a,但 a.txt 排在 a/ 内所有 key 之前,. 0x2E 小于 / 0x2F),再跑同一个变异体:红例从 4 升到 6,answers in ascending key ordersees nested keys under a bare prefix 在 local 侧也红了。fixture 的这段理由已写进用例文件。

另有一处「模板预设与实测不符、按实测改」:storage-adapter-list-contract.test.ts 的 arity 用例初稿断言 Function.length === 1(理由写成「length 停在第一个可选参数」)。实测是 2 —— 该规则针对的是有默认值的参数,TS 的 options? 编译成普通参数。断言与注释都按实测改了,并且这反而让 arity 成为对退役单参数形态的一个真实判别(2 vs 1)。

退役 pin:翻转,不是删除

storage-adapter-list-retirement.test.tsstorage-adapter-list-contract.test.ts(git 识别为改名,历史保留)。它原先压「被摘除的形状没有溜回来」,现在压「两个适配器都带回了该成员,且是游标形态而非数组形态」。承载的载荷只是移动,没有消失 —— list可选成员,适配器悄悄不实现它也照样编译,tsc 看不见,和 #5541 记录的盲区是同一个、方向相反。

契约侧 packages/spec/src/contracts/storage-service.test.ts 的两条 @ts-expect-error 同样翻转:一条改为压「退役的数组形态仍是类型错误」(仍在生效 —— 否则 tsc 会报 unused directive,pnpm typecheck 已绿),一条改为压「list 保持可选,无枚举能力的适配器仍满足契约」。

ADR-0087 台账:修订而非撤销

storage-service-list-retired 条目的 replacement 原文是 no replacement。它会与替代品在同一个版本发布,把升级者推向「自己手写 S3 分页」—— 恰是裁定明确否决的 option A。因此改了 replacement 并在 acceptanceCriteria 末尾追加一段带日期与出处的修订说明;reason(历史分析,仍然成立)一字未动。单参数 list(prefix) 仍然作废,调用它仍编译不过。spec-changes.jsondocs/protocol-upgrade-guide.mdgen:spec-changes / gen:upgrade-guide 重新生成(各 4 行)。

卡片未裁定、由本 PR 决定的三处判断(欢迎推翻)

  1. 对外游标用 StartAfter + key,而非 S3 的 ContinuationToken issue 写的是「S3:ListObjectsV2 + ContinuationToken loop honoring limit」。本 PR 把它读成单次调用内部的循环机制并照做;对外游标另用 key 派生。理由:ContinuationToken 是 S3 私有的不透明串,让它当对外游标会把「游标是什么」变成第二套按后端分叉的方言 —— 正是本卡要消灭的缺陷形状,只是搬到了续传上。用 key 之后两个后端游标同形、可跨后端比对、可跨 swap() 续跑,且坏令牌的拒绝信封也一致。两条 issue 要求都仍然满足。
  2. list 声明为可选(list?)。 issue 正文写作 list(prefix, opts?) 未带 ?,但同时要求 changeset 为 minor。必选成员会打断每一个第三方适配器(contract 头部就写明「S3、Azure Blob、Local FS 等」应实现它),那是 major。两者冲突时按 minor 解,并与本契约其余能力(getSignedUrl / presigned / chunked)一致。
  3. 前缀是原始 key 字符串前缀,不是路径前缀。 issue 只说「recursive prefix match」+「a/b/clist('a') 下可见」,两种读法都满足,但对 list('a') 是否命中 ab.txt 未裁定。取 S3 原生语义(issue 指名 ListObjectsV2),让 local 去贴合。代价是一个真实脚坑:list('t/1') 也命中 t/10/...,对删除类清扫就是一个租户与十一个租户的差别。已在契约注释里用 ⚠️ 显式写下,并有专门用例把这个行为钉住而不是留给读者猜。

我刻意没做的

  • 不碰消费侧:cloud#1197 / cloud PR feat: add organization member and invitation management UI #1205.objectstack-sha 重定向、以及两个 cloud 调用方的行为级测试,按 issue 正文归 cloud#1203。
  • 不为 S3 侧的 StartAfter 之外再加去重层:跨页不重不漏由「严格大于游标 key」保证,并发写入期间的可见性已写进契约。
  • 不修 .parts 之外的隐藏文件规则:只跳过适配器自身的分片暂存区,.foo 这样的用户 key 照常返回(旧实现跳过全部点开头条目,那是没写下来的第三套语义)。
  • 不给 list 加 REST 路由 / SDK 面:本卡只要求服务契约,桶枚举也不该是外部可达的能力。

验证

  • pnpm --filter @objectstack/service-storage test23 files / 324 tests passed
  • packages/spec 全量 vitest run347 files / 8913 tests passed
  • pnpm --filter @objectstack/spec typechecktsc --noEmit + scripts + check:test-typecheck)→ 绿
  • pnpm --filter @objectstack/service-storage build(tsup DTS 即 tsc)→ 绿
  • check:api-surface0 breaking (removed/narrowed), 6 added,已 gen:api-surface
  • check:generated → All 10 generated artifacts are up to date
  • check-nul-bytes / check-error-code-casing / check-route-envelope / check-engine-double-contract / check-type-check-coverage / check-empty-changeset / check-changeset-no-major / check-adr-0087-registration → 全绿
  • eslint --no-inline-config 对全部改动文件 → 无输出

🤖 Generated with Claude Code

https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn

…it }) (#6781)

#5540 / #5541 以「仓内无人调用」摘除了 `IStorageService.list?(prefix)`。该测量对本仓
为真,对隔壁 cloud 为假:cloud 有两个生产调用方(环境删除时的租户附件回收 —— cloud#935
即该清扫静默空转的事故;以及 marketplace 快照 GC)。两处摘除注记都逐字预留了唯一一条
回归路线,本 PR 就是它(cloud#1203 维护者裁定 option B)。

恢复的是预留的形状,不是旧签名:

    list?(prefix: string, options?: StorageListOptions): Promise<StorageListPage>;

#5266 测得的两个缺陷因此变得不可表达:S3 在 1000 处静默截断 —— 现在 `nextCursor`
当且仅当仍有剩余时出现,1000 成为默认 `limit`,被截断的页会自己说出来;local 只列一层
而 S3 递归 —— 现在只有一套语义(原始 key 字符串前缀、递归匹配),并由
`storage-adapter-list.conformance.test.ts` 用同一张表同时压在两个后端上。

`limit` 与 `cursor` 一律拒绝而非纠正(VALIDATION_ERROR / 400,ADR-0112)。校验器与游标
编解码放在契约上(`resolveStorageListLimit` / `encodeStorageListCursor` /
`decodeStorageListCursor`),不在各适配器里,两个后端因此不可能对同一个坏参数给出两种
回答。附带结果:游标在任何后端都只表示一件事 —— 「从这个 key 之后继续」—— 两个适配器
发出字节相同的游标,`SwappableStorageService` 中途换适配器可续跑而非重头来过。

`list` 保持可选(additive/minor):无枚举能力的第三方适配器不受影响。

- S3:单次调用内用 ContinuationToken 循环 ListObjectsV2,使超过 MaxKeys 1000 上限的
  `limit` 也能整页返回;跨调用用 StartAfter 续跑。
- local:以受剪枝的遍历模拟 S3 key 空间,内存由 `limit` 而非目录树规模决定;目录、
  非常规文件与适配器自身的 `.parts` 分片暂存区均不入结果。
- `storage-adapter-list-retirement.test.ts` 更名为 `storage-adapter-list-contract.test.ts`
  并翻转(而非删除):它原先压「被摘除的形状没有溜回来」,现在压「两个适配器都带回了
  该成员,且是游标形态而非数组形态」。
- ADR-0087 台账条目 `storage-service-list-retired` 为「修订」而非「撤销」:单参数
  `list(prefix)` 仍然作废且调用它仍编译不过;改的只是 `replacement` —— 它原本写着
  「no replacement」,否则将与替代品同一版本发布,把升级者推向裁定明确否决的
  「自己手写 S3 分页」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 2:36am

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-storage, @objectstack/spec.

113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/api/plugin-endpoints.mdx (via @objectstack/service-storage)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-storage, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/service-storage, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-storage, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 9, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 9, 2026 03:00
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 20526f5 Aug 9, 2026
28 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-6781-storage-cursor-enumeration branch August 9, 2026 03:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants