|
| 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. |
0 commit comments