Skip to content

Commit efedd28

Browse files
qq9340100claude
andauthored
refactor(spec)!: 按 ADR-0049 摘除 IStorageService.list(prefix) —— 零消费方、双适配器语义分叉 (#5540) (#5983)
* refactor(spec)!: retire IStorageService.list(prefix) (#5540) One contract method, two adapter dialects, both silently incomplete, and no caller. ADR-0049 enforce-or-remove; maintainer ruling 2026-08-05 on #5266. Same disposition as IDataDriver.findStream (#4484): a TS/API contract that code IMPLEMENTS and nothing ever .parse()s, so there is no tombstone and no D2 source rewrite -- tsc is the channel and it reports at the call site. The retirement is registered as the ADR-0087 D3 semantic entry `storage-service-list-retired` in the protocol-17 chain step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * refactor(service-storage): drop the SwappableStorageService.list passthrough (#5540) The contract member it forwarded to is gone, so `this.inner.list` no longer type-checks. That break is CI-visible through tsup's DTS step (which runs tsc), not through a `typecheck` script -- `@objectstack/service-storage#build` failed and took Build Core / Test Core / Dogfood with it. PM ruling on #5540: land the contract removal and its only caller's deletion atomically, so `main` is never red. Scope is the proxy passthrough plus its two test sites only; the adapters' own `list` implementations stay for #5541. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 739f496 commit efedd28

11 files changed

Lines changed: 252 additions & 60 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire `IStorageService.list(prefix)` — one contract method, two adapter dialects, both silently incomplete, and no caller (#5540, ADR-0049 enforce-or-remove)
6+
7+
`list?(prefix)` was an optional method on the storage contract, documented as
8+
9+
> List files in a directory/prefix
10+
11+
and the two shipped adapters answered the same call with two different meanings.
12+
Neither told you.
13+
14+
**The local adapter listed one level and counted directories as files.**
15+
`LocalStorageAdapter.list` was a plain `readdir` over the prefix directory, so a
16+
nested key `a/b/c` was invisible under `list('a')` — you got `a/b` — and every
17+
subdirectory that `stat` succeeded on was pushed into the result as if it were a
18+
file, producing a `StorageFileInfo` whose `size` is a directory inode and which
19+
`download()` cannot fetch.
20+
21+
**The S3 adapter recursed and stopped at 1000.** `S3StorageAdapter.list` issued
22+
one `ListObjectsV2` with the prefix — which matches the whole key, so it is
23+
recursive, not one level — and read neither `IsTruncated` nor
24+
`ContinuationToken`. Past 1000 objects, the "all files under this prefix" a
25+
caller received was the first page, with nothing to distinguish it from a
26+
complete answer.
27+
28+
So the same call was one-level-plus-junk on one deployment and
29+
recursive-but-truncated on the other, and the first feature that genuinely
30+
needed to enumerate a prefix — backup, orphan sweep, migration audit — would
31+
have got two different wrong answers and no error on either.
32+
33+
**Nothing called it.** The only call site in the repository was the
34+
`SwappableStorageService` pass-through, which rejects anyway when the active
35+
adapter has no `list`. REST, the CLI and the storage routes never called it.
36+
#5172 came closest: it planned to reclaim email-attachment content by listing
37+
`EMAIL_ATTACHMENT_KEY_PREFIX`, discovered the local adapter could not see one
38+
level down, and switched to queue-driven deferred work — the divergence cost a
39+
design, and the method still had no consumer afterwards.
40+
41+
**Migration.**
42+
43+
| Wrote | Write instead |
44+
| --- | --- |
45+
| `await storage.list('attachments/task/')` | query the records you wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL |
46+
| `list?(prefix) { … }` on your own adapter | delete the method (see below) |
47+
| `if (typeof storage.list === 'function')` capability probe | delete the branch; the contract has no `list` to probe for |
48+
49+
Querying your own records is not a workaround for the missing method — it is the
50+
only form that was ever correct across both backends and past 1000 objects. The
51+
bucket was never the system of record for "which files exist"; the rows are.
52+
53+
**Adapter authors: nothing breaks on you.** An implementation left in place still
54+
compiles — an extra method is not an error on a class — it is simply unreachable
55+
through the contract, so deleting it is cleanup you can do whenever. The break is
56+
on the **caller** side: `storage.list(...)` no longer type-checks. That includes a
57+
*proxy* typed against `IStorageService` that forwards to `inner.list`; the one in
58+
`@objectstack/service-storage` is removed with the adapters in #5541.
59+
60+
**No tombstone, deliberately.** `IStorageService` is a contract that code
61+
*implements*; nothing anywhere runs a storage adapter through a `.parse()`, so a
62+
`retiredKey()` prescription would have no one to reach. The channel that can
63+
carry it is `tsc`, and `tsc` reports it where it is actionable — at the call
64+
site. This is the same disposition, for the same reason, as
65+
`IDataDriver.findStream` (#4484). The retirement is registered as the
66+
`storage-service-list-retired` semantic entry in the protocol-17 chain step
67+
(ADR-0087 D3), so `spec-changes.json`, the generated upgrade guide and the
68+
`spec_changes` MCP tool all carry it. There is no `os migrate meta` step: an
69+
adapter is code, never stack metadata, so the chain has no source to rewrite.
70+
71+
**No replacement, on purpose.** A prefix listing that cannot paginate is the
72+
wrong signature to inherit. If a first-party caller ever needs real bucket
73+
enumeration it comes back cursor-shaped — `list(prefix, { cursor, limit })`
74+
returning a page plus a continuation token — with adapter-conformance cases
75+
(nested keys, directory entries, more than 1000 objects) proving both backends
76+
agree before either ships. Maintainer ruling 2026-08-05 on #5266 chose this over
77+
aligning the two adapters, which would have grown a conformance surface nobody
78+
walks.

content/docs/kernel/contracts/storage-service.mdx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@ export interface IStorageService {
2525
exists(key: string): Promise<boolean>;
2626
getInfo(key: string): Promise<StorageFileInfo>;
2727

28-
// Listing (optional — adapter may not implement)
29-
list?(prefix: string): Promise<StorageFileInfo[]>;
30-
3128
// Signed URL (optional)
3229
getSignedUrl?(key: string, expiresIn: number, options?: PresignedDownloadOptions): Promise<string>;
3330

@@ -55,8 +52,8 @@ export interface IStorageService {
5552
```
5653

5754
<Callout type="warn">
58-
Only `upload`, `download`, `delete`, `exists`, and `getInfo` are required. Listing,
59-
signed/presigned URLs, and chunked upload are optional — call them only after checking
55+
Only `upload`, `download`, `delete`, `exists`, and `getInfo` are required.
56+
Signed/presigned URLs and chunked upload are optional — call them only after checking
6057
the method exists on the active adapter (the local and S3 adapters implement them).
6158
</Callout>
6259

@@ -172,22 +169,22 @@ export interface StorageUploadOptions {
172169

173170
## Listing Files
174171

175-
### list
176-
177-
Lists files under a key prefix. This method is optional — verify the active
178-
adapter implements it before calling. It takes a single `prefix` argument
179-
(no pagination options) and returns `StorageFileInfo[]`.
180-
181-
```typescript
182-
// List all attachments for a task
183-
const files = await storageService.list?.(
184-
'attachments/tasks/tsk_01HQ4A7B/'
185-
) ?? [];
186-
187-
for (const file of files) {
188-
console.log(`${file.key} (${file.size} bytes)`);
189-
}
190-
```
172+
<Callout type="warn">
173+
**`list(prefix)` was removed** in `@objectstack/spec` 5.x — the contract has no
174+
prefix-enumeration method. It was declared but never consumed, and the two shipped
175+
adapters answered it differently while both silently returned an incomplete answer:
176+
the local adapter listed a single level and reported directories as files, the S3
177+
adapter recursed and stopped at 1000 objects without reading `IsTruncated` /
178+
`ContinuationToken`.
179+
180+
There is no drop-in replacement, deliberately — a prefix listing that cannot paginate
181+
is the wrong shape to keep. If you were enumerating a prefix, track the keys you wrote
182+
(the `sys_file` / file-reference records already do this, and are queryable through
183+
ObjectQL with real pagination) instead of asking the bucket. When a first-party caller
184+
genuinely needs bucket enumeration, it returns cursor-shaped —
185+
`list(prefix, { cursor, limit })` — with adapter-conformance cases proving both
186+
backends agree.
187+
</Callout>
191188

192189
---
193190

@@ -273,7 +270,7 @@ const key = await storageService.completeChunkedUpload?.(uploadId, [
273270

274271
## StorageFileInfo
275272

276-
The return type for file metadata (`getInfo`, `list`).
273+
The return type for file metadata (`getInfo`).
277274

278275
{/* os:check */}
279276
```typescript

content/docs/kernel/runtime-services/storage-service.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ services.storage.download(key: string): Promise<Buffer>
1616
services.storage.delete(key: string): Promise<void>
1717
services.storage.exists(key: string): Promise<boolean>
1818
services.storage.getInfo(key: string): Promise<StorageFileInfo>
19-
services.storage.list?(prefix: string): Promise<StorageFileInfo[]>
2019
services.storage.getSignedUrl?(key: string, expiresIn: number, options?: PresignedDownloadOptions): Promise<string>
2120
```
2221
@@ -31,6 +30,16 @@ services.storage.completeChunkedUpload?(uploadId: string, parts: Array<{ partNum
3130
services.storage.abortChunkedUpload?(uploadId: string): Promise<void>
3231
```
3332
33+
## Removed
34+
35+
`services.storage.list?(prefix)` was removed in `@objectstack/spec` 5.x — the contract
36+
has no prefix-enumeration method. It had no consumer, and the two shipped adapters gave
37+
the same call two different, silently-incomplete answers (local: one level, directories
38+
reported as files; S3: recursive, truncated at 1000 objects). Query the file records you
39+
wrote rather than the bucket; a future enumeration returns cursor-shaped
40+
(`list(prefix, { cursor, limit })`). See the
41+
[contract reference](/docs/kernel/contracts/storage-service).
42+
3443
## Typical Errors
3544
3645
Storage methods reject with a plain `Error` carrying a descriptive message — the

0 commit comments

Comments
 (0)